インポートパス設定
プロジェクトルートに pyproject.toml を設置して以下の内容を記載
[tool.pytest.ini_options]
pythonpath = "src"
testpaths = ["tests",]
これにより src 以下の相対パスで import 文が書けるようになる
pytest導入
以下を実行
-
pipenv install -d pytest
-
pipenv install -d pytest-mock
-
pipenv install -d pytest-cov
標準の unittest モジュールよりも高機能なのでこちらの使用を推奨
ディレクトリ構成
-
src
ディレクトリをトレースする形でtests
ディレクトリを構成する - 各ディレクトリには
conftest.py
を配置し、必要が無ければ内容を空にしておく
+ [project-root]
+-- src
+-- foo
+-- sample1.py
+-- sample2.py
+-- bar
+-- sample3.py
+-- sample4.py
+-- tests
+-- foo
+-- conftest.py
+-- sample1_test.py
+-- sample2_test.py
+-- bar
+-- conftest.py
+-- sample3_test.py
+-- sample4_test.py
+-- conftest.py
+-- Pipfile
+-- pyproject.toml
テストコード実装
- xUnit系にあるようなテストクラスは設けない
- テストメソッドごとに以下の内容を記載していく
- テスト対象クラスのインスタンス化
- テスト対象メソッドの実行
- テスト対象メソッド実行結果の検証
- テストメソッド間に結果の依存が無いようにする (後述)
import pytest
from foo.sample1 import Sample
def test_execute():
sample = Sample()
result = sample.execute('test_test')
assert result == 123
def test_execute_with_invalid_param():
sample = Sample()
with pytest.raises(FooBarException):
sample.execute('invalid')
fixture実装… の前に
以下の例では一方のテストメソッドを実行した結果 path/to/test_file
にゴミファイルが残る
そのためもう一方のテストメソッドが期待通りの振る舞いをしてくれない
import os
import pathlib
from bar.sample3 import Sample
def test_write_foo():
sample = Sample()
test_file_path = 'path/to/test_file'
pathlib.Path(test_file_path).touch()
sample.write_foo(test_file_path)
assert Path(test_file_path).read_text() == 'foo'
def test_write_bar():
sample = Sample()
test_file_path = 'path/to/test_file'
pathlib.Path(test_file_path).touch()
sample.write_bar(test_file_path)
assert Path(test_file_path).read_text() == 'bar'
# expect: 'bar', actual: 'foobar'
この例ではテストメソッドごとに異なるファイルパスを指定するという回避策もある
しかし本来はテストメソッドによる実行環境汚染自体を避けるべきである
よって、以下のようにファイル削除の後処理を行うのが正しい
import os
import pathlib
from bar.sample3 import Sample
def test_write_foo():
sample = Sample()
test_file_path = 'path/to/test_file'
pathlib.Path(test_file_path).touch()
try:
sample.write_foo(test_file_path)
assert Path(test_file_path).read_text() == 'foo'
finally:
if os.path.exists(test_file_path):
os.remove(test_file_path)
def test_write_bar():
sample = Sample()
test_file_path = 'path/to/test_file'
pathlib.Path(test_file_path).touch()
try:
sample.write_bar(test_file_path)
assert Path(test_file_path).read_text() == 'bar'
finally:
if os.path.exists(test_file_path):
os.remove(test_file_path)
補足
より最悪なのが他のテストメソッドの実行結果を前処理代わりにしている例
import os
import pathlib
from foo.sample2 import Sample
def test_create_file():
sample = Sample()
sample.create_file('path/to/file')
def test_delete_file():
sample = Sample()
sample.delete_file('path/to/file')
# test_create_file() の実行によりファイルが存在する前提のテスト
fixture実装
上記の前処理/後処理を全テストメソッドに記載するのは冗長である
fixture の仕組みを使うことで以下のように共通処理を書き直せる
-
yield
の前が前処理、後ろが後処理 - 後処理が存在しなければ
yield
自体不要
import os
import pathlib
from bar.sample3 import Sample
@pytest.fixture(scope='function', autouse=True)
def setup_file():
test_file_path = 'path/to/test_file'
pathlib.Path(test_file_path).touch()
yield
if os.path.exists(test_file_path):
os.remove(test_file_path)
def test_create_foo_file():
sample = Sample()
test_file_path = 'path/to/test_file'
sample.write_foo(test_file_path)
assert Path(test_file_path).read_text() == 'foo'
def test_create_bar_file():
sample = Sample()
test_file_path = 'path/to/test_file'
sample.write_bar(test_file_path)
assert Path(test_file_path).read_text() == 'bar'
conftest.py
複数のテストモジュールに共通するfixtureを実装する場合に使用する
例として以下の foo/conftest.py
にfixtureを実装した場合、内容は foo
ディレクトリ以下のテストに適用される
+-- tests
+-- foo
+-- conftest.py
+-- sample1_test.py
+-- sample2_test.py
例えばテストに使用するデータベースの構築など
@pytest.fixture(scope='function', autouse=True)
def setup_database():
initialize_database()
yield
cleanup_database()
def initialize_database():
...
def cleanup_database():
...
また、fixtureのもう一つの用途としてテストに使用するインスタンスの生成がある
これまでに出てきた Sample()
など単純に扱えるクラスでは不要だが、
以下のように前処理/後処理を伴う場合は fixture で共通化するべき
def test_read_table():
database = DatabaseAccessor()
try:
database.initialize()
database.read_table()
finally:
database.close()
def test_drop_table():
database = DatabaseAccessor()
try:
database.initialize()
database.drop_table()
finally:
database.close()
インスタンス生成用のfixtureは autouse=False
として、テストメソッドの引数にメソッド名を指定するのが良い
これによって yield
で指定した戻り値を得られる
- 後処理が不要な場合は
yield
ではなくreturn
@pytest.fixture(scope='function', autouse=False)
def database():
database = DatabaseAccessor()
database.initialize()
yield database
database.close()
def test_read_table(database):
database.read_table()
def test_drop_table(database):
database.drop_table()
fixtureは応用の幅が広すぎるため、今回はこのあたりで解説を切り上げ
Mockの活用
あるクラスのテスト結果が、別のクラスの処理に大きく依存してしまう実装は避けるべきである
以下は悪いテストコードの例
class Controller:
def read(self):
...
def write(self):
...
def execute(self):
...
class Sample:
def foo(self):
controller = Controller()
controller.read()
controller.write()
controller.execute()
def test_foo():
sample = Sample()
sample.foo()
# テスト結果が Controller クラスの処理に依存する
正しくは以下のように Controller
のコンストラクタの呼び出しをMock化する
Controller
や Sample
の実装に手を加える必要は無い
-
pytest
の場合mocker
をテストメソッドの引数に加えるだけで諸々利用可能
def test_foo(mocker):
mock_controller = mocker.patch('sample.Controller', autospec=True)
sample = Sample()
sample.foo()
mock_controller.return_value.read.assert_called_once()
mock_controller.return_value.write.assert_called_once()
mock_controller.return_value.execute.assert_called_once()
Mockもfixtureと同じく応用幅が広すぎるのでここまでで解説を終わる
ユニットテスト&カバレッジ計測
以下を実行
-
pipenv run pytest --cov=src --cov-report=html
カレントディレクトリに生成される
htmlcov/index.html
を開いて結果を見る
Docker環境を使ったユニットテスト
本番環境用のDockerfileを使い、以下のシェルスクリプトによりコンテナ内でユニットテストを実行する
- 本番環境用のDockerfile には
dev-packages
をインストールする処理が無いはずなので併せて実行
#!/bin/sh
IMAGE_NAME="my-unittest"
cd `dirname $0`
case "$1" in
--build | -b)
docker build . -t $IMAGE_NAME --no-cache
;;
esac
docker run --rm -it -v $(pwd):/opt/backend $IMAGE_NAME pipenv install -d && pipenv run pytest --cov=src --cov-report=html
このようにコンテナ仮想化はサービス(永続的処理)の稼働元とするだけではなく、
バッチ処理(非永続的処理)の実行環境とする用法も強力
DockerComposeを使用
特にデータベースを用いる場合などに使用する
本番環境用の docker-compose.yml
にはDBの定義等が無いケースの方が多いので、
docker-compose.local.yml
を用意してそちらに定義する想定
- frontendコンテナなどは不要なので、ビルド&起動するコンテナを指定する
-
down
の際にvolumes
も削除することでDBの汚染を排除する
#!/bin/sh
BACKEND_CONTAINER_NAME="my-container"
cd `dirname $0`
case "$1" in
--build | -b)
docker compose -f ../docker-compose.local.yml build backend appdb --no-cache
;;
esac
docker compose -f ../docker-compose.local.yml up backend appdb -d
docker exec $BACKEND_CONTAINER_NAME sh -c "pipenv install -d && pipenv run pytest --cov=src --cov-report=html"
docker compose -f ../docker-compose.local.yml down --volumes
services:
backend:
build:
context: backend
dockerfile: Dockerfile
image: my-backend
container_name: my-backend
ports:
- 8000:8000
volumes:
- ./backend:/opt/backend
networks:
- my-network
environment:
APP_DB_HOST: appdb
APP_DB_PORT: 5432
tty: true
restart: on-failure
command: sh start.sh
frontend:
# 省略
appdb:
build:
context: local/appdb
dockerfile: Dockerfile
image: my-appdb
container_name: my-appdb
ports:
- 5432:5432
volumes:
- my-appdb-volume:/var/lib/postgresql/data
networks:
- my-network
environment:
POSTGRES_USER: myusername
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: mydatabase
tty: true
restart: on-failure
volumes:
my-appdb-volume:
networks:
my-network: