前回
レガシーコードのテストピラミッドの歪み
つまり、E2Eテストが持つテスト・ケースをもっとも少なくし、統合テストが持つテスト・ケースをE2Eテストより多くしながらも、単体テストよりも少なくし、単体テストが持つテスト・ケースをもっとも多くする、ということです。
『単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.126
これは、私が現在携わっているようなレガシーコードの多いプロジェクトではそう上手く行かないように思われる。 レガシーコードは単体テストを書こうにも、「サービスクラス」と呼ばれている何かがたくさんの依存関係と複雑なロジックの両方を扱い、本書でいうところの「過度に複雑なコード」に陥っていることが多い。 つまり本書で単体テストの対象とされている「ドメインモデル」が存在しないし、実際に単体テストを書こうとしてもプロセス外依存とのinfrastructure層に適切に隠蔽されていないので、実行時間が短い、各テストケースが隔離されているという条件を達成するのはまず不可能になる。 単体テストを書こうと思ったらまずは「過度に複雑なコード」を複雑なロジックを扱うドメインモデルと、多くの依存の間をとりもつコントローラに分離する必要があるのだが、テスト対象のメソッドが呼び出すコードが合計で数百行あったり、依存しているクラスも全くテストされていなかったりするといきなり手を加えるというのも少々こわい。 結果として最初に統合テストを1つ用意し、あとはそれを頼りにリファクタしていくというやり方になりがち。
ただ、統合テストはやはりセットアップのコードが長くなりがちだし、実行に時間がかかり迅速なフィードバックが得られないのがきついので、正常なテストピラミッドに近づけていきたいという思いもある。 この書籍は質のいい単体テストを書くにはまずコードがまともなアーキテクチャに則って書かれている必要があると訴えているように感じ、レガシーコードの多いプロジェクトの扱いはスコープ外なのだろうが、いつかそういう場合にどうやってテストピラミッドの歪みを直すかについても知りたいと思った。
関数型アーキテクチャと現実との折り合い
今回見てきた訪問者記録システムは関数型アーキテクチャをうまく導入することができたのですが、その理由は、関数的核(functional core)を呼び出す前に、すべての入力値を集めることができたからです。しかしながら、実際のシステムでは、決定を下す流れがこの訪問者記録システムほど単純ではないことがよくあります。たとえば、決定を下している最中に、途中で得た結果を使って新たにプロセス外依存からデータを取得しなければならないような場合です。
『単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.206
私としてはせっかく業務でScalaを使っているので可能な限り関数型アーキテクチャで行きたいと思ったが、実際、ちょうどその時業務で実装していた機能で
- 他社の外部システム(Aとする)からデータを取得する
- Aから取得したデータにビジネスロジックから考えて不正な部分が無いかバリデーションを行う
- Aから取得したデータに、自社DBの特定のテーブルのキーとなる値が含まれているので、それを元に結合する
- ただし、自社DBにキーに対応するレコードがまだ登録されていないことがあり、その場合見つからなかったデータをまた別の外部システム(Bとする)から取得する
- 外部システムからキーに対応するデータがBから見つかれば、それを自社DBに登録し、Aから取得したデータと結合する
- Aから取得した全てのデータに対し自社DBのレコードが紐づけられたら後続の処理を行う
という処理を実装する必要があり、関数型アーキテクチャと現実の乖離を実感した。 この場合、処理を行うためには
- Aからのデータ
- 自社DBのデータ
- Bからのデータ
が必要になり、最初にそれらを全て取得してしまうこともできないわけではないが、個人的には2のバリデーションに引っかかるようなおかしなデータをAから受け取った場合は、自社DBやBへのアクセスは無駄でしかないので行いたくない。 となると、本書にあるとおりどうしても可変殻(純粋でない処理を行う部分)→関数的核(純粋な計算のみ行う部分)→可変殻→関数的核→...というように、純粋な計算と純粋でない処理が入り混じったフローにならざるを得ない。
本書では処理をDBや外部APIなどのプロセス外部に依存する部分と、プロセス外部に依存しないビジネスロジックの部分に分け、プロセス外部に依存する部分でどのような処理を行うかという判定をなるべくビジネスロジックの部分でやるというアプローチをとっていた。 私も、アーキテクチャに銀の弾丸は無いのだろうしそれが妥当な落とし所ではないかと思うのだが、一方でそれでも関数型アーキテクチャを実践する人はこの問題にどう立ち向かっているのかということも気になってきた。 この問題については「Domain Modeling Made Functional」, 「Functional and Reactive Domain Modeling」という書籍を読むのがよさそうだったので、いつか手にとってみようと思う。
感想
7章が単体テストに留まらない設計の話も多く、アーキテクチャには銀の弾丸が無いので、本当にそれでいいのか?と悩みながら読み進めることが多かった。
たとえば、本書では以下のようなコードがリファクタ後のコードとして紹介されている。
public class User { public int UserId { get; private set; } public string Email { get; private set; } public UserType Type { get; private set; } public void ChangeEmail(string, newEmail, Company company) { if (Email == newEmail) return; UserType newType = company.IsEmailCorporate(newEmail) ? UserType.Employee : UserType.Customer; if (Type != newType) { int delta = newType == UserType.Employee ? 1 : -1; company.ChangeNumberOfEmployees(delta); } Email = newEmail; Type = newType; } } // 『単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.234
コードの背景をざっくり説明すると
ChangeEmail
が受け取ったnewEmail
のドメインがcompanyのものであれば、User
のUserType
をUserType.Employee
にし、そうでなければUserType.Customer
にするUserType
の変更があった場合はcompany
が持つ従業員数プロパティを増減させる(UserType
がUserType.Employee
に変更されたら1増やし、UserType.Customer
に変更されたら1減らす)
というものだ。
私が気になるのはやはり、この方法ではChangeNumberOfEmployees
というメソッドが用意されていることからもわかるようにCompany
を可変にしなければならないということだ。
ただ、Company
を不変にするとChangeEmail
が新しいCompany
を返す必要があり、それはそれで不自然な気もする。
また、本書で紹介されているドメインイベントを用いた副作用のハンドリングについても、User
がドメインイベントをどんどん追加していくための可変なコレクションをプロパティとして持たなければならないという問題がある。
とはいえこちらはドメインイベントを保持するコレクションを不変にしても、User
のメソッドが新しい状態のUser
を返すという動作になり、あまり不自然さは感じないか。
しかし、あまり不変にこだわると今度は関数型アーキテクチャが抱えるパフォーマンスの問題に行き着いてしまう。
ただ、テストの観点から言えばどのようなアーキテクチャを選ぼうとも以下の2点が大事になるのではないかという気がした。
- ビジネスロジックが書かれるクラスでDBや外部APIやファイルシステムなどのプロセス外依存を扱わないようにし、単体テストが書けるようにしておく
- リポジトリや外部APIのクライアントのクラスへの依存をたくさん持ち、なおかつ複雑なロジックが書かれているクラスはテストしないでいると危険だがテストが書きづらいクラスなので、そういうクラスが生まれないよう、複雑なロジックが書かれるドメインモデルと、ドメインモデルとリポジトリなどのプロセス外依存を扱うクラスの連携を指揮するコントローラに分離し、ドメインモデルは単体テストを、コントローラは統合テストを書く
関数型アーキテクチャやドメインイベントについてはまだまだ前提知識が足りていないので、より理解を深めてScalaに合うアーキテクチャを探っていきたい。