概要
ソフトウェア開発においては、DDD(ドメイン駆動設計)やクリーンアーキテクチャといった、プロダクションコードの設計に関する話は活発に行われています。しかし、良い設計を追求する一方で「テストコードの書き方」については、あまり深く議論されている現場を見たことがありません。
私の経験ですが「単体テスト」についての知見を深めると、良いテストを書くためには良いプロダクションコードを書く必要があることを知り、自ずとコード全体の品質が上がっていきます。
本記事では、ついやってしまいがちな単体テストのアンチパターンを紹介しながら、良いテストを書くためのポイントを解説していきます。
ちなみに記事中に出てくるサンプルコードは全部 TypeScript です。私が最近一番慣れている言語だからという理由です。
単体テストの目的
単体テストを書く目的は、一言でいうと「ソフトウェアの成長を持続可能なものにすること」です。
これは言い換えると
- 単体テストに費やした時間に対し、見合った結果を引き出せること
- 単体テストにかける労力をできるだけ抑え、テストから最大限の価値を引き出すこと
です。逆にアンチパターンといえる悪い単体テストを書いてしまうと、逆にソフトウェア開発の足枷となってしまうことがあります。
テストの種類
ここで「単体テスト」の定義をはっきりさせておきましょう。
テストの種類には以下のものがあります。
単体テスト
書籍「単体テストの考え方/使い方」では、以下の3つの性質を持ったテストを「単体テスト」と定義していました。
- 「単体(unit)」と呼ばれる少量のコードを検証するもの
- 実行時間が短いもの
- 隔離された状態で実行できるもの
「隔離された状態」とは、外部への依存(API, DB等)がなく、それ単体で実行できる状態をさす
単体テストは基本的にAAAパターンという構成になります。準備(Arrange)、実行(Act)、確認(Assert)の3つのステップです。
具体例を見てみましょう。以下のような関数があるとします。
/**
* 2つの数値を足し算する関数
* @param a - 1番目の数値
* @param b - 2番目の数値
* @returns - 2つの数値の合計
*/
export function add(a: number, b: number): number {
return a + b;
}
これに対する単体テストは以下のように書きます。
import { add } from '../src/math';
describe('add関数', () => {
test('2つの整数を正しく足し算できるか', () => {
// 1. Arrange(準備)
const num1: number = 5;
const num2: number = 3;
const expected: number = 8; // 期待する結果
// 2. Act(実行)
const result: number = add(num1, num2);
// 3. Assert(検証)
expect(result).toBe(expected); // 一致するか検証
});
test('負の数と正の数の足し算を正しく処理できるか', () => {
// 1. Arrange(準備)
const numA: number = -10;
const numB: number = 7;
const expectedResult: number = -3;
// 2. Act(実行)
const actualResult: number = add(numA, numB);
// 3. Assert(検証)
expect(actualResult).toBe(expectedResult);
});
});
単体テストは基本このAAAパターンで書くと可読性が高いでしょう。
AAAパターンに従っている場合は、わざわざコメントで「1. 準備」「2. 実行」「3. 検証」と書かなくてもわかると思います。
統合テスト
単体テストの持つべき3つの性質(少量のコード、実行時間の短さ、隔離された状態)を1でも欠いたテストのことです。
例えばデータベースなど外部依存のあるコード、コントローラーに分類されるコードのテストは統合テストに分類されるでしょう。
統合テストで検証したいこと
- 1件の正常系ルート(ハッピーパス)を検証すること
- すべてのプロセス外依存とのやりとりを検証できるような長いハッピーパスを見つけて検証します。ハッピーパスが見つからないのであれば、テストケースを増やしていき、すべてのプロセス外依存を検証できるようにします。
- 単体テストでは検証できないすべての異常ケースを検証すること
E2Eテスト
End-to-End テストという名の通り、エンドユーザ視点でシステムをテストするものです。テスト対象のアプリケーションのほぼ全ての外部依存をそのまま使ってテストします。例えばデータベース、クラウド、他システムのAPIなどの外部依存を、モックに置き換えることなく実行します。
実装の詳細を意識せず、最終的な振る舞いをテストする有用な方法です。
ただし、E2Eテストは保守コストが高く、実行に時間がかかることが欠点です。そのためコードベースをE2Eテストだけでカバーすることは現実的ではないでしょう。
そこで次のテストピラミッドの話に繋がります。
テストピラミッド
一般的には以下のようなボリュームでテストを作るのが良いとされています。
ただし、例外もあります。
- E2Eテストを作成・保守するコストが非常に高い場合、頑張って作らなくても良いと思います。
- テスト対象のアプリケーションが基本的なCRUD操作しかしないみたいなケースは、逆に単体テストがいらないかもしれません。ドメインロジックなどがない場合は、単体テストよりも統合テストでデータベースと接続したテストを多く書くほうが良いでしょう。
- 例えばテスト対象のアプリケーションがプロセス外依存(例えばデータベース)しか扱わないAPIのようなケースでは、E2Eテストの比重を多くしたほうがいいです。ユーザインターフェースがないためE2Eテストであっても高速なのと、プロセス外依存が1つしかないため保守コストもそれほど高くないからです。この場合はE2Eテストと統合テストにほとんど差がなくなりそうです。
つまりは、テストピラミッドにとらわれすぎず、プロジェクトやプロダクトの性質によってバランスを考えましょうね、ということです。
単体テストのアンチパターン
では本題の単体テストのアンチパターンを見ていきましょう。
1. 同じフェーズを複数書く
AAAパターンの基本である、準備 → 実行 → 確認 に続いて、別の実行 → 別の確認 のように繋げてはいけません。1つのテストケースで複数の振る舞いをテストしていることになるため、統合テストに近くなります。また、AAAパターンを崩しているため可読性も低下します。
1つのテストケースにたくさん盛り込まず、テストケースを適切に分割しましょう。
2. if for 文などを使用する
これは単体テストに限った話ではないですが、if for while switch などのロジックがない単純な作りにしましょう。テストケースの中にロジックを作ってしまうと、今度はそのロジックのテストもしなければなりません。
テストケースにロジックを書きたくなったら、何かおかしいと疑いましょう。
3. テストケースが別のテストケースに影響を与える
テストケースは完全に独立している必要があります。例えばデータベースやグローバル変数、静的プロパティを共有してしまっていると、テストケース間で影響を与え合ってしまいます。そうすると、テストの順序を入れ替えたり、特定のテストケースだけ実行したときに失敗する可能性があります。
また、並列でテストを実行したときに「たまになぜかテストが失敗する」みたいな現象が発生して苦しんだ経験もあります。しかも原因調査が難しいので時間をかなり無駄に消費するでしょう。
他のテストに影響を与えないようにしましょう。
4. 間違った理由で頻繁にテストが失敗する
先ほどの話にも繋がりますが、状況によって失敗するテストがあると、チームメンバーはテストへの信頼を失います。そして最悪のケースでは誰もテストの失敗を気にしなくなり、本当にテストが失敗したときに気づけなくなるのです。
このようなテストを作成するくらいなら、ないほうがマシと言えます。
5. テストの後始末をする
AAAパターンにはテストの後始末の工程は入っていません。それは通常、単体テストでは副作用のないメソッドのテストになるため、後始末が不要だからです。
単体テストの後始末が必要になったら何かおかしいと思いましょう。後始末が必要になるのは統合テストからです。
6. テストの実行に時間がかかりすぎる
単体テストは素早く実行できないといけません。
テストが完了するまで15分や20分かかるようでは、テストを回したくなくなり、バグに気づくのが遅れます。
もしテストの実行に時間がかかりすぎる場合は、不要なテストを棚卸する、分割して実行できるようにする、などの工夫をした方が良いでしょう。
7. 取るに足らないコードをテストする
取るに足らないコードに対してテストを書く必要はありません。1行のコードで表せるようなメソッド、内容が少なくビジネスロジックもほとんどないものなどは、テストを書くだけ無駄な労力です。
例えば以下のようなコードがあったとします。
// ユーザーデータを保持するクラス
export class User {
private name: string;
private age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
// ユーザ名を取得するメソッド
public getName(): string {
return this.name;
}
// 年齢を取得するメソッド
public getAge(): number {
return this.age;
}
}
これに対するテストケースはまったく価値がありません。
describe('Userクラスのテスト', () => {
test('getName()は初期化された名前を正しく返す', () => {
const expectedName = 'Alice';
const user = new User(expectedName, 30);
const result: string = user.getName();
expect(result).toBe(expectedName);
});
});
これはプロダクションコードと同じことを別の書き方をしているだけであり、常に成功するような意味のないことを検証しています。つまり何も検証していないのと同じです。このようなテストケースはあるだけ負債になるので、削除しましょう。
8. 準備フェーズが大きすぎる
そもそも準備フェーズが大きすぎるのは単体テストの性質を欠いている可能性があります。
それでもどうしても準備フェーズが大きくなってしまう場合は、メソッドに切り出したり、ファクトリを作ってテストケース間で共有すると良いでしょう。
9. 準備フェーズをコンストラクタで実行する
コンストラクタに準備フェーズを記載してしまうと、AAAパターンを崩して可読性が低下します。また、テストケースが別のテストケースに影響を与え合ってしまう可能性があります。
統合テストでDB接続をテストする場合などは、逆にコンストラクタでDB接続を強制してしまったほうが良い場合もあります。
10. 実行フェーズが複数行になる
AAAパターンの実行フェーズが複数行になる場合、テスト対象のクラス・メソッドがちゃんと設計されていないことを表しています。
11. プロダクションコードに定義されたリテラルや定数を使う
前提として、テストはプロダクションコードを信用してはいけない、という考え方があります。そのためプロダクションコードのリテラルや定数をテストコードでそのまま使用するのはやめましょう。それは何も検証していないのと同じになってしまいます。
DRY原則が染み付いていると、定数などをテストコードに複製するのに抵抗を感じるかもしれません。しかしこれはプロダクションコードの影響を受けずに検証するために必要なことです。
12. テストへドメイン知識を漏洩させる。
例えば以下のようなコードがあったとして
function add(value1: number, value2: number) {
return value1 + value2
}
これを検証するのに以下のようなテストを書いてはいけません。
const value1 = 2
const value2 = 3
const expected = value1 + value2 // 漏洩!
const actual = add(value1, value2)
expect(actual).toBe(expected);
これは簡単な例なので一見問題なさそうに見えるかもしれませんが、やっていることはプロダクションコードのコピーです。これではテストをする意味がなくなります。
13. プロダクションコードを汚染する
プロダクションコードにテストでしか通らない分岐を作ったり処理を書くことはやめましょう。例えば以下のように、ログを出力するメソッドでテストコードのための分岐を作るようなことです。
function log(string message) {
if(isTestEnv) {
return
}
...
}
これを避けるには、ロガークラスをインターフェースにしておきテスト時に置き換える、などの手段があります。
14. 実装の詳細をテストする
実装の詳細をテストしてしまうと、リファクタリングしたときに壊れる可能性があります。ここでいうリファクタリングとは、最終的な振る舞いは変えずにコードを改善することです。ただのリファクタリングをした場合、本来はテストコード自体は変えなくても良いはずです。しかしリファクタリングによってテストが失敗するようになってしまっては良くありません。
例えば以下のようなコードに対して
// ユーザー名に接頭辞をつける関数
export function formatUserName(id: number, name: string): string {
return `${createPrefix(id)}: ${name}`;
}
// 内部で利用されるヘルパー関数
export function createPrefix(id: number): string {
return `ID-${id}`;
}
以下のようなテストをしたとします。
describe('formatUserName関数 (悪いテストの例)', () => {
test('formatUserNameは、必ず内部でcreatePrefixを呼び出すべきである', () => {
const userId: number = 123;
const userName: string = 'Taro';
// createPrefixがモックであるため、呼び出し履歴をリセット
(createPrefix as jest.Mock).mockClear();
const result: string = formatUserName(userId, userName);
// ⛔ 悪い例:実装の詳細(createPrefixの呼び出し)を検証している
expect(createPrefix).toHaveBeenCalledWith(userId);
// 🌟 良い例:振る舞い(最終的な出力)を検証している
expect(result).toBe('ID-123: Taro');
});
});
例えばプロダクションコードを以下のように変更すると
export function formatUserName(id: number, name: string): string {
// ヘルパー関数createPrefixを呼ばずに、直接文字列を生成するようにリファクタリング
return `ID-${id}: ${name}`;
}
最終的な振る舞いは変わっていないにも関わらず、悪い例のテストの書き方ではテストが失敗するようになってしまいます。これは振る舞いではなく実装の詳細をテストしてしまっているからです。
15. プライベートなメソッドをテストする
プライベートなメソッドは「実装の詳細」であり、メソッドの外から観測可能な振る舞いではありません。プライベートメソッドをテストしたくなったら元のコードの設計を疑いましょう。
また、テストができるようにプライベートなメソッドを公開するのもアンチパターンです。
16. 具象クラスをモック化する
具象クラスをモックにするのもアンチパターンです。
例えば以下のようなコントローラがあったとします。
class CustomerController {
private readonly _calculator: StatisticsCalculator;
constructor(calculator: StatisticsCalculator) {
this._calculator = calculator;
}
public getStatistics(customerId: number): string {
const [totalWeight, totalCost] = this._calculator.calculate(customerId);
return `Total weight delivered: ${totalWeight}. ` +
`Total cost: ${totalCost}`;
}
}
これに対して以下のようにテストを書きました。テスト対象をモック化し、一部のメソッドをテスト用に書き換えました。
// 配送のない顧客
test('Customer_with_no_deliveries', () => {
// 準備 (Arrange)
const stub = new StatisticsCalculator();
// GetDeliveriesメソッドのみをモック化
jest.spyOn(stub, 'getDeliveries').mockReturnValue([]);
const sut = new CustomerController(stub);
// 実行 (Act)
const result = sut.getStatistics(1);
// 確認 (Assert)
expect(result).toBe("Total weight delivered: 0. Total cost: 0");
});
しかしこれはアンチパターンです。いくつか問題があります。
- 実装の詳細をテストしてしまっている可能性が高い
- テスト対象のコードが「単一責任の原則」を破っている可能性が高い
クラスの一部だけモック化したいようなケースは、そのクラスが「テストしたいドメインロジック」と「外部依存などの切り離したいロジック」の両方を持ってしまっているからです。
テスト対象のコードを適切にファイル分割し、インターフェースや純粋なデータに依存するように設計を見直してみましょう。
17. モックを使う
そもそもですが、単体テストでモックを使いたくなったら、それは単体テストではなくなっている可能性が高いです。
モックを扱うのは統合テストからと覚えておきましょう。
18. スタブを検証する
そもそもモックを使わないようにすることが前提ですが、それはもちろんスタブ(内部だけで使われるモック)も同様です。
スタブは振る舞いに影響しない内部で使うデータであり、実装の詳細です。これを検証すると壊れやすいテストができてしまいます。
19. 現在時刻を独自クラスで扱う
よくある悩みが現在時刻を扱うテストケースをどのように書くかということです。
例えば以下のように DateTime という独自クラスを用意し、それを使うようにしてみましょう。
class DateTime {
private static func: () => Date;
public static get now(): Date {
return this.func();
}
public static init(func: () => Date): void {
this.func = func;
}
}
// 本番環境で使う場合
DateTime.init(() => new Date());
// 単体テストで使う場合
DateTime.init(() => new Date(2020, 12, 15));
しかしこれはアンチパターンです。プロダクションコードを複雑にする、テストケース間で値が共有されてしまう、というデメリットがあります。
現在時刻は明示的な依存として「値」を渡すほうが良いです。例えば以下のような書き方です。
type DateTimeNow = Date;
class InquiryController {
private readonly dateTime: DateTimeNow;
constructor(dateTime: DateTimeNow) {
this.dateTime = dateTime;
}
public approveInquiry(id: number): void {
const inquiry = this.getById(id);
inquiry.approve(this.dateTime);
this.saveInquiry(inquiry);
}
}
20. カバレッジ 100% を目指す
カバレッジ(網羅率)とは、テストケースがカバーするプロダクションコードの割合のことです。たまに「カバレッジ100%を目指します!」というチームを見ますが、100%に近いからといってソフトウェアの品質が高いとは限りません。なぜかというと、そもそもカバレッジの計算方法が大雑把だからです。
一般的にカバレッジは以下の計算式で求められます。
カバレッジ = 実行されたコードの行数 / 総行数
例えば、テストコードはそのままに、プロダクションコードをリファクタリングしてコード行数が減ったりすると、カバレッジは簡単に上がります。
別のカバレッジ計算方法で「分岐網羅率(ブランチカバレッジ)」というものもあります。こちらのほうが信頼できる数値ですが、これも結局目安でしかありません。「カバレッジ100%を目指す!」といってテストを頑張って書いてしまうと、間違った方向に労力を使ってしまう可能性があります。
良い単体テストの書き方
単体テストのアンチパターンを見てきました。
では逆に、良い単体テストのプラクティスはあるでしょうか。
1. テスト対象の変数は sut と名づける
テスト対象システムの変数は「sut(System Under Test)」と名付けます。sutで統一することで何をテスト対象としているのかがわかりやすくなります。
2. コード量を少なくする
プロダクションコード同様にテストのコードの量も少ないほうが良いです。
当たり前のようですが、私は数々の膨大な行数のテストコードを見てきました。
テストコードを書いたら書いただけ資産になると思いこんでいる人がいますが、テストコードもちゃんと負債になります。コード量は少なく、理解しやすいものを心がけましょう。
3. 「シナリオ」について語る
テストケースではプロダクションコードが解決しようとしている「シナリオ」について語るべきです。テストではドメインロジックを検証したいので、プロダクションコードが何をしようとしていて、何をさせたくないのかを意識しましょう。
そのため単体テストの命名も振る舞いにフォーカスした名前にすることをおすすめします。例えば「メソッド名_条件_結果」というような命名規則はあまり意味をなしません。
4. 特に重要な部分のみがテスト対象になっている
コードベースの特に重要な部分のみがテスト対象となっていること。いわゆるドメインロジックに対するテストが重要です。
最小限の保守コストで最大限の価値を生み出すことを意識しましょう。全てのテストケースが平等ではありません。テストケースが負債となるのか、利益を生み出すのか、見極めながら書かなければいけません。
5. テストケースが多い場合はパラメータ化する
テストする振る舞いが非常に多い場合はパラメータ化すると良いでしょう。ほとんどのテストフレームワークではこのパラメータ化のテストがサポートされています。
6. 良いプロダクションコードを書く
そもそも的な話ですが、良いテストを書くには良いプロダクションコードを書かなくてはいけません。
実装の詳細がきちんとカプセル化されていること、クラスが公開するメソッドや状態は最小限に抑えること、などの基本ができていないと、そもそも良いテストコードを書くのが難しくなります。
テストケースを書き直してみる
では、以上のことを踏まえて、最初のテストケースのサンプルを書き直してみましょう。
// テスト対象の関数をインポート(sutと命名)
import { add as sut } from '../src/math';
describe('加算処理', () => {
test('2つの正の数を加算すると正しい合計値が返される', () => {
const num1: number = 5;
const num2: number = 3;
const expected: number = 8;
const result: number = sut(num1, num2);
expect(result).toBe(expected);
});
test('負の数と正の数を加算すると正しい差分値が返される', () => {
const numA: number = -10;
const numB: number = 7;
const expectedResult: number = -3;
const actualResult: number = sut(numA, numB);
expect(actualResult).toBe(expectedResult);
});
// パラメータ化
test.each([
[0, 0, 0, 'ゼロとゼロ'],
[10, 5, 15, '2つの正の数'],
[-5, -3, -8, '2つの負の数'],
[100, -50, 50, '正と負の数'],
[-50, 100, 50, '負と正の数'],
])(
'入力 (%i) と (%i) を加算すると期待値 (%i) が返されるべき: %s',
(a, b, expected, description) => {
const result = sut(a, b);
expect(result).toBe(expected);
}
);
});
より良くなりました。
(そもそもテスト対象のコードが「取るに足らないコードではないか」と突っ込めた方は素晴らしいと思います)
まとめ
いかがだったでしょうか。良い単体テストを書こうとすると「実装の詳細をカプセル化する」「クラスやメソッドの責務を適切に分割する」などを意識する必要が出てきて、プロダクションコードの品質も一緒に上がるということにお気づきいただけましたでしょうか。単体テストを書くことが一般的になっている今こそ、単体テストの品質にも目を向けてみると、ソフトウェア開発全体を良くすることができると思います。
もし興味が出てきたら書籍「単体テストの考え方/使い方」などを読んでみることをおすすめします。単体テストの話だけではなく、プロダクションコードの設計の本質まで踏み込んだ話が書いてあります。
