Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

2023年 研究開発部 新卒技術研修 ~ テストコード編 ~

こんにちは、研究開発部 Data Analysisグループの笛木です。

4/26(水)〜 4/28(金)で研究開発部内の技術研修を行いました。

こちらのブログの続きでテストコードについての研修資料を一部公開します。研修では新卒2年目の私が1年間で部内のコードなどから学んだ情報を共有しました。至らない部分もあるかもしれませんが、ご参考になれば幸いです。 こちらの研修で使用したGitHubのコードリンクは以下です。適宜、ご参照ください。

github.com

目次

はじめに

この研修の目的

  • テストコードの重要性について理解し、テストコードを書くことが義務ではなく、活用できるようにしてもらう。
  • pytest等の各ツールのユースケースを示すことで、実際の業務での利用シーンをイメージしてもらう。

研修スコープ外

  • pytestの基本的な使い方については、公式documentや『テスト駆動Python 第2版』1等の書籍等でキャッチアップしてもらう。

テストコードについて

テストコードの便利な点

実行確認しやすい
jupyterやコンソールで書き捨ての実行コードを書くのであれば、はじめからテストコードのフォーマットで書いた方が色々と便利です。デバッグ編
1. 1行ずつ実行ができます → debug mode
2. 入出力データを他でも使いまわせます → pytest fixture
3. 書き捨てではなくなるので、GitHubで管理して使い回せます → testsディレクトリをGitHubへpush

リファクタリングや修正後に実行結果や振る舞いが変わっていないことを確認できる
1. まずは動くコードを書いて、テストを通します。
2. レビューに出す前にリファクタリングをします。
3. 変わらずテストが通っていれば、処理内容が変わっていない状態でコードが整理できたことをある程度は確認できます。※ 完全ではないので注意が必要です。

コードの挙動が理解しやすい
テストコードでは、テストしたい対象への入力とその期待する出力が書いてあるため、どのような使用を想定されているのかがコード作成者以外にも伝わりやすいです。
  → よって、コードだけではなく、テストコードも分かりやすいコードにすることを心がけてください。

PR時には、レビュー対象のコードとそのテストを同時に出すことを推奨します。 レビュー時には、そのコードを実際に動作させることが困難な場合もあります。 そのような動作を確認できない場合でも、ある程度の挙動が把握できれば、バグにつながりそうな問題をレビューで指摘してもらいやすくなります。 テストコードなしでは、使用方法や期待する挙動が分かりにくく、深い部分までの指摘がしにくくなってしまいます。

簡単にテストが書けるコードを意識できる
簡単にテストが書けるコードを意識することで、個々の機能・処理がそれぞれ依存しすぎない形で正常に動作していることが確認しやすくなります。
例えば、1つの関数やメソッドに色々な処理内容が含まれていたり、色々なクラスや関数に依存しすぎてしまっていたりすると、テストが書きにくくなってしまいます。

(気持ちに少しでもゆとりができる)
一定の心理的安全性が手に入ります。

※ 上記の便利な点はテスト駆動開発にも近しいものを感じているので、より詳しく知りたい方はこちら2をご参照ください。

テストコードの悪い例

  • テスト同士を依存させてしまう
    • 「テストAをしてからテストBを実行した時」「テストBをしてからテストAを実行した時」とで、結果が変わってしまったということは起きてはいけません。
  • 実際の出力結果を見て、期待出力を書いてしまう
    • 一回実行してみて、その出力を正解として期待出力にしてしまうと、テストを書くということが目的になってしまいます。あくまで、テストを書く目的は自分が期待する出力を返すようなコードになっているかを確認することです。

テストコードに関するFAQ

ここでは、私が新卒1年目に抱いた疑問をいくつか取り上げます。

Q1. テストコードの書き方がわからない場合はどうすれば良いですか?
A1. 他レポジトリのテストコードを参考にしてください。それでも難しい場合は、テストができそうな部分だけ書きましょう。

Q2. テストコードを書く時間がない場合は省略してもいいですか?
A2. テストを書かないために起きてしまうバグにより余計に時間がかかってしまうこともあります。テストコードを書く時間を鑑みて、ゆとりをもった開発スケジュールを立てると良いでしょう。

