ウェブエンジニア問題集
第2章

AAAパターン(Arrange-Act-Assert)で書く単体テストの構造

14
この章の目次開く
学習者学習者

テストコードってどういう順番で書けばいいの?なんとなく書くと、すぐ散らかっちゃう…。

単体テストは、単に expect を書けば良いわけではありません。

テストコードは「仕様を読むためのコード」でもあります。失敗したときに原因をすぐ追えること、数ヶ月後に読んでも意図が分かること、実装を変えても必要以上に壊れないことが重要です。

この章では、単体テストを読みやすく保つための基本構造を整理します。

AAAパターンとは、単体テストを「準備(Arrange)」「実行(Act)」「検証(Assert)」の3つに分けて書く構成ルールです。
テストコードをチェックしている人のイラスト

単体テストはAAAパターンで書く

単体テストの記述はAAAパターンで記述するのが基本です。

AAAパターンとは、Arrange-Act-Assertの略で、単体テストを書くときの3つのステップを表しています。

  1. Arrange テストを実行するための準備
  2. Act テストを実行
  3. 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);
  });
});
typescript

この例は単純ですが、構造としては次のことが分かります。

  • テストに必要な値は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);
});
typescript

この形にしておくと、「どんな会員なら」「何を実行して」「何を期待しているか」が上から順に読めます。

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);
});
typescript

モックやスタブを使う場合も、基本的にはArrangeに置きます。vi.fn()mockResolvedValuemockRejectedValue のような設定は、テスト対象を実行する前提を作っているからです。モックとスタブの違いは モックとは で詳しく扱います。

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' });
});
typescript

例外や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');
});
typescript

ここでは 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);
typescript

toBetoEqual の迷いどころは、値の種類で考えると整理できます。数値や文字列は 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;
}
typescript

この関数の単体テストは、AAAで分けると読みやすくなります。

describe('validateRegisterInput', () => {
  it('メールアドレスが不正な場合、メールアドレスのエラーを返す', () => {
    // Arrange
    const input = {
      email: 'invalid-email',
      password: 'password123',
    };
 
    // Act
    const errors = validateRegisterInput(input);
 
    // Assert
    expect(errors).toContain('メールアドレスの形式が正しくありません');
  });
});
typescript

このテストで大事なのは、validateRegisterInput の内部でどのように判定しているかではなく、「不正なメールアドレスを渡したら、メールアドレスのエラーが返る」という振る舞いを検証している点です。

テストケースを選んでいる人のイラスト

テスト名は「条件」と「期待する振る舞い」を書く

テスト名は、テストが失敗したときに最初に読む情報です。

よいテスト名は、次の2つを含みます。

  • どんな条件で
  • どう振る舞うべきか
it('メールアドレスが不正な場合、メールアドレスのエラーを返す', () => {
  // ...
});
typescript

逆に、次のような名前は情報量が足りません。

it('バリデーションできる', () => {
  // ...
});
 
it('validateRegisterInputのテスト', () => {
  // ...
});
typescript

テスト名を読むだけで仕様が分かる状態にすると、テストコード全体がドキュメントとして機能します。

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);
});
typescript

このテストは一見問題なさそうですが、「追加できる」と「削除できる」という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);
  });
});
typescript

2つ目のテストでは、削除を検証するために事前状態として cart.add(item) を呼んでいます。これはActではなくArrangeです。

「そのテストが主役として検証したい操作」がActです。

学習者学習者

Arrangeの中でメソッドを呼ぶこともあるなら、Actとの違いが分かりにくいです。

先生先生

そのテストが失敗したときに主原因として調べたい操作がActです。前提を作るための操作はArrangeに置きます。

Assertは「結果」を見る

Assertでは、できるだけ外部から観測できる結果を検証します。

expect(errors).toContain('メールアドレスの形式が正しくありません');
typescript

これは「ユーザーに返されるエラー」という結果を見ています。

一方で、次のように内部メソッドの呼び出しを細かく検証しすぎると、実装変更に弱くなります。

expect(emailValidator.validate).toHaveBeenCalledWith('invalid-email');
typescript

この検証が必要な場面もありますが、戻り値や状態変化で振る舞いを確認できるなら、そちらを優先します。

内部の呼び出しを検証するテストは、リファクタリングで壊れやすいからです。

この話は、次章の実装の変更に振り回されないことで詳しく扱います。

TDDではAssertから書くこともある

通常は準備のArrangeから書き始めますが、テスト駆動開発(TDD)では最初にAssertを書くことがあります。

it('メールアドレスが不正な場合、メールアドレスのエラーを返す', () => {
  expect(errors).toContain('メールアドレスの形式が正しくありません');
});
typescript

この時点では errorsvalidateRegisterInput もまだ存在しないかもしれません。

それでも先に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);
  });
});
typescript

このような共通の準備は、フィクスチャとしてまとめられます。

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);
  });
});
typescript

ただし、何でも 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);
  });
});
typescript

ファクトリ関数にすると、共通のデフォルト値を持ちながら、テストごとの差分だけを明示できます。

よくあるハマりどころ

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);
});
typescript

この場合は、テスト名やファクトリ関数で重要な条件を目立たせます。不要な前提は削り、必要ならテスト対象の設計も見直します。

ActとAssertが何度も出てくる

ActとAssertが何度も出てくるテストは、複数の仕様をまとめて確認している可能性があります。

cart.add(item);
expect(cart.items).toHaveLength(1);
 
cart.remove(item.id);
expect(cart.items).toHaveLength(0);
typescript

このようなテストは、「追加できる」と「削除できる」に分けると失敗原因が明確になります。前提を作るために cart.add(item) が必要なら、それは削除テストのArrangeに置きます。

Assertで内部実装を見すぎている

toHaveBeenCalledWith は便利ですが、内部の呼び出しを細かく検証しすぎると、実装を少し変えただけでテストが壊れます。

外部への通知やメール送信のように「呼び出し自体が仕様」の場合はモック検証が有効です。一方で、戻り値や状態変化で確認できるなら、そちらを優先します。

AAAパターンが合わないケースもある

AAAは単体テストの基本形として強力ですが、すべてのテストを無理に3行構成へ押し込む必要はありません。

統合テストやE2Eテストでは、前提条件の作成、画面操作、APIレスポンス待ち、DB確認などが複数段階になることがあります。この場合は、AAAのコメントを機械的に置くよりも、ユーザー操作の流れやシナリオが読みやすい構造を優先します。

ただし、その場合でも「準備」「実行」「検証」が混ざると読みにくくなる点は同じです。AAAは厳密なテンプレートというより、テストを整理するための判断基準として使うと実務に馴染みます。

読みやすい単体テストのチェックリスト

単体テストを書いた後は、次の観点で見直すと品質を上げやすくなります。

  • テスト名だけで条件と期待する振る舞いが分かる
  • Arrange、Act、Assertの境界が分かる
  • Actが原則1回になっている
  • Assertが内部実装ではなく観測できる結果を見ている
  • テストごとの差分がテスト内で読める
  • フィクスチャが大きくなりすぎていない
  • 1つのテストが1つの理由で失敗する

単体テストの構造が整うと、テストは「壊れたかどうかを知らせる仕組み」だけでなく、「仕様を読み返すための資料」になります。

次章では、この構造を前提に、リファクタリングしても壊れにくいテストについて考えます。

ユニットテストクイズに挑戦するこの章で学んだユニットテストの知識を、4択クイズでアウトプットして定着させよう