테스트를 하는 과정 중에는 우리가 작성한 코드 뿐만 아니라 외부 서비스들과의 연결도 하게 된다. 이러한 외부 서비스에는 필연적으로 부작용이 존재한다.
모의 객체는 원하지 않는 부작용으로부터 테스트 코드를 보호하는 가장 좋은 방법 중 하나이다. 단위 테스트는 빠르게 실행되어야 하기 때문에 이러한 대기 시간을 감당할 수 없다. 따라서 단위 테스트에는 이러한 외부 서비스를 호출하지 않는다. 단위 테스트에서는 이것들이 호출되는지만 확인하면 된다.
모의 객체는 유용하지만 남용하여 안티패턴을 만들지 않도록 유의해야 한다.
패치와 모의에 대한 주의사항
단위 테스트는 보다 나은 코드를 작성하는데 도움이 된다. 특정 코드를 테스트하려면 테스트가 가능하도록 짜야 하는데, 이는 코드가 응집력이 뛰어나고 세분화 되어 있으며 작다는 것을 의미한다. 이는 코드가 응집력이 뛰어나고, 세분화 되어 있으며, 작다는 것을 의미한다. 이는 소프트웨어 컴포넌트에 있어서도 모두 좋은 특징들이다.
또한 테스트를 통해 문제가 없다고 생각하던 부분에서 나쁜 부분을 찾아낼 수 있다.
unittest 모듈은 객체를 패치하기 위한 도구를 제공한다. 패치란 임포트 중에 경로를 지정했던 원본 코드를 다른 것으로 대체하는 것을 말한다. 이렇게 하면 런타임 중에 코드가 바뀌어 테스트가 어려워지는 단점이 있다. 런타임 시 인터프리터에서 객체를 수정하는 오버헤드도 있으므로 성능상 이슈도 있다.
몽키패치, 모의 객체를 사용하는 것 자체가 문제가 되지는 않지만 몽키 패치를 남용한다면 원본 코드를 개선할 여지가 있다는 신호이다.
Mock 객체 사용하기
단위 테스트에서 말하는 테스트 더블의 카테고리에 속하는 타입에는 여러 객체가 있다. 테스트 더블은 여러가지 이유로 테스트 스위트에서 실제 코드를 대신해 실제인 것처럼 동작하는 코드를 말한다. 실제 상용 코드의 불필요, 권한 부재, 부작용 등의 이유로 사용된다.
테스트 더블에는 더미(dummy), 스텁(stub), 스파이(spy), 모의 객체(mock)와 같은 다양한 타입의 객체가 있다. 모의 객체는 가장 일반적 유형의 객체이며 융통성이 있고 다양한 기능을 가지고 있기 때문에 모든 경우에 적합하다.
모의 객체(mock)는 스펙을 따르는 객체 타입으로 응답 값을 수정할 수 있다. 즉 모의 객체 호출 시 응답해야 하는 값이나 행동을 특정할 수 있다. 모의 객체는 내부에 호출 방법을 기록하고 나중에 이 정보를 사용하여 애플리케이션의 동작을 검증한다.
Mock 객체의 종류
파이썬 표준 라이브러리는 unittest.mock 모듈에서 Mock과 MagicMock 객체를 제공한다. Mock은 모든 값을 반환하도록 설정할 수 있는 테스트 더블이며 모든 호출을 추적한다. MagicMock 은 Mock의 기능을 모두 가지고 매직 메서드 또한 지원한다. Mock 객체에서 매직 메서드를 사용하려고 하면 에러가 발생한다.
테스트 더블의 사용 예
from datetime import datetime
import requests
from constants import STATUS_ENDPOINT
class BuildStatus:
"""CI 도구에서의 머지 리퀘스트 상태"""
@staticmethod
def build_date() -> str:
return datetime.utcnow().isoformat()
@classmethod
def notify(cls, merge_request_id, status):
build_status = {
"id": merge_request_id,
"status": status,
"built_at": cls.build_date(),
}
response = requests.post(STATUS_ENDPOINT, json=build_status)
response.raise_for_status() # 200이 아닐 경우 예외 발생
return response이 클래스의 부작용중 하나는 외부 모듈에 의존성이 너무 크다는 것이다.
지금 테스트 하려는 것은 정보가 적절하게 구성되어 API에 잘 전달되었는지 여부이다. 따라서 실제로 API를 호출할 필요는 없고 API가 잘 호출 되는지만 확인하면 된다. 또 다른 문제는 API에 전달되는 값 중 시간 값이 있는데, 이 값은 실시간으로 변하는 값이므로 정확히 예측을 할 수가 없다는 점이다.
datetime 모듈은 C로 작성되었으므로 직접 패치할 수는 없다. 따라서 여기에서는 직접 패치할 수 있는 build_date 정적 메서드를 래핑한다.
from unittest import mock
from constants import STATUS_ENDPOINT
from mock_2 import BuildStatus
@mock.patch("mock_2.requests")
def test_build_notification_sent(mock_requests):
build_date = "2018-01-01T00:00:01"
with mock.patch("mock_2.BuildStatus.build_date", return_value=build_date):
BuildStatus.notify(123, "OK")
expected_payload = {"id": 123, "status": "OK", "built_at": build_date}
mock_requests.post.assert_called_with(STATUS_ENDPOINT, json=expected_payload)@mock.patch를 데코레이터를 사용하여 테스트 안에서 mock_2.request를 호출하면 mock_requests라는 객체가 대신할 것이라고 알려준다. 그리고 mock.patch 함수를 컨텍스트 매니저로 사용하여 build_date() 메서드 호출 시 어설션에 사용할 build_date를 반환하도록 패치한다.
이는 mock_2.requests.post에 날짜가 포함된 데이터가 파라미터로 전달될 경우 HTTP 상태가 STATUS_ENDPOINT이 될 것이라는 지정을 한 셈이다.
따라서 mock_request.post 에 동일한 파라미터를 사용해 호출하면 assert_called_with는 성공하게 된다.
Mock 사용예제
# user_manager.py
import requests
def get_user(id):
response = requests.get(f"https://jsonplaceholder.typicode.com/users/{id}")
if response.status_code != 200:
raise Exception("Failed to get a user.")
return response.json()
def create_user(user):
response = requests.post(f"https://jsonplaceholder.typicode.com/users", data=user)
if response.status_code != 201:
raise Exception("Failed to create a user.")
return response.json()get_user 테스트
from unittest import TestCase
from unittest.mock import patch
import user_manager
class TestUserManager(TestCase):
@patch("requests.get")
def test_get_user(self, mock_get):
res = mock_get.return_value
res.status_code = 200
res.json.return_value = {
"name": "Test User",
"email": "user@test.com",
}
# 내부에서 requests.get()을 호출한다.
user = user_manager.get_user(1)
self.assertEqual(user["name"], "Test User")
self.assertEqual(user["email"], "user@test.com")
# mock_get()이 정확한 파라미터로 호출되었는지 확인한다.
mock_get.assert_called_once_with("<https://jsonplaceholder.typicode.com/users/1>")create_user 테스트
from unittest import TestCase
from unittest.mock import patch
import user_manager
class TestUserManager(TestCase):
@patch("requests.post")
def test_create_user(self, mock_post):
res = mock_post.return_value
res.status_code = 201
res.json.return_value = {"id": 99}
user = user_manager.create_user(
{"name": "Test User", "email": "user@test.com",}
)
self.assertEqual(user["id"], 99)
mock_post.assert_called_once_with(
"<https://jsonplaceholder.typicode.com/users>",
data={"name": "Test User", "email": "user@test.com",},
)