Notice
Recent Posts
Recent Comments
05-21 07:17
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

Byeol Lo

Flask - 8. Tutorial Make the Project Installable and Test Coverage 본문

BackEnd/Flask

Flask - 8. Tutorial Make the Project Installable and Test Coverage

알 수 없는 사용자 2024. 2. 25. 16:37

이제 프로젝트를 서버에 설치 가능하도록 하려면 외부 환경에 해당 프로젝트를 간편하게 설치할 수 있게 도움을 주는 휠 파일(wheel file)을 생성 해야한다. 이는 Flask와 같이 프로젝트 내에서 설치한 것과 같은 방식으로 배포를 단순화한다. 또한, 프로젝트를 설치하는 것은 다음과 같은 추가적인 이점을 제공한다.

  1. 어디든지 프로젝트를 실행시킬 수 있다
  2. 의존성을 패키지처럼 관리할 수 있게 되고, pip install yourproject.whl 명령만 간단하게 실행시켜 모든 의존성을 한 번에 다운 가능하다.
  3. 테스트 도구는 개발 환경과 분리된 별도의 환경을 생성할 수 있다.

 

Describe the Project

 프로젝트 pyproject.toml 파일은 프로젝트와 프로젝트 파일들이 어떻게 빌드되는지 서술한다.

# pyproject.toml
[project]
name = "flaskr"
version = "1.0.0"
description = "The basic blog app built in the Flask tutorial"
dependencies = [
    "flask",
]

[build-system]
requires = ["flit_core<4"]
build-backend = "flit_core.buildapi"

project 섹션은 프로젝트에 대한 정보를 정의하고 이름, 버전, 설명, 의존성 등을 나열한다. build-system 섹션은 프로젝트를 빌드하는 데 사용되는 도구에 대한 정보를 정의한다. 여기선 Flit를 사용하고, Flit의 버전이 4 미만인 경우를 요구한다. 빌드 도구로는 flit_core.buildapi를 사용한다.

 

Install the Project

 이제 프로젝트를 다른 환경에서 설치해보자. pip를 통해 가상환경에서 우리의 프로젝트에 맞는 정해진 모듈들을 설치해준다.

pip install -e .

 

 해당 명령어를 통해 pip는 현재 디렉토리에 있는 pyprojec.toml을 찾아서 해당 내용을 기반으로 편집 가능한 개발 모드( editable )로 설치하도록 지시한다. 편집 가능 모드는 코드를 변경할 때 종속성과 같은 프로젝트에 대한 메타데이터를 변경하는 경우에만 다시 설치하면 된다는 것을 말한다. 즉, 코드를 변경하고 저장한 후에는 다시 설치하지 않도록 하는 모드이다.

다음은 프로젝트의 package 관리에 대한 자세한 설명이다.

https://packaging.python.org/en/latest/tutorials/packaging-projects/

 

Packaging Python Projects - Python Packaging User Guide

Previous Managing Application Dependencies

packaging.python.org

 

 이제 새로운 환경에 대한 적용도 간단히 해봤으니 테스트만 하기만 하면 된다. 어플리케이션에 대한 unit test를 작성하면 코드가 작성된 대로 제대로 동작하는지 확인할 수 있다. Flask에서는 이 요청을 시뮬레이션하고 응답 데이터를 반환하는 테스트 클라이언트를 제공한다.

 테스트 코드를 작성할 때에는 가능한 한 많은 테스트 코드를 작성하고, 함수 내의 코드는 함수가 호출될 때에만 실행되어야 하고, if문과 같은 분기 문은 조건이 충족될 때에만 실행이 되어야 한다. 이를 위해 테스트 범위를 설정해주어야 한다.

 

Setup and Fixtures

pip install pytest coverage

 위 두 모듈은 코드를 테스트하고 측정하기 위해 사용된다. 테스트 코드는 tests/ 라는 디렉토리에 나와 있으며, 이 디렉터리는 패키지 내부가 아닌 flaskr 패키지 와 같은 위치의 디렉토리에 있다. tests/conftest.py 파일에는 각 테스트에서 사용할 fixture 라 불리는 함수가 포함되어 있다.

