Skip to content

Python unittest: 유닛 테스트 작성과 실행 (완전 가이드)

Updated on

사용자 데이터를 처리하는 함수를 하나 작성했다고 해봅시다. 몇 가지 입력으로 수동 테스트를 했을 때는 잘 동작합니다. 그런데 어느 날, 그 함수가 의존하는 헬퍼 함수를 동료가 변경했고, 여러분의 코드는 조용히 잘못된 결과를 반환하기 시작합니다. 아무도 눈치채지 못한 채 3주가 지나고, 결국 버그는 프로덕션까지 반영됩니다. 고객 레코드가 손상됩니다. 원인은 “기존 동작을 검증하지 않은” 단 한 줄짜리 변경이었습니다. 이것이 바로 유닛 테스트가 막아주는 대표적인 실패 패턴입니다. Python은 표준 라이브러리에 unittest를 포함해 제공합니다. 서드파티 의존성 없이(=추가 설치 없이) 회귀(regression)를 프로덕션 전에 잡아내는 완전한 기능의 테스트 프레임워크입니다.

📚

unittest란 무엇이며, 왜 사용해야 하나요?

unittest는 Java의 JUnit을 모델로 한 Python 내장 테스트 프레임워크입니다. Python 2.1부터 표준 라이브러리의 일부였기 때문에 모든 Python 설치 환경에 이미 들어 있습니다. pip 설치가 필요 없고, 의존성 관리도 없습니다. 테스트 클래스를 만들고, 테스트 메서드를 정의한 뒤, 커맨드라인에서 실행하면 됩니다.

유닛 테스트는 코드의 개별 단위(함수, 메서드, 클래스)가 독립적으로 올바르게 동작하는지 검증합니다. 각 유닛이 단독으로 제대로 동작하면, 이를 조합해 통합할 때 숨어 있는 버그가 생길 가능성이 크게 줄어듭니다.

unittest가 기본 제공하는 기능은 다음과 같습니다:

  • 자동 테스트 디스커버리가 가능한 TestCase 클래스
  • 풍부한 assertion 메서드(동등성, truthiness, 예외, 경고 등)
  • 메서드/클래스 레벨의 setUp/tearDown 훅
  • 테스트를 구성/그룹화하는 테스트 스위트(Test suite)
  • unittest.mock를 통한 mocking (Python 3.3에서 추가)
  • verbosity 제어가 가능한 커맨드라인 테스트 러너

첫 번째 유닛 테스트

테스트할 함수와 대응하는 테스트 클래스로 시작해 봅시다.

# calculator.py
def add(a, b):
    return a + b
 
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b
# test_calculator.py
import unittest
from calculator import add, divide
 
class TestCalculator(unittest.TestCase):
 
    def test_add_positive_numbers(self):
        self.assertEqual(add(2, 3), 5)
 
    def test_add_negative_numbers(self):
        self.assertEqual(add(-1, -1), -2)
 
    def test_add_zero(self):
        self.assertEqual(add(0, 0), 0)
 
    def test_divide_normal(self):
        self.assertEqual(divide(10, 2), 5.0)
 
    def test_divide_by_zero_raises(self):
        with self.assertRaises(ValueError):
            divide(10, 0)
 
if __name__ == "__main__":
    unittest.main()

실행:

python -m unittest test_calculator.py -v

출력:

test_add_negative_numbers (test_calculator.TestCalculator) ... ok
test_add_positive_numbers (test_calculator.TestCalculator) ... ok
test_add_zero (test_calculator.TestCalculator) ... ok
test_divide_by_zero_raises (test_calculator.TestCalculator) ... ok
test_divide_normal (test_calculator.TestCalculator) ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.001s

OK

이름이 test_로 시작하는 모든 메서드는 자동으로 감지되어 실행됩니다. 테스트 클래스는 unittest.TestCase를 상속하며, 이 클래스가 다양한 assertion 메서드를 제공합니다.

Assertion 메서드 레퍼런스

unittest.TestCase에는 매우 포괄적인 assertion 메서드가 들어 있습니다. 각각은 체크가 실패할 때 이해하기 쉬운 실패 메시지를 제공합니다.

동등성(Equality) 및 동일성(Identity) Assertion

MethodChecksExample
assertEqual(a, b)a == bself.assertEqual(result, 42)
assertNotEqual(a, b)a != bself.assertNotEqual(result, 0)
assertIs(a, b)a is bself.assertIs(singleton, instance)
assertIsNot(a, b)a is not bself.assertIsNot(obj1, obj2)
assertIsNone(x)x is Noneself.assertIsNone(result)
assertIsNotNone(x)x is not Noneself.assertIsNotNone(user)

Boolean 및 포함(Membership) Assertion

MethodChecksExample
assertTrue(x)bool(x) is Trueself.assertTrue(is_valid)
assertFalse(x)bool(x) is Falseself.assertFalse(has_errors)
assertIn(a, b)a in bself.assertIn("admin", roles)
assertNotIn(a, b)a not in bself.assertNotIn("deleted", status)
assertIsInstance(a, b)isinstance(a, b)self.assertIsInstance(result, dict)

숫자 및 컬렉션 Assertion

MethodChecksExample
assertAlmostEqual(a, b)round(a-b, 7) == 0self.assertAlmostEqual(0.1 + 0.2, 0.3)
assertGreater(a, b)a > bself.assertGreater(len(results), 0)
assertLess(a, b)a < bself.assertLess(latency, 1.0)
assertCountEqual(a, b)같은 요소(순서 무관)self.assertCountEqual([3,1,2], [1,2,3])
assertListEqual(a, b)리스트 동일self.assertListEqual(result, expected)
assertDictEqual(a, b)딕셔너리 동일self.assertDictEqual(config, defaults)

예외 및 경고 Assertion

import unittest
import warnings
 
class TestExceptions(unittest.TestCase):
 
    def test_raises_value_error(self):
        """assertRaises checks that the exception is raised."""
        with self.assertRaises(ValueError):
            int("not_a_number")
 
    def test_raises_with_message(self):
        """assertRaisesRegex checks both the exception and its message."""
        with self.assertRaisesRegex(ValueError, "invalid literal"):
            int("not_a_number")
 
    def test_warns_deprecation(self):
        """assertWarns checks that a warning is issued."""
        with self.assertWarns(DeprecationWarning):
            warnings.warn("old function", DeprecationWarning)

가능하면 assertTrue보다 구체적인 assertion을 선호하세요. 예를 들어 self.assertTrue(result == 42) 대신 self.assertEqual(result, 42)를 사용하세요. 구체적인 assertion은 42 != 41처럼 명확한 실패 메시지를 주는 반면, assertTrueFalse is not true 정도만 알려줍니다.

setUp과 tearDown: 테스트 픽스처(Test Fixtures)

대부분의 테스트는 초기 상태가 필요합니다. 예를 들어 DB 연결, 임시 파일, 미리 설정된 객체 등이 있습니다. setUptearDown은 각 테스트 메서드 전/후에 실행되어, 모든 테스트에 “새 출발점”을 제공합니다.

import unittest
import os
import tempfile
 
class TestFileProcessor(unittest.TestCase):
 
    def setUp(self):
        """Runs before EACH test method."""
        self.test_dir = tempfile.mkdtemp()
        self.test_file = os.path.join(self.test_dir, "data.txt")
        with open(self.test_file, "w") as f:
            f.write("line1\nline2\nline3\n")
 
    def tearDown(self):
        """Runs after EACH test method."""
        os.remove(self.test_file)
        os.rmdir(self.test_dir)
 
    def test_read_lines(self):
        with open(self.test_file, "r") as f:
            lines = f.readlines()
        self.assertEqual(len(lines), 3)
 
    def test_file_exists(self):
        self.assertTrue(os.path.exists(self.test_file))
 
    def test_first_line_content(self):
        with open(self.test_file, "r") as f:
            first_line = f.readline().strip()
        self.assertEqual(first_line, "line1")

각 테스트 메서드는 setUp을 개별적으로 호출받습니다. test_read_lines가 파일을 수정하더라도, setUp이 파일을 다시 만들기 때문에 test_first_line_content는 원래 내용을 보게 됩니다.

