테스트 코드를 짜는 일은 정말로 어려운 일이다. 좋은 테스트 코드는 독립적이어야 한다. 그러나 우리가 작성한 테스트 코드들은 항상 외부 로직들로부터 위협받는다.
예를 들자면 “상품 주문 테스트 코드”는 “인증실패”, “할인실패”, “결제실패”, 와 같은 위협을 받는다.
이를 해결하려고 당신은 상품 주문 테스트 코드를 작성하다가
“뜬금없이”
인증 실패를 막기 위해 “인증 로직과 권한을 테스트 코드에 넣고”,
할인 실패를 막으려고 할인 로직을 테스트코드에 욱여 넣고는
외부 API인 결제 실패는 막을방법이 없어서 Pray Driven Development(기도 주도 개발)을 시전하고
“어쩔 수 없는 일이야”라는 정신 승리와 함께 그 테스트코드를 커밋 할 것이다.
이렇게 테스트 코드를 작성한다면 가까운 미래에 다른 사람이 인증로직을 수정하고 Full Test를 돌렸는데 뜬금없이 상품 주문 테스트가 실패해버릴 것이다.
그 사람은 매우 당황하면서 당신이 뜬금없이 인증실패를 막기 위해 인증 로직을 작성하고 할인실패를 막으려고 할인로직을 욱여넣은 것처럼 그 사람은 “뜬금없이” 인증로직을 작성하다가 상품 주문 테스트케이스를 수정하고 있을 것이다.
할인 로직에서도 그런일이 또 다시 벌어진다면 동료개발자의 맨탈과 본인의 안전을 보장할 수 없다.
Mock객체 테스트케이스를 독립적으로
1. Basic Mocking 예제
아래 예제에 사용된 프로젝트 패키지구조, Mocking된 객체의 패키지 경로에 주목해야한다
@patch('shopping_mall.api.request_weather_api', return_value={ 'temperature': 23.3, 'wind': 3, 'is_rain': 'false', } ) def test_function_mocking(self, request_weather_api_mock): result = api.check_shopping_mall_weather()
self.assertEqual(result, { 'temperature': 23.3, 'wind': 3, 'is_rain': 'false', } )
주의: Mocking하고자 하는 대상이 사용되는 곳을 경로로 잡아야한다
흔히 하는 실수이다. 2번으로 하면 안된다
1. @patch('shopping_mall.api.request_weather_api')
2. @patch('shopping_mall.utils.request_weather_api')
2.번 경로는 실제 request_weather_api 함수가 위치하는 경로이다.
많은 사람들이 mocking 하고자하는 대상이 실제 위치한 곳을 경로로 주는 실수를 범한다.
1번이 맞는 경로이다.
2번처럼 주면 테스트케이스에서 mocking이 되지않는다.
# 경로 : shopping_mall.api.py # 해당 예제 보러가기
# 이녀석이 mocking 타겟 from shopping_mall.utils import request_weather_api def check_shopping_mall_weather(): print('외부 날씨 api 를 요청하는 로직이 들어있다.') response = request_weather_api(date=datetime.now()) return response
2. Detail Mocking 예제 Mocking 속의 Mocking
2.1 requests 객체 Mocking 하기
2.1.1 Mock객체속 method [response.json()]
method 같은 경우는 아래와 같이 두번 return_value 값을 작성해줘야한다.
requests_get_mock.return_value.json.return_value = {'amount': 70000, }
2.1.2 Mock 객체 속 property [response.status_code]
requests_get_mock.return_value.status_code = HTTPStatus.OK
2.1.3 디테일한 Mocking Full snippet
# 해당 예제 보러가기 @patch('shopping_mall.payment_utils.requests.get') def test_requests_json(self, requests_get_mock):
requests_get_mock.return_value.json.return_value = {'amount': 70000, } requests_get_mock.return_value.status_code = HTTPStatus.OK order = Order() self.assertEqual(order.payment.amount, 70000)
3. @Property Decorator Mocking 예제
3.1 PropertyMock
우리가 Mocking하고자 하는 대상이 아래와 같다면 조금 난감하다
class User(object): user_id: str password: str email: str password_updated_at: datetime
# Mocking 하고싶은 대상 @property def is_expired_password_period(self): return '비밀번호 변경 아직 안해도 됩니다.' if self.password_updated_at > datetime.now() else '비번 변경해야됩니다'
이런 경우 별다른 옵션없이 Mocking하면 아래와 같은 문제가 발생한다
# is_expired_password_period 는 Mock()객체라서 객체와 문자열을 비교해버린다 # 무조건 False user.is_expired_password_period == '비번 변경해야됩니다'
아래와 같이 Mock을 확장한 PropertyMock이라는 객체를 따로 지정해줘야한다
# property Mocking @patch('shopping_mall.models.User.is_expired_password_period', new_callable=PropertyMock, return_value='목킹당함!!') def test_property_mock(self, mock_property): user = User() self.assertEqual(user.is_expired_password_period, '목킹당함!!')
(추가) 4. django model.objects Mocking
잘 없지만 드물게 QuerySet의 결과를 목킹 하고싶을때가 있다
더미 데이터의 모델이 model.ForeignKey(null=False) 이라서
주문 객체 → 상품 → 상점주인 → 회원 →…….
이런 연관관계가 전부 null=False이면
model1.objects.create() model2.objects.create(c=model1) model3.objects.create(b=model2) model4.objects.create(a=model3) …
더미데이터가 연쇄적으로 물려있어서 난감한상황에 필요할지도 모른다
query_set mocking snippet
@patch('product.models.Product.objects.get') def test_query_set_mocking(self, mock_product): cusomter = Customer() cusomter.id = 1 cusomter.name = '더미 고객' cusomter.address = '서울시 서초구...' cusomter.phone = '010-7237-1234' mock_product.return_value.name = '맥북프로 키보드' mock_product.return_value.owner = cusomter
# 이걸 대체한다. # Product.objects.create(name='맥북프로 키보드', owner=cusomter)
# create해야되는 객체나 field수가 많다면 # 이런 방식이 깔끔할수도있다 product = Product.objects.get(id=1) self.assertEqual(product.owner.name, '더미 고객') self.assertEqual(product.name, '맥북프로 키보드')
김성렬, Backend Developer @Delivery Hero Korea