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

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

1부의 긴 이야기의 마지막에서
우리는 requests.get 메서드를 패치하여'blah blah'라는 스트링을 반환하게 하도록 했다.

그러나 우리는,

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

Response 반환하기

1번은 그리 어렵지 않다.
Response 객체를 만들어주면 된다! 어떻게?

Custom Response

class Response:
    def __init__(self, **kwargs):
        self.status_code = kwargs['status']
        self.data = kwargs['data']

    def json(self):
        return self.data

res_data = {
    'message': 'hello, response',
}
res = Response(status=200, data=res_data)
res.status_code
> 200

res_json = res.json()
res_json['message']
> 'hello, response'

필자는 모든 Response에서 status_code, json() 어트리뷰트만 사용한다.
그래서 이 두 기능만 있는 최소한의 Custom Response 객체를 생성했다.
Custom Response를 직접 적용해보자!

# test.py

from unittest import mock
from rest_framework.test import APITestCase

class Response:
    def __init__(self, **kwargs):
        self.status_code = kwargs['status']
        self.data = kwargs['data']

    def json(self):
        return self.data

@mock.patch("hello_world.requests.get")
class TestAPIHello(APITestCase):
    def setUp(self):
    	self.user = get_user()
    	do_stuff()
       
    def testcase_00_hello_world(self, mock_get):
        mock_get.return_value = Response(
            status=200,
            data={'user': 'Lee'}
        )
    	response = self.client.get(url="/api/hello-world/")
        self.assertEqual(response.status_code, 200)

짜잔!

실제 Response로 반환하기

만약 requests모듈의 Response를 반환해주고 싶다면 아래처럼 하면 된다.

# test.py

import requests
from unittest import mock
from rest_framework.test import APITestCase

@mock.patch("hello_world.requests.get")
class TestAPIHello(APITestCase):
    def setUp(self):
    	self.user = get_user()
    	do_stuff()
       
    def testcase_00_hello_world(self, mock_get):
        my_response = requests.models.Response()
        response.status_code = 200
        response_content = json.dumps({'user': 'Lee'})
        response._content = str.encode(response_content)
        
        mock_get.return_value = my_response
    	response = self.client.get(url="/api/hello-world/")
        self.assertEqual(response.status_code, 200)

testcase_00_hello_world 메서드 안에서 실제 Response 모델을 사용했다.
이 방식이 아쉬운 점은, _content라는 protected attribute를 직접 접근해야한다는 것이다.


다양한 Response 반환

근데 우리는 모든 requests.get() 메서드가 똑같은 Response를 보내줄 거라고 기대하지 않는다.
당연히 다양한 분기도 있고 예외처리도 많이 있을 터인데,
우리 테스트 코드에서 이 분기들과 예외처리 부분들을 모두 커버하려면?

상황에 따라 다양한 Response가 발생해야 한다!
이럴 때 쓰라고 있는 기능이 또 있지요.

side_effect

Mock의 side_effect는 예외를 mock 인스턴스에서 예외를 발생시키게 하거나
mock 인스턴스에 우리가 만든 함수를 적용시킬 수 있다.
공식문서side_effect를 사용하는 다양한 방법을 설명한다.
아래 간단한 예시 코드가 있다.

from unittest import mock

def hello_world(name):
    return f'Hello, world and {name}'

m = mock.Mock()
m.side_effect = hello_world
m('Lee !!')
> Hello, world and Lee !!

가즈아!!

예시 코드

다양한 url

테스트 되는 코드와 테스트 코드를 잘 보자.

# hello_world.py

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

class HelloView(APIView):
    def get(self, request):
        res1 = requests.get(url='https://some.com/api/user-info/')
        
        # Send one more request !!
        res2 = requests.get(url='https://some.com/api/org-info/')
        
        if res1.status_code == res2.status_code == 200:
            res1_json = res1.json()
            res2_json = res2.json()
            data = {
                "message": "Hello, World!",
                "user": res1_json["user"],
                "org": rest2_json["org"],
            }
            return Response(data, status=200)
        
        data = {
            "message": "Error!",
            "user": "No user",
        }
        return Response(data, status=503)

두 url에 request를 보내게 된다.


# test.py

import requests
from unittest import mock
from rest_framework.test import APITestCase

