[Python] 湯婆婆で学ぶpytest


はじめに

この記事は 湯婆婆 Advent Calendar 2020 の16日目です。

湯婆婆(入力された名前から1文字選んで新しい名前を付ける)機能の実装が流行っていますね。
わずか数日で令和のHello World!の地位を築いた、中毒性の高い実装課題だと思います。

しかしもう単純に何かのプログラミング言語で実装するのはネタ切れ感が否めません。
だったら、湯婆婆を教材にして技術を学べばいいじゃない。

ということで、Pythonで書いた 湯婆婆モジュール を題材に、pytest を使ってユニットテストを書く練習をしてみます。

※宣伝: 過去に書いたpytest関連の記事もよろしければどうぞ。(なぜか過去の投稿の中で一番人気)
pytestに入門してみたメモ - Qiita

検証環境

  • Cygwin (Windows 10 20H2 Home)
  • Python 3.6.10
  • pytest 6.1.2
  • pytest-mock 3.3.1

事前準備

pipからパッケージをインストールしてください。

$ pip3 install pytest pytest-mock

仕様

今回の「湯婆婆モジュール」の仕様は、以下のようにします。

  • yubaba.make_newname(name)
    • name の中から1文字選んで返すこと。
    • name == "" のときにはクラッシュする(例外を送出する)こと。
    • 1文字選ぶ処理は random.choice() を1回だけ呼び出して実装すること。

練習なのでこれくらい単純でいいでしょう。

書いてみる

以下の3つのファイルに分けて書きます。

  • main.py: 湯婆婆モジュールを呼び出して動く目的のスクリプト。
  • yubaba.py: 湯婆婆モジュールの本体。
  • test_yubaba.py: 湯婆婆モジュールのテストを書いたメインスクリプト。

空の実装からスタート

メインスクリプトは以下のようになるでしょう。
本当はセリフを出力するところなども湯婆婆モジュールに入れるべきでしょうが、今回は目をつぶっていただければ…。

main.py
import sys
import yubaba

print("契約書だよ。そこに名前を書きな。")
name = sys.stdin.readline().rstrip()
print(f"フン。{name}というのかい。贅沢な名だねぇ。")
newName = yubaba.make_newname(name)
print(f"今からお前の名前は{newName}だ。いいかい、{newName}だよ。分かったら返事をするんだ、{newName}!!")

湯婆婆モジュールは、最初は何も実装しない状態から始めてみます。

yubaba.py
def make_newname(name):
    raise NotImplementedError()

テストも簡単なものだけ。

test_yubaba.py
import pytest
import yubaba

@pytest.mark.parametrize("name", ["田中太郎", "山田花子", "𠮷野家", "👵👵👵"])
def test_yubaba(name):
    newname = yubaba.make_newname(name)
    assert len(newname) == 1 and newname in name

このテストは、yubaba.make_newname(name) を呼び出した結果が1文字であり、かつ元の name から取られていることをチェックします。念のため、テストケースにサロゲートペアを含む例を入れておきます1

もちろん最初は何も実装していないのでテストは失敗します。

Terminal
$ python3 test_yubaba.py
======================================= test session starts ========================================
(略)
===================================== short test summary info ======================================
FAILED test_yubaba.py::test_yubaba[\u7530\u4e2d\u592a\u90ce] - NotImplementedError
FAILED test_yubaba.py::test_yubaba[\u5c71\u7530\u82b1\u5b50] - NotImplementedError
FAILED test_yubaba.py::test_yubaba[\U00020bb7\u91ce\u5bb6] - NotImplementedError
FAILED test_yubaba.py::test_yubaba[\U0001f475\U0001f475\U0001f475] - NotImplementedError
======================================== 4 failed in 0.43s =========================================

実装を追加する

最初の1文字を選ぶように実装してみます。

yubaba.py
def make_newname(name):
    return name[0:1] # [0]ではなくわざわざ[0:1]と書いている理由は後ほど

これでテストは成功します。戻り値が1文字であり、かつ元の name から取られていますからね。

Terminal
$ pytest test_yubaba.py
======================================= test session starts ========================================
(略)
test_yubaba.py ....                                                                          [100%]

======================================== 4 passed in 0.07s =========================================

空文字列を入れてもクラッシュしない?

そうなのです。実際、以下のようになります。

Terminal
$ python3 main.py
契約書だよ。そこに名前を書きな。

フン。というのかい。贅沢な名だねぇ。
今からお前の名前はだ。いいかい、だよ。分かったら返事をするんだ、!!

空文字列を入れてもびくともしないのでは仕様に反します。テストが足りていません。
ということで、空文字列を入れたときに落ちるかどうかを検証するテストを追加します。

test_yubaba.py
import pytest
import yubaba

@pytest.mark.parametrize("name", ["田中太郎", "山田花子", "𠮷野家", "👵👵👵"])
def test_yubaba(name):
    newname = yubaba.make_newname(name)
    assert len(newname) == 1 and newname in name

# これを追加
def test_crashing_yubaba():
    with pytest.raises(Exception):
        newname = yubaba.make_newname("")

with pytest.raises(Exception) を使うと、with 文の中で指定した例外が送出されるかどうかを検証できます。今回は例外の種類を限定しないとして Exception を指定していますが、OSError や独自の例外を指定することもあるでしょう。

Terminal
$ pytest test_yubaba.py
======================================= test session starts ========================================
(略)
============================================= FAILURES =============================================
_______________________________________ test_crashing_yubaba _______________________________________

    def test_crashing_yubaba():
        with pytest.raises(Exception):