Fixture
 테스트 코드에서 사용되는 준비 작업이나 초기 설정을 말하는데, 테스트 케이스를 실행하기 전에 특정한 상태를 만들어주기 위해 사용된다. 즉 데이터베이스 연결이나 임시 파일 생성이나 가짜 데이터 생성과 같은 작업을 사전에 한다는 것이다.

 다음 예시를 보자.

# file_operations.py

def read_file(file_path):
    with open(file_path, 'r') as f:
        return f.read()

def write_to_file(file_path, data):
    with open(file_path, 'w') as f:
        f.write(data)

위는 어플리케이션의 두 기능 read, write를 작성한 것이다. 이 기능을 테스트하기 위해 test 파일을 따로 만들어야 한다.

# test_file_operations.py
import os
import pytest
from file_operations import read_file, write_to_file

@pytest.fixture
def temp_file(tmpdir):
    temp_file_path = os.path.join(tmpdir, 'test.txt')
    yield temp_file_path
    if os.path.exists(temp_file_path):
        os.remove(temp_file_path)

def test_write_to_file(temp_file):
    data = "Hello, world!"
    write_to_file(temp_file, data)
    assert os.path.exists(temp_file)
    with open(temp_file, 'r') as f:
        assert f.read() == data

def test_read_file(temp_file):
    data = "This is a test."
    with open(temp_file, 'w') as f:
        f.write(data)
    assert read_file(temp_file) == data

@pytest.fixture 로 어플리케이션의 기능들을 테스트하기 전에 해당 함수를 실행하도록 한다. 그런 후에 사전 설정을 해주는 함수를 인자로 넣어주기만하면 끝이다. 이제 테스트를 실행해야 하는데, pytest와 coverage를 사용해 테스트를 실행하면 된다.

pytest --cov=file_operations

 

이렇게 하면 테스트 코드의 실행 결과와 코드 커버리지를 확인할 수 있다.

 

 본론으로 다시 돌아와서 테스트를 위해 임시 데이터베이스가 생성되고, 이 데이터베이스에 임시 유저, 데이터가 필요할 수 있다. 이를 위해 각 테스트들은 새로운 임시 데이터베이스 파일과 몇몇의 데이터가 생성할 것이다. 다음 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');

 위를 복붙하자.

 

 이제 해당 데이터 베이스로 설정된 것을 토대로 소프트웨어 테스트에서 로컬 개발 환경과 테스트 환경을 분리하여 사용 할 것이다. 이를 위해 테스트의 환경 설정을 해주어야 한다.

# 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()

 app fixture 는 test 용 flask 어플리케이션을 생성하고 설정한다. 또한 테스트용 SQLite 데이터베이스를 tempfile.mkstemp()를 통해서 임시로 생성하고, 이를 어플리케이션에 연결하게 된다. 데이터베이스에 초기 데이터를 삽입하고, 테스트를 위해 어플리케이션을 사용할 수 있게 yield를 통해 app을 반환해주고, 테스트가 끝나면 데이터베이스 파일을 삭제한다.

 client fixture는 app fixture를 의존으로 하여 Flask 어플리케이션의 클라이언트를 생성하고 반환한다. 이를 통해 HTTP 요청을 보낼 수 있다. runner fixture도 유사하게 어플리케이션의 CLI 를 생성하여 CLI 명령을 실행할 수 있도록 한다.

 

Factory

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!'

 팩토리는 어플리케이션을 생성하는 것인데, 어플리케이션 만드는 것 자체의 테스트를 해야 할 수도 있기에 위의 코드를 작성한다. 하지만 어플리케이션을 만드는 팩토리에서는 테스트 할 것이 그렇게 많지 않다. 변경할 수 있는 유일한 동작은 테스트 구성을 다양하게 전달하는 것 뿐이다. config가 전달되지 않으면 일부 기본 구성을 통해서 생성해야하는 기본 설정 코드가 있어야 하며, 그렇지 않으면 구성을 재정의하는 코드를 짠다.

 위는 튜토리얼 시작 부분에서 팩토리를 작성할 때 예로 경로를 추가한 것인데, "Hello, World!"를 반환하므로 테스트에서는 응답 데이터가 일치하는지 확인한다.

 

