今回の内容は初心者の方にとっては難しい内容となっているかもしれません。
ただ、今回話す内容を理解できると、開発スキルも大きく伸ばすことができます
一回で理解できない場合は、何度も記事・サンプルコード・図を読み返したり、この記事で出てくるキーワードで検索して、理解を深めていただけた幸いです。
テストが書きやすいコードとは、保守や拡張がしやすく、バグを早期に発見できるコードです。その鍵となるのは、疎結合と依存性の管理にあります。
この記事では、抽象に依存することと依存性の注入(DI)を用いて疎結合なコードを実現し、テストが書きやすくなるテクニックについてサンプルコードや図を使いながら説明します。
サンプルコードはTypeScriptで書いていますが他の言語でも使えるテクニックです。
普段別の言語をお使いの方は、「PHPだったらこうかな?」「Javaだったらこうかな?」という感じで読み替えていただけたらと思います。
記事の最後の方には、今回紹介するテクニックを使って実装したコード(テストコード含む)が置いてあるGitHubレポジトリのリンクも用意しているので、そちらも参考にしていただけたらと思います。
目次
テストが書きやすいコードとは
テストが書きやすいコードは、各コンポーネント(クラス)が独立していて、外部の変更に強く、内部の変更が他に影響を与えにくい疎結合な構造を持っています。
これにより、一部の機能にバグがあった場合でも、その部分だけを修正しやすく、テストもしやすくなります。
疎結合とは
疎結合とは、各コンポーネント間の依存関係が少ない状態を指します。
これにより、コンポーネントの再利用性が高まり、変更や拡張が容易になります。
依存性の高いコード(密結合なコード)
依存性が高いコード、つまり密結合なコードは、あるコンポーネントが変更された時に、それに依存する他のコンポーネントも修正が必要になることが多いです。
これはテストの観点から見ると、非常に扱いにくい状態です。
疎結合と密結合の比較(図・サンプルコード)
例えば、以下に「User」「UserRepository」「UserService」の3つのクラスを用意しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class User { private name; constructor(name: string) { this.name = name; } } class UserRepository { public save(user: User) { // ユーザーをどこかしらに保存する処理(DBに保存、配列に保存など) } } class UserService { private repository: UserRepository; constructor() { this.repository = new UserRepository(); } registerUser(user: User) { this.repository.save(user) } } |
このときの各クラス間の依存関係は以下のようになります。
- Userクラス・・・依存なし
- UserRepository・・・Userクラスに依存している。
- UserServiceクラス・・・UserRepositoryとUserに依存している。
UserRepositoryに関しては、saveメソッドの引数でUserクラスのインスタンスを受け取っています。
saveメソッドはUserクラスに依存していますが、テストコードを書く観点で考えた時、引数を通して値を渡すことは良い方法です。その理由については後述する「疎結合にするには「抽象に依存」と「依存性の注入(DI)」」で詳しく話します。
もう1つの依存があるクラスであるUserServiceについても見てみましょう。
UserServiceクラスはUserRepositoryクラスとUserクラスに依存しています。
そのうちの1つであるregisterUserメソッドのUserクラスへの依存に関しては、先ほどのUserRepositoryのsaveメソッドと同じように、引数を通して値を外から渡しているので特に問題はありません。
ただ、もう一方のconstructor内で「new UserRepository();」のように、インスタンスを初期化しているのは問題となります。
例えばUserRepositoryがMySQLやPostgreSQLなどの外部のデータベースと繋がっているとします。
プログラムで〇〇Repositoryと名前のつくクラスがあったら、データベースなどのデータを保管している箇所とやりとりをするクラスだと認識してもらって大丈夫です。
このとき、UserServiceのテストを実行する時に、データベースとの接続も必要になってきます。(データベースに依存している状態)
データベースとのやりとりを行うのはRepositoryの責務なのに、UserServiceまでデータベースのことを意識しないといけないのは、うまく責務分割できていない状態となっています。
今回のサンプルコードで言うと、UserServiceクラスのregisterUserメソッドを実行した際に、テストで気にするべきポイントは「UserRepositoryのsaveメソッドが意図した引数で実行されたか」だけです。UserRepositoryのsaveメソッドの内部の処理まで気にする必要はありません。
ここで使うテクニックが、次に説明する「依存性の注入(DI)」と「抽象に依存」になります。
「依存性の注入(DI)」と「抽象に依存」でテストを書きやすくする
まずは「依存性の注入」から説明します。
「依存性の注入」はDI(=Dependency Injection)と略して呼ばれることが多いプログラミングのデザインパターンの1つです。
ここで一回おさらいですが、UserRepositoryのsaveメソッドやUserServiceのregisterメソッドを実行する際に、引数にUserインスタンスを渡していたのを覚えているでしょうか?
まさに、これもDIの1つで、引数を通して依存する値を外から渡しています。
これをUserServiceが依存するUserRepositoryに関してもDIで外から渡すと以下のような実装になります。
1 2 3 4 5 6 7 8 9 10 11 |
class UserService { private repository: UserRepository; constructor(repository: UserRepository) { this.repository = repository; } registerUser(user: User) { this.repository.save(user) } } |
最初のサンプルコードで、UserServiceのインスタンスを作る時にconstructor内で「new UserRepository();」と書いていた時よりも、動的にUserRepositoryをセットできるようになりました。
ここまでのUserServiceの依存関係を図に示すと以下のようになります。
ただ、これでも上の図を見ていただくとわかる通り、UserRepositoryがデータベースとやりとりするクラスである場合、間接的にUserServiceがデータベースに依存している状態は変わりません。
この問題を解決する方法として「抽象に依存」という実装パターンがあります。まずは以下のコードを見てください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
// 抽象 interface IUserRepository { save(user: User): void; } // UserRepositoryインターフェイスの具体的な実装 class MySQLUserRepository implements IUserRepository { save(user: User): void { // MySQLデータベースにユーザー情報を保存 } } class PGUserRepository implements IUserRepository { save(user: User): void { // PostgreSQLデータベースにユーザー情報を保存 } } class MemoryUserRepository implements IUserRepository { save(user: User): void { // メモリ上(配列など)にユーザー情報を保存 } } class UserService { // 具体的なRepositoryではなく、抽象のRepositoryに依存 private repository: IUserRepository; constructor(repository: IUserRepository) { this.repository = repository; } registerUser(user: User) { this.repository.save(user) } } |
上のサンプルコードで言うと、抽象は一番上の「IUserRepository」インターフェイスになります。
その後に続く3つのRepositoryクラス「MySQLUserRepository」「PGUserRepository」「MemoryUserRepository」は、IUserRepositoryインターフェイスを満たすクラスとなります。
IUserRepositoryインターフェイスでは「saveメソッド」が定義されているので、それを満たす具体的なRepositoryクラスは必ず「saveメソッド」を実装する必要がありますが、具体的な処理は各Repositoryクラスが自由に実装できます。
各Repositoryクラスのsaveメソッド内のコメントを見ていただくと、それぞれ異なる保存方法を実装しようとしているのがわかります。
- MySQLUserRepository・・・MySQLに保存する
- PGUserRepository・・・PostgreSQLに保存する
- MemoryUserRepository・・・メモリ上(配列など)に保存する
そして、最後のUserServiceクラスについても、先ほどまではconstructorで具体的なUserRepositoryクラスに依存していましたが、抽象であるIUserRepositoryに依存するように修正しています。
constructorの引数の型も「IUserRepository」になっているように、DIで外からRepositoryを受け取る際は、IUserRepositoryを満たすRepositoryであればなんでも受け取れるようになりました。
こうすることで、テスト時にはデータベースに依存しないテスト用のRepositoryを渡すことでテストが書きやすくなりました。
最後に紹介したサンプルコードで依存関係を図に示すと以下のようになります。
依存性逆転の原則(補足情報)
抽象(インターフェイス)を使わずに実装したパターンの図と、抽象を使って実装したパターンの図を改めて比較してみましょう。
2つ目の抽象に依存している図の方を見ると、UserRepositoryクラスが抽象であるIUserRepositoryインターフェイスを介してUserServiceクラスの方に依存の方向が向いているのがわかるかと思います。
このようなことを「依存性逆転の原則」と呼んだりもします。
今回の記事の内容をさらに深ぼって学習したい場合は「依存性逆転の原則」「依存性の逆転」などで調べていただくと良いかと思います。
テストコードのサンプル
今回の「依存の注入(DI)」や「抽象に依存」などを用いて、具体的にテストコードの実装はどうなるのかを見てみたい方向けのGitHubレポジトリを用意しました。GitHubレポジトリの内容はTodoアプリのAPIの実装になります。
https://github.com/tsuyopon-xyz/hono-todo-api-with-bun/tree/develop
どの部分を読むと良いかについては、READMEの「サンプルコード」のセクションに書いているので、まずはそちらをご確認いただけたらと思います。
追記: 2024年4月4日 よりシンプルなサンプルを用意しました
1つ前に紹介したTodoアプリのAPI実装のGitHubレポジトリよりも、よりシンプルに実装したものを用意しました。
先ほどのGitHubレポジトリの内容が難しいと感じた方はこちらをご確認いただけたらと思います。
また、こちらのGitHubレポジトリのREADMEに、どのような順番で実装を進めると良いか、その思考プロセスも含めた説明を加えました。
「どんな流れで実装を進めれば良いかわからない」という方は、ぜひREADMEの内容もご確認ください。
https://github.com/tsuyopon-xyz/writing-testable-code-with-di