本エントリーは大本は.NET Conf 2018 Tokyo, Japanの発表内容を記事としてまとめたものになります。参考によかったらご覧ください。
なお本セッションのソースコードはこちらのGithubに
https://github.com/nuitsjp/Continuous-Testable-Design
スライドはこちらに別途公開しています。
www.slideshare.net
皆さん、日常的に自動テスト書いてますか?私は結構書いてます。
SIerのテストというと、Excel!スクショ!エビデンス!的なイメージがあるかも知れませんがたまたま巡り合わせが良くて、2000年からJUnitを触っていたと思います。
SIerって、プロダクトチームの維持が難しくて、半年ごとに8割のメンバーが入れ替わってるみたいな地獄みたいな事が良くあるので、自動テストが無いと怖くて生きていけないというのが、私のテストのモチベーションなんだと思います。
ただ、テストを書くのは良いんですが、本当に難しいのはプロダクトの改修に追随してテストを維持していくことだと、私は思っています。
テストを維持し続けるのは本当に難しいです。
プロジェクトのスケジュールが厳しくなると、すぐ悪魔が囁き始めますし、テストを維持する事自体が、ソフトウェアの変更を阻害するようなこともあります。実際に、テストをいくらか破棄して泣く泣く規模を縮小したりしたこともあります。
というわけで、本セッションの概要です。
今日は、そういった茨の道で試行錯誤してきたベストプラクティスの中から、特に大切だと思っている、三つのエッセンスについてお話ししたいと思います。
- 一つは制御の流れと依存性の分離について
- 二つ目は依存方向の制御と、安定性と柔軟性の管理について
- そして最後に現実的なテスト戦略について
です。
当然これだけで継続的なテストの維持ができるようになる訳ではありませんが、特に重要なことだと私は思っていますのでお付き合いください。
本セッションでは、実際のコードを見ながら、最初はテスト不可能なコードからスタートしてリファクタリングしながら、継続的にテスト可能な設計を目指してコードを見ていただきました。
コードはGithubに諸々公開しているので良かったら参考にしてください。
なお、テストの対象は今回はクラス単位のテストをする前提とさせていただきます。
実際の例に用いるプログラムですが、SQL ServerのサンプルDBのAdventureWorksを利用させてもらって、プロダクト別の総売上をCSV出力するようなコンソールアプリを想定します。
そのアプリから利用するデータベースは、同一システムの別機能からも利用されるものとします。
なおコードや、コードの英語に不備不満のある方は、そっとプルリクを送っていただけると助かります。
コードですがクラス図に落とすと、こんな感じです。
今日のテスト対象はクラスですから、バツ印のクラス間が直接依存してるせいで上流のクラスが単体テストできない状態になっています。
例えばControllerとBusinessLogicの関係の詳細を見てみると、その二つの間には、オブジェクトの生成と利用という2種類の依存関係があります。これらが、Controllerをテストダブルを利用して単体テストすることを阻んでいます。
という訳で、これらのクラス間の直接的な依存関係を取り除いていきます。
まずはインターフェースを抽出します。
さて、こうしたことで、BusinessLogicを利用している箇所の依存関係はクラスからインターフェース移すことができました。
ただし、インスタンスを生成する個所に依存がまだ残っています。
インスタンス生成の依存関係を取り除くには、基本的にはインスタンスの生成を、つぎの二つの何れかの方法で解決する必要があります。
一つはControllerが能動的に、インスタンスをいずれかのレジストリーに取得しに行く方法。もう一つは依存オブジェクトをControllerの外から、誰かに注入してもらう方法です。
それぞれ代表的な実現方法として、ServiceLocatorパターンとDependency Injectionパターンがあります。
今回はDependency Injectionパターン、つまりDIを利用します。
なぜDIを利用するかは、ざっくりいうと、ServiceLocatorには大きな問題が二つあって、一つはテストをマルチスレッドで実装しにくいということと、ServiceLocatorパターンにするとBusinessLogicへの依存は減るけど、ServiceLocatorへの依存が増えて、依存数が減らないという欠点があるためです。
詳細はこちらをご覧ください。
http://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/
というわけでDIしたのちがこちらです。無事にControllerからインスタンス生成のロジックも除去出来て完全にクラス間の依存関係を無くせました。こうすることで
簡単にBusinessLogicをテストダブルに置き換えが可能になってテスタブルになります。
で、その他の箇所も解消したのがこちらになります。
View、BusinessLogic、Repositoryの全てにインターフェースを導出し、DIを適用します。こうする事で
こんな感じで、全てのクラスにたいしてクラス単位のテストが実施できるようになりました。
ところで先ほどのクラス図、良く見ると不吉なにおいがしますね?
特にこの、ControllerとViewの間です。もう少し詳細にみてみましょう。
ControllerがViewに依存しているます。一般的にViewは最も変化が多いと言われていますよね。それが正しいとするとViewに依存しているControllerはViewに引きずられて、頻繁に変更する必要がでてきます。結果、プロダクションコードだけでなく、テストコードもテストダブルも頻繁に変更しなくてはならなくなります。
これは辛いです。
そしてもう一つ、個人的にはこっちの方が嫌な予感がします。
このBusinessLogicがIRepositoryに依存しているところです。
最初にお話しした通り、このシステムで利用するデータベースは他の機能からも利用されます。
ということは、このシステムではない、別の機能起因で変更が入る可能性があります。
そもそもデータが安定的であるというのも、私は懐疑的です。現代において企業は、顧客に対して新しい価値を次々提供し続けなくてはなりません。新しい価値を提供するためには、往々にして新しいデータが発生します。データの取り扱いが無くなる事は少なくても、追加変更はそれなりに発生するのが現実だと私は思っています。
つまり、何らかの要因で参照しているテーブルに変更が発生するとリポジトリが変更されます。
当然そうなるとテストケースの修正が入って、高い確率でリポジトリのインターフェースが変更されます。
するとビジネスロジックが影響を受けて、リポジトリのモックとビジネスロジックのテストががががが。。。全部なおさなきゃ!みたいな
最悪だ!と、なってしまうかもしれないです。実際稀に良くありますし。
結局何が悪いかというと、安定させたいビジネスロジックが、不安定なモジュールにたいして制御の流れが原因で引きずられて、依存してしまっているのがこの問題の根幹にあります。これを解決するには
制御の流れから依存方向を分離して、逆方向に依存させれば解決できます。もちろん実現できることです。
具体的には、Beforeのようになっているところを、Afterのようにします。
重要なのは、インターフェースを移動することではなくて、リポジトリをビジネスロジックの文脈で定義することです
リポジトリの詳細を見てみましょう
現在の実装は、リポジトリが完全にデータベースの文脈で記述されているのが見て取れます。
しかし、ビジネスロジックで実現したいのは、プロダクト別の総売上額です。
つまりビジネスロジックに必要なのはこの4項目だけです。そこで
リポジトリインターフェースをビジネスロジックの文脈へリファクタリングします。
結果、データベースの詳細が隠蔽されて、ビジネスロジックの文脈で定義されているのが分かるかと思います。
一旦整理しましょう。
制御の流れと、依存方向は、必ずしも一致させる必要はありません。クラスとクラスの直接依存を避けて、インターフェースを定義し、疎結合にした上で、インターフェースを依存させたい側の文脈で定義することで、依存方向は制御の流れから分離して、自由にコントロールできます。
そして、依存方向によって安定性と柔軟性が変化します。
リポジトリインターフェースは、ビジネスロジックの文脈で記述されています。つまり、データベースの影響を受けにくく、安定性が高くなります。しかし逆にいうと、ビジネスロジックを変更すると、リポジトリの実装が影響を受ける可能性があるためビジネスロジックの柔軟性は低くなります。
逆にリポジトリの実装はビジネスロジックの変更の影響を受ける為、安定性は低くなります。しかし、リポジトリの変更はビジネスロジックへ影響を与えにくいため、変更しやすく、柔軟性が高いといえます。
つまり、安定性と柔軟性は設計上のトレードオフにあるわけです。
あらためて全体を見てみましょう。
クラスを書くと煩雑なので、パッケージだけ記述しました。
最初の設計では制御の流れと依存関係が一致していたためそれぞれのパッケージの安定性と柔軟性は、この図のような 状態にあります。ビューとリポジトリが一番安定している。つまり変更しにくい状態にありそれらを修正すると、システム全体への影響が大きい状態になってしまっています。
実際には安定性と柔軟性は、だいたいこんな感じにしたい仮定します。
ビジネスロジックを最も安定させビューとレポジトリーは柔軟性を高めたい、つまり変更しやすくしたいとします。
その為にはコントローラーとビュー、ビジネスロジックとリポジトリの間の依存関係を逆転してあげれば、望む通りの安定性と柔軟性を得ることができるわけです。
これで変更を受けやすい部分の柔軟性が高くなり、それ以外への影響がおよびにくくなりました。
つまり、テストコードへの影響も局所化できて、継続的にテストをしやすい状態になったはずです。
なったんですが、そこで疑問が発生します。
全部テストできるからと、すべてのクラスを同じレベルでテストする必要があるんでしょうか?
当たり前のことですが、重要なクラスの安定性が高くなるようにコントロールすることで重要なクラスへのテストコードの寿命は延びます。逆に言うと、安定性の低いクラスのテストコードの寿命は短くなります。
また、この表はあくまで例ですが、テスト価値と、テストの難易度には一貫性がないのではないかと感じています。
重要な部分はテストしにくい部分から切り離す努力はしますが、かならずしもできるとは限りません。例えばViewなんかは安定性が低いわりに、テスト難易度が高かったりして、きっと皆さん思いますよね?
Viewのテストしたくねえなあって。
また別の視点もあります
そもそも、クラス単体でテストする価値ってどのくらいあるんでしょうか?
ソフトウェアはクラスとクラスがまとまってコンポーネントとなり、コンポーネントとコンポーネントがまとまってサブシステムになります。そしてサブシステムとサブシステムがまとまってシステムになります。
ここではクラス・コンポーネント・サブシステム・システムを、ソフトウェア エンティティと呼ぶことにします。
ソフトウェアエンティティのすべてにおいて、ここまでした話の内容は適用できます。
もちろんサブシステム間は例えばWeb APIになったりするでしょうけど、どの要素間であっても、インターフェースの文脈を管理する事で 制御の流れと依存関係は制御することが可能です。そして依存関係を制御することで、安定性と柔軟性をコントロールすることが可能となります。
そしてソフトウェア エンティティのレベルが高くなれば高くなるほど、それらに対するテストの価値は高まります。ただし、難易度も併せて高くなります。
クラスが動いても、システムが動かなければ価値を生まないので当たり前ですよね。ただし、テストの比率を上位レイヤーに置くと、障害の発見が遅れます。
さらにここでジレンマが発生します。
ソフトウェアの品質を上げたいという要求があったら、通常テストを増やして品質をあげますよね。ところがテストを増やすと、当然ながら、テスト維持コストが上昇します。その結果、テストが維持できなくなります。
その結果、品質が下がりそして最初にもどるという。
つまり継続的にテストをするには、テスト技術だけじゃどうにもなんないわけです。
我々には計画的なテスト戦略が絶対に必要です。ではテスト戦略ってどうたてればいいのか。
ここからは完全にオレオレ方式というか、弊社方式オレ編みたいなもんですが
テスト戦略の立案には、プロジェクト計画やビジネスユースケースだったり、システムユースケース、機能要件や、非機能要件、システムアーキテクチャなどなどをインプットにして立案していきます
で、どんな内容を計画していくかというとインプットになった情報をもとに、どの要件をどのレイヤーで、どうテストするかこの表みたいな感じで、例えばここでは5W3Hなどで整理してバランスをとっていくことが、大事だと考えています。
もうちょっと掘り下げてみてみましょう。例えばシステムテストであれば、こんな感じです。
システムテストは、ビジネスシナリオの実現性を評価します。評価は開発サイドのテストチームが、評価可能になったビジネスシナリオから逐次テストします。
システムテスト用の環境でビジネスシナリオに則って手動でテストし、想定されうるビジネスシナリオすべてに対して行います。
全ビジネスシナリオで〇〇人月投入します。
みたいな感じです。もちろん、これは方針レベルでしかないものですが、ここから初めて詳細に落としていくやり方をとっています。
とうわけで、最後のテスト戦略に関してはふわっとした物になってしまって申しえ訳ありませんが、私からお伝えしたいことは以上です。 最後に改めてまとめたいと思います。
継続的にテスト可能な設計を目指すには、次の三つのエッセンスが重要だと、私は考えています。
- 制御の流れ流されず、適切に依存方向をコントロールする必要があります
- 依存方向によって安定性と柔軟性を、計画的に制御しましょう
- そして、そういった技術論だけでは継続的なテストの維持は困難なので多面的な要素を考慮した、現実的なテスト戦略を立てる必要があるかと思います
テスト戦略の話は、抽象的な話に終始してしまいましたが先に話した二つのエッセンスを、そのまますべてのレイヤーのすべての要素に適用すると、確実に爆死するので、うまくバランスをとって継続可能なテストを目指していっていただけたらと思います。
以上です。