Python TDD 개발 방법: 테스트 주도 개발

소프트웨어 개발자로서 다양한 프로젝트를 진행하면서, 테스트 주도 개발(TDD)이 내 코드 품질을 얼마나 향상시켰는지 직접 경험했습니다. 특히 Python 프로젝트에서 TDD를 도입한 후, 버그는 줄어들고 유지보수는 훨씬 쉬워졌죠. 이 글에서는 제가 실무에서 터득한 Python TDD 노하우를 공유하려 합니다.

TDD 개발

TDD, 도대체 뭐길래? 왜 써야 할까?

테스트 주도 개발(TDD)은 그저 테스트 방법론이 아니라 개발 사고방식의 전환이라고 봐요. 코드부터 작성하는 게 아니라, 먼저 테스트를 설계하고 그 테스트를 통과하는 최소한의 코드만 만드는 방식이죠.

솔직히 처음 TDD를 접했을 땐 "아직 없는 코드의 테스트를 왜 먼저 짜지?"라는 의문이 들었어요. 하지만 실제로 적용해보니 코드의 목적과 요구사항을 훨씬 명확히 이해하게 됐고, 결과적으로 더 튼튼한 코드를 만들 수 있었답니다.

TDD로 얻은 실제 효과들

  • 버그 감소: 프로젝트 막바지에 발견되던 심각한 버그가 70%나 줄었어요
  • 리팩토링 용기: 테스트 덕분에 코드 개선에 대한 두려움이 싹 사라졌죠
  • 살아있는 문서화: 테스트 코드가 실행 가능한 문서 역할을 톡톡히 해요
  • 설계 향상: 테스트 중심 사고가 더 모듈화된 설계로 자연스레 이어지더군요

TDD의 3단계 사이클: Red-Green-Refactor

TDD의 핵심은 다음 세 단계를 계속 반복하는 거예요:

1. Red: 실패하는 테스트 작성하기

일단 구현하려는 기능의 테스트 코드부터 작성해요. 아직 실제 코드가 없으니 이 테스트는 당연히 실패하게 돼요.

import unittest
from calculator import add

class TestCalculator(unittest.TestCase):
    def test_add_two_numbers(self):
        self.assertEqual(add(3, 5), 8)
        self.assertEqual(add(-1, 1), 0)
        self.assertEqual(add(0, 0), 0)

if __name__ == "__main__":
    unittest.main()

이 테스트를 돌리면 calculator 모듈이나 add 함수가 없어서 오류가 날 거예요.

2. Green: 테스트 통과하는 최소한의 코드 작성하기

이제 테스트를 통과할 수 있는 가장 간단한 코드를 짜볼게요.

# calculator.py
def add(a, b):
    return a + b

이제 테스트를 다시 돌려보면 모든 테스트가 통과할 거예요.

3. Refactor: 코드 개선하기

테스트가 통과했다면, 이제 코드를 더 좋게 만들 차례예요. 이 간단한 예제에선 개선할 게 별로 없지만, 실제 프로젝트에선 중복 제거, 가독성 향상, 성능 최적화 등을 할 수 있죠.

실제 프로젝트에선 이 세 단계를 작은 단위로 빠르게 반복하는 게 핵심이에요. 전 보통 5-10분 주기로 이 사이클을 돌린답니다.

Python 테스트 프레임워크: unittest랑 pytest 비교

Python에서는 주로 두 가지 테스트 프레임워크를 많이 쓰게 돼요:

unittest: 표준 라이브러리의 강점

unittest는 Python 표준 라이브러리에 포함돼 있어서 따로 설치할 필요 없이 바로 쓸 수 있어요.

import unittest

class TestStringMethods(unittest.TestCase):
    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')
    
    def test_isupper(self):
        self.assertTrue('FOO'.isupper())
        self.assertFalse('Foo'.isupper())

if __name__ == '__main__':
    unittest.main()

pytest: 현대적이고 유연한 테스트

pytest는 더 간결한 문법과 강력한 기능을 제공해요. 특히 픽스처(fixture)와 파라미터화 테스트가 정말 뛰어나죠.

# pytest 설치: pip install pytest

def test_upper():
    assert 'foo'.upper() == 'FOO'

def test_isupper():
    assert 'FOO'.isupper()
    assert not 'Foo'.isupper()

개인적으로는 pytest를 더 좋아해요. 문법이 간결하고, 테스트 결과 보고서도 더 읽기 쉬우며, 픽스처 관리도 한결 편하거든요. 그래도 프로젝트 상황에 맞게 도구를 고르는 게 중요하죠.

실전 Python TDD 예제: 쇼핑 카트 만들기

실제 프로젝트처럼 TDD를 적용해 볼까요? 간단한 쇼핑 카트 클래스를 TDD로 구현해 보겠습니다.

1단계: 테스트 작성 (Red)

import unittest
from shopping_cart import ShoppingCart

