AAAパターン(Arrange-Act-Assert)で書く単体テストの構造
この章の目次開く
- 単体テストはAAAパターンで書く
- 最小の例
- AAAパターンのメリット
- Arrange / Act / Assert の書き方
- Arrangeでよく使う準備
- Actはテスト対象の操作を1つに絞る
- Assertは期待する結果に合う matcher を選ぶ
- 実践例: ユーザー登録のバリデーションをテストする
- テスト名は「条件」と「期待する振る舞い」を書く
- Actは原則1回にする
- Assertは「結果」を見る
- TDDではAssertから書くこともある
- フィクスチャは共通のArrangeを抽出する仕組み
- フィクスチャを使う判断基準
- よくあるハマりどころ
- Arrangeが長すぎて何を試しているか分からない
- ActとAssertが何度も出てくる
- Assertで内部実装を見すぎている
- AAAパターンが合わないケースもある
- 読みやすい単体テストのチェックリスト
学習者テストコードってどういう順番で書けばいいの?なんとなく書くと、すぐ散らかっちゃう…。
単体テストは、単に expect を書けば良いわけではありません。
テストコードは「仕様を読むためのコード」でもあります。失敗したときに原因をすぐ追えること、数ヶ月後に読んでも意図が分かること、実装を変えても必要以上に壊れないことが重要です。
この章では、単体テストを読みやすく保つための基本構造を整理します。
AAAパターンとは、単体テストを「準備(Arrange)」「実行(Act)」「検証(Assert)」の3つに分けて書く構成ルールです。
単体テストはAAAパターンで書く
単体テストの記述はAAAパターンで記述するのが基本です。
AAAパターンとは、Arrange-Act-Assertの略で、単体テストを書くときの3つのステップを表しています。
- Arrange テストを実行するための準備
- Act テストを実行
- Assert テストの結果を検証
単体テストが読みにくくなる原因の多くは、この3つの役割が混ざることです。
たとえば「準備をしながら実行している」「検証のためにさらに処理を呼んでいる」「何を期待しているのか最後まで読まないと分からない」といった状態です。
最小の例
describe('Unit Test', () => {
it('should be true', () => {
// Arrange
const a = 1;
const b = 2;
const expected = 3;
// Act
const result = a + b;
// Assert
expect(result).toBe(expected);
});
});この例は単純ですが、構造としては次のことが分かります。
- テストに必要な値はArrangeに集める
- テスト対象の実行はActに1つだけ置く
- 結果の検証はAssertに集める
先生AAAは「テストコードの目次」です。読む人が、準備・実行・検証を迷わず追える状態を作ります。
AAAパターンのメリット
AAAパターンの良さは、テストコードを「どこを読めば何が分かるか」が明確な形にできることです。
| メリット | どう効くか |
|---|---|
| 意図を読み取りやすい | 準備・実行・検証の順番で仕様を追える |
| 失敗原因を探しやすい | Arrange、Act、Assertのどこが怪しいか切り分けやすい |
| テストを分割しやすい | Actが複数あるテストに気づきやすい |
| 保守しやすい | 前提条件と期待値が散らばりにくい |
特に単体テストでは、1つのテストが小さいほど読みやすくなります。AAAで区切ると、テストが大きくなり始めたタイミングに気づきやすくなります。
it('プレミアム会員の場合、送料を無料にする', () => {
// Arrange: 条件を作る
const user = { rank: 'premium' as const };
// Act: テスト対象を実行する
const shippingFee = calculateShippingFee(user);
// Assert: 期待する結果を確認する
expect(shippingFee).toBe(0);
});この形にしておくと、「どんな会員なら」「何を実行して」「何を期待しているか」が上から順に読めます。
Arrange / Act / Assert の書き方
AAAは名前だけ覚えても、実際のテストで境界が曖昧になりがちです。各ステップでよく使う書き方を押さえておくと、迷いが減ります。
Arrangeでよく使う準備
Arrangeでは、テスト対象を動かすために必要な前提を作ります。
よく使う準備は次のようなものです。
- テストデータを作る
beforeEachで毎回同じ初期状態を作る- ファクトリ関数でテストごとの差分だけ変える
- 依存先の戻り値をスタブ化する
- 外部呼び出しを検証するためのモックを用意する
type User = {
id: string;
email: string;
rank: 'standard' | 'premium';
};
const createUser = (overrides: Partial<User> = {}) => ({
id: 'user-1',
email: 'alice@example.com',
rank: 'standard' as const,
...overrides,
});
it('プレミアム会員の場合、割引率を20%にする', () => {
// Arrange
const user = createUser({ rank: 'premium' });
// Act
const rate = calculateDiscountRate(user);
// Assert
expect(rate).toBe(0.2);
});モックやスタブを使う場合も、基本的にはArrangeに置きます。vi.fn()、mockResolvedValue、mockRejectedValue のような設定は、テスト対象を実行する前提を作っているからです。モックとスタブの違いは モックとは で詳しく扱います。
Actはテスト対象の操作を1つに絞る
Actには、そのテストで主役として検証したい操作を置きます。
非同期処理をテストする場合は、Actで await まで含めて結果を受け取ります。
it('ユーザーを取得できる', async () => {
// Arrange
const repository = {
findById: vi.fn().mockResolvedValue({ id: 'user-1', name: 'Alice' }),
};
const service = new UserService(repository);
// Act
const user = await service.getUser('user-1');
// Assert
expect(user).toEqual({ id: 'user-1', name: 'Alice' });
});例外やrejectを検証したいときも、Actを曖昧にしないようにします。
it('ユーザーが存在しない場合、エラーになる', async () => {
// Arrange
const service = new UserService({
findById: vi.fn().mockRejectedValue(new Error('not found')),
});
// Act
const action = () => service.getUser('missing-user');
// Assert
await expect(action()).rejects.toThrow('not found');
});ここでは action がActにあたります。先に関数として取り出すことで、「何を実行したらエラーになるのか」が読みやすくなります。
Assertは期待する結果に合う matcher を選ぶ
Assertでは、期待値の種類に合わせて matcher を選びます。
| matcher | 向いている検証 |
|---|---|
toBe | 数値・文字列・booleanなどのプリミティブ値 |
toEqual | オブジェクトや配列の中身 |
toContain | 配列や文字列に特定の値が含まれること |
toHaveLength | 配列や文字列の長さ |
toHaveBeenCalledWith | モック関数が指定した引数で呼ばれたこと |
expect(total).toBe(1200);
expect(user).toEqual({ id: 'user-1', name: 'Alice' });
expect(errors).toContain('メールアドレスの形式が正しくありません');
expect(items).toHaveLength(2);toBe と toEqual の迷いどころは、値の種類で考えると整理できます。数値や文字列は toBe、オブジェクトや配列の中身を比べるときは toEqual を使います。戻り値・状態変化・モック呼び出しのどれを見るべきかは、単体テストのメソッド で扱います。
実践例: ユーザー登録のバリデーションをテストする
もう少し実務に近い例で考えます。
次の関数は、ユーザー登録の入力値を検証してエラーメッセージを返します。
type RegisterInput = {
email: string;
password: string;
};
function validateRegisterInput(input: RegisterInput): string[] {
const errors: string[] = [];
if (!input.email.includes('@')) {
errors.push('メールアドレスの形式が正しくありません');
}
if (input.password.length < 8) {
errors.push('パスワードは8文字以上で入力してください');
}
return errors;
}この関数の単体テストは、AAAで分けると読みやすくなります。
describe('validateRegisterInput', () => {
it('メールアドレスが不正な場合、メールアドレスのエラーを返す', () => {
// Arrange
const input = {
email: 'invalid-email',
password: 'password123',
};
// Act
const errors = validateRegisterInput(input);
// Assert
expect(errors).toContain('メールアドレスの形式が正しくありません');
});
});このテストで大事なのは、validateRegisterInput の内部でどのように判定しているかではなく、「不正なメールアドレスを渡したら、メールアドレスのエラーが返る」という振る舞いを検証している点です。