Q3. 簡単な実装の場合でも、テストコードを書いた方が良いのでしょうか?
A3. 思わぬところで間違っていることもあるので、簡単なコードでもテストを書くことをお勧めします。

pytestによるテストコードの書き方

ファイル名

テストコードのファイル名は、test_hoge.py とします。
※ これをしないとテストコードのファイルであることが認識されません。

ディレクトリ

ディレクトリ上でのテストコード群の配置例を2つ示します。

例1

.
├── src
│   └── package_name
│       ├── proprocessors
│       │   ├── __init__.py
│       │   └── preprocessor.py
│       └── postprocessors
│           ├── __init__.py
│           └── postprocessor.py
├── tests
│   ├── __init__.py
│   ├── conftest.py
│   ├── proprocessors
│   │   ├── __init__.py
│   │   └── test_preprocessor.py
│   └── postprocessors
│       ├── __init__.py
│       └── test_postprocessor.py
├── pyproject.toml
└── poetry.lock
  • src配下のディレクトリ構造とtests配下のディレクトリ構造が等しいため、対応関係がわかりやすいです。
  • 既存のコードにテストを追加する場合でも、どこにテストを配置すれば良いのかわかりやすいです。

例2

.
├── src
│   └── package_name
│       ├── proprocessors
│       │   ├── __init__.py
│       │   └── preprocessor.py
│       └── postprocessors
│           ├── __init__.py
│           └── postprocessor.py
├── tests
│   ├── __init__.py
│   ├── conftest.py
│   └── unit_test
│       ├── test_preprocessor.py
│       └── test_postprocessor.py
├── pyproject.toml
└── poetry.lock
  • この構成も存在します。例1と例2が混在していると、どちらに合わせればいいのか、どこにテストファイルを置けばいいのかが分かりにくくなってしまうため、できる限りどちらかに合わせましょう。

基本編

関数やメソッド名は、test_(テスト対象の関数名やメソッド名) とします
※ これをしないとテストコードであることが認識されず、テスト実行時にスキップされてしまいます。

Parametrize

Parametrizeは複数の異なるテストケースでテストを実行する際に便利です。

  • Parametrizeを使用しない場合
from pytest_examples.preprocessors.space_remover import remove_space

def test_remove_space_no_parametrize() -> None:
    """
    `@pytest.mark.parametrize`を使用しないでテストを書いた例
    このテスト関数では, テストケースが独立して実行されないため, 1つ目のassert文が失敗してしまうと, 2つ目のassert文は比較されない.
    """
    assert remove_space(text="San san") == "Sansan"
    assert remove_space(text="Yon yon") == "Yonyon"
  • Parametrizeを使用する場合
import pytest

from pytest_examples.preprocessors.space_remover import remove_space

@pytest.mark.parametrize(
    ("text", "expected_text"),
    [
        ("San san", "Sansan"),
        ("Yon yon", "Yonyon"),
    ],
)
def test_remove_space(text: str, expected_text: str) -> None:
    """
    Args:
        text: `remove_space`関数に入力するテキスト
        expected_text: `remove_space`関数の出力として期待するテキスト
    """
    assert remove_space(text=text) == expected_text

tips: 煩雑なテストケースを見やすくする方法

from typing import NamedTuple

import pytest

from pytest_examples.preprocessors.space_remover import remove_space

class RemoveSpaceTestCase(NamedTuple):
    text: str
    expected_text: str

@pytest.mark.parametrize(
    RemoveSpaceTestCase._fields,
    [
        RemoveSpaceTestCase(text="San san", expected_text="Sansan"),
        RemoveSpaceTestCase(text="Yon yon", expected_text="Yonyon"),
    ],
)
def test_remove_space_clean_test_case(text: str, expected_text: str) -> None:
    """
    テストケースが複雑になる場合は, 専用のNamedTupleで定義することで見やすくなります
    Args:
        text: `remove_space`関数に入力するテキスト
        expected_text: `remove_space`関数の出力として期待するテキスト
    """
    assert remove_space(text=text) == expected_text

Fixture

