[Python3] mock과의 한판 승부 - 1

SangHun·2021년 5월 2일
1
post-thumbnail
post-custom-banner

회사에서 개발중이던 API에 엔드 포인트를 추가했다.
기능을 하나 더 넣었다는 소리다.

즐겁게 PR을 날리고 리뷰를 요청했다.

5분도 안되서 리뷰가 달렸다.

Code coverage가 너무 낮아요! 테스트 코드 작성해주세요~

codecov는 테스트 코드가 전체 코드를 얼마나 cover해주는지 측정해주는 툴이다. Github과 연동하면 이렇게 PR마다 coverage를 체크할 수 있다. 위는 통과! coverage가 감소하는 commit에 대해서는 fail이 뜬다...😭

이 coverage를 높히기 위해! 테스트 코드를 작성했다.
...사실 지금까지 API를 쪼물딱대면서 테스트 코드를 안 만져본 스스로에게 부끄러움을 느꼈었다.

룰루랄라 기존 테스트 코드들을 베껴가며 복붙을 하다가 난관에 부딪혔다.

먼저 우리의 테스트 코드에 대해 간략히 설명하자면...
우리의 테스트 코드는 Django rest-framework의 test모듈에서 APITestcase를 사용한다.
아래는 예시 코드.

# test.py

from rest_framework.test import APITestCase

class TestAPIHello(APITestCase):
    def setUp(self):
    	self.user = get_user()
    	do_stuff()
       
    def testcase_00_hello_world(self):
    	response = self.client.get(url="/api/hello-world/")
        self.assertEqual(response.status_code, 200)

애증의 rest_framework...

대략 이렇게 생겼다. 나름 심플하고 직관적이다. request를 일일이 보내주고 response를 확인하는 방식.
테스트를 실행하면 rest_framework.test 모듈이 테스트용 DB를 생성해주고 테스트가 끝나면 이 DB는 사라지는 방식이다. 이거 껌이네!
...

문제점 발견, 그리고 Mock

우리의 API 서버가 우리의 DB에서만 정보를 읽고 반환해준다면 껌이다.
우리의 API 서버가 요청을 처리하다가 중간에 외부 API 서버에 요청을 보내거나, 외부 모듈을 통해 통신한다면 말이 달라진다. 아아..

선배 개발자에게 질문했다. 어찌합니까?
그러자 외부 서버와 모듈을 mock하거라 대답할지니.

Mock! (가짜의, 모의의)라는 뜻이다. 혹은 (흉내낸다)는 뜻이다.
공식 문서를 참고하셔도 좋다!

목적은?

  1. method 패치하기
  2. 객체에 대한 호출 기록하기

공식문서에는 이렇게 적혀있다. 나는 1번 문구가 이해되지 않는다. 클래스 패치도 하는데 왜 method라고 명시했는지... 아무튼! 2번의 목적으로는 사용해본적 없으니 넘어가겠습니다 ㅎㅎ~

예시 코드

from unittest import mock

m = mock.Mock()
m.return_value = 'I am mock.'
m()
> 'I am mock.'

m.hello_mock.return_value = 'hello mock!'
m.hello_mock()
> 'hello mock!'

아주 간단한 형태의 mock 예시이다.
이런 mock을 객체의 method에 패치할 수도 있고, 클래스에 패치할 수도 있다.

두 예시를 한 번에 보자!

from unittest import mock

class Production:
    def hello_world(self):
        return 'hello world!'

    def introduce(self):
        return 'I am Production.'

real_prod = Production()
real_prod.hello_world()
> 'hello world!'
real_prod.introduce()
> 'I am Production.'

# Mock method
mock_method = mock.Mock()
mock_method.return_value = 'hello, MOCK method!'
real_prod.hello_world = mock_method
real_prod.hello_world()
> 'hello, MOCK method!'

# Mock instance
mock_instance = mock.Mock()
mock_instance.introduce.return_value = 'I am Mock instance.'
real_prod = mock_instance
real_prod.introduce()
> 'I am Mock instance.'

나름 직관적으로 코드를 짜보려고 했다. 이해가 잘되시는지용??
공식 문서를 보면 다양한 방식으로 mock을 다루는데 사실 뭔 소린지 이해하기 어려워서 일관된 방식으로 먼저 예시 코드를 짜봤다.

이 정도로 이해하고 그대로 테스트 코드로 돌격!
했으나 바로 벽에 막혔다...

Mock 적용하기