setUpClass와 tearDownClass: 1회성 설정

DB 연결, 큰 데이터 픽스처, 서버 프로세스처럼 생성 비용이 큰 리소스는 테스트마다 만들고 지우면 비효율적입니다. 이런 경우 setUpClasstearDownClass로 테스트 클래스 전체에 대해 한 번만 생성/정리할 수 있습니다.

import unittest
import sqlite3
 
class TestDatabase(unittest.TestCase):
 
    @classmethod
    def setUpClass(cls):
        """Runs ONCE before all tests in this class."""
        cls.conn = sqlite3.connect(":memory:")
        cls.cursor = cls.conn.cursor()
        cls.cursor.execute("""
            CREATE TABLE users (
                id INTEGER PRIMARY KEY,
                name TEXT NOT NULL,
                email TEXT UNIQUE NOT NULL
            )
        """)
        cls.cursor.executemany(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            [
                ("Alice", "alice@example.com"),
                ("Bob", "bob@example.com"),
                ("Charlie", "charlie@example.com"),
            ],
        )
        cls.conn.commit()
 
    @classmethod
    def tearDownClass(cls):
        """Runs ONCE after all tests in this class."""
        cls.conn.close()
 
    def test_user_count(self):
        self.cursor.execute("SELECT COUNT(*) FROM users")
        count = self.cursor.fetchone()[0]
        self.assertEqual(count, 3)
 
    def test_find_user_by_email(self):
        self.cursor.execute(
            "SELECT name FROM users WHERE email = ?",
            ("bob@example.com",),
        )
        name = self.cursor.fetchone()[0]
        self.assertEqual(name, "Bob")
 
    def test_unique_emails(self):
        with self.assertRaises(sqlite3.IntegrityError):
            self.cursor.execute(
                "INSERT INTO users (name, email) VALUES (?, ?)",
                ("Duplicate", "alice@example.com"),
            )
HookRunsDecoratorUse Case
setUp각 테스트 메서드 전None새 객체 생성, 상태 초기화
tearDown각 테스트 메서드 후None파일 정리, mock 리셋
setUpClass클래스 내 모든 테스트 전 1회@classmethodDB 연결, 비용 큰 픽스처
tearDownClass클래스 내 모든 테스트 후 1회@classmethod연결 종료, 공유 리소스 삭제

unittest.mock으로 Mocking 하기

실제 애플리케이션은 DB, API, 파일 시스템, 네트워크 서비스에 의존합니다. 유닛 테스트가 프로덕션 API를 호출하거나 실행 중인 DB를 요구하도록 만들고 싶지는 않을 것입니다. unittest.mock은 이런 의존성을 통제 가능한 대체물로 바꿔줍니다.

기본 Mock 객체

from unittest.mock import Mock
 
# Create a mock object
api_client = Mock()
 
# Configure return values
api_client.get_user.return_value = {"id": 1, "name": "Alice"}
 
# Use it like a real object
user = api_client.get_user(user_id=1)
print(user)  # {'id': 1, 'name': 'Alice'}
 
# Verify it was called correctly
api_client.get_user.assert_called_once_with(user_id=1)

@patch로 Patching 하기

@patch 데코레이터는 특정 모듈 안의 객체를 테스트 동안만 교체합니다. 이는 의존성으로부터 유닛을 격리하는 핵심 도구입니다.

# user_service.py
import requests
 
