이 내용은 “단위 테스트” 를 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다.
2장은 해당 코드를 살펴봐주세요. 코드 링크
Chapter 2. 단위 테스트란 무엇인가
커맨드로 테스트를 직접 실행하기 위해선 현재 디렉토리로 이동한다.
cd pt1/ch02
앞으로 꾸준히 나올 단어에 대해:
단위 테스트를 바라보는 관점은 생각 보다 중요하다. 접근 방법에 따라 고전파(classical school)과 런던파(london school)로 나뉜다(이걸 얘기하고자 하는 건 아니다. 이렇게 단도직입적으로 풀 문제도 아니다. 좀 더 살펴보면서 차이점을 비교해보자!).
🍅 tips
단위 테스트의 고전파와 런던파
고전파
- 단위 테스트와 테스트 주도 개발에 원론적으로 접근하는 방식으로 인해 고전(classic)이라고 부른다
런던파?
- 코드 격리에 대한 부분을 mocking으로 해결하고자 한다. 이런 기조가 런던의 프로그래밍 커뮤니티에서 시작된 탓에 런던파라고 부른다.
단위 테스트의 중요한 세 가지 속성은 아래와 같다:
앞서 살펴본 두가지는 논란의 여지가 없다. 작은 코드조각을 검증한다는 것은 말할 것도 없다. 빠르게 구동한다는 것은, 테스트 스위트의 실행시간이 충분하면 테스트는 충분히 빠르다로 이해할 수 있기 때문이다.
‘격리’를 어떻게 해석하는지에 따라 두 분파별로 관점이 달라진다( 2.3 에서 설명)
장점?
고전파의 접근방식으로 테스트를 짜면 아래와 같다. 여기서는 Store
, Customer
에 대한 내용은 생략했다. 상세한 내용은 test/
디렉토리 내의 파일을 참고하길 바란다.
#
# test\test_01_classical_way.py 의 일부분
#
def test_purchase_succeeds_when_enough_inventory():
# Arrange
store = Store(Product("Shampoo", 10))
customer = Customer()
# Act
success = customer.purchase(store, Product("Shampoo", 5))
# Assert
assert success is True
assert 5 == store.item.count
def test_purchase_fails_when_not_enough_money():
# Arrange
store = Store(Product("Shampoo", 10))
customer = Customer()
# Act
success = customer.purchase(store, Product("Shampoo", 15))
# Assert
assert success is False
assert 10 == store.item.count
테스트는 아래 명령으로 구동한다:
pytest test\test_01_classical_way.py -v
=============================================== test session starts ===============================================
platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 -- C:\REDACTED\python.exe
cachedir: .pytest_cache
rootdir: C:\unit_testing\pt1\ch02
plugins: cov-4.1.0
collected 2 items
test/test_01_classical_way.py::test_purchase_succeeds_when_enough_inventory PASSED [ 50%]
test/test_01_classical_way.py::test_purchase_fails_when_not_enough_money PASSED [100%]
================================================ 2 passed in 0.04s ================================================
Arrange-Act-Assert 접근 방식은 5장에서 다시 살펴볼 예정이다. 쉽게 말해 아래 과정을 포함한다고 보면 된다.
Customer
(SUT), Store
(협력자) 일 것이다.Store
인스턴스를 아규먼트로 쓰기 때문Customer.purchase()
의 호출결과로 상점 제품 수량이 감소할 가능성이 있기 때문🍅 tips
테스트 대상 메소드(MUT, Method under test)?
테스트 대상 메소드(MUT)는 테스트에서 호출한 SUT의 메소드를 의미한다. MUT는 흔히 메소드를, SUT는 클래스 전체를 가리킨다.
이어서 고전파의 스타일대로 짠 코드를 설명한다.
Store
클래스)를 대체하지 않는다. 운영용 인스턴스를 사용한다.
Customer
, Store
둘 다 검증한다.Customer
가 정상작동 해도 Store
안에 버그가 있으면 테스트는 실패한다 → 테스트가 서로 격리되어있지 않다런던파의 접근방식을 따라가보자. 동일한 테스트에서 Store
인스턴스를 테스트 더블(구체적으로는 목으로)로 교체한다. (상세한 내용은 5장으로)
목(Mock)
SUT와 협력자 간의 상호작용을 검사할 수 있는 특별한 테스트 더블이다.
목은 테스트 더블의 부분집합이다. 테스트 더블에는 많은 접근방법이 있다. 다시말해 아래와 같다
런던파의 접근방식으로 테스트를 짜면 아래와 같다:
#
# test\test_02_london_school_way.py 의 일부분
#
def test_purchase_succeeds_when_enough_inventory(mocker):
# Arrange
mock_store = mocker.MagicMock(spec=Store)
mock_product = mocker.MagicMock(spec=Product)
mock_store.has_enough_inventory.return_value = True
customer = Customer()
# Act
success = customer.purchase(mock_store, mock_product)
# Assert
assert success is True
mock_store.sell.assert_called_once_with(mock_product)
def test_purchase_fails_when_not_enough_money(mocker):
# Arrange
mock_store = mocker.MagicMock(spec=Store)
mock_product = mocker.MagicMock(spec=Product)
mock_store.has_enough_inventory.return_value = False
customer = Customer()
# Act
success = customer.purchase(mock_store, mock_product)
# Assert
assert success is False
mock_store.sell.assert_not_called()
테스트는 아래 명령으로 구동한다:
pytest test\test_01_london_school_way.py -v
======================================================= test session starts ========================================================
platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 -- C:\REDACTED\python.exe
cachedir: .pytest_cache
rootdir: C:\unit_testing\pt1\ch02
plugins: cov-4.1.0, mock-3.11.1
collected 2 items
test/test_01_london_school_way.py::test_purchase_succeeds_when_enough_inventory PASSED [ 50%]
test/test_01_london_school_way.py::test_purchase_fails_when_not_enough_money PASSED [100%]
======================================================== 2 passed in 0.02s =========================================================
어떤 식으로 다른지 살펴보자:
has_enough_inventory
메소드 호출에 어떻게 응답할지 목에 직접 정의한다test_purchase_succeeds_when_enough_inventory
테스트에서는 Store
의 실제 상태와 관련없이 True
를 리턴하도록 가정한다Customer
객체가 호출하였는지 확인하기 위해, Store
내의 특정 메소드가 호출되었는지(assert_called_once_with
) 확인한다
test_purchase_succeeds_when_enough_inventory
테스트에서는 한 번만 호출했는지 살펴본다test_purchase_fails_when_not_enough_money
테스트에서는 한 번도 호출되지 않았음 을 살펴본다런던 스타일은 테스트 더블(여기서는 목)으로 테스트 대상 조각을 분리해서 격리 요구사항에 다가간다. 이 관점은 각 분파의 코드 조각(단위)에 대한 견해를 보여주기도 한다.
단위 테스트의 속성을 다시 살펴보자:
그렇다면,
격리 특성을 해석하는 또 하나의 방법을 고전파의 방식으로 살펴보자:
공유 의존성, 비공개 의존성, 프로세스 외부 의존성?
공유 의존성
- 테스트 간에 서로 공유되고 서로의 결과에 영향을 미칠 수 있는 수단을 제공하는 의존성
- E.g., 정적 가변 필드(static mutable field), 데이터베이스
비공개 의존성
- 공유하지 않는 의존성
프로세스 외부 의존성
- 애플리케이션 실행 프로세스 외부에서 실행되는 의존성
- 아직 메모리에 없는 데이터에 대한 프록시
- 프로세스 외부 의존성은 “대부분” 공유 의존성에 해당된다.
- E.g., 데이터베이스(프로세스 외부 의존성 이자 공유 의존성)
- 도커 컨테이너로 DB를 실행시키면 테스트가 더 이상 동일 인스턴스로 작동하지 않는다.
격리 문제에 대한 견해는 테스트 더블 사용(목 포함) 그 이상의 견해가 뒤따른다.
공유 의존성, 휘발성 의존성에 대해
휘발성 의존성은 다음 속성 중 하나를 나타내는 의존성이다. 1. - 개발자 머신에 기본 설치된 환경 외에 런타임 환경의 설정, 구성을 요구함 - 추가설정이 필요하며, 시스템에 기본적으로 없음 - E.g., DB 혹은 API 서비스 2. - 비결정적 동작(non-deterministic behavior)을 포함함 (때에 따라 다른 결과가 나오기 때문) - E.g., 난수 생성기, “현재” 날짜 및 시간을 리턴하는 클래스
휘발성 의존성은 공유 의존성과 겹치는 부분이 있다. 아래 사항들의 의존성을 예시로 살펴보자:
테스트 대상 공유 의존성? 휘발성 의존성? 비고 데이터베이스 O O . 파일 시스템 O X 모든 개발자 머신에 설치되고, 대부분 결정적으로 작동함 난수 생성기 X O 각 테스트에 별도의 인스턴스를 제공할 수 있음
공유 의존성을 대체하는 다른 이유: 테스트 실행속도 향상
두 분파는 격리 특성의 견해 차이로 인해 나누어졌다.
종합하자면, 아래 세 가지 주요 주제에 대해 의견 차이가 있다.
표로 정리해보자:
\ | 격리 주체 | 단위의 크기 | 테스트 더블 사용대상 |
---|---|---|---|
런던파 | 단위 | 단일 클래스 | 불변 의존성 외의 모든 의존성 |
고전파 | 단위 테스트 | 단일 클래스 또는 클래스 세트 | 공유 의존성 |
테스트 더블은 어디서든 쓰지만, 런던파는 테스트에서 일부 의존성을 그대로 쓸 수 있도록 하고있다. 불변객체(immutable objects)는 굳이 바꾸지 않아도 된다. 런던 스타일의 코드로 살펴보자.
#
# test\test_02_london_school_way.py 의 일부분
#
def test_purchase_succeeds_when_enough_inventory(mocker):
# Arrange
mock_store = mocker.MagicMock(spec=Store)
mock_product = mocker.MagicMock(spec=Product("Shampoo", 5))
mock_store.has_enough_inventory.return_value = True
customer = Customer()
# Act
success = customer.purchase(mock_store, mock_product)
# Assert
assert success is True
mock_store.sell.assert_called_once_with(mock_product)
Customer
의 두 가지 의존성 중, Store
만 시간에 따라 변할 수 있는 내부 상태를 포함하고 있고, Product
객체는 이뮤터블(여기서는 namedtuple
)이다. 그래서 여기서는 Store 인스턴스만 바꿔주고, Product
값은 VO(Value objects)로써 그대로 사용한 것이다.
아래 그림은 의존성에 대한 종류를 나타내고, 동시에 단위테스트의 두 분파가 의존성을 어떤식으로 처리하는지 보여준다.
Store
인스턴스: 변경 가능한 비공개 의존성Product
인스턴스: 불변인 비공개 의존성(VO의 예시)협력자(Collaborator)와 의존성
협력자
- 공유하거나 변경 가능한 의존성이다
- E.g.,
- 데이터베이스 접근 권한을 제공하는 클래스
Store
객체
모든 프로세스 외부 의존성이 공유 의존성의 범주에 속하지 않는다.
상기 사항에 대한 예시를 살펴보자.
고전파와 런던파의 차이는 단위테스트에서의 격리 문제를 처리하는 방안으로 갈린다. 이는 테스트 단위 처리와 의존성 취급에 대한 방법이라 말할 수 있다.
저자는 목을 사용하는 테스트는 고전적 테스트보다 불안정한 경향이 있다 라는 표현을 사용했다. (5장에서 다시 볼 것) 런던파의 장점을 살펴보자.
저자는 상기 세 장점이 가지는 맹점을 비판한다.
런던파는 클래스를 단위로 간주하고, 이로 인해 클래스를 테스트에서 검증할 최소한의 단위로 취급한다.
다만 이러한 관점은 코드의 입자성에 집중하게 되어, 문제 영역에 의미가 있는 것을 검증하는 것을 멀리할 수도 있다.
🍅 tips
테스트는 코드의 단위를 검증하는 것이 아니라, 동작의 단위(문제영역)를 검증해야 한다.
비즈니스 담당자가 유용하다고 인식할 수 있는 것을 검증해야 한다.
동작의 단위는 아주 작은 메소드가 될 수도 있고, 한 클래스에만 있을 수도 있고, 심지어는 여러 클래스에 걸쳐있을 수도 있다.
따라서 식별할 수 있는 동작의 주제를 살피고, 내부적인 세부 구현과 어떻게 구별짓는지 살펴본다(5장에서).
런던파는 실제 협력자 대신 목을 사용하고, 의존성 그래프가 복잡할 때 테스트 더블을 써서 전체 복잡한 객체 그래프에 대해 잘 대체하고 테스트할 수 있다고 한다.
다만 저자는 클래스 그래프가 커진 것을 설계 문제의 결과로 판단한다. 클래스 그래프를 작게 가지도록 하는 것을 주문한다.
따라서 코드 조각을 테스트 더블 없이 면밀하게 설계할 수 있도록 기본적인 코드 설계를 풀어보는 과정을 살펴본다(2부에서).
런던파 철학을 따르는 테스트 위의 시스템에 버그가 생기면, SUT에 버그가 포함된 테스트만 실패한다. 고전적인 방식을 통해, 테스트할 때는 원하지 않았던 파급효과까지 테스트 할 수 있음을 시사한다.
다만 저자는 다른 관점으로 본다. 테스트를 정기적으로 수행하여 마지막으로 바꾼 코드 변화가 어떤 테스트 실패를 초래하였는지 볼 수 있다고 한다.
그리고 테스트 스위트 전체에 걸쳐 계단식으로 실패하는 것 또한 주목할 만한 지표로 판단한다. 고장낸 코드 조각(코드베이스의 본 로직)이 큰 가치가 있는 코드임을 알 수 있다. 이는 시스템이 그 코드에 의존한다는 것을 의미한다.
고전파와 런던파 사이에는 아래 차이점이 더 존재한다
🍅 tips
TDD(Test-driven Development)란?
테스트에 의존하여 프로젝트 개발을 추진하는 소프트웨어 개발 프로세스. 아래 세 단계로 구성되며, 각 테스트케이스마다 이를 반복한다:
- 추가할 기능과 작동에 대한 테스트 코드 작성 → 테스트 구동 시 빨간막대 생성
- 테스트를 통과하는 코드베이스를 작성 → 테스트 구동 시 초록막대로 변함
- 코드 리팩토링 → 테스트 구동 시 초록막대를 유지하도록 리팩토링
런던파는 실제 협력자 객체를 사용하는 모든 테스트를 통합 테스트로 간주한다.
저자는 본 책에서 단위 테스트와 통합 테스트의 고전적 정의를 사용한다. 고전파의 관점에서 단위 테스트는 아래와 같다:
통합 테스트는 이런 기준 중 하나를 충족하지 않는 테스트다
E.g., DB 접근 테스트 → 다른 테스트와 분리하여 실행할 수 없다
E.g., 프로세스 외부 의존성이 있는 테스트 → 테스트가 느려진다
E.g., 둘 이상의 동작 범위를 검증할 때의 테스트 → 통합 테스트로 간주된다
E.g., 다른 모듈 이상을 둘 이상 검증할 때의 테스트 → 통합 테스트로 간주된다
종합하면, 통합 테스트는 소프트웨어 품질향상에 기여하는 주요 요소 중 하나다. (3부에서 다시 살펴볼 예정)