본문 바로가기

web/Django_rest_framework

[Django rest framework] 5. test

이번 포스팅의 소스는 여기에 있습니다.

 

 

- python3.6, django 2.1, django-rest-framework 3.8, 을 사용합니다.

 

 

코드 테스트는 아주 중요합니다.

테스트 코드를 능숙하게 작성하고 사용하면 코드의 의도를 보다 명확히하는데 좋을 뿐 아니라, 아키텍처의 결합도를 낮출 수 있습니다.

 

 

테스트의 일반 원칙

  • 테스트 유닛은 각 기능의 가장 작은 단위에 집중한다.
  • 해당 기능이 정확히 동작하는지를 증명한다.
  • 각 테스트 유닛은 반드시 독립적이어야 한다. (다른 테스트에 영향을 끼쳐선 안된다.)

위의 원칙을 지키며, 자신의 코드를 테스트 할수 있는 테스트 코드를 만든다면, 배포 및 유지보수에 큰 도움이 됩니다.

그러면 이제부터 기본적인 테스트코드와 테스트코드에서 사용하는 mock, patch에 대해서 알아보도록 하겠습니다.

 

 

설치

$ pip3 install pytest pytest-cov pytest-django pytest-xdist

- pytest는 파이썬 표준 라이블러리인 unittest가 보일러플레이트가 없어서 만들어진 패키지 입니다. 기본적인 테스트 패키지로 생각하시면 됩니다.

- pytest-cov는 테스트 이후 해당 테스트가 전체 코드의 커버리지를 얼만큼 하는지 볼수 있는 패키지 입니다. 해당 패키지를 통해, 커버리지 통계가 파일로 만들어 지면, 여러 통계지표 어플리케이션을 통해 관리할수 있습니다. 자세한 사항은 이전 포스팅인 sonarQube 적정 코드 분석(기초편)을 봐주세요.

- pytest-django 는 django를 테스트 하기 위한 패키지 입니다. (API 호출 및 디비 트랜잭션이 들어있습니다.)

- pytest-xdist는 pytest를 병렬로 실행하는 패키지 입니다. 각 테스트 함수 단위를 병렬로 처리해줍니다.

 

 

 

간단한 테스트 함수

# test_sample.py

# -*- coding: utf-8 -*-
def func(x):
    return x + 1

def test_answer():
    assert func(3) == 5

test_sample.py를 만들고 위의 코드를 넣어줍니다. 해당 파일에서 func 함수를 테스트 할 것이며 테스트 코드는 test_answer()에 정의되어 있습니다.

 

여기에서 assert가 검사 구문이며, 해당 구문이 올바르게 체크되는지 확인합니다. func(3)을 넣었으니 리턴값은 4가 되며, 4 == 5가 되어 에러가 나옵니다. 이제 pytest를 실행해 봅니다.

 

pytest를 커맨드로 입력하면 되며, 특정 테스트 파일만 할경우 뒤에 경로만 입력해 주면됩니다. (ex: pytest ./test_sample.py)

$ pytest
E       assert 4 == 5
E        +  where 4 = func(3)

blog/tests/sample.py:6: AssertionError
========================================================= short test summary info ==========================================================
FAILED blog/tests/sample.py::test_answer - assert 4 == 5

이렇듯 어느 구분에서 에러가 났는지 친절하게 알려줍니다. 이제 해당 함수가 목적에 맞게 설계 되었는지, 예외처리가 잘되었는지 확인하기 위해서 테스트 코드를 작성하는 것입니다.

 

 

위의 테스트 코드를 수정해 보면

# -*- coding: utf-8 -*-
def func(x):
return x + 1

def test_answer():
assert func(3) == 4

 

가 되며 결과로는  아래와 같이 passed가 나오게 됩니다. 

blog/tests/test_sample.py .                                                                                                          [100%]

---------- coverage: platform darwin, python 3.6.8-final-0 -----------
Coverage XML written to file coverage.xml

============================================================ 1 passed in 0.18s =============================================================

 

 

mock

mock 클래스를 이용하면 다음과 같은 일을 할 수 있습니다.

from unittest.mock import Mock