テスト名は「条件」と「期待する振る舞い」を書く
テスト名は、テストが失敗したときに最初に読む情報です。
よいテスト名は、次の2つを含みます。
- どんな条件で
- どう振る舞うべきか
it('メールアドレスが不正な場合、メールアドレスのエラーを返す', () => {
// ...
});逆に、次のような名前は情報量が足りません。
it('バリデーションできる', () => {
// ...
});
it('validateRegisterInputのテスト', () => {
// ...
});テスト名を読むだけで仕様が分かる状態にすると、テストコード全体がドキュメントとして機能します。
Actは原則1回にする
AAAパターンでは、Actを複数回にすることは避けるべきです。
Act → Assert のサイクルが複数回になっているテストは、複数の振る舞いを1つのテストで確認している可能性があります。
it('カートに商品を追加して削除できる', () => {
const cart = new Cart();
const item = { id: 'book', price: 1000 };
cart.add(item);
expect(cart.items).toHaveLength(1);
cart.remove('book');
expect(cart.items).toHaveLength(0);
});このテストは一見問題なさそうですが、「追加できる」と「削除できる」という2つの振る舞いを同時に確認しています。
失敗したときに原因が曖昧になり、どちらの仕様が壊れたのか追いづらくなります。
分けると、テストの意図が明確になります。
describe('Cart', () => {
it('商品を追加すると、カート内の商品数が増える', () => {
const cart = new Cart();
const item = { id: 'book', price: 1000 };
cart.add(item);
expect(cart.items).toHaveLength(1);
});
it('商品を削除すると、カートから商品がなくなる', () => {
const cart = new Cart();
const item = { id: 'book', price: 1000 };
cart.add(item);
cart.remove('book');
expect(cart.items).toHaveLength(0);
});
});2つ目のテストでは、削除を検証するために事前状態として cart.add(item) を呼んでいます。これはActではなくArrangeです。
「そのテストが主役として検証したい操作」がActです。
学習者Arrangeの中でメソッドを呼ぶこともあるなら、Actとの違いが分かりにくいです。
先生そのテストが失敗したときに主原因として調べたい操作がActです。前提を作るための操作はArrangeに置きます。
Assertは「結果」を見る
Assertでは、できるだけ外部から観測できる結果を検証します。
expect(errors).toContain('メールアドレスの形式が正しくありません');これは「ユーザーに返されるエラー」という結果を見ています。
一方で、次のように内部メソッドの呼び出しを細かく検証しすぎると、実装変更に弱くなります。
expect(emailValidator.validate).toHaveBeenCalledWith('invalid-email');この検証が必要な場面もありますが、戻り値や状態変化で振る舞いを確認できるなら、そちらを優先します。
内部の呼び出しを検証するテストは、リファクタリングで壊れやすいからです。
この話は、次章の実装の変更に振り回されないことで詳しく扱います。
TDDではAssertから書くこともある
通常は準備のArrangeから書き始めますが、テスト駆動開発(TDD)では最初にAssertを書くことがあります。
it('メールアドレスが不正な場合、メールアドレスのエラーを返す', () => {
expect(errors).toContain('メールアドレスの形式が正しくありません');
});この時点では errors も validateRegisterInput もまだ存在しないかもしれません。
それでも先にAssertを書くことで、「最終的に何を保証したいのか」を明確にできます。
このような書き方は直感的でないように感じるかもしれませんが、解決したい問題は何かというゴールから逆算していくという意味では自然な流れです。
すでに実装済みのコードがある場合は、まずはそのコードをテストするために必要なArrangeから始めると書きやすいです。
フィクスチャは共通のArrangeを抽出する仕組み
テストフィクスチャとは、テストの実行に必要な前提条件や環境のことです。
テストデータ、モックオブジェクト、DB接続、設定済みのインスタンスなど、テストが動くために必要な「お膳立て」全体を指します。
単体テストでは、複数のテストで同じArrangeが繰り返されることがあります。
describe('User', () => {
it('名前が正しい', () => {
const user = { name: 'Alice', age: 25 };
expect(user.name).toBe('Alice');
});
it('成人である', () => {
const user = { name: 'Alice', age: 25 };
expect(user.age).toBeGreaterThanOrEqual(18);
});
});このような共通の準備は、フィクスチャとしてまとめられます。
let user: { name: string; age: number };
beforeEach(() => {
// 各テストの前に共通のArrangeを実行
user = { name: 'Alice', age: 25 };
});
describe('User', () => {
it('名前が正しい', () => {
expect(user.name).toBe('Alice');
});
it('成人である', () => {
expect(user.age).toBeGreaterThanOrEqual(18);
});
});ただし、何でも beforeEach に寄せれば良いわけではありません。
フィクスチャが大きくなると、各テストを読むたびに上の方へ戻って前提を確認する必要が出ます。

