概要
- 前回(8.1~8.3)のおさらい
- ざっくり統合テストをどうやるべきなのか? をコードを見ながら解説
- 今回やること
- もうちょっと詳細に見ていく。具体的には以下
- 管理下にない外部依存にはインタフェースとモックを使う
- モックを効果的に使うための統合テストのベスト・プラクティス
- ログ出力のあり方とそのテスト方法について
- もうちょっと詳細に見ていく。具体的には以下
8.4 インタフェースを使った依存の抽象化
8.4.1~3 管理下にない外部依存はモックを使おう
統合テストでは協調して動く外部依存をどう処理してテストするか? という点が重要になります。そこで、適宜インタフェースとモックを利用してテストを書くことになります。
外部プロセスをモックに置き換えるか否かは、「8.3.2 モックに置き換えるか否かの依存の分類」にあるように、その依存が管理下にあるか否かが左右します。
例えば、対象のシステムのみが使うDBへの依存は「管理下の依存」と呼べます。この場合モックでは置き換えません。
逆に外部からのメッセージバスは「管理下にない依存」であるため、モックで置き換えます。
インタフェースを利用したモックの実例は以下の通りです。
public class UserController
{
private readonly Database _database;
private readonly IMessageBus _messageBus;
public UserController(Database database, IMessageBus messageBus)
{
_database = database;
_messageBus = messageBus;
}
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
foreach (EmailChangedEvent ev in user.EmailChangedEvents)
{
_messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail);
}
return "OK";
}
}
public interface IMessageBus
{
void SendEmailChangedMessage(int userId, string newEmail);
}
public class MessageBus : IMessageBus
{
private IBus _bus;
public void SendEmailChangedMessage(int userId, string newEmail)
{
_bus.Send($"Subject: USER; Type: EMAIL CHANGED; Id: {userId}; NewEmail: {newEmail}");
}
}
長いコラム:お前らのインタフェースは間違っとる
長いコラム:お前らのインタフェースは間違っとる
インタフェースの使われ方にはしばし誤解があります。以下は典型的なインタフェースのコードです。
publilc interface IMessageBus
public class MessageBus : IMessageBus
public interface IUserRepository
public class UserRepository : IUserRepository
このような例はインタフェースのメリットを享受できていません。なぜでしょうか?
インタフェースのメリットと、今回の例がそのメリットを受けられない理由は次の通りです。
- インタフェースのメリット
- プロセス外依存を抽象化でき、疎結合となる
- 契約に基づいている、DIできるなどの理由
- 既存のコードを変更せずに新しい機能を追加できる
- →解放閉鎖原則の遵守
- プロセス外依存を抽象化でき、疎結合となる
- しかし、上記の例がダメな理由
- 「プロセス外依存を抽象化でき」てないし、「疎結合とな」っていない
- インタフェースの実装クラスがひとつだけのとき、そのインタフェースは抽象と呼べない
- つまり、具象クラスをそのまま使う場合と比べて疎結合になっていない
- そもそも抽象化とは発見することであり、作り出すものではない
- 複数ある実装クラスに共通のインタフェースを見つけ出すもの。無理やり作るな
- インタフェースの実装クラスがひとつだけのとき、そのインタフェースは抽象と呼べない
- 解放閉鎖原則(OCP)はYAGNIに反する
- 将来の拡張性、というのは現在のニーズではないため、YAGNIに反する
- 現在必要ではない機能開発に有限な時間を割くべきではない
- コードベースが増えることによって保守コストが増していることを認識せよ
- 将来の拡張性、というのは現在のニーズではないため、YAGNIに反する
- 「プロセス外依存を抽象化でき」てないし、「疎結合とな」っていない
なお、「OCP vs YAGNI」に関しては著者のブログにて解説があるため、より詳しく理解したい人は読んでみてください。
以下に翻訳しておきました。
8.5 統合テストのベスト・プラクティス
では、統合テストのベストプラクティスを見ていきましょう!
本書で紹介されているのは以下の3つです。どれもモックを正しく使うためにどうするか? という視点で語られています。
- ドメインモデルの境界を明確にする
- アプリケーションを構成する層を減らす
- 循環依存を取り除く
8.5.1 ドメイン・モデルの境界を明確にする
ドメイン・モデルを配置する場所をコードベースの明確でわかりやすいところに用意しましょう。
ドメイン・モデルを用意することで、該当のコードが何をしているのか分かりやすくなります。また、ドメイン・モデルとコントローラが明確に分けられていることで、そのコードを単体テストでテストすれば良いのか、それとも統合テストでテストすれば良いのかの判断がしやすくなります。
8.5.2 アプリケーションを構成する層を減らす
コードの抽象化や汎用化をするとき、新しいレイヤーを追加することは良い手段です。デビッド・ホイーラーのソフトウェア工学の基本定理を引用しましょう。
We can solve any problem by introducing an extra level of indirection.
コンピュータ科学のいかなる問題も他のレベルの間接参照(indirection)によって解決できる。
なおこの言葉は、次の一文を付け加えられることが多いようです。
"…except for the problem of too many levels of indirection,"
ただし、間接参照の層があまりに多くなる問題は除く
では、どういった理由でレイヤーの追加自体が問題になるのでしょうか? 本書では次のような指摘がされています。
- 開発者の認知的負荷が増す
- レイヤーが増えることで、各機能に関する記述が分散し、どこになにが書いてあるのか分かりにくくなります。このことは次の問題点を引き起こします。
- 単体テストや統合テストが実行しずらくなる
- 間接参照の層が多くなると、コントローラーとモデルの境界が曖昧になります。単体テストと統合テストの境界もまた曖昧になります。
- 各層を個別に検証した結果、テスト対象の層にある少量のコードだけを実行する価値の低いテスト・ケースをたくさん抱えることになります。これでは退行に対する保護やリファクタリング耐性を損ないます。
- そもそも不要
- 本当にそこまで多くの層は必要でしょうか? ほとんどのソフトウェアは、ドメイン層、アプリケーション・サービス層、インフラ層があれば十分です。
- (※投稿者注:DDDとの兼ね合いはどうなのか? については著者のサイトを読んでみてもいいかもしれません。DDD関連の記事も多く書いています。ジャストでこれについて書いている記事はなさそうですが......。)
8.5.3 循環依存を取り除く
循環依存とは、適切に機能させるためにふたつ以上のクラスが直接的もしくは間接的にお互いに依存する状態のことを指します。
具体的には次のような、コールバックの例があります。
namespace Book.Chapter8.Circular
{
public class CheckOutService
{
public void CheckOut(int orderId)
{
var service = new ReportGenerationService();
service.GenerateReport(orderId, this);
/* other work */
}
}
public class ReportGenerationService
{
public void GenerateReport(
int orderId,
CheckOutService checkOutService)
{
/* レポートの生成が完了したらCheckOutServiceを呼び出す */
/* そんで次の処理に移ってもらう? */
}
}
}
このようなコードは、単純に開発者の認知的な負荷を高めます。
またこの循環依存はどうテストを行うのでしょうか? 循環依存自体はインタフェースを利用することで解消できます。しかし、ドメイン・モデルの検証にモックを使うテストは望ましくありません。そしてそもそも、この複雑なコードにさらにインタフェースを追加するということがあまり考えたくないことです。
というわけで、この循環依存は次のように分解できます。
namespace Book.Chapter8.NonCircular
{
public class CheckOutService
{
public void CheckOut(int orderId)
{
var service = new ReportGenerationService();
Report report = service.GenerateReport(orderId);
/* other work */
}
}
public class ReportGenerationService
{
public Report GenerateReport(int orderId)
{
/* ... */
return null;
}
}
public class Report
{
}
}
補足: ひとつのテスト・ケースに複数の実行(Act)フェーズを用いたい
8.6 ログ出力に対するテスト
ログ出力をテストでどう扱うかというのは微妙なところです。ここでは以下の観点からログ出力のテストについて考えてみましょう。
- ログ出力をテストするか?
- どのようにテストするか?
- どのくらいのログを出力するか?
- どうやってログ出力オブジェクトを受け渡すか?
8.6.1 そもそも、ログ出力をテストすべきか?
ログ出力をテストすべきかの答えは明確です。それはテスト対象のログがプロセス外依存に対して副作用を及ぼすか否かです。つまり、ログが外部(ユーザ、アプリケーションクライアント、非開発者)から見られることを意図しているのであれば、テストは必要です。逆にログ出力を開発者だけが見るのであれば、テストは不要です。
この2種類のログのことをそれぞれ「サポートログ」「診断ログ」と呼びます。(Growing Object-Oriented Software, Guided by Tests)
- サポート・ログ
- システムのサポート・スタッフやシステム管理者によってみられることを意図した特定のイベントを記録するログ
- 診断ログ
- 開発者がアプリケーション内で何が起こっているのかを把握できるようにするためのログ
8.6.2 どのようにログ出力をテストすべきか?
では、サポートログのテストをどのように行うべきでしょうか? これまで学んできたように、プロセス外依存とのやりとりを行う以上はモックを使った検証となります。
また、ここで大切なのはこのモックを汎用的なILoggerインタフェースを利用して作らないことです。サポート・ログはビジネス要件を反映した機能であり、ドメイン知識が反映されているのですから、DomainLoggerとして切り出してこの機能がビジネス要件を反映したものであることを明確にする必要があります。
具体的なコード例を見てみましょう。
public void ChangeEmail(string newEmail, Company company)
{
_logger.Info( // ←診断ログ
$"Changing email for user {UserId} to {newEmail}");
Precondition.Requires(CanChangeEmail() == null);
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);
// サポートログ
_domainLogger.UserTypeHasChanged(UserId, Type, newType));
}
Email = newEmail;
Type = newType;
EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));
_logger.Info($"Email is changed for user {UserId}");
}
上記では、診断ログに対してはILogger型のオブジェクト(_logger)を使う一方で、サポート・ログにはIDomainLogger型のオブジェクト(_domainLogger)を使っています。また、IDomainLoggerインタフェースの実装クラスとなるのが次の例です。
public class DomainLogger : IDomainLogger
{
private readonly ILogger _logger;
public DomainLogger(ILogger logger)
{
_logger = logger;
}
public void UserTypeHasChanged(
int userId, UserType oldType, UserType newType)
{
_logger.Info(
$"User {userId} changed type " +
$"from {oldType} to {newType}");
}
}
このようにサポート・ログを出力する各メソッドの名前にテスト対象のドメインで使われている用語が使われることで、どのようなサポート・ログを出力するのが明確になります。
構造化ログ(structured log)とは?
構造化ログ(structured log)とは?
先ほどのログ出力の書き方は、ログ・ファイルの後処理や解析において柔軟な対応ができるようになる「構造化ログ」の概念と共通する部分を持ちます。
一般に構造化ログとはJSON形式などのmachine readableなログのことを指すようですが、ここではログ・データの生成と出力とを切り離すログ出力のテクニックとして紹介されています。そして、生成と出力を切り離せるということは、人間にとって読みやすい形式と機械にとって解読しやすい形式を柔軟に切り替えられるということです。
以下の箇所に注目してみてください。
public void UserTypeHasChanged(
int userId, UserType oldType, UserType newType)
{
_logger.Info(
$"User {userId} changed type " +
$"from {oldType} to {newType}");
}
これは正確には構造化ログを出力するものではありません。ただ、メッセージテンプレートとパラメータという構成要素があり、その出力方法を自由に切り替えられるという点で構造化ログの概念を踏襲しています。
(※投稿者注:いまいちピンとこない)
ログ出力はドメイン・イベントとして扱う(structured log)とは?
ログ出力はドメイン・イベントとして扱う
ChangeEmail()のログに関する問題がもうひとつあります。それはUserクラスがログ出力というプロセス外依存を扱うという点です。ビジネスロジックに関する責務とプロセス外依存とのコミュニケーションに関する責務を分離できなくなっており、これは第7章にある通り、テストや保守の難しい過度に複雑なコードとなっています。
このような場合、どうするのが良かったでしょうか? それはドメイン・イベントの導入です。
ユーザのメールアドレスが変更されたことをドメイン・イベントを利用してDomainLoggerクラスに伝えるのです。
・Userクラスの例
public void ChangeEmail(string newEmail, Company company)
{
_logger.Info(
$"Changing email for user {UserId} to {newEmail}");
// 中略
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
AddDomainEvent(
new UserTypeChangedEvent(UserId, Type, newType)); // ドメイン・イベント
}
Email = newEmail;
Type = newType;
AddDomainEvent(new EmailChangedEvent(UserId, newEmail)); // ドメイン・イベント
_logger.Info($"Email is changed for user {UserId}");
}
・呼び出す側のコントローラ
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
// メールアドレス変更すると、Userクラス側でイベントが発行される
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
EventDispatcher.Dispatch(user.DomainEvents); // ログ出力イベントの処理
return "OK";
}
8.6.3 どのくらいログを出力すれば十分なのか?
適切なログ出力量とはどの程度なのでしょうか?
サポート・ログはビジネス要件となるためその量をコントロールできませんが、診断ログのコントロールは可能です。
では、診断ログは多ければ多いほど良いのでしょうか? それとも少ない方が良いのでしょうか?
なんとなく予想がつきますが、過度に多い診断ログは望ましくありません。コードの本筋と関係ないログが増えればその分プロダクションコードが読みにくくなりますし、またログが多くなるほど本当にほしい情報が埋もれてしまいます。
このことから、一時的なデバッグなどでログを埋め込む場合を除いて、ドメインロジックに診断ログを書くことは強く避けるべきです。もしどうしてもドメインロジックで診断ログを出したい場合でも、コントローラー側で代わりにログを出力できるケースがほとんどです。
8.6.4 どのようにログ出力オブジェクト(Logger)を受け渡すのか?
環境コンテキスト(Ambient Context)アンチ・パターン
では、どのようにログ出力オブジェクトを利用する側のコードに受け渡せば良いのでしょうか?
そのひとつの解決策が以下のように静的なメソッドからログ出力オブジェクトを取得できるようにすることです。
public class User
{
private static readonly ILogger _logger = LogManager.GetLogger(typeof(User));
public void ChangeEmail(string newEmail, Company company)
{
_logger.Info($"Changing email for user {UserId} to {newEmamil}");
// ather code
_logger.Info($"Email is changed for user {UserId}");
}
}
このような依存の取得は環境コンテキスト(ambient context)というアンチ・パターンに当たります。(「環境コンテキスト(Ambient context)アンチパターン」翻訳 - Qiita)
このやり方がアンチ・パターンであるのには以下の理由があります。
- 依存関係が隠れてしまい、変更が難しくなる
- テストがより難しくなる
また、同時にこのアンチパターンを採用しなければならないようなコードには、他に本質的な問題を抱えていることがほとんどです。ログ出力オブジェクトをドメイン・クラスに明示的に渡す(外部から依存性注入してあげる)ことが難しい状況を考えてみましょう。それはあまりにも多くのログを出力していたり、経由する間接参照の層がおおすぎるかのどちらかでしょう。それらを取り除き、環境コンテキストというやり方を取らずに済むよう対処するべきです。
その他のDIパターン
受け渡し方には色々とありますが、例えばメソッドインジェクションやコンストラクタ経由のものがあります。以下はメソッドインジェクションの例です。
public void ChangeEmail(
string newEmail,
Company company,
ILogger logger
)
{
logger.Info($"Changing email for user {UserId} to {newEmamil}");
// ather code
logger.Info($"Email is changed for user {UserId}");
}
}
まとめ
- 統合テストでモックを使うか否かの判断は管理下にある依存かどうか
- 管理下にある: モックを使わない
- 管理下にない: モックを使う
- 統合テストのベスト・プラクティス
- ドメインモデルの境界を明確にする
- アプリケーションを構成する層を減らす
- 循環依存を取り除く
- どれもモックを正しく使うためのお膳立てといえる
- ログ出力
- サポートログと診断ログがある
- サポートログはIDomainLoggerインタフェースとモックを使ってテストする
- 診断ログは必要なものに絞る
- ログ出力オブジェクトは適切な形で依存性を注入するとよい
参考にした情報など
-
OCP vs YAGNI - Enterprisecraftmanship.com
-
「OCP vs YAGNI」翻訳 - Qiita
- そこそこ読みやすく訳しました
-
「OCP vs YAGNI」翻訳 - Qiita
- ソフトウェア工学の基本定理 - wikipedia
- Growing Object-Oriented Software, Guided by Tests
-
Dipendency Injection Principle, Plactices, Patterns
- DIのワンテーマで6,000円くらい持っていくヤベ〜本。
- The Ambient Context Anti-Pattern - Manning Free Content Center
-
「環境コンテキスト(Ambient context)アンチパターン」翻訳 - Qiita
- 機械翻訳にかけただけ。前提の情報も多いので読みにくいかも。そのうち読みやすくします。
- サンプルコード類
- こちらのリンクから取得しました
- 感想
- 10分では足りんな〜
- 技術書の翻訳やりて〜〜〜〜〜
- 正直わかっとらんところ
- callbackと循環依存。これライフサイクル処理のためのやつ? checkout以外の別メソッドを呼んだりするの?
- 構造化ログ、ピンとこない