Fixtureとは、さまざまなコミュニティで異なる意味を持つため、ここでの説明は @pytest.fixture の関数・メソッドについて説明します。Fixtureでは、テストに使用するデータの準備を行います。Fixureを使用することで、前処理コード(ファイルをオープンするコード、etc…)や後処理コードをテスト関数から切り離すことができるため、テスト関数自体が簡潔になり、何をテストしたいのかが分かりやすくなります。

fixtureを使い回す際には、テスト同士が依存し合わないように注意してください。
テスト同士を依存させてしまう

scopeは適切に設定してください。
クラスをテストしたい場合、クラスのインスタンスをfixtureにすることで、何回もインスタンス化しなくても済みます(ここでは scope=”class” にしているため)。

from typing import NamedTuple

import pytest

from pytest_examples.preprocessors.text_normalizer import TextNormalizer

class TextNormalizerTestCase(NamedTuple):
    text: str
    expected_text: str

class TestTextNormalizer:
    @pytest.fixture(scope="class")
    def text_normalizer(self) -> TextNormalizer:
        return TextNormalizer()

    @pytest.mark.parametrize(
        TextNormalizerTestCase._fields,
        [
            TextNormalizerTestCase(text="A", expected_text="A"),
            TextNormalizerTestCase(text="ギ", expected_text="ギ"),
        ],
        ids=["正規化する必要がない文字はそのまま出力されるケース", "半角カタカナが全角カタカナに変換されるケース"],
    )
    def test_call(self, text_normalizer: TextNormalizer, text: str, expected_text: str) -> None:
        assert text_normalizer(text) == expected_text

 

ポイント

  • 自作クラスのメソッドをテストしたい場合は、Testクラスにまとめることで分かりやすくなる場合もあります。
    • fixtureのscopeも適切に設定しやすいことがメリットです。
  • 例えば以下のようなインスタンス化に時間がかかるようなクラスのテストについても、メソッドごとにインスタンス化しなくても良いため、インスタンス化にかかる時間を省略できます。
import time

class RandomModel:
    """機械学習モデルを想定したクラス"""

    def __init__(self) -> None:
        time.sleep(5)  # 非常に重い初期化処理を想定している

異常系

意図してエラーになるケースもテストします。

以下のように PermissionError が出力される実装をした場合は、PermissionError が期待通りに出力されるかをテストします。

import os

class StorageClient:
    """
    以下のようなクラスを想定してのデモクラス
    - GCPのCloudStorageにアクセスするためのクラス
    - AWSのS3にアクセスするためのクラス
    このクラスのメソッドを実行するためには何かしらの権限が必要になる(ことを想定)
    特定の条件下でインスタンス化でエラーが起きるクラスを用いるテストするときの対処法を説明するために作成したクラス
    """

    def __init__(self) -> None:
        # 権限がないことを環境変数で表現している
        if os.environ.get("HAS_STORAGE_AUTHORIZATION") != "true":
            raise PermissionError("Not authorized.")

テストコードは以下です。

import pytest

from pytest_examples.clients.storage_client import StorageClient

class TestStorageClient:
    def test_initialize_permission_error(self) -> None:
        with pytest.raises(PermissionError, match="Not authorized."):
            StorageClient()

Mock

Mockを使用することで、テストしたい部分外の振る舞いについてコントロール可能にすることができます。 以下で、例を用いて説明していきます。

以下の引数の storage_client に入力されるインスタンスは、外部と通信をするクラスであるため、テスト環境では外部と接続する権限がなく、使用できないとします。

from pytest_examples.clients.protocol import IClient
from pytest_examples.models.random_model import RandomModel
from pytest_examples.preprocessors.protocol import IPreprocessor

class Extractor:
    def __init__(self, preprocessor: IPreprocessor, model: RandomModel, storage_client: IClient) -> None:
        self.preprocessor = preprocessor
        self.model = model
        self.storage_client = storage_client

Extractor クラスをテストしたいのですが、storage_client に入力できるものがなくテストができません…

→ Mockを作成して、代替の入力にすることでこの問題を回避できます。

from unittest.mock import Mock

import pytest

from pytest_examples.clients.protocol import IClient