フィクスチャを使う判断基準
フィクスチャは、重複を減らすためだけに使うとテストを読みにくくすることがあります。
使うかどうかは、次の基準で判断します。
| 判断基準 | 方針 |
|---|---|
| すべてのテストで同じ前提が必要 | beforeEach にまとめる |
| 一部のテストだけで必要 | そのテスト内に書く |
| 値を少しずつ変えたい | ファクトリ関数を使う |
| 準備を読むだけで数十行ある | テスト対象の設計を見直す |
特におすすめなのは、ファクトリ関数を使う方法です。
function createUser(overrides: Partial<{ name: string; age: number }> = {}) {
return {
name: 'Alice',
age: 25,
...overrides,
};
}
describe('User', () => {
it('成人である', () => {
const user = createUser({ age: 20 });
expect(user.age).toBeGreaterThanOrEqual(18);
});
it('未成年ではない', () => {
const user = createUser({ age: 15 });
expect(user.age).toBeLessThan(18);
});
});ファクトリ関数にすると、共通のデフォルト値を持ちながら、テストごとの差分だけを明示できます。
よくあるハマりどころ
AAAパターンは単純なルールですが、テストが大きくなると少しずつ崩れます。次の形になっていたら、テストの分割や前提条件の整理を検討します。
Arrangeが長すぎて何を試しているか分からない
Arrangeが長すぎるテストは、読む人が「結局どの条件が重要なのか」を見失いやすくなります。
it('送料無料になる', () => {
const user = createUser({ rank: 'premium' });
const item = createItem({ price: 1200 });
const cart = createCart({ user, items: [item] });
const coupon = createCoupon({ type: 'shipping-free' });
const settings = createSettings({ campaignEnabled: true });
const fee = calculateShippingFee(cart, coupon, settings);
expect(fee).toBe(0);
});この場合は、テスト名やファクトリ関数で重要な条件を目立たせます。不要な前提は削り、必要ならテスト対象の設計も見直します。
ActとAssertが何度も出てくる
ActとAssertが何度も出てくるテストは、複数の仕様をまとめて確認している可能性があります。
cart.add(item);
expect(cart.items).toHaveLength(1);
cart.remove(item.id);
expect(cart.items).toHaveLength(0);このようなテストは、「追加できる」と「削除できる」に分けると失敗原因が明確になります。前提を作るために cart.add(item) が必要なら、それは削除テストのArrangeに置きます。
Assertで内部実装を見すぎている
toHaveBeenCalledWith は便利ですが、内部の呼び出しを細かく検証しすぎると、実装を少し変えただけでテストが壊れます。
外部への通知やメール送信のように「呼び出し自体が仕様」の場合はモック検証が有効です。一方で、戻り値や状態変化で確認できるなら、そちらを優先します。
AAAパターンが合わないケースもある
AAAは単体テストの基本形として強力ですが、すべてのテストを無理に3行構成へ押し込む必要はありません。
統合テストやE2Eテストでは、前提条件の作成、画面操作、APIレスポンス待ち、DB確認などが複数段階になることがあります。この場合は、AAAのコメントを機械的に置くよりも、ユーザー操作の流れやシナリオが読みやすい構造を優先します。
ただし、その場合でも「準備」「実行」「検証」が混ざると読みにくくなる点は同じです。AAAは厳密なテンプレートというより、テストを整理するための判断基準として使うと実務に馴染みます。
読みやすい単体テストのチェックリスト
単体テストを書いた後は、次の観点で見直すと品質を上げやすくなります。
- テスト名だけで条件と期待する振る舞いが分かる
- Arrange、Act、Assertの境界が分かる
- Actが原則1回になっている
- Assertが内部実装ではなく観測できる結果を見ている
- テストごとの差分がテスト内で読める
- フィクスチャが大きくなりすぎていない
- 1つのテストが1つの理由で失敗する
単体テストの構造が整うと、テストは「壊れたかどうかを知らせる仕組み」だけでなく、「仕様を読み返すための資料」になります。
次章では、この構造を前提に、リファクタリングしても壊れにくいテストについて考えます。
