Skip to content

Latest commit

 

History

History
422 lines (323 loc) · 19.6 KB

File metadata and controls

422 lines (323 loc) · 19.6 KB

Test Coverage

Flask Framework 튜토리얼 테스트 커버리지에 대한 문서입니다. 자세한 내용은 여기를 참고하세요.

유닛 테스트를 작성하면 우리의 어플리케이션이 기대한대로 동작하는지 확인할 수 있습니다. Flask는 프로그램에 대한 요청을 시뮬레이션하고 응답 데이터를 반환하는 테스트 클라이언트를 제공합니다.

우리는 가능한 많은 코드 테스트해야 합니다. 함수에 작성된 코드는 그 함수가 호출될 때만 실행되고, if 블록 같은 분기 안의 코드는 조건이 충족될 때만 실행됩니다. 우리는 각 함수가 모든 분기를 커버하는 데이터로 테스트되고 있는지 확인하고자 합니다.

100% 커버리지에 가까워질수록, 코드를 수정하더라도 다른 코드의 동작에 예상치못한 영향을 끼칠까봐 걱정하지 않아도 됩니다. 그러나 100% 커버리지가 우리의 프로그램에 버그가 없음을 보장해주지는 않습니다. 사용자가 브라우저를 통해 어떻게 프로그램과 상호작용하는지 등은 유닛 테스트의 대상이 아닙니다. 그럼에도 불구하고, 테스트 커버리지는 개발 과정에서 매우 중요한 도구입니다.

Note

이 장은 튜토리얼 후반부에 소개되고 있지만, 실제로 프로젝트를 진행하게 되면 개발과 테스트를 동시에 진행하는 것이 좋습니다.

테스트와 커버리지 측정을 위해 pytestcoverage를 사용합니다. 터미널에 아래와 같이 입력해 설치해주세요.

$ pip install pytest coverage

Setup and Fixtures

테스트 코드는 tests 디렉토리에 위치합니다. tests 디렉토리는 flaskr 패키지 안에 있는 것이 아니라, 같은 레벨에 위치합니다. tests/conftest.py 파일은 각 테스트에서 사용할 setup 함수들을 포함하고 있습니다. 이 함수들을 fixture라고 합니다. 테스트는 test_로 시작하는 파이썬 모듈에 작성하며, 이 모듈 안의 각 테스트 함수 이름도 test_로 시작합니다.

각 테스트는 임시 데이터베이스 파일을 새로 생성하고 테스트에 사용할 데이터를 채웁니다. 데이터 삽입에 사용할 SQL 파일을 작성하겠습니다.

tests/data.sql

INSERT INTO user (username, password)
VALUES
  ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
  ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');

INSERT INTO post (title, body, author_id, created)
VALUES
  ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');

app fixture는 factory를 호출하고 test_config를 factory에 전달합니다. 즉, 어플리케이션과 데이터베이스가 로컬 개발 환경 설정이 아닌 테스트 환경에 맞춰 설정됩니다.

tests/conftest.py

import os
import tempfile

import pytest
from flaskr import create_app
from flaskr.db import get_db, init_db

with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f:
    _data_sql = f.read().decode('utf8')


@pytest.fixture
def app():
    db_fd, db_path = tempfile.mkstemp()

    app = create_app({
        'TESTING': True,
        'DATABASE': db_path,
    })

    with app.app_context():
        init_db()
        get_db().executescript(_data_sql)

    yield app

    os.close(db_fd)
    os.unlink(db_path)


@pytest.fixture
def client(app):
    return app.test_client()


@pytest.fixture
def runner(app):
    return app.test_cli_runner()

tempfile.mkstemp() 임시 파일을 생성하고 열어, 파일 오브젝트와 해당 파일의 경로를 리턴합니다. DATABASE 경로는 오버라이드되어 instance 폴더가 아니라 앞에서 생성한 임시 파일 경로를 가리키게 됩니다. 경로를 설정한 후, 데이터베이스 테이블이 생성되고 테스트 데이터가 채워집니다. 테스트가 끝나면, 임시 파일이 닫히고 삭제됩니다.

TESTING은 Flask에게 현재 app이 테스트 모드임을 알려줍니다. 그럼 Flask는 테스트하기 쉽도록 내부적인 동작을 일부 변경하고, 확장 기능 (extensions) 역시 플래그를 사용해 테스트하기 쉽도록 세팅합니다.

client fixture는 app.test_client()를 호출합니다. 이 때 app fixture가 생성한 app 오브젝트를 사용합니다. 이제 각 테스트들은 client를 사용해 서버를 실행시키지 않고도 어플리케이션에 요청을 보낼 수 있습니다.