Database

 애플리케이션 컨텍스트 내에서는 get_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

 runner는 CLI 애플리케이션을 실행하고 테스트하는 데 사용되는 Flask의 'FlaskCliRunner' 객체이며, monkeypatch는 코드를 테스트할 때 실제 코드를 변경하거나 모의 객체(Mock Object)를 사용할 때 사용되는 도구이다. 이를 통해 실제 코드의 동작을 변경하거나 임의의 동작을 주입하여 코드를 테스트할 수 있다.

 모의 객체에 대해 살펴보자.

Mock Object
 모의 객체는 테스트를 진행할 때 실제 객체를 대신하여 사용되는 가짜 객체인데, 실제 객체와 동일한 인터페이스를 제공하지만, 그 동작이 테스트의 필요에 따라 제어를 할 수 있다. 가령 외부 API 호출 대체, 파일 시스템 조작, 이메일 발송, 데이터베이스 상호작용, 랜덤 값 임의 설정 등등 다양한 것을 할 수 있다.
import requests
from unittest.mock import patch

def get_data_from_api():
    response = requests.get('https://api.example.com/data')
    if response.status_code == 200:
        return response.json()
    else:
        return None

def test_get_data_from_api():
    # 모의 객체를 사용하여 requests.get 함수의 반환 값을 시뮬레이션
    with patch('requests.get') as mock_get:
        # 모의 객체의 반환 값 설정
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = {'key': 'value'}
        
        # 테스트 대상 함수 호출
        data = get_data_from_api()
        
        # 함수가 올바른 값을 반환하는지 확인
        assert data == {'key': 'value'}

 해당 코드는 gpt를 통해 예시를 뽑은건데, patch('requests.get')은 unittest.mock 모듈에서 제공하는 함수로, 실제 코드에서 'requests.get' 함수를 대체하는 모의 객체를 생성한다. 이 함수를 사용하면 특정 모듈의 함수를 모의 객체로 대체하여 그 동작을 제어할 수 있게 된다. 여기서 'requests.get'은 대체할 대상 함수의 경로를 나타내고, 'requests.get'은 외부 HTTP 요청을 보내는 함수이며, 이를 테스트할 때, 네트워크 연결이 필요하기 때문에 모의 객체를 사용하여 대체하는 것이 더 조작이 쉬울 것이다. mock_get 을 통해 이렇게 되면 HTTP 요청을 보내지 않고도 테스트를 수행할 수 있게 된다.

 직관적으로 말하면 requests.get이라는 함수를 patch를 통해서 mock_get으로 대체시켜버리고 그 반환값을 조작해버릴 수 있다는 것이다. 따라서 data = get_dtat_from_api()의 내부에서 requests.get을 쓰는 곳은 mock_get에 의한 값이 반환되는 것이다.

 

 다시 본론으로 돌아와서

# 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

 init_db 명령을 테스트하는 코드이다. fixture로 등록되어 있는 runner를 통해서 cli를 실행할 수 있도록 인자를 통해 얻어주고, pytest에 기본적으로 내장되어 있는 monkeypatch fixture는 flaskr.db.init_db 의 함수를 fake_init_db로 대체하기 위함이다. Reorder의 클래스를 정의함으로써 테스트에 대한 결과로 logging을 해주고( 굳이 클래스를 정의하여 log 를 기록하는 이유는 코드 가독성과, 명확한 변수의 범위를 위해 외부 변수를 끌어들여 nonlocal의 변수 범위 설정 예약어를 쓰는 것은 별로 좋지 않다 ), init-db를 cli로 실행해준다. 반환된 값은 result에 저장되고 결과 값을 'Initialized' 가 있는지 없는지 보고, 만약 없다면 에러를, 또 Recorder가 제대로 기록을 했는지 보고 아니라면 AssertionError를 발생시킨다.

 

Authetication

 유저의 로그인에 대해 test를 하는 가장 쉬운 방법은 POST 요청을 만들어 login view를 띄우는 것이다.