class TestExtractor:
    @pytest.fixture(scope="class")
    def dummy_storage_client(self) -> Mock:
        storage_client_mock = Mock(spec=IClient)
        storage_client_mock.get_text.return_value = ""  # ここの値は今回テストしたい範囲外のためダミー文字列
        return storage_client_mock

ここでは、 dummy_storage_client という IClient と同じインターフェースを持ったfixture(get_text メソッドを実行したら "" を返すインスタンス)を作成して、Extractor クラスに注入します。

 

他のMockのユースケース

  • ランダムに出力された値を後処理したいクラスのテスト
    • ランダムに出力されてしまってはテストを実行するたびに出力変わってしまうため、期待出力を決めることができません。このような時には、ランダムに出力する部分をMockにして制御可能にします。

indirect

@pytest.mark.parametrize の引数の1つで、与えた入力を直接テストに使用するのではなく、間接的に使用することができます。

先ほどのようにMockを単体で用いると、固定の値を返すようなMockしか作成できません。

一方で、テストケースごとにこちらが意図する出力をするようなMockが欲しい場合にはindirectを使用します。

from unittest.mock import Mock

import pytest

from pytest_examples.clients.protocol import IClient
from pytest_examples.extractors.extractor import Extractor
from pytest_examples.models.random_model import RandomModel
from pytest_examples.preprocessors.protocol import IPreprocessor
from pytest_examples.schemas.model_output import ModelOutput

class TestExtractor:
    @pytest.fixture(scope="class")
    def dummy_preprocessor(self, request: pytest.FixtureRequest) -> Mock:
        preprocessor_mock = Mock(spec=IPreprocessor)
        preprocessor_mock.return_value = request.param
        return preprocessor_mock

    @pytest.fixture(scope="class")
    def dummy_model(self, request: pytest.FixtureRequest) -> Mock:
        model_mock = Mock(spec=RandomModel)
        model_mock.predict.return_value = request.param
        return model_mock

    @pytest.fixture(scope="class")
    def dummy_storage_client(self) -> Mock:
        storage_client_mock = Mock(spec=IClient)
        storage_client_mock.get_text.return_value = ""  # ここの値は今回テストしたい範囲外のためダミー文字列
        return storage_client_mock

    @pytest.fixture(scope="function")
    def extractor(
        self,
        dummy_preprocessor: Mock,
        dummy_model: Mock,
        dummy_storage_client: Mock,
    ) -> Extractor:
        return Extractor(
            preprocessor=dummy_preprocessor,
            model=dummy_model,
            storage_client=dummy_storage_client,
        )

    @pytest.mark.parametrize(
        ("dummy_preprocessor", "dummy_model", "extracted_text"),
        [
            ("Sansan株式会社", ModelOutput(text_length=10, start_position=0, end_position=6), "Sansan"),
        ],
        indirect=["dummy_preprocessor", "dummy_model"],
    )
    def test_extract(self, extractor: Extractor, extracted_text: str) -> None:
        assert extractor.extract(document_id="33") == extracted_text

dummy_model は、indirectの引数に与えられているため、以下のfixtureを経由して、テストに入力されます。

    @pytest.fixture(scope="class")
    def dummy_model(self, request: pytest.FixtureRequest) -> Mock:
        model_mock = Mock(spec=RandomModel)
        model_mock.predict.return_value = request.param
        return model_mock

indirectで与えられる値をfixture内に渡すときは request 引数経由で渡されます。 (request.param に値が格納されているのでここからアクセスできます。)

indirectの挙動を理解するには初見では困難なため、適宜、デバッグ編 を使用して、理解に役立ててください。

indirectを使いすぎると、逆に可読性が下がるので、indirectを使用した方がわかりやすくなるかを検討してから、使用するように注意してください。

 

他のユースケース

  • 画像を入力にしたテストに対して、ファイルパスを入力にしたい場合に便利です。これをすることにより、逐一、画像をopenするコードを書かずに済みます。

conftest

複数のテストファイルで使用したいfixtureは、conftest.pyに記述することで、importなしで使用することができます。

以下のようなルートのパスは汎用的で複数のテストファイルで使用するという場合を考えます。conftest.pyの一箇所にまとめることで共通化して使用できます。

