はじめに
こんにちは!自分はTDDや自動テストの技術が好きで、社内で自動テストの推進活動をしています。
ある日の勉強会で、FizzBuzzを使ってTDDのデモをやってみようとなったときに、こんなやりとりがありました。
- 「表示するテストは難しいので、このテストは一旦後回しにしたいと思います。」
- 「...なんでこのテストは難しいんでしょうか???」
このやり取りを通じて「自動テストの難しさ」の判断や感覚は、経験が必要だなーと改めて感じました。
そこでこの記事では、テスト容易性(testability) という観点から、自分なりに「テストケースから実装コードのテスト容易性を予測するコツ」を言語化してまとめてみたいと思います。
この記事の目的
以下がわかることを目指します
- テストケースを見ただけでなんとなくテストコードが書きやすそうか予測できる
- テストコードを書くのが難しいと感じたときに、原因を不安定さと準備コストの2つに分けて整理できるようになる
- FizzBuzzを例に、ロジックのテストと入出力(CLI)のテストがなぜ別物になりやすいかを整理する
前提
- 勉強会での状況
- Kent BeckのCanon TDDに則って進めていました
- 作成するのはFizzBuzzができるCLIツールのイメージです
- テストコードと実装コードという名前を使って、テスト用のコードとテスト対象の実装のコードを分けて表現します
- テストケースとは「とある状況ときに、期待する結果になっていることを確認するための最小単位」とします
- 普段はTypeScriptを使っているのでTSっぽい書き方が少し出てきます
- この記事では筆者の考えをまとめたり添削したりするために一部AIを使っています(構成・表現の調整など)
テスト容易性とは?
ざっくり言うと「その実装コード(または仕様・設計)がどれだけテストしやすいか」という性質です。
例えば次のような点が揃っていると「テスト容易性が高い実装コード」と言えます。
- 確認しやすい:出力がはっきりしていて、テストで検証しやすい
- 準備しやすい:テストしたい入力や前提条件を、テストコード側で簡単に用意できる
- 影響を受けにくい:他の処理や外部要因に左右されない
- 安定している:同じ条件なら同じ結果になり、テストが安定する(結果がブレない)
裏返すと、テストコードを書くのが難しいと感じるときはテスト対象の実装コードがこのうちのどれかに違反していることが多いです。
テストケースから実装コードのテスト容易性を予測する
自動テスト経験者であればテストケースを見れば、実装コードのテスト容易性が高そうか低そうかをある程度予想することができます。
勉強会中に作成したFizzBuzz問題のテストリストを元に、実装コードのテスト容易性を予測してみましょう。
FizzBuzz問題のテストリスト
勉強会ではCanon TDDに乗っ取り、テストリストの作成から行いました。下記がテストリストです。
# FizzBuzzのCLIツールを作成
- 1と入力したら1が表示される
- 3と入力したらFizzが表示される
- 6と入力したらFizzが表示される
- 5と入力したらBuzzが表示される
- 10と入力したらBuzzが表示される
- 15と入力したらFizzBuzzが表示される
- etc...
# 入力する・表示される系のテストは準備が難しいのでFizzBuzz関数を作成する
- 引数が1の時1を返す
- 引数が3の時Fizzを返す
- 引数が6の時Fizzを返す
- 引数が5の時Buzzを返す
- 引数が10の時Buzzを返す
- 引数が15の時FizzBuzzを返す
- etc...
テスト容易性が高そうなテストケース
「入力に対して戻り値が返る」というような関数に対するテストは以下の点でテスト容易性が高いと言えます。
- 入力に対する出力(戻り値)をそのまま比較できる
- 関数を呼び出すだけなので
- テストコードが書きやすい
- 外部環境の準備が必要ない
テストリストの中だと、後半の FizzBuzz関数に対するテストケースがこれに当てはまりそうです。
テスト対象は関数のみですね。
テスト容易性が低そうなテストケース
「3と入力したらFizzが表示される」と「引数が3のときFizzを返す関数」は似ていますが、テストの中で必要なものと確認しているものが違います。
- 前者:CLIの入出力まで含めて正しく動くこと(E2Eに近いのテスト)
- 後者:FizzBuzzの判定ロジックが正しいこと(ロジックのテスト)
なので前者の実装コードはロジックの関数だけではなく、標準入力から受け付けたり、標準出力に対するprint処理なんかもテスト対象に含まれそうな感じがします。
テストするには「標準入力に3を流し込む」「標準出力にFizzが出たか確認する」といった形を、テストコード側で再現・差し替えする必要があります。
これは実際にやってみるとかなり大変です。例えばCLIが console.log で出力していたり、process.stdin をそのまま呼んでいたりすると
- テストコードから標準入力を準備しづらい(テストコードでstdinの差し替えが必要)
- 標準出力を確認しづらい(テストコードでstdoutの捕捉が必要)
これらのテストは、全くテストできないわけではないですがテストコードでの準備コストが高くなります。E2Eに近いテストはロジックのテストに比べてテストコードでの準備コストが高くなりやすいため、テスト容易性が低いと言えます。
このようにテストケースで確認したいことはなんなのか?を考え続けることで、ある程度テストが簡単にできそうかどうかを予測できます。
今回の勉強会の目的はTDDのやり方をデモして見せるためだったので、準備コストの高いテストケースは時間がかかるし避けたかったんですね。
テスト容易性が高い実装コードの特徴
ここからは「実装コード側の性質」の話です。おさらいですが実装コードはテスト対象のコードのことです。
テスト容易性の高い実装コードには、だいたい次のような性質があります。
- 同じ条件で同じ結果になる(結果が安定する)
- 外部とのやりとり(副作用)とロジックが分離されている
同じ条件で同じ結果になる(結果が安定する)
自動テストは何度も繰り返し実行されるので、基本的には同じ入力であれば何度実行しても同じ結果になることが望ましいです。
例えば add 関数は、入力が決まれば出力も必ず決まるためテストしやすいです。このような性質を参照透過性と言ったりします。
function add(x: number, y: number): number {
return x + y;
};
逆に、次のような要素が実装コード内で直接参照されていると条件が不安定になり、結果も不安定になります。また、テストコードも準備が必要で書きづらくなりがちです。
- ランダムな値を返す
- 現在時刻
- 環境変数
- グローバル状態(DBの状態も含みます)
外部とのやりとり(副作用)とロジックが分離されている
ここで言う「副作用」は、ざっくり言うと戻り値を返す以外に、外の世界に触れることです。
- 標準出力に表示する
- ファイルに書き込む
- DB を更新する
- API を叩く(ネットワーク通信)
- グローバル変数を変更する
関数として見ると「戻り値」ではなく、外部の状態を変更するような処理ですね。
こういった処理は、テスト時にDBや標準出力などの 環境を再現することになりやすく、準備や後片付けが増えます。結果としてテスト容易性は下がります。
副作用とロジックの分離の具体的な方法は長くなってしまうので、気が向いたら別の記事で書こうと思います!
まとめ
- 「自動テストを書くのが難しい」と感じるとき、テスト対象の実装コードは以下のような場合になっていることが多いです
- 不安定さ(実行ごとに結果が変わる)
- 準備コスト(外部環境・差し替え・後片付け)が高い
- FizzBuzz問題の例では、同じ「3を入力してFizzを返す」というテストケースに見えても違いがあった
- 「関数・ロジック(入力→戻り値)」のコードはテストコードを書きやすい
- 「CLI(入力→表示)」は入出力の再現・観測が必要になり、準備コストが高くなりやすい
- 実装コードのテスト容易性を上げる実装には大きく二つの性質がある
- 同じ条件で同じ結果になる(結果が安定する)
- 副作用がロジックと分離されている
最後に
実装コードを書く上で「このコードは副作用があるか?」「同じ入力であれば同じ結果が得られるか?」という観点はテスト容易性や設計の柔軟性のためにも非常に重要な感覚になってくると思っています。ぜひ意識してコードを観察してみてください!
AI時代もRun with Tests!
それではよい自動テストライフを!
参考文献