class ProductionClass():
    def method(self):
        return 0

    def foo(self, value):
        return 2*value

def test_mock():
    thing = ProductionClass()

    assert thing.foo(2) == 4

    mock_foo = Mock(thing.foo, return_value='text')
    assert mock_foo(2) == 'text'
    assert mock_foo(100) == 'text'

 

테스트 코드를 보시면 첫번째 테스트 코드인 thing.foo(2) == 4는 True가 됩니다.

그다음 Mock()에 thing.foo 와 return_value를 설정한  mock_foo 를 테스트 하는 코드를 보시면

mock_foo(2) == 'text' / mock_foo(100) == 'text' 모두 리턴이 text가 되어 True가 되는것을 볼수 있습니다.

 

mock은 특정한 함수에 대한 응답값을 설정 할수 있습니다. 그러면 왜 만든 함수를 테스트하기 위함인데, 강제로 리턴값을 정해버린 mock을 왜 쓰는 걸까요?

 

mock은 사용자가 컨트롤 할 수 없는 외부에 의존적인 함수를 치환하는 방법으로 씁니다. 즉 시간마다 리턴값이 달라진다던지, 외부 API를 활용하여 테스트의 값이 지속적으로 달라진다던지, 혹은 꼭 테스트 할 필요가 없이 특정할 값만 배출해야만 함수라면 Mock을 통해 리턴값을 강제 합니다.  Mock을 사용함으로서 외부에 연관된 함수에 리턴값을 강제하여, 그와 연관된 다른 함수들을 테스트 하는 목적입니다.

 

 

patch

from unittest.mock import patch


class ProductionClass():
    def method(self):
        return 0

    def foo(self, value):
        return 2*value


@patch('blog.utils.ProductionClass.foo')
def test_patch(mock_foo):
    mock_foo.return_value = 'mocked return value'
    assert mock_foo() == 'mocked return value'


def test_patch_result():
    pc = ProductionClass()
    assert pc.foo(2) == 4

프로그램이 내부 프로그램을 로컬에서 잠시 다른 기능을 수행하도록 수정하거나 확장하도록 하는 방법이며 이는 로컬이므로 런타임시에만 영향을 주는 방식입니다.

 

위의 예제를 보시면 이전 mock의 함수를 test_patch()에서 patch() 데코레이터로 받아서 return_value를 재정의 한것을 볼수 있습니다.

그리고 test_patch_result()에서 test_patch()와 기존의 ProductionClass()를 테스트 한것을 볼수있습니다.

 

patch는 특정 함수의 리턴값을 지정한다는것에서 mock과 비슷하지만, 특정 범위안에서만 mocking이 가능하도록  설계하기위한 함수 입니다.  patching이 필요한 단위 테스트 메서드에 patch() 데코레이터를 선언해줌으로써 해당 메서드 내에서만 patching이 이뤄지게 합니다.

 

 

 

api test

앞서 만들었던 api들을 테스트 하기 위해선 pytest와 django를 연결해 줘야 합니다.

해당 연결 작업은 pytest 실행시 파라미터로 할수 있으나, pytest.ini파일을 생성하여 설정해 주면 pytest 실행시 자동으로 파라미터를 읽고 설정해 줍니다.

pytest.ini