def get_user_name(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status()
    return response.json()["name"]
# test_user_service.py
import unittest
from unittest.mock import patch, Mock
from user_service import get_user_name
 
class TestUserService(unittest.TestCase):
 
    @patch("user_service.requests.get")
    def test_get_user_name_success(self, mock_get):
        """Mock the requests.get call to avoid hitting the real API."""
        mock_response = Mock()
        mock_response.json.return_value = {"id": 1, "name": "Alice"}
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response
 
        result = get_user_name(1)
 
        self.assertEqual(result, "Alice")
        mock_get.assert_called_once_with("https://api.example.com/users/1")
 
    @patch("user_service.requests.get")
    def test_get_user_name_http_error(self, mock_get):
        """Verify that HTTP errors propagate correctly."""
        import requests
        mock_get.return_value.raise_for_status.side_effect = (
            requests.exceptions.HTTPError("404 Not Found")
        )
 
        with self.assertRaises(requests.exceptions.HTTPError):
            get_user_name(999)

컨텍스트 매니저로 Patching 하기

import unittest
from unittest.mock import patch
 
class TestConfig(unittest.TestCase):
 
    def test_reads_environment_variable(self):
        with patch.dict("os.environ", {"DATABASE_URL": "sqlite:///test.db"}):
            import os
            self.assertEqual(os.environ["DATABASE_URL"], "sqlite:///test.db")

MagicMock vs Mock

MagicMockMock의 서브클래스로, 매직 메서드(__len__, __iter__, __getitem__ 등)가 기본 구성되어 있습니다. 테스트 대상 코드가 mock 객체의 dunder 메서드를 호출한다면 MagicMock을 사용하세요.

from unittest.mock import MagicMock
 
mock_list = MagicMock()
mock_list.__len__.return_value = 5
mock_list.__getitem__.return_value = "item"
 
print(len(mock_list))     # 5
print(mock_list[0])       # item

복잡한 동작을 위한 side_effect

side_effect를 사용하면 mock이 예외를 던지게 하거나, 호출될 때마다 다른 값을 반환하게 하거나, 커스텀 함수를 실행하게 할 수 있습니다.

from unittest.mock import Mock
 
# Raise an exception
mock_db = Mock()
mock_db.connect.side_effect = ConnectionError("Database unreachable")
 
# Return different values on successive calls
mock_api = Mock()
mock_api.fetch.side_effect = [{"page": 1}, {"page": 2}, StopIteration]
 
print(mock_api.fetch())  # {'page': 1}
print(mock_api.fetch())  # {'page': 2}
 
# Custom function
def validate_input(x):
    if x < 0:
        raise ValueError("Negative input")
    return x * 2
 
mock_fn = Mock(side_effect=validate_input)
print(mock_fn(5))  # 10

테스트 디스커버리(Test Discovery)

모든 테스트 파일을 수동으로 나열할 필요는 없습니다. Python의 테스트 디스커버리는 네이밍 규칙을 따르는 모든 테스트를 찾아 실행합니다.

# Discover and run all tests in the current directory tree
python -m unittest discover
 
# Specify a start directory and pattern
python -m unittest discover -s tests -p "test_*.py"
 
# Verbose output
python -m unittest discover -v

테스트 디스커버리는 test_*.py(기본 패턴)에 매칭되는 파일을 찾고 import한 다음, unittest.TestCase를 상속한 클래스의 테스트들을 실행합니다.

권장 프로젝트 구조

my_project/
    src/
        calculator.py
        user_service.py
        utils.py
    tests/
        __init__.py
        test_calculator.py
        test_user_service.py
        test_utils.py
    setup.py

프로젝트 루트에서 모든 테스트 실행:

python -m unittest discover -s tests -v

Test Suite로 테스트 구성하기

어떤 테스트를 실행할지 더 세밀하게 제어하고 싶다면 테스트 스위트를 수동으로 구성할 수 있습니다.

import unittest
from test_calculator import TestCalculator
from test_user_service import TestUserService
 
def fast_tests():
    """Suite of tests that run quickly (no I/O, no network)."""
    suite = unittest.TestSuite()
    suite.addTest(TestCalculator("test_add_positive_numbers"))
    suite.addTest(TestCalculator("test_add_negative_numbers"))
    suite.addTest(TestCalculator("test_divide_normal"))
    return suite
 
def all_tests():
    """Full test suite."""
    loader = unittest.TestLoader()
    suite = unittest.TestSuite()
    suite.addTests(loader.loadTestsFromTestCase(TestCalculator))
    suite.addTests(loader.loadTestsFromTestCase(TestUserService))
    return suite
 
if __name__ == "__main__":
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(fast_tests())

테스트 스킵과 예상 실패(Expected Failures)

특정 조건에서만 테스트가 실행되어야 할 때가 있습니다. 예: 특정 OS, 특정 Python 버전, 외부 서비스가 사용 가능할 때만 등.

import unittest
import sys
 
class TestPlatformSpecific(unittest.TestCase):
 
    @unittest.skip("Temporarily disabled while refactoring")
    def test_feature_under_construction(self):
        pass
 
    @unittest.skipIf(sys.platform == "win32", "Not supported on Windows")
    def test_unix_permissions(self):
        import os
        self.assertTrue(os.access("/tmp", os.W_OK))
 
    @unittest.skipUnless(sys.platform.startswith("linux"), "Linux only")
    def test_proc_filesystem(self):
        import os
        self.assertTrue(os.path.exists("/proc"))
 
    @unittest.expectedFailure
    def test_known_bug(self):
        """This test documents a known bug. It SHOULD fail."""
        self.assertEqual(1 + 1, 3)
DecoratorEffect
@unittest.skip(reason)항상 이 테스트를 스킵
@unittest.skipIf(condition, reason)condition이 True면 스킵
@unittest.skipUnless(condition, reason)condition이 True일 때만 실행
@unittest.expectedFailure실패가 예상됨으로 표시; 통과하면 오히려 에러로 보고

subTest로 파라미터화 테스트 만들기

같은 로직을 다양한 입력으로 테스트하는 것은 흔합니다. 각 케이스마다 별도 테스트 메서드를 만드는 대신, subTest로 한 메서드 안에서 파라미터화된 assertion을 실행할 수 있습니다.

import unittest
 
def is_palindrome(s):
    cleaned = s.lower().replace(" ", "")
    return cleaned == cleaned[::-1]
 
class TestPalindrome(unittest.TestCase):
 
    def test_palindromes(self):
        test_cases = [
            ("racecar", True),
            ("hello", False),
            ("A man a plan a canal Panama", True),
            ("", True),
            ("ab", False),
            ("madam", True),
            ("Nurses Run", True),
        ]
        for text, expected in test_cases:
            with self.subTest(text=text):
                self.assertEqual(is_palindrome(text), expected)

subTest를 쓰면 한 케이스에서 실패해도 나머지 케이스가 계속 실행됩니다. 출력은 어떤 하위 케이스가 실패했는지 정확히 보여줍니다:

FAIL: test_palindromes (test_palindrome.TestPalindrome) (text='Nurses Run')
AssertionError: False != True

subTest가 없다면 첫 실패에서 메서드가 중단되어 다른 케이스들의 실패 여부를 알기 어렵습니다.

unittest vs pytest vs doctest

Python에는 내장 도구 또는 널리 쓰이는 테스트 도구가 세 가지 있습니다. 각각의 용도가 다릅니다.

Featureunittestpytestdoctest
Included in stdlibYesNo (pip install)Yes
Test styleClass-based (TestCase)Function-based (plain def test_)Embedded in docstrings
Assertionsself.assertEqual, self.assertTrue, etc.Plain assert statementExpected output matching
FixturessetUp/tearDown, setUpClass@pytest.fixture with dependency injectionNone
Parameterized testssubTest (limited)@pytest.mark.parametrize (powerful)One example per docstring
Mockingunittest.mock (built-in)unittest.mock + monkeypatchNot applicable
Test discoverypython -m unittest discoverpytest (auto-discovers)python -m doctest module.py
Output on failureBasic diffDetailed diff with contextShows expected vs actual output
PluginsNone1000+ plugins (coverage, fixtures, etc.)None
Learning curveModerate (OOP patterns)Low (plain functions)Very low
Best forStandard library projects, no extra dependenciesMost Python projects, complex test setupsSimple examples in documentation

unittest를 선택할 때:

  • 외부 의존성을 0개로 유지하고 싶을 때
  • 조직/프로젝트가 이미 unittest를 사용 중일 때
  • 클래스 기반 테스트 구성 방식이 필요할 때
  • 추가 설정 없이 unittest.mock를 쓰고 싶을 때

pytest를 선택할 때:

  • 더 간단한 문법과 더 좋은 실패 출력이 필요할 때
  • 강력한 파라미터화/fixture가 필요할 때
  • pytest 플러그인 생태계(coverage, async, Django 등)에 의존할 때

doctest를 선택할 때:

  • 문서에 있는 코드 예제가 여전히 동작하는지 검증하고 싶을 때
  • 테스트가 단순한 입출력 쌍일 때

또한 pytest는 unittest 스타일 테스트도 수정 없이 실행할 수 있습니다. 많은 팀이 unittest로 시작한 뒤, 기존 테스트 클래스는 유지한 채 러너만 pytest로 바꾸기도 합니다.

실전 클래스 테스트하기: 완전한 예제

쇼핑 카트 구현을 테스트하는 완전한 예제입니다.

# cart.py
class Product:
    def __init__(self, name, price):
        if price < 0:
            raise ValueError("Price cannot be negative")
        self.name = name
        self.price = price
 
class ShoppingCart:
    def __init__(self):
        self.items = []
 
    def add(self, product, quantity=1):
        if quantity <= 0:
            raise ValueError("Quantity must be positive")
        self.items.append({"product": product, "quantity": quantity})
 
    def total(self):
        return sum(
            item["product"].price * item["quantity"]
            for item in self.items
        )
 
    def remove(self, product_name):
        self.items = [
            item for item in self.items
            if item["product"].name != product_name
        ]
 
    def item_count(self):
        return sum(item["quantity"] for item in self.items)
# test_cart.py
import unittest
from cart import Product, ShoppingCart
 
class TestProduct(unittest.TestCase):
 
    def test_create_product(self):
        p = Product("Widget", 9.99)
        self.assertEqual(p.name, "Widget")
        self.assertAlmostEqual(p.price, 9.99)
 
    def test_negative_price_raises(self):
        with self.assertRaises(ValueError):
            Product("Bad", -5.00)
 
class TestShoppingCart(unittest.TestCase):
 
    def setUp(self):
        self.cart = ShoppingCart()
        self.apple = Product("Apple", 1.50)
        self.bread = Product("Bread", 3.00)
 
    def test_empty_cart_total(self):
        self.assertEqual(self.cart.total(), 0)
 
    def test_add_single_item(self):
        self.cart.add(self.apple)
        self.assertEqual(self.cart.item_count(), 1)
        self.assertAlmostEqual(self.cart.total(), 1.50)
 
    def test_add_multiple_items(self):
        self.cart.add(self.apple, quantity=3)
        self.cart.add(self.bread, quantity=2)
        self.assertEqual(self.cart.item_count(), 5)
        self.assertAlmostEqual(self.cart.total(), 10.50)
 
    def test_remove_item(self):
        self.cart.add(self.apple, quantity=2)
        self.cart.add(self.bread)
        self.cart.remove("Apple")
        self.assertEqual(self.cart.item_count(), 1)
        self.assertAlmostEqual(self.cart.total(), 3.00)
 
    def test_remove_nonexistent_item(self):
        self.cart.add(self.apple)
        self.cart.remove("Nonexistent")
        self.assertEqual(self.cart.item_count(), 1)
 
    def test_add_zero_quantity_raises(self):
        with self.assertRaises(ValueError):
            self.cart.add(self.apple, quantity=0)
 
    def test_add_negative_quantity_raises(self):
        with self.assertRaises(ValueError):
            self.cart.add(self.apple, quantity=-1)
 
if __name__ == "__main__":
    unittest.main()

unittest 베스트 프랙티스

1. “개념”당 하나의 Assertion

각 테스트 메서드는 하나의 논리적 개념을 검증해야 합니다. 하나의 작업(예: 유저 생성)의 여러 측면을 확인하는 여러 assertion은 괜찮습니다.

# GOOD -- multiple assertions about the same operation
def test_user_creation(self):
    user = create_user("alice", "alice@example.com")
    self.assertEqual(user.name, "alice")
    self.assertEqual(user.email, "alice@example.com")
    self.assertIsNotNone(user.id)
 
# BAD -- testing unrelated things in one method
def test_everything(self):
    user = create_user("alice", "alice@example.com")
    self.assertEqual(user.name, "alice")
    users = list_all_users()
    self.assertGreater(len(users), 0)  # Unrelated assertion

2. 설명적인 테스트 이름 사용

테스트 이름은 시나리오와 기대 결과를 설명해야 합니다. CI에서 실패했을 때 이름만 보고도 무엇이 문제인지 유추할 수 있어야 합니다.

# GOOD
def test_divide_by_zero_raises_value_error(self):
    ...
 
def test_empty_cart_returns_zero_total(self):
    ...
 
# BAD
def test_divide(self):
    ...
 
def test1(self):
    ...

3. 테스트는 서로 독립적이어야 함

어떤 테스트도 다른 테스트의 출력이나 실행 순서에 의존하면 안 됩니다. 각 테스트는 자체적으로 상태를 구성하고, 끝나면 정리해야 합니다.

4. 테스트를 빠르게 유지

유닛 테스트는 밀리초 단위로 돌아야 합니다. DB가 필요하면 mock 처리하고, API가 필요하면 mock 처리하세요. 느린 통합 테스트는 별도 스위트로 분리하세요.

5. 엣지 케이스 테스트

항상 경계 조건을 테스트하세요: 빈 입력, 0 값, None, 매우 큰 입력, 잘못된 타입 등.

def test_edge_cases(self):
    test_cases = [
        ([], 0),          # empty list
        ([0], 0),         # single zero
        ([-1, -2], -3),   # all negative
        ([999999999], 999999999),  # large number
    ]
    for inputs, expected in test_cases:
        with self.subTest(inputs=inputs):
            self.assertEqual(sum(inputs), expected)

6. 구현 상세(Implementation Details)를 테스트하지 말 것

내부 상태나 private 메서드가 아니라, 공개 인터페이스와 동작을 테스트하세요. 내부를 리팩토링하더라도 테스트는 계속 통과해야 합니다.

커맨드라인에서 테스트 실행하기

# Run a specific test file
python -m unittest test_calculator.py
 
# Run a specific test class
python -m unittest test_calculator.TestCalculator
 
# Run a specific test method
python -m unittest test_calculator.TestCalculator.test_add_positive_numbers
 
# Verbose output (shows each test name and result)
python -m unittest -v
 
# Discover all tests in a directory
python -m unittest discover -s tests -p "test_*.py" -v
 
# Stop on first failure (fail-fast mode)
python -m unittest -f

RunCell로 테스트 작성 및 디버깅하기

Jupyter notebook에서 개발할 때(데이터 사이언스/탐색적 프로그래밍에서 흔함) 유닛 테스트 작성과 실행은 어색하게 느껴질 수 있습니다. 노트북은 셀을 대화형으로 실행하지만, unittest는 모듈과 테스트 러너를 기대합니다. 그래서 코드를 노트북과 테스트 파일 사이에서 복사/붙여넣기 하다가 인터랙티브한 피드백 루프가 끊기기도 합니다.

RunCell (opens in a new tab)은 Jupyter를 위해 설계된 AI agent로, 이 간극을 메워줍니다. 노트북 셀에 정의한 함수에 대해 unittest 호환 테스트 케이스를 생성하고, 노트북 환경 안에서 실행하며, 실패 원인을 문맥과 함께 설명해줍니다. mock 설정이 잘못되었거나 assertion이 실패하면 RunCell은 라이브 변수를 검사해 “assertion error 메시지”만이 아니라 실제 값이 무엇이었는지도 보여줍니다. DataFrame 변환 결과가 기대한 shape/값을 가지는지 확인해야 하는 데이터 파이프라인에서도, RunCell이 테스트 구조와 assertion을 스캐폴딩해 주기 때문에 보일러플레이트보다 로직에 집중할 수 있습니다.

FAQ

unittest와 pytest의 차이는 무엇인가요?

unittest는 클래스 기반 API를 가진 Python 내장 테스트 프레임워크입니다. pytest는 일반 함수와 assert 문을 사용하는 서드파티 프레임워크입니다. pytest는 더 풍부한 플러그인 생태계와 더 나은 실패 출력이 장점이지만 설치가 필요합니다. unittest는 추가 의존성 없이 Python이 동작하는 어디서나 사용할 수 있습니다.

unittest에서 특정 테스트 메서드 하나만 실행하려면 어떻게 하나요?

python -m unittest test_module.TestClass.test_method 명령을 사용하세요. 예: python -m unittest test_calculator.TestCalculator.test_add_positive_numbers. 그러면 파일 안의 다른 테스트는 실행하지 않고 해당 메서드만 실행합니다.

unittest에서 setUp과 setUpClass의 차이는 무엇인가요?

setUp은 각 테스트 메서드 실행 전에 매번 실행되어 매번 새로운 상태를 만듭니다. setUpClass는 클래스 안의 모든 테스트가 실행되기 전에 한 번만 실행되며 @classmethod입니다. DB 연결처럼 비용이 큰 설정에는 setUpClass를, 테스트마다 가벼운 상태가 필요한 경우에는 setUp을 사용하세요.

unittest에서 외부 API를 어떻게 mock 하나요?

unittest.mock.patch로 API를 호출하는 함수를 대체하세요. patch는 함수가 “정의된 곳”이 아니라 “사용되는(import된) 경로”를 기준으로 적용해야 합니다. 예를 들어 user_service.pyrequests.get를 import해서 쓴다면 requests.get가 아니라 user_service.requests.get를 patch해야 합니다.

pytest가 unittest 테스트를 실행할 수 있나요?

가능합니다. pytest는 unittest 스타일 테스트 클래스와 완전히 호환됩니다. unittest.TestCase 기반 테스트를 사용하는 프로젝트에서도 수정 없이 pytest로 실행할 수 있습니다. 그래서 마이그레이션을 점진적으로 진행할 수 있습니다. 기존 테스트는 unittest 스타일로 유지하고, 새 테스트만 pytest 스타일로 작성해도 됩니다.

함수가 예외를 발생시키는지 어떻게 테스트하나요?

self.assertRaises(ExceptionType)를 컨텍스트 매니저로 사용하세요. with 블록 안의 코드가 지정한 예외를 발생시키면 테스트는 통과하고, 예외가 없거나 다른 예외가 발생하면 실패합니다. 예외 메시지까지 확인하려면 assertRaisesRegex를 사용하세요.

결론

Python의 unittest 프레임워크는 모든 Python 설치에 포함된 완전한 테스트 툴킷입니다. 테스트 케이스 클래스, 풍부한 assertion 메서드, 메서드/클래스 레벨의 setup 및 teardown 훅, unittest.mock를 통한 mocking, 그리고 내장 테스트 디스커버리 기능을 제공합니다. 신뢰할 수 있는 테스트를 작성하기 위해 별도의 설치는 필요하지 않습니다.

핵심은 간단합니다: TestCase를 상속하고, 메서드 이름을 test_로 시작하게 만들고, 구체적인 assertion 메서드를 사용한 뒤, python -m unittest로 실행하세요. 프로젝트가 커지면 테스트 격리를 위해 setUp/tearDown을 추가하고, 외부 의존성을 mock 처리하기 위해 @patch를 사용하며, 파라미터화 테스트에는 subTest를 활용하세요. 테스트는 tests/ 디렉터리에 구성하고, 테스트 디스커버리에 맡기면 됩니다.

테스트를 쓰는 데는 초기 시간이 듭니다. 하지만 그보다 훨씬 많은 시간을 이후 단계에서 절약해 줍니다. 유닛 테스트가 잡아낸 모든 회귀는 “일어나지 않은 프로덕션 장애”이고, “접수되지 않은 고객 불만”이며, “시작되지 않은 디버깅 세션”입니다. unittest를 계속 쓰든, 언젠가 pytest로 옮기든, unittest의 패턴(격리, 명확한 assertion, mock된 의존성, 엣지 케이스 포괄)을 통해 길러지는 테스트 습관은 Python 테스트 전반에 보편적으로 적용됩니다.

📚