from pathlib import Path

import pytest

@pytest.fixture(scope="session")
def root_path() -> Path:
    return Path(__file__).parent.joinpath("dummy_documents")

以下のファイル中には root_path は定義していませんが、conftest.pyに存在するため、問題なく使用することができます。テストファイル内に定義されていない変数があれば、まずは、conftest.pyを確認してみてください。

from pathlib import Path

import pytest

from pytest_examples.clients.local_storage_client import LocalStorageClient

class TestLocalStorageClient:
    @pytest.fixture(scope="class")
    def local_storage_client(
        self,
        root_path: Path,  # conftest.pyにfixtureが実装されているため, それを参照する
    ) -> LocalStorageClient:
        return LocalStorageClient(root_path)

 

ポイント

  • conftest.pyを配置する場所は、tests配下でも良いですが、共通して使用したいテストファイルがあるディレクトリに置いた方がどこで使われるか分かりやすいのでおすすめです。
    • このconftest.pyでは、tests/clients/配下のテストファイルにしか使用しないため、ここに配置しています。
    • tests/全体で使用する場合は、tests/直下に配置します。

知っておくと活用する場面があるかも編

一時的な環境変数の設定

元々、環境変数によって制御をしていたクラスがあったとします。そのような以下のクラスが正常にインスタンス化できるかをテストするとき、一時的に環境変数を設定したいケースを考えます。一時的に設定したい理由としては、設定していないケースのテストも存在しているためです。

import os

class StorageClient:
    """
    以下のようなクラスを想定してのデモクラス
    - GCPのCloudStorageにアクセスするためのクラス
    - AWSのS3にアクセスするためのクラス
    このクラスのメソッドを実行するためには何かしらの権限が必要になる(ことを想定)
    DI化しているとテストするときに便利であることを説明するためにインスタンス化でエラーが起きるクラスを作成した
    """

    def __init__(self) -> None:
        # 権限がないことを環境変数で表現している
        if os.environ.get("HAS_STORAGE_AUTHORIZATION") != "true":
            raise PermissionError("Not authorized.")

@mock.patch.dict を使用することで、一時的に環境変数を設定できます。

import os
from unittest import mock

class TestStorageClient:
    @mock.patch.dict(os.environ, {"HAS_STORAGE_AUTHORIZATION": "true"})
    def test_initialize(self) -> None:
        StorageClient()

 

参考

おまけ

テストしやすいコードについて

Mock において、以下のような Extractor だったため、比較的テストが容易でした。

一方で、以下のようなクラスを考えます。 storage_client を内部でインスタンス化するコードだと、 StorageClient の仕様上、権限がない環境下では、 PermissionError になってしまい、テストをすることができません(他の便利ツールを使用することでこのような問題を回避することは可能です)。

実装する際にはこのコードはテストしやすいだろうかという視点も持って、実装してください。

from pytest_examples.clients.protocol import IClient
from pytest_examples.models.random_model import RandomModel
from pytest_examples.preprocessors.protocol import IPreprocessor

class BadExtractor:
    def __init__(self, preprocessor: IPreprocessor, model: RandomModel) -> None:
        self.preprocessor = preprocessor
        self.model = model
        self.storage_client = StorageClient()  # motoとかpytest_mockならテストすることは可能だがイレギュラーケース

    def extract(self, document_id: str) -> str:
        input_text = self.storage_client.get_text(document_id)
        preprocessed_text = self.preprocessor(input_text)
        model_output = self.model.predict(preprocessed_text)
        start_position = model_output.start_position
        end_position = model_output.end_position
        return preprocessed_text[start_position:end_position]

VSCodeでのテスト実行方法

CUI上からコマンドを入力することでもテストは実行できますが、VSCodeで開発している場合は、ポチポチでテストを実行することができるため、コマンドを覚えたり、入力する手間が省けるためおすすめです。

準備編

1. 意図した環境が使用されているかを確認します。

  • 認識がされていない場合
    • 赤枠内をクリックして、Enter interpreter path…をクリックします。
    • .venv/bin/配下にpythonがあるため、そのpathをコピペします。