[pytest]
DJANGO_SETTINGS_MODULE=django_best_practice_example.settings
norecursedirs=.git locale/*
addopts=--nomigrations --cov-report=xml:coverage.xml --junitxml=./pytest-report.xml --cov=./
env_files=.env
junit_family=xunit1

- DJANGO_SETTINGS_MODULE : pytest가 django를 실행하기 위한 설정값입니다. 프로젝트의 settings파일의 경로를 넣어주시면 됩니다.

- norecursedirs ; pytest에서 제외할 파일/폴더를 지정합니다.

- addopts : pytest의 기타 설정값들로, --nomigrations의 경우 pytest가 실행시 자동으로 migration을 실행하게 됩니다. 이를 막기 위한 키워드 입니다. cov / junit의 경우 커버리지 관련 파라미터로 pytest실행시 해당 파일들로 커버리지의 파일이 생성됩니다.

- env_files : 만일 django 실행시 env파일을 사용하신다면 해당 파라미터로 지정해 주면 됩니다.

 

 

이제 간단한 api test를 구현해 보도록 하겠습니다.

 

먼저 API 통신을 하기 위해서 rest_freamwork.test Dml APIClient()모듈을 써야 합니다. 해당 모듈엔 http의 메소드가 정의 되어 있으며, 사용에 맞는 함수를 호출하면 됩니다. 저는 해당 APIClient()를 계속해서 호출하므로, ClientRequest()라는 클래스로 만들어 사용합니다.

# test_post.py

import pytest
from rest_framework.test import APIClient, APITestCase


class TestView(APITestCase):
    def setUp(self):
        self.client = APIClient()
        self.c = ClientRequest(self.client)

ClientRequest()에는 APIClient()의 객체를 받고, 사용자가 호출하는 메소드에 따라 파라미터를 집어넣고 호출한 다음 response를 리턴하는 간단한 구조 입니다.

class ClientRequest:
    def __init__(self, client):
        self.client = client

    def __call__(self, type, url, data=None):
        content_type = "application/json"

        if type == "get":
            res = self.client.get(
                url, {}, content_type=content_type
            )
        elif type == "post":
            res = self.client.post(
                url,
                json.dumps(data),
                content_type=content_type
            )
        elif type == "del":
            res = self.client.delete(
                url, {}, content_type=content_type
            )
        else:
            res = self.client.put(
                url,
                json.dumps(data),
                content_type=content_type
            )
        return res

 

이제 테스트 코드의 시작 부분입니다. APITestCase는 시작과 함께 SetUp을 통해 테스트 코드에 사용하는 변수들을 선언해 둘수 있습니다.

테스트에 필요한 init 정보를 setup()에 정의 하면 됩니다. 저는 APIClient()와 유저생성 및 해당 유저의 토큰값을 사용합니다.

class TestView(APITestCase):
    def setUp(self):
        self.client = APIClient()
        self.c = ClientRequest(self.client)
        # 유저 더미 생성
        self.password = "password"
        self.user = User.objects.create_user(
            username='uiandwe',
            email='uiandwe@test.com',
            password=self.password,
        )
        self.user.set_password(self.password)
        self.user.save()

        url = '/api/auth/'
        res = self.client.post(
            url,
            json.dumps({"username": "uiandwe", "password": "password"}),
            content_type="application/json"
        )
        self.token = res.data['token']

 

간단한 get 테스트 입니다. 저번 포스팅에서 만들었던 post의 get 메소드 호출 테스트 입니다.

res로 리턴된 값이 200이며, 아직 테이터를 생성하지 않았기 때문에 []이 리턴되는지를 확인합니다.

    def test_post_get_success(self):
        url = "/api/v1/post/"
        http_author = 'Token {}'.format(self.token)
        res = self.client.get(
            url, {}, content_type="application/json", HTTP_AUTHORIZATION=http_author
        )
        assert res.status_code == 200
        assert res.data['results'] == []

 

post 테스트 입니다. 해당 유저로 post를 생성하고 정상적으로 돌아오는지 테스트 합니다.

    def test_post_create_success(self):
        url = "/api/v1/post/"
        http_author = 'Token {}'.format(self.token)
        res = self.client.post(
            url, json.dumps({"message": "message 1"}), content_type="application/json", HTTP_AUTHORIZATION=http_author
        )
        assert res.status_code == 201
        assert res.data['id'] == 1
        assert res.data['message'] == "message 1"

        res = self.client.get(
            url, {}, content_type="application/json", HTTP_AUTHORIZATION=http_author
        )

        assert res.status_code == 200

        assert res.data['results'][0]['message'] == "message 1"
        assert res.data['results'][0]['owner'] == "uiandwe"

 

이제 pytest 로 실행 해 보면 api 테스트가 정상적으로 통과 되었음을 확인 할 수 있습니다.

 

모든 테스트 코드는 blog/tests/test_post.py에서 확인 가능합니다.

 

 

 

참고자료

- https://python-guide-kr.readthedocs.io/ko/latest/writing/tests.html

- https://python.flowdas.com/library/unittest.mock.html

- https://djangostars.com/blog/django-pytest-testing/