class TestShoppingCart(unittest.TestCase):
    def test_add_item(self):
        cart = ShoppingCart()
        cart.add_item("사과", 1000, 2)
        self.assertEqual(cart.total_items(), 1)
    
    def test_calculate_total(self):
        cart = ShoppingCart()
        cart.add_item("사과", 1000, 2)
        cart.add_item("바나나", 1500, 3)
        self.assertEqual(cart.calculate_total(), 6500)  # 1000*2 + 1500*3 = 6500

if __name__ == "__main__":
    unittest.main()

2단계: 기능 구현 (Green)

# shopping_cart.py
class ShoppingCart:
    def __init__(self):
        self.items = {}
    
    def add_item(self, name, price, quantity):
        self.items[name] = {"price": price, "quantity": quantity}
    
    def total_items(self):
        return len(self.items)
    
    def calculate_total(self):
        total = 0
        for item in self.items.values():
            total += item["price"] * item["quantity"]
        return total

3단계: 리팩토링

그다지 리팩토링할 건 없지만, 코드를 더 읽기 좋게 만들 수 있어요:

# shopping_cart.py (refactored)
class ShoppingCart:
    def __init__(self):
        self.items = {}
    
    def add_item(self, name, price, quantity):
        self.items[name] = {"price": price, "quantity": quantity}
    
    def total_items(self):
        return len(self.items)
    
    def calculate_total(self):
        return sum(
            item["price"] * item["quantity"]
            for item in self.items.values()
        )

더 복잡한 기능을 추가하고 싶다면 같은 사이클을 계속 반복하면 돼요.

TDD 도입 시 현장 꿀팁

TDD를 시작할 때 제가 겪었던 어려움과 해결책을 나눠볼게요:

1. 작은 단위로 시작하기

처음엔 너무 큰 기능 단위로 테스트를 작성하려다 헤맸어요. 알고 보니 가장 작은 기능 단위로 테스트를 짜는 게 훨씬 효과적이더군요. 예를 들어, 사용자 인증 시스템을 만들 때 전체 기능이 아닌 '비밀번호 유효성 검사' 같은 작은 기능부터 테스트했죠.

2. 모킹(Mocking) 활용하기

외부 시스템이나 DB에 의존하는 코드를 테스트할 땐 unittest.mock이나 pytest-mock이 정말 유용했어요:

from unittest.mock import patch

def test_user_service_with_mock():
    with patch('module.database_client') as mock_db:
        mock_db.get_user.return_value = {"id": 1, "name": "테스트 유저"}
        # 테스트 코드 실행
        result = user_service.get_user_info(1)
        assert result["name"] == "테스트 유저"

3. 테스트 픽스처 활용하기

반복되는 테스트 설정은 픽스처로 빼는 게 편했어요:

@pytest.fixture
def populated_cart():
    cart = ShoppingCart()
    cart.add_item("사과", 1000, 2)
    cart.add_item("바나나", 1500, 3)
    return cart

def test_with_fixture(populated_cart):
    assert populated_cart.calculate_total() == 6500

4. 테스트 커버리지 지켜보기

coverage 도구로 테스트 커버리지를 확인하는 습관을 들였어요:

pip install coverage
coverage run -m pytest
coverage report
coverage html  # 상세 보고서 만들기

자주 묻는 질문(FAQ)

Q: TDD 쓰면 개발 속도가 느려지지 않나요?

A: 처음엔 테스트 작성에 시간이 더 걸리지만, 장기적으론 버그 수정과 유지보수에 드는 시간이 크게 줄어들어요. 실제로 TDD 도입 후 전체 개발 주기가 약 15-20% 단축된 걸 경험했답니다.

Q: 모든 코드에 TDD를 적용해야 하나요?

A: 글쎄요, 꼭 그럴 필요는 없어요. UI 컴포넌트나 간단한 CRUD 작업처럼 테스트 작성이 번거로운 부분은 선택적으로 적용하는 게 현명하죠. 전 비즈니스 로직과 핵심 알고리즘에 집중해서 TDD를 적용해요.

Q: 레거시 코드에 TDD를 적용하려면 어떻게 해야 하죠?

A: 새로운 기능부터 TDD를 적용하고, 기존 코드는 버그 수정이나 리팩토링할 때 조금씩 테스트를 추가하는 게 좋아요. "Working Effectively with Legacy Code" 책은 이런 상황에 큰 도움이 됐어요.

마무리: TDD는 기술이 아닌 습관이에요

TDD는 단순한 기술이나 도구가 아니라 개발 습관이자 마인드셋이라고 생각해요. 처음엔 어색하고 귀찮게 느껴질 수 있지만, 꾸준히 실천하다 보면 자연스러운 개발 과정이 되더라고요.

제 경험상 TDD의 진짜 가치는 코드 품질 향상뿐만 아니라, 개발자가 더 명확히 생각하고 미래의 자신과 동료를 배려하는 코드를 작성하도록 도와준다는 점이에요.

중요한 건 완벽하게 시작하려고 부담 갖기보다는, 작은 단계부터 시작해 조금씩 TDD 습관을 기르는 거예요. Python의 풍부한 테스트 생태계는 이 여정을 더 쉽게 만들어 줄 거예요.

시작이 반이라는 말처럼, 오늘부터 작은 테스트 하나 작성하는 것부터 시작해 볼까요?