# 빠르게 시작하는 pytest

pytest를 적용하면서 공부한 내용을 정리하는거라 간단하게 정리합니다. 추후 더 공부하며 업데이트 하겠습니다.

pytest is a framework that makes building simple and scalable tests easy. Tests are expressive and readable—no boilerplate code required. Get started in minutes with a small unit test or complex functional test for your application or library.

  • 보일러플레이트 없이 바로 테스트 할 수 있는 테스트 프레임워크

공식 문서 : https://docs.pytest.org/en/latest/contents.html (opens new window)

# Installation

$ pip install -U pytest

# Quick start

# test_function.py
def func(x):
    return x + 1


def test_answer():
    assert func(3) == 5
$ pytest
# or
$ python -m pytest

# 프로젝트 구조

크게 어플리케이션 내부에 혹은 바깥에 테스트 코드를 두는 두가지 방법이 있다.

# 외부에 둘 경우

tests/ 하위에 테스트 파일들

setup.py
mypkg/
    __init__.py
    app.py
    view.py
tests/
    test_app.py
    test_view.py
    ...

만약, 어플리케이션과 동일한 이름의 모듈(폴더)가 있다면 __init__.py가 필요하다.

mypkg/
    ...
tests/
    __init__.py
    foo/
        __init__.py
        test_view.py
    bar/
        __init__.py
        test_view.py

# 어플리케이션 코드와 함께둘 경우

setup.py
mypkg/
    __init__.py
    app.py
    view.py
    test/
        __init__.py
        test_app.py
        test_view.py
        ...

reference: https://docs.pytest.org/en/latest/goodpractices.html (opens new window)

# 설정파일

설정파일은 가장 상위 rootdir에 만든다. (혹은 --rootdir=path 와 같이 직접 값을 넘겨줄 수도 있다. rootdir에 대한 좀 더 상세한 내용은 여기 (opens new window)서 확인 할 수 있다.)

# 테스트 할 파일들 설정

# pytest.ini
[pytest]
python_files = tests.py test_*.py *_tests.py 
# 정의한 형태의 파일만 테스트 파일로 읽는다.

# default command line options

# pytest.ini
[pytest]
addopts = -ra -q

그 밖의 command-line option 들 ...

  • --maxfail=2 : 몇 개까지 실패할때까지 테스트 할 것인지. 이 경우, 2개 실패하면 더이상 테스트를 진행하지 않고 멈춘다.
  • -s : 테스트 내 print, logging 다 보기

더 많은 설정 옵션들은 여기 (opens new window)에서 확인 할 수 있다.

# @pytest.mark.skip(reason="Just give me a reason")

  • skip 하고자 하는 테스트
  • 컨디션에 따라 테스트를 스킵하고 싶을 경우 아래와 같이 사용
@pytest.mark.skipif(os.environ.get("PROFILE", "local") != 'local', reason="run this test only at local")

# @pytest.fixture

# 사용방법

@pytest.fixture()
def random_number():
    import random
    return random.randrange(1,10)

def test_random_range(random_number):
    assert random_number > 1 and random_number < 10 

# 사용할 수 있는 fixture 확인하는 방법

$ pytest --fixtures

# @pytest.mark.asyncio

  • install
$ pip install pytest-asyncio

# 다른 Framework들과의 테스트

# with Sanic

@pytest.yield_fixture
def app():
    app = Sanic("test_sanic_app")

    @app.route("/test_get", methods=['GET'])
    async def test_get(request):
        return response.json({"GET": True})

    @app.route("/test_post", methods=['POST'])
    async def test_post(request):
        return response.json({"POST": True})

    yield app


@pytest.fixture
def test_cli(loop, app, test_client):
    return loop.run_until_complete(test_client(app, protocol=WebSocketProtocol))


#########
# Tests #
#########

async def test_fixture_test_client_get(test_cli):
    """
    GET request
    """
    resp = await test_cli.get('/test_get')
    assert resp.status == 200
    resp_json = await resp.json()
    assert resp_json == {"GET": True}

j = lambda **kwargs: json.dumps(kwargs)

async def test_fixture_test_client_post(test_cli):
    """
    POST request with graphql
    """
    data = j(query='''
            query{
 	            user(id:"123") {
                    id
                }
            }
        ''')
    resp = await test_cli.post('/graphql', data=data , headers={'content-type': 'application/json', 'token': '1234')
    assert resp.status == 200
    resp_json = await resp.json()
    assert resp_json == {"POST": True}

위 방법은 test route를 다시 만드는 방식.

테스트하려는 어플리케이션 Sanic App 만드는 부분을 app에 주입해주면 어플리케이션의 라우트 테스트 가능

from app.app import create_app

@pytest.fixture
def app():
    app = create_app()

    yield app

# with Peewee

DB를 테스트 하는 방법은 크게 3가지 방법이 있다.

  • in-memory DB를 이용해 만들었다가 없애는 방법
  • test용 database를 새로 만들었다가 지우는 방법
  • transaction을 이용해 테스트가 끝나고 rollback하는 방법

peewee는 SQLite에 대해 in-memory DB를 제공하는데 (opens new window), 같이 사용중인 peewee-async 에서는 Postgresql과 Mysql (opens new window)만 지원하여 transaction을 이용하는 방법을 사용하였다.

transaction을 이용한 테스트에서 create 할 경우, auto_increment 하는 필드들이 영향을 받을수 있으므로 테스트 환경에 유의 한다.

class TestAsyncDatabase:
    manager = None
    database = MySQLDatabase(None)

    @classmethod
    async def setup_database(cls, app):
        app.database = cls.database
        cls.database.init(database="database")
        app.database.set_allow_sync(False)
        app.objects = cls.manager = Manager(app.database)

# transaction/rollback decorator 
def db_unittest_run_loop(func, *args, **kwargs):
    async def do_transaction(func, self, *inner_args, **inner_kwargs):
        async with TestAsyncDatabase.manager.atomic() as txn:
            await func(self, *inner_args, **inner_kwargs)
            await txn.rollback()

    @functools.wraps(func, *args, **kwargs)
    def new_func(self, *inner_args, **inner_kwargs):
        task = do_transaction(func, self, *inner_args, **inner_kwargs)
        return self.loop.run_until_complete(task)

    return new_func

class TestModel(BaseDatabaseTestCase):
    @db_unittest_run_loop
    async def test_db_connect(self): # connction 테스트
        async def get_conn(objects):
            await objects.connect()
            return objects.database._async_conn
        manager = TestAsyncDatabase.manager
        c1 = await get_conn(manager)
        c2 = await get_conn(manager)
        
        assert c1 == c2
        assert manager.is_connected

    
    @pytest.mark.asyncio    
    @db_unittest_run_loop    
    async def test_create(self):
        manager = TestAsyncDatabase.manager
        async with manager.atomic():
            obj1 = await manager.create(PeeweeModel, name="name")
            obj2 = await manager.get(PeeweeModel, id=obj1.id)
            assert obj1 == obj2
            assert obj1.id == obj2.id

# describe-context-it pattern

from pytest import mark as m

@m.describe("예시용 클래스")
class TestExample(object):

    @m.context("@pytest.mark.it을 이용할 때")
    @m.it("'- It: ' 데코레이터에 맞게 보여준다")
    def test_it_decorator(self):
        pass
- Describe: 예시용 클래스...

  - Context: @pytest.mark.it을 이용할 때...
    - ✓ It: '- It: ' 데코레이터에 맞게 보여준다

# references