2. フラスコをクリックして、 Configure Python Tests をクリックします。

3. pytestを使用するため、pytestを選択します。

4. テストコードのルートディレクトリは tests/なので、testsを選択します。

5. 以下のような画面になっていれば準備完了です。

.vscode/settings.json が作成されているはずです。

{
    "python.testing.pytestArgs": [
    "tests"
    ],
    "python.testing.unittestEnabled": false,
    "python.testing.pytestEnabled": true
}

実行編

このボタンを押すと、テストが全て実行され、成功した場合は、全てにチェックマークがつきます。

エラー編

以下のように、Pytest Discovery Errorが出現したら、コード上にエラーが存在していて、テストができないことを意味します。適宜、エラーを修正する必要があります。

エラーが見つからない場合

エラー原因は OUTPUT に出力されています。(右上のトグルを Python にする必要あり)

あるあるなエラー

  • 引数名が一致していない。
    • エラー原因: expected_stringexpected_text で一致していない
@pytest.mark.parametrize(
    ("text", "expected_string"),
    [
        ("San san", "Sansan"),
        ("Yon yon", "Yonyon"),
    ],
)
def test_remove_space(text: str, expected_text: str) -> None:
    assert remove_space(text=text) == expected_text
  • 引数が足りない。
    • エラー原因: expected_text が足りない
@pytest.mark.parametrize(
    ("text",),
    [
        ("San san", "Sansan"),
        ("Yon yon", "Yonyon"),
    ],
)
def test_remove_space(text: str, expected_text: str) -> None:
    assert remove_space(text=text) == expected_text

デバッグ編

テストコードに対して、デバッグを行うことができます。

基本的な使用方法

テストしたい行番号の左をクリックすると、赤丸(breakpoint)がついて、その行でストップすることができます。このbreakpointがないと、ストップせずに実行終了してしまうので注意してください。

あるテストケースでデバッグしたい場合は下の赤枠内のボタンをクリックします。

breakpointで止まると以下のような画面になります。

この状態で、以下の画面に移動すると、変数の格納状況を把握できます。

以下のボタンは、左から、「Continue / Pause (次のbreakpointまで実行)」「Step Over (現在の行を実行)」「Step Into (1つ内側のコードに入る)」「Step Out (1つ外側のコードに出る)」「Restart (最初から実行し直す)」「Stop (デバッグを終了する)」です。参照

便利なDEBUG CONSOLE

[command] + j でターミナルが起動します

DEBUG CONSOLE を押すと、現在の環境下でコードが実行できます。

e.g.) 小文字に変換できるか確認したりできる。

自作コード以外のコードにもデバッグで潜りたい

モチベーションとしては、内部の実装がどうなっているか確認したい場合です。

現在の設定では、左から3番目のステップインボタンを押しても自作関数内にしかステップインできません。

設定方法

1. create a launch.json fileをクリックします。

2. Python Fileをクリックします。

3. .vscode/launch.json が作成されるので、以下に変更します。(参考: https://code.visualstudio.com/docs/python/testing)

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: Current File",
            "type": "python",
            "request": "launch",
            "program": "${file}",
            "console": "integratedTerminal",
            "justMyCode": false
        },
        {
            "name": "Python: Debug Tests",
            "type": "python",
            "request": "launch",
            "program": "${file}",
            "purpose": ["debug-test"],
            "console": "integratedTerminal",
            "justMyCode": false
        }
    ]
}

4. デバッグ実行後、自作コード以外のコードにもステップインすることができます。

終わりに

新卒社員のメンバーの多くは、テストコードを今まで書く習慣がないとのことでしたが、この研修で大まかな概念や必要性、どのように役に立たせるかを理解できたようでした。一方で、座学形式だったので、実際に書く時間がほしいという意見もありました。ここについては、来年度以降、コンテンツを拡充していきたいと考えています。

研究開発部にご興味のある方は是非、以下からご応募ください。

media.sansan-engineering.com


  1. Brian Okken. テスト駆動Python 第2版. 翔泳社, 2022
  2. Kent Beck. テスト駆動開発. オーム社, 2017

© Sansan, Inc.