우리 애증의 rest_framework 예시 테스트 코드를 먼저 보자.

# test.py

from rest_framework.test import APITestCase

class TestAPIHello(APITestCase):
    def setUp(self):
    	self.user = get_user()
    	do_stuff()
       
    def testcase_00_hello_world(self):
    	response = self.client.get(url="/api/hello-world/")
        self.assertEqual(response.status_code, 200)

그리고 아래는 위 테스트 코드로 테스트되는 예시 코드이다.

# hello_world.py

import requests
from rest_framework.views import APIView
from rest_framework.response import Response

class HelloView(APIView):
    def get(self, request):
        res = requests.get(url='https://some.com/api/user-info/')
        res_json = res.json()
        if res_json.status_code == 200:
            data = {
                "message": "Hello, World!",
                "user": res_json['user'],
            }
            return Response(data, status=200)
            
        data = {
            "message": "Error!",
            "user": "No user",
        }
        return Response(data, status=503)

개행 맞추기 정말 어렵네요. 아직 velog에 서툴러요...

자, 우리의 API는 https://some.com/ 이라는 외부 API 서버에 request를 보낸다.
테스트할 때만 외부에 요청을 보내는 부분을 mock으로 대체하고 싶다. 어떻게 해야할까??

hello_world.py 안에서 mock 인스턴스를 생성한 뒤 테스트 환경 일때만 패치 해줄까?
아니면 테스트 코드에서만 requests 모듈 자체를 mock 인스턴스로 패치 해줄까?

필자가 한 방식은 후자에 가깝다. 테스트 코드가 실제 코드 중간에 끼어들어간다면 더이상 단위 테스트라고 할 수 없어질 것이다.

그럼 테스트 코드에서 어떻게 requests 모듈을 패치할까?
여기서 많이들 헷갈리는 부분이 있다.
requests 모듈의 절대 경로로 가서 패치하는 방법을 떠올릴 수 있지만, 그렇게 하면 mock이 패치되지 않는다.
requests 모듈이 실제로 사용되는 곳에서 패치를 시켜줘야 한다.
무슨 말인지 모르시겠다구요?

이렇게 사용합니다

mock.patch 데코레이터를 사용한 예시!

# test.py

from unittest import mock
from rest_framework.test import APITestCase


@mock.patch("hello_world.requests.get") # HERE !!
class TestAPIHello(APITestCase):
    def setUp(self):
    	self.user = get_user()
    	do_stuff()
       
    def testcase_00_hello_world(self, mock_get):
        mock_get.return_value = "blah blah"
    	response = self.client.get(url="/api/hello-world/")
        self.assertEqual(response.status_code, 200)

mock.patch 데코레이터를 테스트 class에 적용하면 클래스 아래에 있는 모든 테스트 method에 mock 인스턴스를 인자로 넘겨준다.
아래처럼 테스트 method에만 적용할 수도 있다.

# test.py

from unittest import mock
from rest_framework.test import APITestCase

class TestAPIHello(APITestCase):
    def setUp(self):
    	self.user = get_user()
    	do_stuff()
       
    @mock.patch("hello_world.requests.get") # HERE !!
    def testcase_00_hello_world(self, mock_get):
        mock_get.return_value = "blah blah"
    	response = self.client.get(url="/api/hello-world/")
        self.assertEqual(response.status_code, 200)

mock.patch 데코레이터 부분을 보자.
requests 모듈의 절대 경로가 아니라 테스트되는 코드에서의 requests를 패치 한다.

그리고 testcase_00 어쩌구 메서드에 mock_get 가 있다!
위처럼 데코레이터를 사용하면 테스트 메서드에 mock 인스턴스를 인자로 넘겨준다.
요약하자면,

testcase_00 어쩌구 메서드안에서 설정된 mock_get 인스턴스는 testcase_00 어쩌구 메서드가 실행될 때 hello_world.pyrequests.get 메서드에 패치된다!

위 예시에서는 mock_get의 반환값으로 blah blah가 들어갔으므로 hello_world.py가 테스트 될 때는

class HelloView(APIView):
    def get(self, request):
        res = requests.get(url='https://some.com/api/user-info/')

res == 'blah blah'
가 된다.
근데 우리는 ...

  1. Response 객체를 반환 받고 싶다!
  2. getposturl이나 header에 따라 다양한 Response를 반환하게 하고 싶다!

그럼 어떻게 할까?

2부에 계속...!!!

profile
개발괴발자
post-custom-banner

0개의 댓글