Rubyでテストを書くときの方針をまとめる
はじめに
「オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方」を読んで、テストを書くときにこの書籍のテスト関係の話がすぐに思い出せるようにまとめが欲しいと思ったのでまとめてみます。
オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方
- 作者: Sandi Metz,?山泰基
- 出版社/メーカー: 技術評論社
- 発売日: 2016/09/02
- メディア: 大型本
- この商品を含むブログ (1件) を見る
この記事では、この書籍で語られている多くの背景が省略されているため、手放しでこのまとめにあることを実践するのはおすすめしません。また、私の言葉で述べているところがたくさんあるので、ぜひ一度書籍を読むことをおすすめします。テストだけでなく設計の話も大変参考になります。
簡易まとめ
書いてみたら長くなってしまったのでサッと確認する用にまとめておきます。
- テストを書く対象
- テスト対象
- 受信メッセージの戻り値
- コマンドメッセージの送信(引数・回数)
- テスト対象外
- クエリメッセージ
- テスト対象
- テスト中に利用可能な情報
- テスト対象オブジェクトから得られる情報のみ
- 受信メッセージのテスト
- パブリックインタフェースのテスト
- 未使用インタフェースは削除(削除しないならテストを書く)
- ロールのテスト
- ロールを定義するためのテストを書く
- テストダブルを使用(ロール定義テストをincludeしてロールの要件を満たすことを証明する)
- プライベートメソッドはテストしない
- 送信メッセージのテスト
- コマンドメッセージのテスト
- モックを使って正しい引数・回数で呼び出されることをテスト
- コマンドメッセージのテスト
- 継承されたコードのテスト
以下から各項目について詳細を書いていきます。
何をテストするか
あるオブジェクトに対してテストを書く対象を決定する方針は以下の通りです。
- テスト対象
- 受信メッセージの戻り値
- 副作用を持つ送信メッセージ(コマンドメッセージ)が適切な引数・回数で送られたこと
- テスト対象外
- 副作用を持たない送信メッセージ(クエリメッセージ)
コマンドメッセージは戻り値を気にせず送られたことだけをテスト対象としています。これは、送信メッセージは送信先オブジェクトにとっての受信メッセージであり、その戻り値に関するテストは送信先オブジェクトでテストされているべきだからです。
テスト中に利用可能な情報
テストで利用できる情報はテスト対象オブジェクトから取得できる情報のみです。テスト対象オブジェクト以外に関する情報については可能な限り無知であり続けることで、テスト対象ではないオブジェクトのコード変更の影響を受けずに済みます。
受信メッセージのテスト
パブリックインタフェースをテストする
考えられるすべての状況において、受信メッセージを実行することで戻される値や状態が正しいことを証明します。
使われていないインタフェースは削除する
受信メッセージがどのオブジェクトからも依存されていなさそうな場合、それは存在しない要件を推測した実装です。そのメッセージを残すことはテストとメンテナンスのコストを増加させるだけなのでそのメッセージは削除します。削除しないのならば必ずテストを書きます。
ロールをテストする
テスト対象が特定のロールに依存するとき、テストをする際にそのロールを担う実際のオブジェクトをテスト対象に渡してテストしていれば、そのロールのインタフェースが変わったとき(ロールを担うオブジェクトのメソッド名が変わったとき)、テストは当然失敗します。ロールを担うオブジェクトが1つしかないのであればそのようなテストでも問題はありません。しかしロールを担うオブジェクトが複数存在する場合には、そのロールの存在を示すようにテストを書くことも検討すべきです。
例えば、以下のように渡されたwheelがdiameterメソッドに応答することを期待するGearクラスがある場合を考えてみます。
class Gear def gear_inches(wheel) ratio * wheel.diameter end end
diameterメソッドを持つDiamaterizalbeロールが存在(Diamaterizalbeがコード上に宣言されているのではなく、diameterメソッドに応答するオブジェクトが存在するという意味)していることを示したい場合、テストダブルを利用します。 下記のようなテストダブルのオブジェクトをテストコード内で定義し、gear_inchesメソッドに渡すことでテスト対象はdiameterメソッドを持つロールに依存していることを示すことができます。
# Sandi Metz. オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方 (Kindle の位置No.5780-5781). 株式会社技術評論社. Kindle 版. class DiameterDouble def diameter 10 end end
しかし、この方法には1つ問題があります。Diamarerizableロールを実装するアプリケーション内で実際に使われるメソッド名が変更されても、テストダブルはdiameterメソッドに応答できるためにテストが成功してしまい、実際のアプリケーションではエラーになることに気づけません。そこで、ロールのインタフェースをテストするモジュールを定義します。
# Sandi Metz. オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方 (Kindle の位置No.6208-6210). 株式会社技術評論社. Kindle 版. module DiameterizableInterfaceTest def test_implements_the_diameter_interface assert_respond_to(@object, :diameter) end
このモジュールをDiamaterizableロールを担うすべてのオブジェクトのテストにincludeすることでロールのインタフェースが変わったときにテストが正しく壊れるようにすることができます。
# Diamaterizableロールを実装する具体的なクラスのテスト # Sandi Metz. オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方 (Kindle の位置No.6214-6218). 株式会社技術評論社. Kindle 版. class WheelTest < MiniTest::Unit::TestCase include DiameterizableInterfaceTest def setup @wheel = @object = Wheel.new(26, 1.5) end def test_calculates_diameter # ... end end # テストダブルがDiamaterizableロールを実装することを示すテスト # Sandi Metz. オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方 (Kindle の位置No.6234-6237). 株式会社技術評論社. Kindle 版. class DiameterDoubleTest < MiniTest::Unit::TestCase include DiameterizableInterfaceTest def setup @object = DiameterDouble.new end end
これにより、Wheelクラスのdiameterメソッド名が変更されると、Gearクラスのテストは成功しますが、WheelTest、DiameterDoubleTestが失敗してインタフェースの変更を検知することができます。
プライベートメソッドをテストする
下記の理由からプライベートメソッドはテストすべきではありません。
- プライベートメソッドは不安定
- プライベートメソッドのテストが失敗したならパブリックインタフェースのテストもまた失敗するので冗長
- テストによりプライベートメソッドの使用方法が文書化され、他の人に利用される可能性を高めてしまう
送信メッセージをテストする
クエリメッセージは無視する
クエリメッセージは、それを送ることによる影響がテスト対象内部に限定されるのであればテストする必要はありません。そのためselfに送られたメッセージもテストする必要はありません。
コマンドメッセージはテストする
他のオブジェクトにメッセージを送ることで他のオブジェクトの状態に影響を与えるようなテスト対象のメソッドがある場合、そのメッセージを正しい引数・回数で呼び出すことをテストする必要があります。
これにはモックを使用します。下記の例ではset_cogメソッド内でobserverのchangedメソッドが正しく呼び出されることをテストしています。
# Sandi Metz. オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方 (Kindle の位置No.6015-6020). 株式会社技術評論社. Kindle 版. class GearTest < MiniTest::Unit::TestCase def setup @observer = MiniTest::Mock.new @gear = Gear.new(chainring: 52, cog: 11, observer: @observer) end def test_notifies_observers_when_cogs_change @observer.expect(:changed, true, [52, 27]) @gear.set_cog(27) @observer.verify end end
継承されたコードをテストする
継承階層内のすべてのオブジェクトがリスコフの置換原則に従うことを証明するには、その共通の契約に共有されるテストを書き、すべてのオブジェクトにそのテストをincludeします。共通の契約に共有されるテストは、スーパークラスのインタフェースのテストと、スーパークラスによって課されるサブクラスが満たすべき要件のテストを指します。
スーパークラスのインタフェースのテスト
スーパークラスが満たすべきインタフェースを定義します。このテストを成功するサブクラスはどれでもスーパークラスのように振る舞うことができると判断できます。Bicycle階層構造内のすべてのクラスはこのテストをinclude する必要があります。
# Sandi Metz. オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方 (Kindle の位置No.6320-6322). 株式会社技術評論社. Kindle 版. module BicycleInterfaceTest def test_responds_to_default_tire_size assert_respond_to(@object, :default_tire_size) end # ...インタフェースのテストが続く end
サブクラスに課される要件のテスト
サブクラスが満たすべき要件も共通のテストに記述し、すべてのサブクラスのテストでincludeします。下記のコード内で本当にサブクラス内で実装する必要があるのは、スーパークラスで例外を返すようにしているlocal_sparesメソッドだけで、他のメソッドの実装は任意です。local_spares以外のテストは、サブクラスがおかしなことをしてインタフェースを壊していないか確認する程度のものになります。
# Sandi Metz. オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方 (Kindle の位置No.6364-6367). 株式会社技術評論社. Kindle 版. module BicycleSubclassTest def test_responds_to_post_initialize assert_respond_to(@object, :post_initialize) end def test_responds_to_local_spares assert_respond_to(@object, :local_spares) end def test_responds_to_default_tire_size assert_respond_to(@object, :default_tire_size) end end
新たに追加されるサブクラスに対するテスト
上記のスーパークラスのインターフェースのテスト、およびサブクラスに課される要件のテストをincludeすることで、新たに追加されるサブクラスが標準的なBicycleのサブクラスであることを確信することができます。そのためまだ知識の浅いメンバーでも安全にサブクラスを作成することでできるようになります。
具象サブクラス固有のテスト
上記でサブクラス共通のテストはできているので、あとはサブクラスごとに固有の性質をテストします。上記の例だとdefault_tire_sizeはサブクラスでの実装が必須なので、サブクラスごとにdefault_tire_sizeがどのように振る舞うかテストする必要があります。
抽象スーパークラス固有のテスト
抽象スーパークラスは動作するのに必要なメソッドを実装していないこともあるので、インスタンス化が難しい場合があります。そのときは下記のようにスタブしたサブクラスを作成してスーパークラスのテストに利用することができます。
# Sandi Metz. オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方 (Kindle の位置No.6500-6503). 株式会社技術評論社. Kindle 版. classStubbedBike < Bicycle def default_tire_size 0 end def local_spares {saddle:'painful'} end end
この方法にはテストダブルを作成したときと同様に、サブクラスに課される要件が変わっているのにStubbedBikeを利用したテストが成功してしまう可能性があることに注意する必要があります。この問題への対策もテストダブルのときと同様で、StubbedBikeTestを作り、BicycleSubclassTestをincludeすることでサブクラスの要件の変更による影響を検知できます。
おわりに
テストを書くときの方針となるように自分なりにまとめてみましたが、私はまだ実際にこの方針通りにテストを書いたことがありません。実際にやってみるとこの通りにやるのがうまくいかない場面も出てくるかもしれないので、そのときはこの記事に追記するなり新しい記事なりにしようと思います。