0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonプロジェクトにおけるユニットテスト~カバレッジ計測

Posted at

インポートパス設定

プロジェクトルートに 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系にあるようなテストクラスは設けない
  • テストメソッドごとに以下の内容を記載していく
    • テスト対象クラスのインスタンス化
    • テスト対象メソッドの実行
    • テスト対象メソッド実行結果の検証
  • テストメソッド間に結果の依存が無いようにする (後述)
foo/sample1_test.py
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

例えばテストに使用するデータベースの構築など

conftest.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の活用

あるクラスのテスト結果が、別のクラスの処理に大きく依存してしまう実装は避けるべきである

以下は悪いテストコードの例

sample.py
class Controller:
    def read(self):
        ...
    def write(self):
        ...
    def execute(self):
        ...

class Sample:
    def foo(self):
        controller = Controller()
        controller.read()
        controller.write()
        controller.execute()
sample_test.py
def test_foo():
    sample = Sample()
    sample.foo()
    # テスト結果が Controller クラスの処理に依存する

正しくは以下のように Controller のコンストラクタの呼び出しをMock化する
ControllerSample の実装に手を加える必要は無い

  • pytest の場合 mocker をテストメソッドの引数に加えるだけで諸々利用可能
sample_test.py
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
docker-compose.local.yml
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:

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?