runner fixture도 client와 비슷합니다. app.test_cli_runner()가 어플리케이션에 등록된 Click 커맨드들을 호출할 수 있는 runner를 생성합니다.

Pytest는 fixture 함수 이름과 테스트 함수의 인자 이름을 매치해 fixture를 사용합니다. 예를 들어, 다음에 작성할 test_hello 함수는 client 인자를 받습니다. Pytest는 이 함수를 client fixture 함수와 매치하고, fixture를 호출하여 리턴된 값을 테스트 함수로 넘겨줍니다.

Factory

Factory 자체로는 테스트할 것이 별로 많지 않습니다. 대부분의 코드가 이미 각각의 테스트에서 실행되므로, 만약 뭔가가 실패한다면 다른 테스트에서 알 수 있습니다.

여기서 달라질 수 있는 유일한 동작은 test config를 넘겨주는 것입니다. 만약 config를 넘겨주지 않으면 default config가 있어야 하고, 그렇지 않으면 config가 오버라이드되어야 합니다.

tests/test_factory.py

from flaskr import create_app


def test_config():
    assert not create_app().testing
    assert create_app({'TESTING': True}).testing


def test_hello(client):
    response = client.get('/hello')
    assert response.data == b'Hello, World!'

튜토리얼 도입부에서 factory를 작성할 때, 우리는 예시로 hello route를 작성했습니다. 위 코드를 보면 test_hello에서 이 route가 정상적으로 동작해 "Hello, World!"를 리턴하는지 체크하고 있습니다.

Database

어플리케이션 컨텍스트가 유지되는 동안, get_db는 언제 호출되든지 항상 같은 DB 커넥션을 리턴해야 합니다. 컨텍스트가 종료되면 DB 커넥션도 닫혀야 합니다.

tests/test_db.py

import sqlite3

import pytest
from flaskr.db import get_db


def test_get_close_db(app):
    with app.app_context():
        db = get_db()
        assert db is get_db()

    with pytest.raises(sqlite3.ProgrammingError) as e:
        db.execute('SELECT 1')

    assert 'closed' in str(e.value)

init-db 커맨드는 init_db 함수를 호출하고 메세지를 출력합니다.

tests/test_db.py

def test_init_db_command(runner, monkeypatch):
    class Recorder(object):
        called = False

    def fake_init_db():
        Recorder.called = True

    monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
    result = runner.invoke(args=['init-db'])
    assert 'Initialized' in result.output
    assert Recorder.called

이 테스트는 Pytest의 monkeypatch fixture를 사용해서 init_db 함수를 fake_init_db로 대체합니다. fake_init_dbinit_db가 호출되었다고 기록하는 함수입니다. init-db 커맨드를 이름으로 호출하기 위해 우리가 위에서 작성했던 runner fixture를 사용합니다.

Authentication

대부분의 뷰에서 사용자는 로그인을 해야 합니다. 테스트에서 로그인을 구현하는 가장 쉬운 방법은 client로 login 뷰에 POST 요청을 보내는 것입니다. 이 코드를 매번 작성하지 않고, 해당 내용을 수행하는 method를 갖고 있는 class를 작성합시다. 그리고 fixture를 사용해 이 클래스를 각각의 테스트 클라이언트에게 넘겨줄 것입니다.

tests/conftest.py

class AuthActions(object):
    def __init__(self, client):
        self._client = client

    def login(self, username='test', password='test'):
        return self._client.post(
            '/auth/login',
            data={'username': username, 'password': password}
        )

    def logout(self):
        return self._client.get('/auth/logout')


@pytest.fixture
def auth(client):
    return AuthActions(client)

auth fixture를 사용하면, 각 테스트에서 로그인이 필요할 때 auth.login()을 호출할 수 있습니다. 이 경우, app fixture의 테스트 데이터로 삽입된 test 유저로 로그인됩니다.

register 뷰는 GET 요청을 받았을 때 성공적으로 렌더링되어야 합니다. POST에서는 유효한 회원가입 폼 데이터를 받았을 경우, 데이터베이스에 유저 정보가 저장되어야 하고, 로그인 URL을 리다이렉트해야합니다. 폼 데이터가 유효하지 않을 경우 에러 메세지를 띄워야 합니다.

tests/test_auth.py

import pytest
from flask import g, session
from flaskr.db import get_db


def test_register(client, app):
    assert client.get('/auth/register').status_code == 200
    response = client.post(
        '/auth/register', data={'username': 'a', 'password': 'a'}
    )
    assert 'http://localhost/auth/login' == response.headers['Location']

    with app.app_context():
        assert get_db().execute(
            "select * from user where username = 'a'",
        ).fetchone() is not None


@pytest.mark.parametrize(('username', 'password', 'message'), (
    ('', '', b'Username is required.'),
    ('a', '', b'Password is required.'),
    ('test', 'test', b'already registered'),
))
def test_register_validate_input(client, username, password, message):
    response = client.post(
        '/auth/register',
        data={'username': username, 'password': password}
    )
    assert message in response.data

client.get()GET 요청을 만들어주고 Flask가 반환하는 Response 오브젝트를 리턴합니다. 이와 비슷하게, client.post()POST 요청을 만들고 data dict를 폼 데이터로 변환합니다.

페이지가 잘 렌더되었는지 확인하기 위해 간단한 요청을 만들고 상태 코드가 200 OK인지 확인합니다. 만약 렌더링이 실패하면, Flask는 500 Internal Server Error를 리턴합니다.

register 뷰가 login뷰로 리다이렉트할 때, headersLocation 헤더에 로그인 URL을 갖게 됩니다.

data는 바이트 형태의 response body를 포함합니다. 특정 값이 페이지에 렌더되기를 기대한다면, 그것이 data에 들어가 있는지 확인하세요. 바이트는 반드시 바이트끼리 비교해야 합니다. 만약 유니코드 텍스트를 비교하고 싶다면 get_data(as_text=True)를 사용합시다.

pytest.mark.parametrize는 Pytest에게 같은 테스트 함수를 서로 다른 인자를 사용해 여러 번 실행하라고 알려줍니다. 위 코드를 살펴보면 같은 코드를 세 번 반복해서 작성하지 않고도 하나의 테스트 함수를 세 개의 인자쌍(유효하지 않은 입력값과 에러 메세지)에 대해 실행하고 있습니다.

login 뷰 테스트도 register 뷰와 매우 비슷합니다. 데이터베이스에서 데이터를 테스트하는 대신에, 로그인 이후 세션이 user_id를 갖고 있는지 테스트합니다.

tests/test_auth.py

def test_login(client, auth):
    assert client.get('/auth/login').status_code == 200
    response = auth.login()
    assert response.headers['Location'] == 'http://localhost/'

    with client:
        client.get('/')
        assert session['user_id'] == 1
        assert g.user['username'] == 'test'


@pytest.mark.parametrize(('username', 'password', 'message'), (
    ('a', 'test', b'Incorrect username.'),
    ('test', 'a', b'Incorrect password.'),
))
def test_login_validate_input(auth, username, password, message):
    response = auth.login(username, password)
    assert message in response.data

with 블록 안에서 client를 사용하면, 요청에 대한 응답이 반환된 후 컨텍스트 변수인 세션 등에 접근할 수 있습니다. 대부분의 경우에는 request 외부에서 session에 접근하면 에러가 발생합니다.

logout에 대한 테스트는 login과 반대입니다. 로그아웃한 후에 session이 user_id를 갖고 있지 않아야 합니다.

tests/test_auth.py

def test_logout(client, auth):
    auth.login()

    with client:
        auth.logout()
        assert 'user_id' not in session

Blog

모든 blog 뷰는 앞서 작성했던 auth fixture를 사용합니다. auth.login()을 호출하면 클라이언트의 후속 요청은 test 유저로 로그인된 상태로 처리됩니다.

index 뷰는 테스트 데이터로 추가된 게시물의 정보를 보여주어야 합니다. 해당 게시물의 글쓴이인 유저가 로그인하면, 게시물을 수정할 수 있는 링크가 표시되어야 합니다.

우리는 index 뷰를 테스트하는 동안 인증과 관련한 부분도 테스트할 수 있습니다. 로그인하지 않은 상태라면 각 페이지에 로그인과 회원가입 링크가 있어야 하며, 로그인된 상태라면 로그아웃 링크가 있어야 합니다.

tests/test_blog.py

import pytest
from flaskr.db import get_db


def test_index(client, auth):
    response = client.get('/')
    assert b"Log In" in response.data
    assert b"Register" in response.data

    auth.login()
    response = client.get('/')
    assert b'Log Out' in response.data
    assert b'test title' in response.data
    assert b'by test on 2018-01-01' in response.data
    assert b'test\nbody' in response.data
    assert b'href="/1/update"' in response.data

create, update, delete 뷰에 접근하기 위해서는 반드시 로그인이 되어 있어야 합니다. 특히 updatedelete에 접근하기 위해서는 로그인한 유저가 해당 게시물의 글쓴이여야 합니다. 그렇지 않다면, 403 Forbidden 코드가 반환됩니다. 만약 주어진 id를 갖고 있는 게시물이 존재하지 않는다면, updatedelete404 Not Found를 리턴해야 합니다.