>           newname = yubaba.make_newname("")
E           Failed: DID NOT RAISE <class 'Exception'>

test_yubaba.py:11: Failed
===================================== short test summary info ======================================
FAILED test_yubaba.py::test_crashing_yubaba - Failed: DID NOT RAISE <class 'Exception'>
=================================== 1 failed, 4 passed in 0.37s ====================================

空文字列で落ちなかったのでテストが失敗します。
テストが通るようにするには、例えば以下のようにすればよいでしょう。

yubaba.py
def make_newname(name):
    return name[0]
Terminal
$ pytest test_yubaba.py
======================================= test session starts ========================================
(略)
test_yubaba.py .....                                                                         [100%]

======================================== 5 passed in 0.08s =========================================

本当にランダムに選んでるんですかね

残る仕様は↓ですね。

  • 1文字選ぶ処理は random.choice() を1回だけ呼び出して実装すること。

でも戻り値を検証するだけでは、ランダムに選んでたまたま1文字目が出たのか、いつも1文字目を返してくるのかは判断できません。内部処理として、仕様通り random.choice() を呼び出していることを検証したいものです。

そのための方法の一つに mocker があります。目的の処理をフックして呼び出された履歴を記録し、検証に利用することができます。例を見てみましょう。

test_yubaba.py
import pytest
import yubaba
import random  # この行を追加

@pytest.mark.parametrize("name", ["田中太郎", "山田花子", "𠮷野家", "👵👵👵"])
def test_yubaba(mocker, name):                    # mocker 引数を追加
    random.choice = mocker.spy(random, "choice")  # この行を追加
    newname = yubaba.make_newname(name)
    assert len(newname) == 1 and newname in name
    random.choice.assert_called_once_with(name)   # この行を追加

def test_crashing_yubaba():
    with pytest.raises(Exception):
        newname = yubaba.make_newname("")

テストに mocker 引数を追加しておくと、pytest-mock が呼び出し履歴の記録に必要なオブジェクトを作ってくれます。
mocker.spy(random, "choice") は内部で random.choice() を呼び出して戻り値も普通に返しますが、そのときに呼び出し履歴を記録する機能を持ちます。これにより、後から random.choice.assert_called_once_with(name) を使って「各 name に対してrandom.choice() が ただ1回呼び出された」ことを確認できます。
現在 yubaba.make_newname()random.choice() を使わずに実装されているため、テストが失敗するようになります。

Terminal
$ pytest test_yubaba.py
======================================= test session starts ========================================
(略)
E           AssertionError: Expected 'choice' to be called once. Called 0 times.

/usr/lib/python3.6/unittest/mock.py:824: AssertionError
(略)
=================================== 4 failed, 1 passed in 0.67s =============================================================================

こうなると random.choice() を呼び出すほかはありません。

yubaba.py
import random

def make_newname(name):
    return random.choice(name)

とはいえ、すごく細かいことを言うと、random.choice() が呼び出されていても、それを本当に戻り値に使われているかは分からない、という気もします。極端な例ですが

yubaba.py
def make_newname(name):
    ret = random.choice(name)
    return name[0]

のような実装であってもテストは通ります。

そこで、やはり関数の戻り値も確認しておくことにしましょう。実際に何回か実行して異なる結果が返ってくるかをテストします2。このテストのために test_randomized() という関数を追加しています。

test_yubaba.py
import pytest
import yubaba
import random

names = ["田中太郎", "山田花子", "𠮷野家", "👵👵👵"]

@pytest.mark.parametrize("name", names)
def test_yubaba(mocker, name):
    random.choice = mocker.spy(random, "choice")
    newname = yubaba.make_newname(name)
    assert len(newname) == 1 and newname in name
    random.choice.assert_called_once_with(name)

@pytest.mark.parametrize("name", names)
def test_randomized(name):
    # 2種類以上の文字から構成される場合、実行ごとに異なる値になることをチェック
    if len(set(name)) >= 2:
        assert len(list(set(yubaba.make_newname(name) for i in range(100)))) >= 2

def test_crashing_yubaba():
    with pytest.raises(Exception):
        newname = yubaba.make_newname("")

もし、実は常に name[0] を返すような実装になっていた場合、以下のようにテストが失敗します。

Terminal
$ pytest test_yubaba.py
======================================= test session starts ========================================
(略)
test_yubaba.py:18: AssertionError
===================================== short test summary info ======================================
FAILED test_yubaba.py::test_randomized[\u7530\u4e2d\u592a\u90ce] - AssertionError: assert 1 >= 2
FAILED test_yubaba.py::test_randomized[\u5c71\u7530\u82b1\u5b50] - AssertionError: assert 1 >= 2
FAILED test_yubaba.py::test_randomized[\U00020bb7\u91ce\u5bb6] - AssertionError: assert 1 >= 2
=================================== 3 failed, 6 passed in 0.44s ====================================

最後に

実務面で考えると、こんな仕様で大丈夫なのか、標準ライブラリの random.choice を直接操作するのは行儀が悪いのでは、など色々ご意見はあると思いますが、ネタとして大目に見てください3


  1. 実際には、Python 3.3以降であれば問題ないのですが。→[Python] うちの湯婆婆が「𠮷田」さんに対応しているか知りたい - Qiita 

  2. 厳密には、正しい実装であってもごくまれにテストは失敗します。ただ名前に特定の文字が多く出現する(例えば 中村中 とか 前前前世)などの特殊な場合を除いて、数回実行すれば1回くらいは違う文字が出るでしょう。今回は念のため100回実行しています。 

  3. ネタ記事の割には結構長くなってしまった…。