Hyesung Oh

Python으로 알아보는 테스트 주도 개발 TDD (1) 기초부터 mock, stub, spy 본문

DEV

Python으로 알아보는 테스트 주도 개발 TDD (1) 기초부터 mock, stub, spy

혜성 Hyesung 2022. 6. 10. 18:48
반응형

테스트 주도 개발을 통해 더 좋은 코드와 아키텍처를 만들어 나갈 수 있을까? 반은 맞고 반은 틀리다. 중요한 것은 테스트 그 자체가 아니라 내가 테스트를 하는 이유에 대해 끊임없이 질문하고 개선하는 활동에 있기에 반은 틀리다. TDD의 진짜 의의는 왜 테스트 하고있는지에 물음을 가지는 것에 있다고들 강조한다.


Unit Testing vs Integration Testing vs Functional Testing

Unit Test는 말그대로 하나의 단위를 테스트 하는 것이다. 아래에서 처럼 하나의 함수를 테스트 하는 것을 예를 들 수 있다. 주의할 것은, 단위란 정의하기 나름이다. 예를 들어, 하나의 함 수는 사실 두 개의 함수로 분리되는게 더 바람직 할 수 있다. 

def func(x):
    return x + 1

def test_answer1():
    assert func(3) == 5



Integration Test는 Unit간의 결합을 테스트 하는 것이다. 두 개의 유닛이 합쳐져서 의도한 대로 동작하는지를 테스트 하는 것이다. 예를 들면, 데이터베이스에서 데이터를 불러와서 빌드하는 것 까지를 테스트 해볼 수 있다.

Functional Test는 QA 과정이라 봐도 무방하다. 보통 CI에서 Functional Test까지 결합하진 않고, 운영환경 배포전 dev, stage 환경에 배포하고 개발한 소프트웨어가 명세에 맞게 잘작동하는지, 즉 하나의 소프트웨어 제품이라는 관점에서 제대로 기능을 하는지를 테스트 하는 것이다. 

Unit Test -> Integration Test -> Functionl Test 순으로 갈 수록 시간과 유지 관리 비용이 많이 든다. CI에는 보통 Unit Test 까지 결합되어 있다. Input 대비 Output에서 Unit Test만큼 효율적인게 없기 때문이다. TDD에서 제일 강조하는 부분도 이 Unit Test이다.

Unit Test

하나의 unit을 test 하는 것이므로, 환경과 다른 Unit의 동작으로 부터 영향을 받지 않아야한다. 외부 환경에 종속성을 최대한 줄이면서 unit 간의 결합을 느슨하게 가져가는 가다보면 테스트 하기 좋은 코드가 된다. 역으로 테스트 하기 좋은 코드로 개선하기 위해 고민하다 보면 곧 좋은 코드가 되는 것이다. 

xUnit Styles

https://ko.wikipedia.org/wiki/XUnit

 

xUnit - 위키백과, 우리 모두의 백과사전

 

ko.wikipedia.org

각 프로그래밍 언어 마다 유닛 테스트 프레임워크가 있다. 각 구현체 마다 추가적인 세부 구현사항이 있을 뿐, 1998년 켄트벡에 의해 고안되어 SmallTalk에서 처음 소개된 SUnit 프레임워크의 핵심 컴포넌트들을 공유하고 있다. ex) JUnit for Java, RUnit for R.

핵심 컴포넌트는 아래와 같다. 자세한 글의 후반부에서 예시와 함께 다뤄보도록 하겠다.

Test Runner: xUnit 프레임워크로 정의된 테스트를 실행하는 실행가능한 프로그램이다. 
Test Case: 모든 유닛 테스트는 테스트 케이스 클래스로 부터 상속된다.
Test Fixtures: 테스트 케이스들이 공유하는 precondition 혹은 매개변수이다. 
Test Suites: 유닛 테스트의 집합이다.
Test Execution
Test Result Formatter
Assertions: 기대되는 결과를 검증하기 위한 논리적 조건이다. 예를 들면, A 함수의 기대결과가 1일 때 아래와 같이 작성 할 수 있다.  

def func(num: int):
	return num - 2
def test_answer1():
    assert func(3) == 1


Test Doubles

아래 내용은 마틴 파울러의 블로그에 Mocs Aren't Stubs라는 글을 참고하였다.
예제는 Java로 되어있는 예제를 Python으로 직접 수정하였다.


마틴 파울러는 Test Doble을 테스트계의 스턴트맨이라고 소개한다. 즉, 테스트를 목적으로 실제 대신 사용되는 모든 종류의 가상 객체에 대해 Test Double이라는 용어를 사용한다. 테스트 더블에는 아래와 같은 것들이 있다.

