本記事は KDDIアジャイル開発センター(KAG) Advent Calendar 2024 の8日目です。
開発者が行う自動テストとして単体テスト、統合テスト、E2Eテストが代表的なものに挙げられます。
これらのテストは、「単体テストのテストケースをなるべく多く、E2Eテストは少なく」なるようなバランスが望ましいとされ、これを端的に表したものとしてテストピラミッドという形でよく表現されています。
しかしそれを実現するにはどうしたら良いのか? がよくわからかったため、書籍「単体テストの考え方/使い方」や「Googleのソフトウェアエンジニアリング」などを読んで自分なりに整理した内容をまとめたいと思います。
テストピラミッドとは
開発者が実施する自動テストとして単体テスト、統合テスト、E2Eテストがあります。
単体テストは最も小規模なテストで、関数やクラスなどをテスト対象とするものです。
単一プロセスで実行可能なもの、つまりテストを実行するために別のシステムを用意する必要がなく、それ単体で実行が可能なテストを指します。
統合テストは単体テストではテストできない範囲をテストするものです。
データベースやファイルシステム、外部のAPIなどの外部システムを組み合わせたコミュニケーションなどをテストします。
そのため、単体テストより実行する環境の規模の大きいものが多くなり、単体テストほど高速に実行できなかったり安定性が欠けるなどのデメリットがあります。
E2Eテストはシステム全体をユーザー目線でテストするものです。
実際にユーザーが使うものをテスト対象とするため最もテストの忠実度が高くなりますが、その分テスト対象が大規模になるため実装コストやメンテナンスコストが高く、安定性にも欠け、実行時間も膨大になります。
これら3つの自動テストの性質の違いと望ましいテストケース数のバランスを表にまとめると以下のようになり、これをうまく図に表したものが上記のテストピラミッドというわけです。
単体テスト | 統合テスト | E2Eテスト | |
---|---|---|---|
特徴 | 単一プロセスで実行可能 | 外部システムなどのコミュニケーションをテストする | システム全体をユーザー目線でテストする |
実装・メンテナンスコスト | 低 | 中 | 高 |
実行速度 | 速い | 中くらい | 遅い |
安定性 | 高 | 中 | 低 |
テストの忠実度 | 低 | 中 | 高 |
比率 | 80% | 15% | 5% |
テストピラミッドをどのように実現するか?
それでは本題です。
どのようにしたらテストピラミッドの形を実現できるのでしょうか?
ポイントは以下の2点です。
- 統合テスト・E2Eテストではハッピーパスと下層のテストで確認できないことにテストケースを絞る
- ビジネスロジックは単体テスト可能なクラスや関数に切り出す(外部とコミュニケーションが発生するクラスや関数内でビジネスロジックを実装しない)
この2つを意識することで統合テストやE2Eテストのテストケースを減らしピラミッド型に近づくことができます。
家計簿アプリを例に考えてみます。
以下の要件を満たす画面を作ることを考えます。
- 当月の収入・支出の合計金額がわかる
- 当月の収入・支出のカテゴリごとの合計金額がわかる
- 当月の収入・支出の全項目の中身がわかる
単体テストの例
単体テストではクラスや関数などのビジネスロジックをテストします。
支出を管理する以下の Expenses
クラスを例にします。
export class Expenses {
expenses: Item[] = [];
constructor(expenses: Item[]) {
this.expenses = expenses;
}
// 支出の合計を返す
getTotal(): number {
// ...
}
// カテゴリごとの合計を返す
aggregateByCategory(): { category: string; total: number }[] {
// ...
}
}
単体テストの対象として、ビジネスロジックである getTotal()
と aggregateByCategory()
をテストします。
これらは入力に対して出力が返ってくる単純な関数パターンです。
関数内で外部とコミュニケーションを行う必要がなく、テストは独立して閉じることができます。
言い換えると、テストを並列実行しても必ず結果は同じになります。
こうした、テストの実行にデータベースなどの外部システムが不要で、テストの実行順によらず必ず結果が同じになるものが単体テストの対象になります。
以下は例ですが、テスト対象の振る舞いとして考えうるパターンは網羅できるようにします。
describe("Expensesクラスのテスト", () => {
describe("支出の合計金額を計算する", () => {
it("合計金額を計算できる", () => {
// arrange
const expenses: Item[] = [
{ name: "電車", price: 150, category: "交通費" },
{ name: "バス", price: 300, category: "交通費" },
{ name: "昼ご飯", price: 800, category: "食費" },
];
// act
const sut = new Expenses(expenses);
const actual = sut.getTotal();
// assert
expect(actual).toBe(1250);
});
it("支出が空の場合は0円になる", () => {
// arrange
const expenses: Item[] = [];
// act
const sut = new Expenses(expenses);
const actual = sut.getTotal();
// assert
expect(actual).toBe(0);
});
});
describe("カテゴリごとの合計金額を計算する", () => {
it("カテゴリごとの合計金額を計算できる", () => {
// arrange
const expenses: Item[] = [
{ name: "電車", price: 150, category: "交通費" },
{ name: "バス", price: 300, category: "交通費" },
{ name: "昼ご飯", price: 800, category: "食費" },
{ name: "昼ご飯", price: 850, category: "食費" },
];
// act
const sut = new Expenses(expenses);
const actual = sut.aggregateByCategory();
// assert
expect(actual).toEqual([
{ category: "交通費", total: 450 },
{ category: "食費", total: 1650 },
]);
});
it("支出が空の場合は空配列が返る", () => {
// arrange
const expenses: Item[] = [];
// act
const sut = new Expenses(expenses);
const actual = sut.aggregateByCategory();
// assert
expect(actual).toEqual([]);
});
});
});
同様に収入を管理する Inocmes
クラスを作成しテストをします。
統合テストの例
統合テストでは外部システムなどを組み合わせたコミュニケーションをテストします。
ここでは Expenses
クラスを利用して集計した値を返す ExpensesController
クラスを例にします。
getMonthlyExpenses()
はデータベースから値を取得する処理が含まれています。
export class ExpensesController {
dbClient: DatabaseClient;
constructor(dbClient: DatabaseClient) {
this.dbClient = dbClient;
}
async getMonthlyExpenses(): Promise<{
total: number;
categories: { category: string; total: number }[];
items: Item[];
}> {
// データベースから支出一覧を取得
const items = await this.dbClient.getMonthlyExpenses();
// 支出一覧を集計
const expenses = new Expenses(items);
const categories = expenses.aggregateByCategory();
const total = expenses.getTotal();
return { total, categories, items };
}
}
次にテストを見ます。
getMonthlyExpenses()
のテストでは実際にデータベースを利用します。
テスト実行前の準備としてデータベースに値を登録しておきます。
テストケースとしてはハッピーパスと呼ばれるテスト対象の王道パターン1つと単体テストでは確かめられない範囲のみを考えます。
expenses = []
のときなどは単体テストでカバーしているため、統合テストで実施は不要です。
こうすることで統合テストのテストケース数を減らします。
getMonthlyExpenses()
にはビジネスロジックが含まれておらず、Expenses
クラスを利用しているだけです。
このおかげでgetMonthlyExpenses()
のテストは多くのケースを考える必要がなく、ハッピーパスのみで済んでいます。
describe("ExpensesControllerクラスのテスト", () => {
it("月ごとの支出明細を取得する", async () => {
// arrange
const dbClient = new DatabaseClient();
// テストデータをデータベースに登録
const expenses: Item[] = [
{ name: "電車", price: 150, category: "交通費" },
{ name: "バス", price: 300, category: "交通費" },
{ name: "昼ご飯", price: 800, category: "食費" },
];
for (const x of expenses) {
await dbClient.insert(x);
}
// act
const sut = new ExpensesController(dbClient);
const actual = await sut.getMonthlyExpenses();
// assert
expect(actual).toEqual({
total: 1250,
categories: [
{ category: "交通費", total: 450 },
{ category: "食費", total: 800 },
],
items: [
{ name: "電車", price: 150, category: "交通費" },
{ name: "バス", price: 300, category: "交通費" },
{ name: "昼ご飯", price: 800, category: "食費" },
],
});
});
});
収入についても同様に IncomesController
クラスを作成しテストをします。
E2Eテストの例
E2Eテストでは、システム全体が望み通りの動作をしていることを確かめます。
開発要件を再掲すると、開発対象画面では ExpensesController
と IncomesController
を使ってデータを取得し画面に表示する構成となりそうです。
開発要件
- 当月の収入・支出の合計金額がわかる
- 当月の収入・支出のカテゴリごとの合計金額がわかる
- 当月の収入・支出の全項目の中身がわかる
ここではテストの中身は詳細に示しませんが、以下のような流れで収入・支出についてのハッピーパスを確認できそうです。
- 収入・支出のシードデータをデータベースに投入
- 開発対象画面を開く
- 開発対象画面の要素を取得
- 望み通りの数値が入っているかを比較する
E2Eテストでもハッピーパスを考えてなるべく少ないテストケースで確認したいことを検証できるようにします。
最終的なテスト構成
最終的なテスト構成は上図のようになり、E2E : 統合テスト : 単体テスト = 1 : 2 : 4
とピラミッド型にすることができました。
この形が実現できたのは、直接的には統合テスト・E2Eテストでテストケースをハッピーパスに絞ることで増やさないようにしたからですが、それを実現するにはビジネスロジックを単体テスト可能なクラスや関数に切り出すことが重要だとわかります。
まとめ
以下の2点を意識することでテストピラミッドに近づくことができます。
- 統合テスト・E2Eテストではハッピーパスと下層のテストで確認できないことにテストケースを絞る
- ビジネスロジックは単体テスト可能なクラスや関数に切り出す(外部とコミュニケーションが発生するクラスや関数内でビジネスロジックを実装しない)
参考文献