tests/test_blog.py

@pytest.mark.parametrize('path', (
    '/create',
    '/1/update',
    '/1/delete',
))
def test_login_required(client, path):
    response = client.post(path)
    assert response.headers['Location'] == 'http://localhost/auth/login'


def test_author_required(app, client, auth):
    # change the post author to another user
    with app.app_context():
        db = get_db()
        db.execute('UPDATE post SET author_id = 2 WHERE id = 1')
        db.commit()

    auth.login()
    # current user can't modify other user's post
    assert client.post('/1/update').status_code == 403
    assert client.post('/1/delete').status_code == 403
    # current user doesn't see edit link
    assert b'href="/1/update"' not in client.get('/').data


@pytest.mark.parametrize('path', (
    '/2/update',
    '/2/delete',
))
def test_exists_required(client, auth, path):
    auth.login()
    assert client.post(path).status_code == 404

createupdate 뷰는 GET 요청에 대해 정상적으로 렌더되고, 200 OK를 리턴해야 합니다. POST 요청으로 유효한 데이터가 들어오면, create 뷰는 새로운 게시물의 데이터를 DB에 저장하고, update 뷰는 DB에 존재하는 데이터를 수정해야 합니다. 두 가지 뷰 모두 데이터가 유효하지 않다면 에러 메세지를 띄워야 합니다.

tests/test_blog.py

def test_create(client, auth, app):
    auth.login()
    assert client.get('/create').status_code == 200
    client.post('/create', data={'title': 'created', 'body': ''})

    with app.app_context():
        db = get_db()
        count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0]
        assert count == 2


def test_update(client, auth, app):
    auth.login()
    assert client.get('/1/update').status_code == 200
    client.post('/1/update', data={'title': 'updated', 'body': ''})

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post['title'] == 'updated'


@pytest.mark.parametrize('path', (
    '/create',
    '/1/update',
))
def test_create_update_validate(client, auth, path):
    auth.login()
    response = client.post(path, data={'title': '', 'body': ''})
    assert b'Title is required.' in response.data

delete 뷰는 index URL로 리다이렉트되어야 하며, 해당 게시물은 데이터베이스에 더는 존재하지 않아야 합니다.

tests/test_blog.py

def test_delete(client, auth, app):
    auth.login()
    response = client.post('/1/delete')
    assert response.headers['Location'] == 'http://localhost/'

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post is None

Running the Tests

반드시 필요한 건 아니지만 coverage 통한 pytest 실행을 더 간단하게 하고 싶다면 아래와 같이 setup.cfg를 추가해주면 됩니다.

setup.cfg

[tool:pytest]
testpaths = tests

[coverage:run]
branch = True
source =
    flaskr

테스트를 실행하기 위해 pytest 커맨드를 사용합니다. 이 커맨드를 통해 우리가 작성한 모든 테스트 함수를 찾아 실행할 수 있습니다.

$ pytest

========================= test session starts ==========================
platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: /home/user/Projects/flask-tutorial, inifile: setup.cfg
collected 23 items

tests/test_auth.py ........                                      [ 34%]
tests/test_blog.py ............                                  [ 86%]
tests/test_db.py ..                                              [ 95%]
tests/test_factory.py ..                                         [100%]

====================== 24 passed in 0.64 seconds =======================

실패한 테스트가 있다면, pytest가 어떤 에러가 발생했는지 알려줍니다. pytest -v를 실행하면 '...' 대신 각 모듈에서 실행된 테스트 함수의 리스트를 확인할 수 있습니다.

우리가 작성한 테스트의 코드 커버리지를 측정하려면, coverage 커맨드를 이용해 pytest를 실행해줍니다.

$ coverage run -m pytest

터미널에서 간단한 커버리지 리포트를 확인할 수도 있습니다.

$ coverage report

Name                 Stmts   Miss Branch BrPart  Cover
------------------------------------------------------
flaskr/__init__.py      21      0      2      0   100%
flaskr/auth.py          54      0     22      0   100%
flaskr/blog.py          54      0     16      0   100%
flaskr/db.py            24      0      4      0   100%
------------------------------------------------------
TOTAL                  153      0     44      0   100%

HTML 리포트를 통해 각각의 파일에 어떤 라인이 포함되어 있는지 확인할 수도 있습니다.

$ coverage html

위 커맨드는 htmlcov 디렉토리에 파일을 생성합니다. 브라우저로 htmlcov/index.html 파일을 열어 HTML 리포트를 확인해보세요.

Deploy to Production에서 계속됩니다.