この記事はプロダクションコードの品質とテスタビリティを保つにはどうすれば良いか悩んでいる開発者向けとなります
なぜユニットテストが書きづらいのか
プロダクションコードに対してユニットテストを書こうとして手が止まってしまうことが良くあります。
- テスト対象のクラスをどう初期化すれば良いのか分からない
- 対象のメソッド呼び出し後に何を確認すれば良いか分からない
- テストケースが膨大になりすぎて網羅することができない
プロダクションコード側の問題でユニットテストを書きづらいというのは分かると思います。
では、プロダクションコードをどうすればユニットテストをしやすくなるのでしょうか?
役割が不明確だとテストは書けない
ユニットテストがしづらい場合、大抵そのクラス/メソッドの役割が曖昧で、
誰かに説明しようとしてもできないケースが多いです。
では、役割を明確にするにはどうすれば良いのでしょうか?
それがクラス/メソッドの契約を明確にし、
さらに契約の内容が複雑化しすぎないようにすることです。
契約による設計とは
契約による設計とは、以下を明確にすることです。
| 現象 | 品質低下へのサイン |
|---|---|
| 事前条件 | ・メソッドを呼び出す側が呼び出し前に満たす必要がある条件 ・メソッドに渡す引数の整合性も含む |
| 事後条件 | ・メソッドを呼び出された側が呼び出し後に満たす必要がある条件 戻り値の整合性も含む |
| 不変条件 | ・クラスが存在する間常に満たすべき条件 |
ユニットテストへの反映
契約という言葉から難しそうなイメージを持つかもしれませんが、難しい内容ではないです。
AAAパターンに合わせてユニットテストを作成しながら、契約を確認していきます。
準備(Arrange)
ユニットテスト前に事前条件に従ってメソッド呼び出しに必要な初期化を行います。
あわせて、呼び出しに必要な引数を初期化します。
以下に該当するなら契約を見直します。
| 現象 | 品質低下へのサイン |
|---|---|
| 初期化が30ステップ超 | メソッド内で本当に必要なデータが整理できていない or 神メソッドになりつつある |
| モックが必須 | メソッド内に明確化されていない契約が埋め込まれている |
| 外部システムが必須 | メソッドが不確定な要素に依存している |
これらのサインをもとに、契約を見直し品質を改善する必要があります。
実行(Act)
テスト対象のメソッドを呼び出します。
このフェーズは特に問題ありません。
確認(Assert)
戻り値、クラスの状態が事後条件に従っていることを確認します。
(不変条件については後述します)
以下に該当する場合は契約を見直します。
| 現象 | 品質低下へのサイン |
|---|---|
| assertのステップが20超 | 神メソッドになりつつある |
| 不可視なフィールドを参照しないと結果確認できない | カプセル化の失敗により、メソッド呼び出しによる状態遷移が混乱している |
| 外部システムの変更を直接確認している | メソッドが不確定な要素に依存している |
これらのサインをもとに、契約を見直し品質を改善する必要があります。
契約をコントロールする
ここからは、具体的にどう設計を改善していくかを見ていきます。
契約を明確にするための指針を以下に挙げます。
- 事前条件は人間が把握可能な数に収める(多くても5項目程度)
- 外部の不確定な要素はインターフェースで隔離し、DIすることで事前条件化する
- 業務処理を行うクラスは可能な限り状態を持たない
この指針に従うことで、メソッドの挙動は確定的になり
ユニットテストがしやすいだけでなく、コードの可読性が高くなります。
これらの指針を守るための方法を記載します。
不変条件となる関係性を値オブジェクトで表現する
例として開始日時と終了日時を持つクラスを考えます。
(キャンペーンやセールを表現したクラスをイメージしてください)
開始、終了の日時を変更可能とする場合、当然「開始日時 < 終了日時」という不変条件があります。
この2項目を個別に保持するのではなく、「期間」を表す値オブジェクトにまとめます。
(一応補足すると、値オブジェクト=不変です)
期間は生成時点で「開始日時 < 終了日時」であることを厳守しますので、不変条件の契約が期間クラスに移り、
期間を保持するクラスはこの不変条件から解放されます。
現実的な問題として、不変条件を全ユニットテストで確認するのは無理があります。
そのため、値オブジェクトで不変条件を強制的に守らせることで、
利用側のユニットテストを不要にします。
外部システムへの依存を業務的な視点でインタフェース化して隔離する
DB、サーバ等の外部システムへの依存をそのままインタフェース化するのではなく、
もう一段抽象化してインタフェースとして定義します。
エンティティをDBに保存するのではなく、EntityRepositoryという抽象的なものに保存する、
サーバに処理結果を送信する場合、NotifyListenerに通知する、
というレベルです。
契約は、あくまでこれらの抽象的なものへの操作として考えます。
状態を持つ代わりに別のクラスのインスタンスを生成する
検索エンジンの様なクラスを考えます。
このクラスは検索リクエストで指定された条件を設定後、
検索処理を呼ぶごとに検索結果を100件ずつ順番に返します。
この状態はクラスが検索条件を設定済みか、という状態を持っています。
また、検索条件を再度設定された場合にどうするか、という考慮も必要になります。
この状態は契約が複雑化しています。
代わりに、検索エンジンに対して検索条件を指定することで、検索セッションを返すようにします。
検索セッションは検索処理を呼び出すと、指定された検索条件の検索結果を返し続けます。
こうすることで検索エンジンは状態を保持しなくなり、検索セッションの生成までが契約となり、契約がシンプルになります
検索セッションは検索位置という状態を保持しますが、一連の検索処理で使用するインスタンスであり、
契約の複雑度はさほど高くなりません。
つまり、検索エンジン内に混在していた契約を
最終的に検索エンジンが保持するファクトリ処理の契約と、検索セッションが保持する検索実行の契約に分離し、それぞれの契約のスコープを明確にすることができます。
まとめ
テスタビリティとプロダクションコードの品質について改めて考えてみます。
機能追加により設計の低下
プロダクションコードにつぎはぎで機能追加を行うと、当然プロダクションコードの品質が下がります。
機能追加を行えば、クラスの契約が増え複雑になっていきます。
そのため、機能追加時は常に契約を再確認しリファクタリングを行う必要があります。
テスタビリティが高い ≠ 良い設計
テスタビリティを上げれば設計が良くなる、という考えがありますが残念ながら誤解です。
良い設計であれば、ほぼ間違いなくテスタビリティも高いですが、
テスタビリティが高くても設計が良くないケースはあります。
ただし、テスタビリティが低いということは、ほぼ間違いなく設計が良くないです。
ユニットテストを書きづらいと感じたら、大抵は設計がだいぶ悪いと思った方が良いです。