# 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)

 

 매번 클라이언트에게 로그인 뷰에 POST 요청을 보내는 것 보다는 이를 수행하는 메서드를 클래스에 작성하여 각 테스트에 클라이언트를 전달하기 위해 픽스처를 사용하여 로그인에 대한 사전 설정을 수행할 수 있도록 한다.

 

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 response.headers["Location"] == "/auth/login"
    
    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

 register 와 login 에 대한 테스트 코드이다. client의 픽스쳐를 둘 다 사용하며, 회원가입에 대해서는 GET 요청을 통해 응답 상태 코드가 200인지 확인한다( 페이지가 성공적으로 렌더링이 되어야 하기 위함 ). 테스트 클라이언트에서는 post 메소드를 이용하여 POST 요청을 보낼 수 있다. username과 password를 보내주고 어플리케이션 컨텍스트에 접속하여 db에 a가 잘 저장되어 있는지 확인한다.

 그 밑은 회원가입에 대해 적절한 input이 들어가 있는지 테스트를 한다.

 

# tests/test_auth.py

def test_login(client, auth):
    assert client.get('/auth/login').status_code == 200
    response = auth.login()
    assert response.headers["Location"] == "/"
    
    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

 코드에서 보면 알 수 있듯이, get을 통해 status_code가 200인지, 그리고 response에 대한 header 섹션의 값이 해당 위치가 맞는지에 대해서도 꼼꼼히 확인하고 있는 것을 볼 수 있다. with 문에서는 client의 get 요청을 만들어 보내고, 글로벌 객체인 session에 제대로 저장이 되었는지 확인한다.

 또한 다른 테스트에 대해서도 제대로 된 오류 메시지를 내뱉는지 검사한다.

# tests/test_auth.py

def test_logout(client, auth):
    auth.login()
    
    with client:
        auth.logout()
        assert 'user_id' not in session

 

Blog

 이전까지는 기능, 로직들을 테스트 했다면, 이제 view를 테스트하자.

# 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 reponse.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 view 의 테스트도 해야하기 때문에 로그인을 하고, update와 delete에 접근하기 위해서는 해당 포스트의 작성자 여야 할 것이며, 만약 그렇지 않다면 403 Forbidden 상태가 반환되어야 한다. 또한, 주어진 id로 포스트가 존재하지 않는다면 404 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"] == "/auth/login"


def test_author_required(app, client, auth):
    with app.app_context():
        db = get_db()
        db.execute('UPDATE post SET author_id = 2 WHERE id = 1')
        db.commit()
    
    auth.login()
    assert client.post('/1/update').status_code == 403
    assert client.post('/1/delete').status_code == 403
    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

 

# 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
# tests/test_blog.py
def test_delete(client, auth, app):
    auth.login()
    response = client.post('/1/delete')
    assert response.header["Location"] == "/"
    
    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post is None

create, delete, update 코드들을 통해 어떤 유닛 테스트 코드를 작성해야 하는지 볼 수 있고, 거의 처리되는 형태가 유사함을 알 수 있다.

 

Running the Tests

 pyproject.toml에 다음 설정을 추가한다.

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.coverage.run]
branch = true
source = ["flaskr"]

 ini_options 섹션은 test 할려는 로직들의 디렉토리를 testpaths 에 넣어주면, tests 폴더 내의 모든 .py 파일을 테스트 파일로 인식하게 된다. coverage.run 섹션에서는 분기 커버리지에서 각 분기들이 전부 최소 한 번 이상은 실행되게 할지, 말지 를 설정할 수 있고, source 옵션은 코드 커버리지 측정 대상 경로를 지정한다. 여기서는 flaskr 어플리케이션을 측정하는 것이기 때문에 해당 폴더 내의 모든 .py 파일을 측정 대상으로 한다.

pytest

 이제 pytest를 통해 실행하면 된다.

coverage run -m pytest # 테스트 측정 지표 출력
coverage report # htmlcov 디렉토리에 index.html 생성

위 명령을 통해서 더 자세한 코드 커버리지를 볼 수 있다. htmlcov 디렉토리에 파일을 생성하여 browser에서 report 또한 볼 수 있다.

'BackEnd > Flask' 카테고리의 다른 글

Flask - 9. Deploy to Production  (0) 2024.02.25
Flask - 7. Tutorial Blog Blueprint  (0) 2024.02.15
Flask - 6. Tutorial Templates  (0) 2024.02.14
Flask - 5. Tutorial Blueprint and View  (1) 2024.02.14
Flask - 4. Define and Access the Database  (1) 2024.02.12
Comments