単体テスト入門:最初のテストと失敗diffの読み方
Python unittest で最初のテストを書き、境界値からバグを見つけ、失敗時の diff を正しく読む方法。doctest、オフラインで動かした Dart test、テスト1万件の実行時間まで実際の出力と実測値で解説。
シリーズ:テスト・デバッグ講座 3/6(中級)。 スタックトレースは読めるし、デバッガも使える(第1回・第2回)けれど、自分でテストファイルを書いたことはない人向けです。読み終えると、unittest のテストファイルをゼロから書き、境界値からテストケースを選び、失敗時の diff を推測なしで読み、「テストしなくていいもの」を判断できるようになります。
新人研修を終えて最初のプルリクエストを出したら、コードレビューで「テストは?」とだけコメントが付いた。この記事はそういう人のためのものです。何から書けばいいのか、どこまで書けばいいのか、失敗したら何を見ればいいのかを、実際に動かした出力で説明します。実行環境は Python 3.9.6(一部 3.12.14 でも確認)と Dart 3.11、Apple Silicon Mac(M1 Pro)。出力中のパスは ~/demo/ に縮めています。
先に結論を3つ。ファイル名を1文字間違えたテストは 0件実行なのに OK と表示されました(Python 3.9)。境界値を考えるだけで、正常系のテストが一度も触れない入力が 4つ 見つかりました。そして小さなテスト10,000件は 286ms で終わります。「テストは遅いから」は書かない理由になりません。
5分で最初のテストファイル
用語を3つだけ。単体テスト(unit test)は、小さなコード(たいてい関数1つ)に決まった入力を与えて結果を確かめるものです。アサーションはその確認、「この値はあの値と等しいはず」という一文です。テストランナーはテストを探して実行し、結果を報告するプログラムで、Python には標準ライブラリの unittest があるのでインストールは要りません。
題材は、新人研修の懇親会の割り勘です。金額は円の整数で扱います。
# bill.py
"""新人研修の懇親会の割り勘。金額は円(整数)。"""
FREE_SHIPPING_FROM = 5000 # 5,000円
SHIPPING_FEE = 500 # 500円
def split_bill(total, people):
"""合計を人数で割り、足すと合計に戻る整数のリストを返す。
>>> split_bill(1000, 3)
[334, 333, 333]
>>> split_bill(900, 3)
[300, 300, 300]
>>> split_bill(10.0, 3)
Traceback (most recent call last):
...
TypeError: total must be an int, got float
"""
if not isinstance(total, int):
raise TypeError(f"total must be an int, got {type(total).__name__}")
if people < 1:
raise ValueError(f"people must be >= 1, got {people}")
base, extra = divmod(total, people)
return [base + 1] * extra + [base] * (people - extra)
def shipping_fee(subtotal):
return 0 if subtotal >= FREE_SHIPPING_FROM else SHIPPING_FEE
def format_share(name, amount):
return f"{name}さん: {amount:,}円"
同じフォルダにテストファイルを置きます。
# test_bill.py
import doctest
import unittest
import bill
from bill import format_share, shipping_fee, split_bill
class SplitBillTest(unittest.TestCase):
def test_even_total_gives_equal_shares(self):
# Arrange(準備)
total, people = 9000, 3
# Act(実行)
shares = split_bill(total, people)
# Assert(検証)
self.assertEqual(shares, [3000, 3000, 3000])
def test_remainder_goes_to_first_people(self):
self.assertEqual(split_bill(10000, 3), [3334, 3333, 3333])
def test_shares_always_add_up_to_total(self):
for total in (0, 1, 2, 99, 10000, 10001):
with self.subTest(total=total):
self.assertEqual(sum(split_bill(total, 3)), total)
def test_zero_people_is_rejected(self):
with self.assertRaises(ValueError):
split_bill(1000, 0)
def test_float_total_is_rejected_clearly(self):
with self.assertRaisesRegex(TypeError, "total must be an int"):
split_bill(10.0, 3)
def test_negative_total_still_adds_up(self):
self.assertEqual(split_bill(-100, 3), [-33, -33, -34])
class ShippingFeeTest(unittest.TestCase):
def test_one_yen_below_threshold_pays_fee(self):
self.assertEqual(shipping_fee(4999), 500)
def test_exactly_threshold_is_free(self):
self.assertEqual(shipping_fee(5000), 0)
class FormatShareTest(unittest.TestCase):
def test_uses_thousands_separator(self):
self.assertEqual(format_share("田中", 3305), "田中さん: 3,305円")
def load_tests(loader, tests, ignore):
tests.addTests(doctest.DocTestSuite(bill))
return tests
if __name__ == "__main__":
unittest.main()
2つのファイルがあるフォルダで実行します。
$ python3 -m unittest -v
test_uses_thousands_separator (test_bill.FormatShareTest) ... ok
test_exactly_threshold_is_free (test_bill.ShippingFeeTest) ... ok
test_one_yen_below_threshold_pays_fee (test_bill.ShippingFeeTest) ... ok
test_even_total_gives_equal_shares (test_bill.SplitBillTest) ... ok
test_float_total_is_rejected_clearly (test_bill.SplitBillTest) ... ok
test_negative_total_still_adds_up (test_bill.SplitBillTest) ... ok
test_remainder_goes_to_first_people (test_bill.SplitBillTest) ... ok
test_shares_always_add_up_to_total (test_bill.SplitBillTest) ... ok
test_zero_people_is_rejected (test_bill.SplitBillTest) ... ok
split_bill (bill)
Doctest: bill.split_bill ... ok
----------------------------------------------------------------------
Ran 10 tests in 0.002s
OK
10件の内訳は、メソッド9件と、load_tests が追加した doctest 1件です。実行順はファイルに書いた順ではなく、クラス名・メソッド名のアルファベット順になっている点に注意してください。「前のテストが先に走っている」ことを前提にしたテストは書かないこと。
命名の罠:「Ran 0 tests」で OK
引数なしの python3 -m unittest は 自動検出(discovery)で動きます。test*.py に合うファイルを読み込み、test で始まるメソッドを実行します。ファイル名を bill_tests.py、メソッド名を check_remainder にすると、中のアサーションをわざと間違えておいても、こうなります。
$ python3 -m unittest # Python 3.9.6
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK
$ echo $?
0
$ python3.12 -m unittest # Python 3.12.14、同じファイル
----------------------------------------------------------------------
Ran 0 tests in 0.000s
NO TESTS RAN
$ echo $?
5
ファイル名だけ test_bill.py に直しても、メソッド名が違うのでやはり0件でした。3.9 の CI なら、いつまでも緑のままです。テストを追加したら、一度わざと失敗させて「本当に赤くなる」ことを確かめる。これを習慣にしてください。
Arrange・Act・Assert と、文として読める名前
test_even_total_gives_equal_shares は、良いテストの多くが持つ3段構成になっています。
- Arrange(準備):入力を用意する(次回は偽物の協力オブジェクトもここで用意します)。
- Act(実行):テスト対象を1回だけ呼ぶ。
- Assert(検証):結果を確かめる。
毎回コメントを書く必要はありませんが、Act が2回あるテストはたいてい2つのテストに分けるべきです。名前には「何を」「どんな条件で」「どうなるべきか」を入れます。test_one_yen_below_threshold_pays_fee なら、ファイルを開く前にバグの見当がつきます。test_shipping_2 では、障害対応の夜中に何も分かりません。
subTest は「同じ検証を複数の入力で」繰り返すための道具です。ループだけだと最初に失敗した値で止まりますが、subTest を使うと値ごとに別々に報告されます(後の失敗出力で確認します)。
境界値を考えるとエッジケースが見つかる
最初に書くテストは、たいてい正常系です(9,000円を3人で)。でもバグは端にいます。テストを増やす前に、split_bill の最初の版(isinstance チェックなし)に、境界から選んだ入力を流してみました。1人、人数より少ない金額、0、マイナス、型違いです。
split_bill(10000, 1) -> [10000]
split_bill(2, 3) -> [1, 1, 0]
split_bill(0, 3) -> [0, 0, 0]
split_bill(-100, 3) -> [-33, -33, -34]
split_bill(10000, True) -> [10000]
split_bill(10000, 2.5) -> TypeError: can't multiply sequence by non-int of type 'float'
split_bill(10000.0, 3) -> TypeError: can't multiply sequence by non-int of type 'float'
split_bill(10000, '3') -> TypeError: '<' not supported between instances of 'str' and 'int'
split_bill(10000, 0) -> ValueError: people must be >= 1, got 0
正常系のテストでは絶対に出てこない発見が4つありました。
| 入力 | 起きたこと | 判断 |
|---|---|---|
(2, 3) |
[1, 1, 0]:1人は0円 |
正しい。合計は合っている |
(-100, 3) |
返金。合計はちゃんと -100 | このまま。テストで固定する |
(10000.0, 3) |
「sequence を掛けられない」という、見当違いの TypeError |
float は分かりやすいメッセージで拒否する |
(10000, True) |
bool は int のサブクラスなので True が1人扱い |
受け入れる。ドキュメントに書くだけ |
現実に起きやすいのは float のケースです。フォームから来た値を float() で変換したまま渡す、というのはよくある話で、そのときのエラーメッセージが「sequence」の話をしていたら、調べる人は見当違いの場所から探し始めます。だから split_bill で型を確かめ、test_float_total_is_rejected_clearly は例外の種類だけでなく メッセージ まで確かめています。
境界値のチェックリスト:0・1・たくさん。しきい値のすぐ下・ちょうど・すぐ上(4999 と 5000)。空の入力、マイナス、違う型、とても大きな値。送料のテストがまさに 4999 と 5000 の組なのは、料金計算で一番多い1文字のバグが >= と > の取り違えだからです。
失敗したテストの読み方
ではコードを3か所、疲れたレビュアーが見落としそうな程度に壊します。divmod の処理を [total // people] * people に、shipping_fee の >= を > に、そして桁区切りの :, を削除。実際の出力(一部省略)です。
$ python3 -m unittest
FF...FF.F
======================================================================
FAIL: test_uses_thousands_separator (test_bill.FormatShareTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "~/demo/bill/test_bill.py", line 47, in test_uses_thousands_separator
self.assertEqual(format_share("田中", 3305), "田中さん: 3,305円")
AssertionError: '田中さん: 3305円' != '田中さん: 3,305円'
- 田中さん: 3305円
+ 田中さん: 3,305円
? +
======================================================================
FAIL: test_exactly_threshold_is_free (test_bill.ShippingFeeTest)
...
AssertionError: 500 != 0
======================================================================
FAIL: test_negative_total_still_adds_up (test_bill.SplitBillTest)
...
AssertionError: Lists differ: [-34, -34, -34] != [-33, -33, -34]
First differing element 0:
-34
-33
- [-34, -34, -34]
? ^ ^
+ [-33, -33, -34]
? ^ ^
======================================================================
FAIL: test_shares_always_add_up_to_total (test_bill.SplitBillTest) (total=1)
...
AssertionError: 0 != 1
...
----------------------------------------------------------------------
Ran 10 tests in 0.002s
FAILED (failures=9)
上から順に読みます。
- 進捗行
FF...FF.Fは1テスト1文字です。.成功、F失敗、Eエラー。10件なのに9文字で、失敗は9件。subTestの失敗は値ごとに数えられます(total=1, 2, 10000, 10001 の4件)が、表示は1文字にまとまるからです。 - トレースバックは
unittestの内部ではなく、自分のテストの行で終わります。見るべきはtest_bill.pyの47行目です。 first != second:unittestは2つの引数を「実際の値」「期待値」ではなく、単に first・second と呼びます。いつもassertEqual(実際の値, 期待値)の順で書いているので、-の行がコードの返した値、+の行が欲しかった値です。この順番をチーム全体でそろえないと、diff が嘘をつきます。?の行は違う文字の位置を指します。ただし位置は「文字数」で数えるので、全角文字が混ざると端末上の表示とずれます。上の例の+は先頭から10文字目(カンマの位置)を指していますが、全角文字を2マスで表示するターミナルでは「ん」の下あたりに見えます。実際に足りないのはカンマです。日本語の文字列を比べるときは?の行をうのみにせず、-と+の行を見比べてください。- リストの場合は diff の前に「First differing element 0」が出ます。ここで、マイナスのケースが別のバグを暴いています。切り捨て除算のせいで、100円の返金が102円になっていました。
- テスト名の後ろの
(total=1)がsubTestのラベルです。subTestがなければ、最初に失敗した値しか見えません。
終了コードは 1。CI が見るのはこの値です。
FAIL と ERROR の違い
今度は people < 1 のガードと型チェックを外します。別の文字が出てきます。
....F...EF
======================================================================
ERROR: test_zero_people_is_rejected (test_bill.SplitBillTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "~/demo/bill/test_bill.py", line 27, in test_zero_people_is_rejected
split_bill(1000, 0)
File "~/demo/bill/bill.py", line 19, in split_bill
base, extra = divmod(total, people)
ZeroDivisionError: integer division or modulo by zero
======================================================================
FAIL: test_float_total_is_rejected_clearly (test_bill.SplitBillTest)
----------------------------------------------------------------------
TypeError: can't multiply sequence by non-int of type 'float'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "~/demo/bill/test_bill.py", line 31, in test_float_total_is_rejected_clearly
split_bill(10.0, 3)
AssertionError: "total must be an int" does not match "can't multiply sequence by non-int of type 'float'"
FAIL はアサーションが評価されて偽だったこと。ERROR は判定する前にテストが落ちたことです。assertRaises(ValueError) が捕まえるのは ValueError だけなので、ZeroDivisionError は素通りしました。2つ目のブロックは例外の連鎖(第1回)で、元の TypeError が先に、それを拒否したアサーションが後に出ています。
doctest:古くならない使用例
split_bill の docstring にある >>> の行が doctest です。Python がその行を実行し、表示結果を次の行と比べます。単独なら python3 -m doctest bill.py(成功時は何も表示せず終了コード0)、詳しく見るなら -v。テストファイルの load_tests が、これを通常の unittest 実行に加えています。
docstring の期待値を [333, 333, 334] に書き換えると、こうなりました。
**********************************************************************
File "~/demo/bill/bill.py", line 10, in bill.split_bill
Failed example:
split_bill(1000, 3)
Expected:
[333, 333, 334]
Got:
[334, 333, 333]
**********************************************************************
1 items had failures:
1 of 3 in bill.split_bill
***Test Failed*** 1 failures.
doctest は「ドキュメントを兼ねた短い例」に向いています。表示が安定しないもの、たとえば外部データ由来の dict の順序、桁の長い float、<Foo at 0x10...> のようなアドレスは unittest に回しましょう。
この記事の準備中に踏んだ罠も1つ。docstring を1秒以内に2回書き換え、しかも2回目でファイルサイズが変わらなかったため、Python がキャッシュ済みのバイトコード(.pyc)をそのまま使いました。記録された更新時刻とサイズが一致していたからです。直したはずの doctest が古い内容で失敗し続け、しかもこの環境の Python は .pyc を別のキャッシュ用ディレクトリに置くため、__pycache__ を消しても効きませんでした。1秒待ってファイルを touch したら直りました。手で編集しているぶんにはまず起きませんが、ソースを書き換えるスクリプトでは起こりえます。
同じテストを Dart で(オフライン)
ネットワークなしで Dart の package:test は動くのか。条件は「dart pub get --offline が手元のパッケージキャッシュだけで解決できること」です。結果は1.0秒で解決し、test 1.30.0 が使われました。
// lib/pricing.dart
int shippingFee(int subtotal) {
if (subtotal < 0) throw ArgumentError.value(subtotal, 'subtotal');
return subtotal >= 5000 ? 0 : 500;
}
// test/pricing_test.dart
import 'package:pricing/pricing.dart';
import 'package:test/test.dart';
void main() {
group('shippingFee', () {
test('charges 500 just below the threshold', () {
expect(shippingFee(4999), 500);
});
test('is free exactly at the threshold', () {
expect(shippingFee(5000), 0);
});
test('rejects a negative subtotal', () {
expect(() => shippingFee(-1), throwsArgumentError);
});
});
}
>= を > に変えると、dart test はこう報告しました(終了コード1)。
00:00 +1 -1: test/pricing_test.dart: shippingFee is free exactly at the threshold [E]
Expected: <0>
Actual: <500>
package:matcher expect
test/pricing_test.dart 10:7 main.<fn>.<fn>
00:00 +2 -1: Some tests failed.
Dart の matcher は Expected と Actual を明示するので、引数の順番の問題は unittest ほど深刻ではありません。
package:test が使えない場合の代わりは素の assert ですが、実行環境が assert をどう扱うかを先に確かめてください。今回、次の3つはどれもエラーなしで最後の行まで進みました。
| 実行環境 | 素の assert が無視される条件 | 今回の結果 |
|---|---|---|
| Dart | dart run file.dart(--enable-asserts を付けない限り無効) |
黙ってスキップ |
| Python | python3 -O file.py(assert 文が取り除かれる) |
黙ってスキップ |
| PHP 8.5 | php -d zend.assertions=-1(本番でよくある設定) |
黙ってスキップ |
なお dart test の中では assert は有効で、AssertionError を期待するテストは通りました。この Mac には PHPUnit が入っていないので、PHP は zend.assertions=1 での assert だけを動かしています。
テストが増えると実行時間はどうなるか
「テストは遅い」という言い訳をよく聞きます。小さなテスト(split_bill を1回呼ぶだけ)を N 件並べたファイルを生成し、python3 -m unittest のプロセス全体の時間を測りました。各6回実行し、1回目(コールド)を捨てて残り5回の中央値。Python 3.9.6、Apple Silicon Mac(M1 Pro)。
| テスト数 N | プロセス全体 | 「Ran N tests in」の値 |
|---|---|---|
| 1 | 48.4 ms | 0.0 ms |
| 10 | 46.1 ms | 0.0 ms |
| 100 | 52.7 ms | 1.0 ms |
| 1,000 | 69.8 ms | 19.0 ms |
| 10,000 | 285.9 ms | 192.0 ms |
100件までは、ほぼ Python の起動時間(約45ms)を測っているだけです。そこから先は1件あたり約19〜24マイクロ秒。-v を付けると10,000件で432ms(付けない場合は272ms、別測定)になりました。1行ずつ表示するコストのほうが、テストを実行するコストより大きいわけです。
dart test でも同じ実験をしました(ウォームアップ後に各3回)。1件で0.91〜0.95秒、100件で1.07〜1.24秒、1,000件で1.84〜1.94秒、10,000件で6.3〜6.4秒。Dart はコンパイルと isolate の起動があるぶん固定費が大きく、レポーターの1件あたりの処理も重めです。
結論:小さな単体テストは安い。スイートを遅くするのは、sleep する、ネットワークや本物のデータベースに触る、ほんの数件のテストです。それを置き換える方法が第4回のテーマです。
テストしなくていいもの
テストも保守するコードです。次のものは、たいてい得るものよりコストのほうが大きくなります。
- 言語や標準ライブラリそのもの。
divmod(7, 2) == (3, 1)を確かめても、テストしているのは Python です。 - private なヘルパーを直接。 公開関数を通してテストすれば、ヘルパーを作り直してもテストを書き直さずに済みます。
- 自分が持っていない文言。 Python 自身のメッセージ「can't multiply sequence…」に一致させると、Python のバージョンアップで壊れます。今回のテストが一致させているのは、自分で書いたメッセージです。
- ロジックのない単純なコード。 フィールドを返すだけのプロパティなど。
- 結果ではなく実装。 「
split_billはdivmodを1回呼ぶ」は無害なリファクタリングで壊れます。「分けた金額の合計が元の金額になる」はリファクタリングを生き延びます。 - 乱数や時刻に依存する出力をそのまま。 先にシードや時計を固定します(第5回)。
逆に必ず書くべきもの:直したバグすべて(先に失敗するテストを書いてから直す)、数字の入ったルールの境界、呼び出し側が頼りにしているエラー処理。
正直な限界・バージョンメモ
- 主な実行環境は Python 3.9.6。Python 3.12.14 はメッセージの比較にだけ使いました。「テストが0件なら終了コード5」と
--durationsオプションはどちらも 3.12 からで、3.9 で--durationsを付けるとunrecognized argumentsになりました。3.12 で最初に--durationsを付けた実行では doctest が0.335秒(コールドスタート)と出て、2回目以降は0.001〜0.002秒でした。 assertRaisesRegexは Python 3.2、subTestは 3.4 から使えます。- Dart は 3.11.5、
test 1.30.0はローカルの pub キャッシュから。何もダウンロードしていません。環境によっては別のバージョンに解決されます。 - 時間は Apple Silicon Mac(M1 Pro)1台での値です。絶対値は環境で変わりますが、「固定の起動コスト+1件あたり数マイクロ秒」という形は変わらないはずです。
- サードパーティの pytest は diff がもっと親切ですが、この環境には入っておらず、動かしていません。
練習問題
split_bill(5, 5)が1円×5人を返すテストと、split_bill(4, 5)が[1, 1, 1, 1, 0]を返すテストを追加してください。実行する前に、通るかどうか予想しましょう。- 誰かが
shipping_feeの>=を>に変えたら失敗するテストを1つ書き、shipping_fee(4999)だけではそのバグを捕まえられない理由を説明してください。
解答
- どちらも通ります。
divmod(5, 5)は(1, 0)で余りなし。divmod(4, 5)は(0, 4)なので、先頭4人が0 + 1、最後の1人が0です。予想が外れたなら、その理由を書き留めてください。それがまだ考えていなかった境界です。 self.assertEqual(shipping_fee(5000), 0)。4999 では>=でも>でも答えは同じ(500)なので区別できません。2つの演算子を見分けられるのは、しきい値 ちょうど の値だけです。
次に読む
- 前回:第2回 print・logging・デバッガ
- 次回:第4回 テストダブル・モック・フィクスチャ
- シリーズの最初:第1回 エラーメッセージとスタックトレースの読み方
- この先:不安定なテスト、git bisect
- 関連:Python の関数・スコープ・デフォルト引数(ミュータブルなデフォルト引数はテストすべき典型例)、Python のプロファイリングと最適化、テストが学習全体のどこに位置するかはプログラミング学習ロードマップへ。
よくある質問
unittest が「Ran 0 tests」なのに OK と表示されるのはなぜ?
自動検出(discovery)は test*.py という名前のファイルと、test で始まるメソッドしか拾いません。bill_tests.py の check_remainder というメソッドは0件実行で、Python 3.9 は OK・終了コード0を返しました。Python 3.12 では NO TESTS RAN と表示され、終了コード5(CI では失敗)になります。
unittest の失敗 diff で、どちらが期待値ですか?
unittest 自身は first と second としか呼びません。assertEqual(実際の値, 期待値) の順で書いていれば、- の行がコードの返した値、+ の行が期待した値です。? の行は違う文字の位置を指します(全角文字があると表示上の位置がずれます)。
テストを増やすと実行時間はどれくらい伸びますか?
Apple Silicon Mac(M1 Pro)・Python 3.9 で、小さなテスト1〜100件は46〜53ms(ほぼ起動時間)、1,000件で70ms、10,000件で286msでした。1件あたり約20マイクロ秒です。ネットワークに触る遅いテスト1件のほうが、速いテスト数千件より高くつきます。