ドメインモデリングの1歩目としてテスト駆動開発(TDD)を導入してみた
10月末で前職を退職したかわうそです。
11月からはこれから創業を迎えるスタートアップに転職して、フルスタックに開発を担当しています。
そんなスタートアップでなかなかテストの文化やドメインモデリングが浸透していなかったので、どんなことを考えて導入を進めていったのかについてお話してみようと思います。
引き続きテックブログの執筆やイベントの登壇は積極的にやっていきたいので、今月も1本記事を書いてみました。
簡単なサマリ
- まずはコアな部分に統合テストを導入してみた
- 向き合うべきなのはドメインモデリングだと気づく
- だけどなかなかドメインモデリングに割く時間がない
- その中でドメインモデリングを行うために導入してみたのがテスト駆動開発(TDD)
- やってみたらけっこう良いことたくさんあった
当時の状況
技術スタックを簡単にだけまとめておきます。
- フロントエンド
- TypeScript, React, TanStack, Tailwind
- バックエンド
- TypeScript, Nestjs, Prisma
- package by featureを採用
- 通信
- REST API(OpenAPI)
ビジネスロジックなどコアな部分にテストがほぼない
なんとなく、ドメインモデリングがあまり行われていない環境ってテストがないよなぁとか思いつつ、いざ参画して眺めてみたら、案の定、ビジネスロジックにはテストがほぼない状態でした。
ただし、ミドルウェアやユーティリティ的な機能など共通化されている部分には単体テストが導入されていました。これは共通部分を利用したり拡張していく上ではとても助かりました。
今までの経験を踏まえても、コアな部分にテストがない状態は変更容易性というより変更に対する恐怖ができてしまったり、仕様の読み取りさの難易度が上がったりしてしまうので、ここはまず手を入れるべきだと考えました。
ひとまず、アプリケーションレイヤーに統合テストを導入
今後、アプリケーションレイヤー(Application ServiceやUsecaseなど)を改修したり、新規参画者にとって仕様を読み取りやすくするためにも、アプリケーションレイヤーにひとまず統合テスト(インテグレーションテスト)を導入できる基盤を構築しました。
ドメインモデリング等が行われていないので、ほぼドメイン的な知識やビジネスロジックはアプリケーションレイヤーに実装されていました。
(手続き型やトランザクションスクリプトなどに近いイメージ)
そこで、アプリケーションレイヤーに統合テストを導入することが一番効果が高いと判断しました。
また、アプリケーションレイヤーに対する統合テストは、管理内プロセスにあるような処理はできる限りモックやスタブを利用せず、DBの読み書き等までを含めているテストを実装しました。
これは僕の経験論でもありますが、アプリケーションレイヤーのテストはモックやスタブを使ってしまうと、処理を流しているだけになってしまいがちでほぼ効果がないと感じています。
結局、アプリケーションレイヤーのテストにおいて評価したいのは「実際に期待している結果がDBに書き込めているのか」だったりするので、アプリケーションレイヤーのテストにはモックやスタブは利用しない方が良いと考えています。
本来のユースケースはドメインのタスク進行管理的な役割であるので、永続化のことまで意識する必要はないと思いますが、そこまで完璧なユースケースに出会ったことはないです笑
また、アプリケーションレイヤーは、バックエンドにおけるAPIを表現していることが多いので、アプリケーションレイヤーの統合テストを実装することは、フロントエンドなどAPIを利用するクライアント側が仕様を理解しやくなったり、開発効率を向上させたりできるものだと考えています。
※ テストを導入したり自動化することをテスト駆動開発(TDD)とは言わないので、誤解がないように注意(参考程度にKent Beckさんの翻訳記事を貼っておきます)
ドメインモデリングに向き合いたいが時間が足りない
これはスタートアップあるあるなのかもしれません。
資金調達であったりARRの目標達成といったタスクを優先することが多く、確実なデッドラインに追われる中で、ドメインモデリングに時間を割くのはなかなか難しいです。
ですが、新しいドメイン知識の獲得やドメインの知識やルールをコードで表現するという上でドメインモデリングすることは重要なことです。
速度を優先すると短期的なところに目がいきがちですが、ドメインモデリングは短期的な部分でも十分効果があると考えています。
手段にとらわれない
僕が思うに、短期的な部分で効果がないと思われがちなのが、ソフトウェアアーキテクチャの整備であったりテスト網羅性とかに焦点を当ててしまい、「短期的に効果はあまりないんじゃない?」となってしまうことです。
(いつかアーキテクチャを整備しよう、いつかテストを書こうなどとかになりがち笑)
いわゆる戦術的DDD、軽量DDDであったりClean Architectureのような手段を優先してしまうことを指しています。
たしかに、ソフトウェアアーキテクチャを整えることはコードの変更容易性に繋がるので、導入する価値は十分にあると思います。中長期的に人が増えていくと考えた場合にも、決められたルールや構造があるだけで、新規参画者のキャッチアップ容易性にもつながります。
これらのアーキテクチャを導入したり、今いるメンバーのアーキテクチャ理解を向上させたり、今あるコードのテストカバレッジを上げたりするには時間がかかります。
必ずとは言わないですが、短期的にすぐ効果が出ることも少ないと思います。
速度を優先するからこそドメインモデリングをすべき
ソフトウェア開発において重要なのは、開発しているプロダクトがどんな価値を提供するのか、どんな問題を解決するのか、そのためにはどんな仕様・機能であるべきなのかだと考えています。
特にスタートアップは、「この思考やプロセスを速いサイクルで回せるか、いかに速く顧客に価値を届けられるか、いかに速く顧客からフィードバックが得られるか」が求められると思っています。
ちゃんとドメインモデリングされていない価値を顧客に届けても、このサイクルは回せないはずです。
だからこそ、開発しているプロダクトにおけるドメインをちゃんとモデリングしていくべきであり、まずやるべきはドメインモデリングであると考えました。
ドメインモデリングに挑戦
ここからは実際にドメインモデリングに挑んでみたときのお話です。
いきなりドメインモデリングしてみようは難しい...
世の中に手法はたくさんあり、なにをモデリングするのかによって手段は変わります。
ドメイン駆動設計(DDD)、イベントストーミング、RADR、C4モデル、データモデリング、etc...
そこでまず目指したのが「ドメインの知識やルールをコードで表現できるようにする」 ことです。
そのために必要なのがドメインモデリングです。
ドメインモデリングは、特定のビジネスや課題の領域を理解し、その領域に存在する概念や関係性を整理・抽象化してモデルとして表現するプロセスです。ソフトウェアにおいてそのドメインの知識やルールを実現するための土台となります。
じゃあ、いきなり次の機能を開発するときからドメインモデリングをやってみようはかなり難しいです笑
ドメインモデリングと言われて思いつく手法としてイベントストーミングやSUDOモデリングがあると思いますが、そこまで経験のあるメンバーが多いわけではなく、しっかりやる時間も少ない中で行うには、厳しいと考えました。
(本当はドメインの理解を更に深めるために必要だと思っていますが、なかなか難しい、、、)
ドメインモデリングの1歩目としてテスト駆動開発(TDD)を導入
限られた時間とリソースの中でドメインモデリングをするために導入したのが テスト駆動開発(TDD) です。
「テスト駆動開発(TDD)でドメインモデリングってできるの?」って思う方もいらっしゃるかもしれません。ですが、テスト駆動開発(TDD)とドメインモデリングは相互に補完し合うような関係性にあります。
テスト駆動開発(TDD: Test-Driven Development)はプログラミングのワークフローだ。あるプログラマが、あるシステム(まだ無いかもしれないが)の振る舞いを変更する必要があるとする。TDDの狙いは、そのプログラマを支援して、システムを下記のような新たな状態に導くことだ。
それまで動作していたものは引き続き全て動作する
新しい振る舞いは期待通りに動作する
システムはさらなる変更の準備ができている
プログラマとその同僚は、上記の点に自信を持っている
【翻訳】テスト駆動開発の定義より引用
先ほど紹介した記事の一部を引っ張ってきたのですが、上記の通り、テスト駆動開発(TDD)の狙いとして「新しい振る舞いは期待通りに動作する」というのがあります。
テスト駆動開発(TDD)では、テストを書く際に、特定の機能に必要な入力・出力・振る舞いを明確化します。この過程で、モデルに不足している要素や設計上の欠陥などを明らかにすることができます。
例えば、「顧客が商品を購入する」というシナリオをテストで表現しようとしたとき、購入ロジックを適切にモデリングするために、新しいエンティティや値オブジェクトが必要だとわかります。
つまり、テスト駆動開発(TDD)はドメインモデリングの検証手段として機能し、モデルがドメイン知識を正しく表現しているかを確認することができます。
テスト駆動開発を導入してドメインモデリングに挑戦してみる
いきなり、テスト駆動開発の導入も簡単ではないですが、他のモデリングと比べたら、ソースコードベースでやり取りが可能になるので、他のモデリング手法とかに比べられたら、導入コストは低いかなと思います。
また、テスト駆動開発の実践例としてかとじゅんさんが用意してくれている便利なサンプルコードがあるので、チャレンジしてみたい方がいたら、ぜひ見てみてください!
せっかくなので、実際にどんなふうにやっているか簡単にご紹介します。
1. ユースケースを洗い出す
まずは求められている価値・機能からユースケースを洗い出します。
このユースケースの洗い出しを行いながら、実際にテストを先に作成していきます。
先程のサンプルコードから引用すると、以下のようなイメージです。
2. ユースケースを1つずつモデリングしながら実装
洗い出したユースケースを元に1つずつ機能を満たすようにモデリングしながら実装していきます。
例えば、"開始時刻が過去の場合は、オークションは作成できない"であれば、以下のように進めていくことになります。
- そもそもオークションというオブジェクトが必要だな
- 開始時刻が~~~ということはオークションに開始時刻という値を持つ必要がありそうだな
- 開始時刻が過去の場合は作成できないので、Validationが必要だな
これを順番に実装していくと、こんな感じになります(あくまで例です)
class Auction {
constructor(
readonly auctionId: string,
readonly startDate: Date,
) {
// 開始時刻が過去の場合は、オークションは作成できない
if (startDate < new Date()) {
throw new Error("開始時刻が過去の場合は、オークションは作成できません。");
}
}
}
...
test("開始時刻が過去の場合は、オークションは作成できない", () => {
const past = new Date();
past.setHours(past.getHours() - 1);
expect(() => new Auction("auctionId", past)).toThrow();
});
...
さらに、以下のようにリファクタリングできそうだなとかも考えて実装していきます。
- コンストラクタは公開しないほうが良さそう
- オブジェクトは不変にしよう
- 現在時刻(new Date())が内部にあるとテストが書きづらそうだから、引数として受け取れるようにしよう
- etc...
このようなリファクタリングも先にテストがあれば安心してリファクタリングをすることができます。
3. すべてのユースケースを満たしたあとに永続化などを実装
上記のように進めていき、すべてのユースケースが網羅しきったら、実際にAPIとして組み込みながら、永続化などを実装していきます。
もちろん、ユースケースを満たしていく中で永続化する必要があるとわかっているのなら、あらかじめインターフェース(I/F)を定義しておくとかもありかなと思います。
インターフェース(I/F)があれば、Mockを作成して以下のようにテストを書くことも可能です。
(もちろん、実際の実装はDBの書き込みがあるので、あくまでテスト用のMockです)
interface AuctionRepository {
save(auction: Auction): Promise<Auction>;
}
class AuctionRepositoryImpl implements AuctionRepository {
async save(auction: Auction): Promise<Auction> {
return Auction.create(auction.auctionId, auction.startDate);
}
}
...
test("オークションを保存できる", async () => {
const auction = Auction.create("auctionId", new Date());
const repository = new AuctionRepositoryImpl();
await repository.save(auction);
});
...
実際にやってみてどうだったか
少しだけやってみた感想とか課題とかを紹介できればなと思います。
モデルがどんどん洗練されていく
実際にやってみると、「この状態の時って登録できていいんだっけ?」とか「そもそもこのモデルにおける目的って違うのでは?」「既存のコードもちゃんとモデリングした方が良いかも」など、機能開発に必要なコミュニケーションが促進されて、よりモデルが洗練されていっている感覚があります。
また、テストとモデルがセットになっていることで、コードをレビューする側もかなり楽になっていますし、本質的なレビューができるようになったと思っています。
せっかくなので、嬉しい声をいくつか紹介しておきます。
いざやろうとしてみるとなかなか進まない?
良いことばかりかというと、そうでもありません。
テスト駆動開発・ドメインモデリングを行う上で事前に必要なのが、要件(ユースケースなど含む)です。先ほども紹介した通り、ユースケースの洗い出しを行いながらテストを先に作成していきます。
そのため、「要件やユースケースが決まっていないと進められない...」みたいなことが発生しがちです。
- 「このケースの場合って登録できて問題ないですか?」
- 「この値のValidationはこれで問題ないですか?」
- 「この権限があるときってこの操作って可能ですか?」
- etc...
これをPdMやPMにすべて確認して合意を取っていたら、彼らが死んでしまいます、、、笑
もちろん、これだけは決めておかないとっていうラインはあると思います。でも全部決めなくても良いし、全部決めることは難しいと思います。これが正解って見えていることは少ないので、仮説検証としての開発が基本になると思います。
なので、エンジニア側で「この業務ルールでやってみよう」「この仕組みでやってみよう」など、挑戦(トライ)としてのモデリングは必要だと思います。もちろん、「やっぱりこの業務ルールだとだめだったから、こっちにしてみよう」なんてことは発生すると思います。
だからこそ、変更容易性の高いモデル(コード)にして変化に強いソフトウェアにすべきであり、そのためにテスト駆動開発であったり、ドメインモデリングが必要になってくるわけです。
つまり、仮説→具体化→検証→フィードバック→改善のサイクルを速く回すために、テスト駆動開発とドメインモデリングが活用できるということです。
(やっぱり、初期フェーズほど力を入れるべきはドメインモデリングである気がよりしてきますねw)
いざやってみたら完璧を追い求めすぎてしまう
モデリングを洗練することは良いことではあるんですが、やっぱり突き詰めてしまうと、キリがないなぁって改めて思いました。
やればやるほど沼にハマる部分はありますし、沼から帰ってこれなくなる感じもあります...笑
(特に未来や中長期的なこと、非機能要件などを考え出すとキリがありませんw)
こういったときこそ、原点に帰って、開発しているプロダクトがどんな価値を提供するのか、どんな問題を解決するのか、そのためにはどんな仕様・機能であるべきなのか、に立ち返るべきなんだと思います。
まずやるべきは、今求められている価値・機能を最速で提供することであり、そこに必要最低限のユースケースやルールを明確化して、必要最低限のモデリングをすべきなんだと思います。
なので、大事なのは"単一目的なモデリングを行う"ことなのではないかと思います。
(なんか、同じようなことをミノ駆動さんも言っていたなぁ...)
まとめ(大事なこと)
- スタートアップはスピードが本当に大事
- そのスピードの正体は顧客に価値を届けるまでの速度
- 顧客に必要な価値を速く届けるためにドメインモデリングが必要
- でもドメインモデリングをいきなりやってみようは難しい
- なので、まずはテスト駆動開発(TDD)から導入してみた
- モデリングにこだわりすぎると沼にハマるので、単一目的なモデリングを行うことを心がける
おわりに
ドメインモデリングに終わりはない、とりあえず、イベントストーミングはやっていきたい笑
Discussion