테스트 하고자 하는 특정 유닛이 다른 유닛과 결합되어 있을 때, 의존하고 있는 유닛을 테스트 더블로 만듦으로서 테스트 환경에 독립적으로 (깨지지 않게) 만들 수 있는 기법이라고 이해하면 된다. 

Meszaros uses the term Test Double as the generic term for any kind of pretend object used in place of a real object for testing purposes. The name comes from the notion of a Stunt Double in movies. (One of his aims was to avoid using any name that was already widely used.) Meszaros then defined five particular kinds of double: * Dummy objects are passed around but never actually used. Usually they are just used to fill parameter lists. * Fake objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an in memory database is a good example). * Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in for the test. * Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent. * Mocks are what we are talking about here: objects pre-programmed with expectations which form a specification of the calls they are expected to receive.

예시를 통해 자세히 알아보자.

# mail_service.py
class MailService:
    def send(self, message: str):
        print(message)
# order.py

from mail_service import MailService
from typing import Optional


class Order:
    def __init__(self, name: str, price: int):
        self.name = name
        self.price = price
        self._mailer: Optional[MailService] = None

    @property
    def mailer(self):
        return self._mailer
    
    @mailer.setter
    def mailer(self, mailer: MailService):
        self._mailer = mailer
    
    def confirm(self):
        self.mailer.send(f"상품 이름:{self.name} 상품 가격:{self.price}")

두 가지 테스트 방식으로 'Order가 confirm 될 경우 메일을 발송하는지'를 테스트해보자. 

Mock vs Stub

실제 MailService의 구현체는 send가 외부 api에 연동되어 있을 것이고, 단위 테스트 시에는 해당 부분을 테스트 더블로 치환하여 실행한다. 외부 api를 호출은 개발환경에 깨지기 쉽다. 따라서 테스트 더블을 이용해 위에서 언급한 `테스트 하고자 하는 특정 유닛이 다른 유닛과 결합되어 있을 때, 의존하고 있는 유닛을 테스트 더블로 만듦으로서 테스트 환경에 독립적으로` 만들고자 하는 목적이다.

Stub

# test_order.py

from typing import List

from functions.mail_service import MailService
from functions.order import Order


class MailServiceStub(MailService):
    message: List[str] = []

    def send(self, message: str):
        self.message.append(message)

    @property
    def sent_cnt(self):
        return len(self.message)


def test_send_mail_when_order_confirmed():
    order = Order('hyesung', 15000)
    mail_service = MailServiceStub()

    order.mailer = mail_service
    order.confirm()

    assert mail_service.sent_cnt == 1

Mock

mock의 경우 python3.3 부터 내장된 unittest 모듈을 이용할 수 있다.

from unittest import mock


def test_send_mail_if_order_confirmed_using_mock():
    order = Order('hyesung', 15000)
    mail_service = MailService()
    mail_service.send = mock.Mock()
    mail_service_send = mail_service.send

    order.mailer = mail_service
    order.confirm()

    assert mail_service_send.assert_called_once()

두 가지의 경우 모두 실제 mailService를 사용하지 않고, Test Double을 사용하고 있다. 사실 두 가지의 테스트는 검증하고자 하는 것이 정확히 일치하며 방식만 다를 뿐이다. Stub은 상태 검증 (최종 결과), Mock은 행동 검증 (올바른 호출)에 초점을 맞춘다.

Mock vs Spy

from unittest import mock


class Foo:
    def bar(self, x, y):
        return x + y + 1
    

def test_bar_using_mock():
    foo = Foo()
    foo.bar = mock.Mock()
    foo.bar.return_value = 1
    
    assert foo.bar(1, 2) == 3  # raise exception


def test_bar_using_spy():
    foo = Foo()
    with mock.patch.object(foo, 'bar', wraps=foo.bar) as wrapped_foo:
        result = foo.bar(1, 2)
        wrapped_foo.assert_called_with(1, 2)
        assert result == 3  # pass

Mock은 실제 내부 구현체가 수행되지 않는다. 그에 반해 Spy는 실제 내부 구현체가 수행되며, 몇 번 호출이 되었는지 또 어떤 인자를 받았는지 검증할 수 있다.

다음 글에선 Python unit test 프레임워크인 Pytest를 이용하여 어떻게 더 잘 단위 테스트를 작성할 수 있을지에 대해 정리할 예정이다.

감사합니다.

반응형
Comments