FizzBuzzのテストコードを書いてみよう

たっぷり練習しましょう。
「私テストコード書けるんじゃない!?」とぜひ思ってください

なお、完成版は goal の下にあります

src/fizzbuzz/core.py
def fizzbuzz(number: int) -> str:
    match number % 3, number % 5:
        case 0, 0: return "FizzBuzz"
        case 0, _: return "Fizz"
        case _, 0: return "Buzz"
        case _, _: return str(number)

ref: Day2 Keynote:A Perfect match ―Mr. Brandt Bucher (PyCon JP 2021 カンファレンスレポート)

tests/test_core.py を書いてみよう。
「3の倍数のときはFizzを返す」

まずはテストの関数を書く

tests/test_core.py
def test_3の倍数のときはFizzを返す():
    # 後述
  • 3の倍数を渡して fizzbuzz 関数を呼び出す

    • 3の倍数としてなにか1つ値を選ぶ(例 3

  • 返り値は Fizz になるはず

呼び出すところまで書いてみる

tests/test_core.py
from fizzbuzz.core import fizzbuzz

def test_3の倍数のときはFizzを返す():
    _ = fizzbuzz(3)

「返り値は Fizz になるはず」、assert の出番!

tests/test_core.py
from fizzbuzz.core import fizzbuzz

def test_3の倍数のときはFizzを返す():
    assert fizzbuzz(3) == "Fizz"

テストが書けました!!

テストを実行すると、(fizzbuzz 関数は完成しているので)通ります。

fizzbuzz 関数を壊すと、テストは落ちます(壊したことに気づける!)

注釈

pytestはassert文を拡張(だから出力が変わっている)

Assertion Rewriting

おすすめの実行方法

  • pytest -vv

    • --verbose

    • -v でも詳しくなるが、 -vv だと assert A == B の差分がとても分かりやすい

  • pytest --ff

    • --failed-first: 最後に落ちたテストから実行

  • ケースの指定 pytest tests/test_practice.py::test_環境構築の確認

  • テスト駆動Python 第2版』を一読すると、もっと知れます

注釈

日本語で書いてる!

t-wadaさんのエントリの中の 日本語テストメソッド より

日本語でコミュニケーションをとっているプロジェクトの場合、日本語でテストメソッド名を書いても良いのではないかという考え方

3A

あえて回りくどく書いてみる
「テスト対象を実行した値は、期待結果と一致する」
tests/test_core.py
def test_3の倍数のときはFizzを返す():
    number = 3
    actual = fizzbuzz(number)

    expected = "Fizz"
    assert actual == expected
actual value:

テスト対象を実行した値

expected value:

期待結果

3A、すなわち3つのA

https://xp123.com/articles/3a-arrange-act-assert/

  • Arrange 準備

  • Act 実行

  • Assert 検証

tests/test_core.py
def test_3の倍数のときはFizzを返す():
    # Arrange: テストの準備(データの用意など)
    number = 3

    # Act: テスト対象の関数を実行
    actual = fizzbuzz(number)

    # Assert: 実行結果が期待値と等しいかを検証
    expected = "Fizz"
    assert actual == expected

先のテスト(assert fizzbuzz(3) == "Fizz")は

  1. Arrange と Act を書いた(fizzbuzz(3)

  2. Assert を書いた

注釈

テストはAssertから書く

個人的にはどこから書いてもよいと思っている。 Assertから書くと「Actが必要だ」「Arrangeが必要だ」と詰まらずに書きやすい印象があるので、初めての方は試してみてもいいかも

なにか1つ、テストを書いてみましょう

  • 5の倍数のとき

  • 15の倍数のとき

  • 3の倍数でも5の倍数でもないとき

注釈

assert の一覧

assert の形式(参考:『テスト駆動Python 第2版』表2-1)

検証したいこと

書き方

actualexpected が等しい

assert actual == expected

actual の期待値は True

assert actual

actual の期待値は False

assert not actual

assert actual is False より assert not actual の方がエラーメッセージが分かりやすくなります

注釈

クラスを使ってテストケースを構造化できる

TDDパートを参照

注釈

pytest-watch

テストを書いて毎回 pytest している。

ファイルに変更があったら自動でpytestが流れるようにするライブラリがある。

ptw -- -vv --ff

pytestはテストコードを書くのをサポートする

テストの概念

  • パラメタ化テスト

  • モック

pytestの機能

  • pytestのフィクスチャ

パラメタ化テスト

🙅‍♂️「3の倍数のテストで、複数の値を実行しちゃえ」

よくない例
def test_3の倍数のときはFizzを返す():
    assert fizzbuzz(3) == "Fizz"
    assert fizzbuzz(6) == "Fizz"
    assert fizzbuzz(9) == "Fizz"

1つのテストメソッドは、1つのことを検証しよう

  • 3のとき

  • 6のとき

  • 9のとき

それぞれ分けたい

注釈

アサーションルーレット(アンチパターン)

t-wadaさんのエントリの中の [ポイント] アサーションルーレット(Assertion Roulette) より

このテストが失敗したときに、どのアサーションが失敗したのかがわかりにくいのです。

では、個別にテストの関数を書くのか?

アサーションルーレットをしないためにこうする?
def test_3の倍数のときはFizzを返す_3の場合():
    assert fizzbuzz(3) == "Fizz"


def test_3の倍数のときはFizzを返す_6の場合():
    assert fizzbuzz(6) == "Fizz"

パラメタ化テスト

t-wadaさんエントリ [ポイント] パラメータ化テスト(Parameterized Test) より

ほぼ同じテスト内容でデータだけを変えたテストメソッドを(列挙であれループであれ)書いているときに、テストメソッドにパラメータを渡せればいいのに、と感じることがあると思います。

pytestにおいては、テストケースの関数を @pytest.mark.parametrize でデコレートする
tests/test_core.py
import pytest

from fizzbuzz.core import fizzbuzz


@pytest.mark.parametrize("number", [3, 6])
def test_3の倍数のときはFizzを返す(number):
    assert fizzbuzz(number) == "Fizz"
最初から狙ってやるものではなく、「パラメタ化できそう」と気づいたら適用するもの
参考:「テストケースがない時に dataProvider から書かない」(拙ブログ

注釈

parametrizeと日本語

エスケープされてしまう

pytestのtest IDにパラメータ由来の日本語を使う方法

設定値 disable_test_id_escaping_and_forfeit_all_rights_to_community_support
ただしこれも限定的(pytest.param のidは未サポート)

pytestのフィクスチャを使う

フィクスチャを知った後の方がモックが説明しやすいため、紹介したときの順番と前後します。
pytestの概念 です

例えば、fizzbuzzの出力をテストするとしたら?

printする関数のテスト?
def print_fizzbuzz(upper_limit: int) -> None:
    for number in range(1, upper_limit + 1):
        print(fizzbuzz(number))

pytestは機能を提供している

capsys (Accessing captured output from a test function (How to capture stdout/stderr output))

capsysはpytestのビルトインのフィクスチャの1つ

フィクスチャは テストの関数の引数に書く

tests/test_core.py
def test_fizzbuzzの出力のテスト(capsys):
    # 後述
  • Actで標準出力を伴う関数を呼び出す

  • Assertにおいて、capsys.readouterr() を呼び出す

    • 標準出力は capsys.readouterr().out (これが actual

    • 標準出力が期待値と一致するかを assert

注釈

PyCon JP 2024より「あなたのアプリケーションをレガシーコードにしないための実践Pytest入門」

pytestのフィクスチャ辞典

(ただし、テスト駆動開発については、こちらの発表内容は誤解が見受けられるので注意(後述))

注釈

pytestのtmp_pathフィクスチャ

一時ディレクトリや一時ファイルを作ってくれる。
標準出力ではなくFizzBuzzをファイルに書き込む場合は、tmp_pathフィクスチャを使ってテストできる

The tmp_path fixture (How to use temporary directories and files in tests)

注釈

capsysやtmp_pathは本当に必要ですか?(発表者の考え)

テストにもいろいろ

  • 単体 unit

  • 結合 integration

pytestは 幅広くテストをカバー しているように思われる(単体も結合もどちらも書ける)

capsysフィクスチャを使えばテストを書けるが、私は print_fizzbuzz 関数のテストは書かなくてもよいと考える。
ただし、 fizzbuzz関数は徹底的にテスト してある前提で。
なぜなら、 print_fizzbuzz 関数は fizzbuzz 関数の出力を print するだけだから。

テストする必要がないほど質素なコードにして、コードに恥をかかせる」(拙ブログより「 printはテストしないという考え方 」)

また、設計の観点から入出力と計算処理は分離したほうが、変更が容易になる(拙ブログ「` ソフトウェアを作りたかった私へ:入出力と計算を分ける <https://nikkie-ftnext.hatenablog.com/entry/sharply-distinguish-io-from-calculation>`__」)。
計算処理を徹底的にテスト し、入出力はテストする必要がないほど質素なコードにする。
参考:単体・結合の分け方以外に
Googleによる Test Sizes

モック

モックとは、ニセモノ

たとえば、出力が変わる関数

import random


def draw_lottery() -> str:
    number = random.randint(1, 6)
    if number == 6:
        return "超吉"
    else:
        return "凶"

注釈

モックの使い所

  • 時間のかかる処理のテストを書く

  • 外部と通信する処理のテストを書く

出力が変わると困るので、テストにおいては random.randint() をニセモノに置き換える

  1. pytestのmonkeypatchフィクスチャを使った例

def test_6の目が出たら超吉と返す(monkeypatch):
    randint_call_count = 0

    def randint_mock(a, b):
        assert (a, b) == (1, 6)
        nonlocal randint_call_count
        randint_call_count += 1
        return 6

    # randintをニセモノに差し替えて、このテストでは1〜6のうち絶対6が出るものとする
    monkeypatch.setattr(random, "randint", randint_mock)

    assert draw_lottery() == "超吉"
    assert randint_call_count == 1
  • 絶対6を返すニセモノの関数 randint_mock を定義した

  • このテストにおいては、monkeypatchを使って random.randintrandint_mock に置き換えた

How to monkeypatch/mock modules and environments

  • draw_lottery のテストは、6の目が出たときの出力の検証に絞れる

  • 実装の中で random.randint() を呼び出しているかも合わせて検証

注釈

nonlocal

Python チュートリアル 9.2. Python のスコープと名前空間

  1. unittest.mock.patch を使った例

@patch("random.randint", return_value=5)
def test_6以外の目が出たら凶と返す(mock_randint):
    assert draw_lottery() == "凶"
    mock_randint.assert_called_once_with(1, 6)
  • unittest.mock.patch を使って random.randintmock_randint に置き換えた

    • mock_randintunittest.mock.MagicMock

    • mock_randint() と呼び出されたときの返り値を設定

      • return_value=5 (呼び出されたら常に5)

  • MagicMock は呼び出され方を記録している

自作の関数を monkeypatch.setattr() したのと同様のことを少ないコードで実現できている

ref: patch デコレータ(unittest.mock --- 入門)

注釈

特殊メソッド __call__()

https://docs.python.org/ja/3/reference/datamodel.html#class-instances

任意のクラスのインスタンスは、クラスで __call__() メソッドを定義することで呼び出し可能になります。

注釈

PyCon JP 2020より「unittest.mockを使って単体テストを書こう 〜より効率的で安定したテストに〜 」

https://pycon.jp/2020/timetable/?id=203572

注釈

Test Doubles

http://xunitpatterns.com/Test%20Double.html

  • 広義のモック

  • テストにおける代役(代役にもいろいろある)

    • スタブ:(HTTP通信などが)実際に動かないようにする代役

    • 狭義のモック:呼び出す処理を実際に動かないようにする + モックを期待通り使っているかassert

拙ブログ PHPUnitのドキュメントを機にxUnit Test Patternsのサイトを確認し、Test Double・Stub・Mockを整理 〜広義のモックと狭義のモック〜

注釈

pytestのフィクスチャの使いこなし

(1)のケースのリファクタリング

@pytest.fixture
def always_6_randint(monkeypatch):
    randint_call_count = 0

    def randint_mock(a, b):
        assert (a, b) == (1, 6)
        nonlocal randint_call_count
        randint_call_count += 1
        return 6

    monkeypatch.setattr(random, "randint", randint_mock)

    yield

    assert randint_call_count == 1


def test_6の目が出たら超吉と返す_リファクタ版(always_6_randint):
    assert draw_lottery() == "超吉"
  • monkeypatchを使った自作のフィクスチャを定義

  • 「6の目が出たら超吉と返す」は自作のフィクスチャを指定して実装

    • 「6の目が出たら超吉と返す」ではmonkeypatchを指定していないが、自作のフィクスチャが指定しているので、これでmonkeypatchを含めて動く

自分で書いたフィクスチャによって、Arrangeをスッキリさせられる

pytest --setup-show でフィクスチャのsetup順が確認できる

小まとめ

「(本ワークショップで)体験する概念」の1と2をカバーしました

  • テスティングフレームワークpytestを使って、テストコードを書いた

  • ここで書いたのは開発者のためのテスト

2点目の参考:拙ブログ 誰のためのテスト?