def mock_request_get(**kwargs):
    response = requests.models.Response()
    response.status_code = 200
    url = kwargs.get('url')
    
    if url == 'https://some.com/api/user-info/':
        response_content = json.dumps({'user': 'Lee'})
        response._content = str.encode(response_content)
        
    elif url == 'https://some.com/api/org-info/':
        response_content = json.dumps({'org': 'Avengers'})
        response._content = str.encode(response_content)
        
    else:
        response.status_code = 400
    return response

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

예외 발생

외부 api 서버에 request를 보냈을 때 에러가 발생하는 경우를 상정하면 아래와 같은 코드도 가능하다.

# hello_world.py

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

class HelloView(APIView):
    def get(self, request):
        try:
            res1 = requests.get(url='https://some.com/api/user-info/')
            res2 = requests.get(url='https://some.com/api/org-info/')
           
        # Return response with status code 500
        # if OSError raises.
        except OSError:
            return Response(status=500)
            
        if res1.status_code == res2.status_code == 200:
            res1_json = res1.json()
            res2_json = res2.json()
            data = {
                "message": "Hello, World!",
                "user": res1_json["user"],
                "org": rest2_json["org"],
            }
            return Response(data, status=200)
        
        data = {
            "message": "Error!",
            "user": "No user",
        }
        return Response(data, status=503)

hello_world.py에서 requests를 쓰는 부분에 에러 핸들링하는 코드가 추가되었다.


# test.py

import requests
from unittest import mock
from rest_framework.test import APITestCase

def mock_request_get(**kwargs):
    response = requests.models.Response()
    response.status_code = 200
    url = kwargs.get('url')
    
    if url == 'https://some.com/api/user-info/':
        response_content = json.dumps({'user': 'Lee'})
        response._content = str.encode(response_content)
        
    elif url == 'https://some.com/api/org-info/':
        response_content = json.dumps({'org': 'Avengers'})
        response._content = str.encode(response_content)
        
    else:
        response.status_code = 400
    return response

@mock.patch("hello_world.requests.get")
class TestAPIHello(APITestCase):
    def setUp(self):
    	self.user = get_user()
    	do_stuff()
       
    def testcase_00_hello_world(self, mock_get):      
        mock_get.side_effect = mock_request_get
    	response = self.client.get(url="/api/hello-world/")
        self.assertEqual(response.status_code, 200)
    
    def testcase_01_external_api_error(self, mock_get):
        mock_get.side_effect = OSError()
        response = self.client.get(url="/api/hello-world/")
        self.assertEqual(response.status_code, 500)

testcase_01_external_api_error 테스트 메서드가 추가되었다.
여기서는 side_effect에 함수를 할당하지 않고 예외를 직접 할당했다.


마치며

처음으로 길게 써본 글이다. 진이 빠진다.. 원래 이렇게 길게 쓰려고 하진 않았는데, 어쩌다보니 주절주절 길게 쓰게 되었다.
원래는 내가 겪은 과정을 쓰려던 글이었는데 거진 코드 위주의 글이 되어버렸다.. 주륵

사실 mock을 적용하면서 고민이 많았다.

모든 test method에서 각자 side_effect에 할당할 함수를 만들어야 하나?

하나의 API 모듈 안에서 requests를 쓰는 모든 부분은 파일을 나눠버려서 side_effect에 할당할 함수를 간단하게 만들어줘야 할까?

이런 고민을 한 이유는, 내가 만든 API 모듈이 요청 하나를 처리하는 동안 적어도 두세번의 요청을 외부 API 서버에 보내기 때문이다...
그래서 side_effect 함수를 보면 url에 따른 다양한 처리를 위한 분기문 지옥이 형성되어있다 ㅠㅠ

side effect에 직접 예외 발생 코드를 넣을까 아니면 내가 만든 함수 안에서 분기에 따라 예외를 발생케 할까?

나름대로 최선의 방법으로 테스트 코드에 mock을 적용했는데, 아직 만족스럽지 못하다. 테스트 코드가 깔끔하지 않아서일까?
아니면 내가 만든 API가 애초에 별로라서 일까?

여러 고민을 뒤로하고 일단 글로 남겨보자, 해서 이렇게 쓰게 되었다.
이 글을 보는 다른 이들에게 조금이라도 도움이 되었다면 만족스러워지리라..

이만!

profile
개발괴발자
post-custom-banner

0개의 댓글