はじめに
副作用に関する記事を書こうと思ってからだいぶ時間が空いてしまった...サッサと書かないとだめですね
前回の記事では、テストケースを見た段階でテスト容易性を予想するということを試してみました。その中で、テスト容易性が低くなりやすい実装コードの特徴のひとつとして、副作用があることを挙げたと思います
今回は「副作用を含む処理」に着目して、どのようにテストしていくのか?をまとめていきたいと思います!
副作用とは
前回からのおさらいです!
ここで言う副作用とは、関数や処理が値を返すこと以外に、外部の状態に影響を与えたり、外部の状態に依存したりすることです
たとえば、次のような処理が副作用にあたります
- DBにデータを保存する
- API通信を行う
- 現在時刻を取得する
- ファイルを読み書きする
- 画面表示やログ出力を行う
こうした処理は、現実のアプリケーションでは欠かせませんが、自動テストを書くという文脈では「実行するたびに結果が変わる可能性がある」「外部環境の準備が必要になるのでテストが書きづらくなる」という問題があります
「副作用を含む処理」はどうやってテストする?
DB通信やAPI通信など、プログラムを動かすうえで副作用を含む処理は必ず存在します
副作用を含む処理をテストしやすくするためには、まずは副作用そのものをなくすのではなくテストしやすい形に分離するのがコツです
1. 計算と副作用を分ける
考え方はシンプルです。次のような考え方で処理を分けるところから始めます
- 純粋関数:ロジック、変換、判定などの処理(入力が同じなら必ず同じ結果を返す)
- 副作用:表示、保存、送信などの処理(外界とのやりとり)
具体例としては、次のように分けられます
- DBから値を取ってきてソートする一つの関数
- ソート関数(純粋関数)
- DB問い合わせ(副作用)
- メールを送信するような関数
- 文面を作成する関数(純粋関数)
- メールを実際に送信する関数(副作用)
計算の部分は前回の記事で論じた通り、テストコードが書きやすい関数となっているはずです
そして、分けたうえで副作用の関数はテストがやりやすい形に差し替えてあげましょう!
この差し替えをやりやすくするのがインターフェース です
2. インターフェースで副作用を差し替え可能にする
分離した副作用の処理を、テスト時だけ「テスト用の実装(偽物)」と差し替えられるようにします。ここで役立つのがインターフェースです。
インターフェースとは、「この機能はこう振る舞う」という約束みたいなものです。契約とも言います。インターフェースの実装を行うことで、呼び出し元からみると、この約束を守っているなら使ってもいいか!ということになります
次の二つの処理は呼び出し元から見ると同じようなものとして扱われることになります。
- 本番用の実装:実際のDBに保存する。何も返さないvoidの関数
- テスト用の実装:メモリ上でデータを保持しておく。何も返さないvoidの関数
呼び出し側(ロジック側)はインターフェースという「約束」だけを見ていればよく、裏側が本物かどうかを意識しなくて済みます。このように、外から具体的な実装を渡してあげる仕組みをDependency Injection(DI) と呼びます。(詳しい説明は割愛するので他の方々の素晴らしい記事をご覧ください)
3. 型を作ってインターフェースをより強い約束にする
引数や戻り値を適切な型で表現し、インターフェースによる約束をさらに一歩強めることができます。型の制約には次のようなメリットがあります
- 不正な値(不正な形式のメールアドレスなど)を入り込みにくくする
- テストの前提条件がコード上で明確になる
さらにインターフェースの約束を強める方法として、値オブジェクトの利用による引数のバリデーションチェックなどの方法もあります。(ここでも詳しい解説は割愛します)
型による制約を増やすことで、テストコードそのものもシンプルになり、実装時のバグも未然に防ぎやすくなります
具体例
例えば、ユーザー登録をするユースケースの中で、ウェルカムメールを送る処理を考えてみます
メールを送るための型を定義しつつ、メール送信処理をインターフェースとして切り出してみましょう。
type Mail = {
to: string;
subject: string;
body: string;
};
interface Mailer {
send(mail: Mail): Promise<void>;
}
では実際にユーザー登録のユースケースの関数を作ってみましょう。本来は名前のバリデーションやユーザーが重複してないか、DBにユーザー登録などの処理があるはずですが、いったん割愛します。
class UserRegistrationUsecase {
constructor(private readonly mailer: Mailer) {}
async execute(userName: string, emailAdress: string): Promise<void> {
///////////////////////////////
// User登録処理を書いておく
///////////////////////////////
// Mailオブジェクトを作る(純粋関数)
const welcomeMail: Mail = createWelcomeMail(userName, emailAdress);
// メール送信処理(副作用)
await this.mailer.send(welcomeMail);
}
}
ここで大事なのは、UserRegistrationUsecase 全体は副作用を含むものの、その内部で「純粋な処理」と「副作用の処理」を分けているという点です。
実際のAPIを動かす場合では、Mailerインターフェースを実装した本物のメール送信機能を使います。実際のコードは省きますが、例えばAWS SESを使うとしたらこんな感じです。
class AwsSesMailer implements Mailer {
async send(mail:Mail): Promise<void> {
///////////////////////////////////////////////////////////////
// AWS SESを使うライブラリを使って実際にメールを送信する処理を書く
///////////////////////////////////////////////////////////////
}
}
一方、テストコードの中ではMailerインターフェースを実装した偽物(モック)を使います。こんな感じです
class MockMailer implements Mailer {
sentMails: Mail[] = [];
async send(mail: Mail): Promise<void> {
this.sentMails.push(mail);
}
}
このMockMailer は、インターフェースを満たしつつ、送信されたメールを記録するためのテスト用フィールドとしてsentMails を持っています。
これを使うと、「メール送信処理が呼ばれたか」「どんな文面が渡されたか」をテストできます。
import { describe, expect, it } from "vitest";
describe("UserRegistrationUsecase", () => {
it("登録時にウェルカムメールを送信する", async () => {
const mockMailer = new mockMailer();
const usecase = new UserRegistrationUsecase(fakeMailer);
await usecase.execute("山田", "yamada@example.com");
expect(mockMailer.sentMails).toHaveLength(1);
expect(mockMailer.sentMails[0]).toEqual({
to: "yamada@example.com",
subject: "Welcome!",
body: "山田さん、登録ありがとうございます。",
});
});
});
このテストで確認している関心ごとは、テストケース名にもある通り、「登録時にウェルカムメールを送信すること」 です
つまり、ここでテストしているのは「メールが実際に届くこと」ではなく、ユースケースが正しくメール送信処理を呼び出していることです
副作用自体のテスト
分離した副作用そのもの、つまりAwsSesMailerのような実装はどうやってテストすればよいのでしょうか。
答えは、別のテストで確認する です。
実際にメールを送る処理では、設定値の誤りやライブラリの不具合などによって例外が発生することもあります
そのため、「設定が正しいか」「実際にメールを送れるか」といった観点は、AwsSesMailer 側の責務として確認する必要があります
ここで重要なのは、ユースケースのテストと、副作用の実装自体のテストでは、関心ごとが異なるということです
- ユースケースのテスト:登録時にメール送信処理を呼び出しているか
- AwsSesMailer のテスト:実際にメールを送れるか
この2つは別の責務を見ているので、同じテストでまとめて確認しようとせず、分けて書くのがポイントです
まとめ
副作用を含む処理は、DBやAPI、現在時刻など外部の状態に依存するため、そのままだとテストが難しくなりがちです
そのため、まずはロジックと副作用を分離し、ロジックは純粋関数としてテストしやすくし、副作用はインターフェースを通して差し替え可能にする、という形に整理することが大切です
また、ユースケースのテストと副作用そのもののテストは、関心ごとが異なります
ユースケースでは「正しいタイミングで副作用を呼び出しているか」を確認し、副作用の実装側では「実際に外部システムと正しく連携できるか」を別途確認します
副作用をなくすことはできなくても、分離して扱いやすくすることはできます
テストしやすい設計を意識することで、実装の見通しもよくなり、安心して変更できるコードに近づけるはずです
それでは、よいテストライフを!
おまけ
上記のコードの中に値オブジェクトチャンスがあります。どこで値オブジェクトが使えそうか探してみてください!