Skip to content

状態変化をテストする

本稿では、銀行口座(BankAccount)ドメインを題材に、テスト駆動開発(TDD)を通じて実装を進めながら、状態に応じて振る舞いが変わる仕様に対応し、最終的にStateパターンによる設計改善へ至るプロセスを記します。

銀行口座クラスをTDDで開発する

要件一覧

  • 銀行口座は初期残高を持つ。
  • 入金により残高が増加する。
  • 出金により残高が減少する。
  • 残高不足時には出金できず、例外をスローする。
  • 口座が凍結されている場合は出金できない。
  • 閉鎖された口座は一切の操作を受け付けない。

Step 1: 初期残高の取得

Red🔴: 失敗するテストを書く

bankAccount.test.ts

ts
import { BankAccount } from '../../src/domain/bankAccount';

describe('銀行口座', () => {
  test('初期残高が取得できる', () => {
    const account = new BankAccount(1000);
    expect(account.getBalance()).toBe(1000);
  });
});

Green🟢: テストを通すコードを書く

bankAccount.ts

ts
export class BankAccount {
  private balance: number;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

  getBalance() {
    return this.balance;
  }
}

Step 2: 入金による残高増加

Red🔴: 失敗するテストを書く

bankAccount.test.ts

ts
import { BankAccount } from '../../src/domain/bankAccount';

describe('銀行口座', () => {
  test('初期残高が取得できる', () => {
    const account = new BankAccount(1000);
    expect(account.getBalance()).toBe(1000);
  });

  test('入金ができる', () => {
    const account = new BankAccount(1000);
    account.deposit(500);
    expect(account.getBalance()).toBe(1500);
  });
});

Green🟢: テストを通すコードを書く

bankAccount.ts

ts
export class BankAccount {
  private balance: number;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

  getBalance() {
    return this.balance;
  }

  deposit(amount: number) {
    this.balance += amount;
  }
}

Step 3: 出金による残高減少

Red🔴: 失敗するテストを書く

bankAccount.test.ts

ts
import { BankAccount } from '../../src/domain/bankAccount';

describe('銀行口座', () => {
  test('初期残高が取得できる', () => {
    const account = new BankAccount(1000);
    expect(account.getBalance()).toBe(1000);
  });

  test('入金ができる', () => {
    const account = new BankAccount(1000);
    account.deposit(500);
    expect(account.getBalance()).toBe(1500);
  });

  test('出金ができる', () => {
    const account = new BankAccount(1000);
    account.withdraw(300);
    expect(account.getBalance()).toBe(700);
  });
});

Green🟢: テストを通すコードを書く

bankAccount.ts

ts
export class BankAccount {
  private balance: number;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

  getBalance() {
    return this.balance;
  }

  deposit(amount: number) {
    this.balance += amount;
  }

  withdraw(amount: number) {
    this.balance -= amount;
  }
}

Step 4: 残高不足時の例外スロー

Red🔴: 失敗するテストを書く

bankAccount.test.ts

ts
import { BankAccount } from '../../src/domain/bankAccount';

describe('銀行口座', () => {
  let account: BankAccount;
  beforeEach(() => {
    account = new BankAccount(1000);
  });

  test('初期残高が取得できる', () => {
    expect(account.getBalance()).toBe(1000);
  });

  test('入金ができる', () => {
    account.deposit(500);
    expect(account.getBalance()).toBe(1500);
  });

  test('出金ができる', () => {
    account.withdraw(300);
    expect(account.getBalance()).toBe(700);
  });

  test('残高不足なら例外が発生する', () => {
    account = new BankAccount(500);
    expect(() => account.withdraw(1000)).toThrow('残高不足');
  });
});

Green🟢: テストを通すコードを書く

bankAccount.ts

ts
export class BankAccount {
  private balance: number;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

  getBalance() {
    return this.balance;
  }

  deposit(amount: number) {
    this.balance += amount;
  }

  withdraw(amount: number) {
    if (amount > this.balance) {
      throw new Error('残高不足');
    }
    this.balance -= amount;
  }
}

Refactor🔵: リファクタリングを行う

bankAccount.test.ts

  • テストを整理(describe ブロック)
  • マジックナンバーを定数に
  • 状態遷移のログを付けたくなったら、別クラスに責務分離も検討
ts
import { BankAccount } from '../../src/domain/bankAccount';

describe('BankAccount クラスのテスト', () => {
  const INITIAL_BALANCE = 1000;

  let account: BankAccount;

  beforeEach(() => {
    account = new BankAccount(INITIAL_BALANCE);
  });

  test('初期残高が取得できる', () => {
    expect(account.getBalance()).toBe(INITIAL_BALANCE);
  });

  test('入金ができる', () => {
    account.deposit(500);
    expect(account.getBalance()).toBe(INITIAL_BALANCE + 500);
  });

  test('出金ができる', () => {
    account.withdraw(300);
    expect(account.getBalance()).toBe(INITIAL_BALANCE - 300);
  });

  test('残高不足なら例外が発生する', () => {
    expect(() => account.withdraw(INITIAL_BALANCE + 1)).toThrow(
      BankAccount.INSUFFICIENT_FUNDS
    );
  });
});

bankAccount.ts

ts
export class BankAccount {
  private balance: number;
  static readonly INSUFFICIENT_FUNDS = '残高不足';

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

  getBalance(): number {
    return this.balance;
  }

  deposit(amount: number): void {
    this.balance += amount;
  }

  withdraw(amount: number): void {
    if (amount > this.balance) {
      throw new Error(BankAccount.INSUFFICIENT_FUNDS);
    }
    this.balance -= amount;
  }
}

振る舞いの変化と状態の導入

要件一覧

  • 銀行口座は初期残高を持つ。
  • 入金により残高が増加する。
  • 出金により残高が減少する。
  • 残高不足時には出金できず、例外をスローする。
  • 口座が凍結されている場合は出金できない。👈 追加
  • 閉鎖された口座は一切の操作を受け付けない。👈 追加

要件追加によるテストの拡張例(bankAccount.test.ts

この時点で BankAccount クラスには複数の if 文が登場し、状態による条件分岐が増え始めました。

状態によって制限される振る舞いに対して、TDDにより以下のようなテストケースを追加しました。

  • 凍結状態での出金が例外となること
  • 凍結解除後は出金可能であること
  • 閉鎖状態では入出金・状態変更がすべて拒否されること

これにより、状態ごとの期待振る舞いが明確になり、テストによって仕様が保証されました。

Red🔴: 失敗するテストを書く

bankAccount

ts
import { BankAccount } from '../../src/domain/bankAccount';

describe('BankAccount クラスのテスト', () => {
  const INITIAL_BALANCE = 1000;

  let account: BankAccount;

  beforeEach(() => {
    account = new BankAccount(INITIAL_BALANCE);
  });

  test('初期残高が取得できる', () => {
    expect(account.getBalance()).toBe(INITIAL_BALANCE);
  });

  test('入金ができる', () => {
    account.deposit(500);
    expect(account.getBalance()).toBe(INITIAL_BALANCE + 500);
  });

  test('出金ができる', () => {
    account.withdraw(300);
    expect(account.getBalance()).toBe(INITIAL_BALANCE - 300);
  });

  test('残高不足なら例外が発生する', () => {
    expect(() => account.withdraw(INITIAL_BALANCE + 1)).toThrow(
      BankAccount.INSUFFICIENT_FUNDS
    );
  });
  test('凍結状態では出金できない', () => {
    account.freeze();
    expect(() => account.withdraw(100)).toThrow('凍結状態では出金できません');
  });

  test('凍結解除で出金が可能になる', () => {
    account.freeze();
    account.unfreeze();
    account.withdraw(200);
    expect(account.getBalance()).toBe(800);
  });
  test('閉鎖状態では入出金・状態変更がすべて拒否されること', () => {
    account.close();
    expect(() => account.deposit(100)).toThrow(
      '閉鎖された口座には操作できません'
    );
    expect(() => account.withdraw(100)).toThrow(
      '閉鎖された口座には操作できません'
    );
    expect(() => account.freeze()).toThrow('閉鎖された口座には操作できません');
    expect(() => account.unfreeze()).toThrow(
      '閉鎖された口座には操作できません'
    );
    expect(() => account.close()).not.toThrow(); // 再度 close は例外にしない設計
  });
});

状態パターンによる設計の進化

状態一覧

状態名説明
Active通常状態(入出金可)
Frozen凍結状態(入金のみ可)
Closed閉鎖状態(全操作不可)

状態遷移図(簡易)

mermaid
stateDiagram-v2
    [*] --> Active : 初期状態

    Active --> Frozen : freeze()
    Active --> Closed : close()

    Frozen --> Active : unfreeze()
    Frozen --> Closed : close()

    Closed --> [*] : 操作不可(終端)

Green🟢: テストを通すコードを書く => Refactor🔵: リファクタリングを行う

抽象状態インターフェース accountState.ts

ts
interface AccountState {
  deposit(account: BankAccount, amount: number): void;
  withdraw(account: BankAccount, amount: number): void;
  freeze(account: BankAccount): void;
  unfreeze(account: BankAccount): void;
  close(account: BankAccount): void;
}

利用状態クラスの実装 activeState.ts

ts
import { AccountState } from './accountState';
import { BankAccount } from './bankAccount';
import { ClosedState } from './closedState';
import { FrozenState } from './frozenState';

export class ActiveState implements AccountState {
  deposit(account: BankAccount, amount: number): void {
    account.incrementBalance(amount);
  }

  withdraw(account: BankAccount, amount: number): void {
    if (account.getBalance() < amount) {
      throw new Error('残高不足');
    }
    account.decrementBalance(amount);
  }

  freeze(account: BankAccount): void {
    account.setState(new FrozenState());
  }

  unfreeze(account: BankAccount): void {
    // 何もしない
  }

  close(account: BankAccount): void {
    account.setState(new ClosedState());
  }
}

凍結状態クラスの実装 frozenState.ts

ts
import { AccountState } from './accountState';
import { ActiveState } from './activeState';
import { BankAccount } from './bankAccount';
import { ClosedState } from './closedState';

export class FrozenState implements AccountState {
  deposit(account: BankAccount, amount: number): void {
    account.incrementBalance(amount);
  }

  withdraw(): void {
    throw new Error('凍結状態では出金できません');
  }

  freeze(): void {
    // 何もしない
  }

  unfreeze(account: BankAccount): void {
    account.setState(new ActiveState());
  }

  close(account: BankAccount): void {
    account.setState(new ClosedState());
  }
}

閉鎖状態クラスの実装 closedState.ts

ts
import { AccountState } from './accountState';

export class ClosedState implements AccountState {
  deposit(): void {
    throw new Error('閉鎖された口座には操作できません');
  }

  withdraw(): void {
    throw new Error('閉鎖された口座には操作できません');
  }

  freeze(): void {
    throw new Error('閉鎖された口座には操作できません');
  }

  unfreeze(): void {
    throw new Error('閉鎖された口座には操作できません');
  }

  close(): void {
    // 何もしない
  }
}

BankAccount クラスのリファクタ (状態を委譲) bankAccount.ts

ts
import { AccountState } from './accountState';
import { ActiveState } from './activeState';

export class BankAccount {
  private balance: number;
  static readonly INSUFFICIENT_FUNDS = '残高不足';
  private state: AccountState;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
    this.state = new ActiveState();
  }

  getBalance(): number {
    return this.balance;
  }

  deposit(amount: number): void {
    this.state.deposit(this, amount);
  }

  withdraw(amount: number): void {
    this.state.withdraw(this, amount);
  }

  freeze(): void {
    this.state.freeze(this);
  }

  unfreeze(): void {
    this.state.unfreeze(this);
  }

  close(): void {
    this.state.close(this);
  }

  // 内部用(状態から操作される)
  setState(newState: AccountState): void {
    this.state = newState;
  }

  incrementBalance(amount: number): void {
    this.balance += amount;
  }

  decrementBalance(amount: number): void {
    this.balance -= amount;
  }
}

テスト結果

sh
DEV  v3.1.1 /Users/bonji/workspace/study/tdd/ts-vite-vitest

 tests/utils/leap-year.test.ts (6 tests) 2ms
 tests/utils/email.test.ts (4 tests) 2ms
 tests/service/theatre.test.ts (5 tests) 2ms
 tests/utils/calculator.test.ts (11 tests) 3ms
 tests/domain/bankAccount.test.ts (7 tests) 3ms
 tests/domain/order-status.test.ts (3 tests) 2ms
 tests/service/user-service.test.ts (1 test) 2ms

 Test Files  7 passed (7)
      Tests  37 passed (37)
   Start at  01:02:55
   Duration  867ms (transform 141ms, setup 0ms, collect 230ms, tests 15ms, environment 1.41s, prepare 266ms)

 PASS  Waiting for file changes...
       press h to show help, press q to quit

まとめ

利点内容
状態ごとの責務分離複雑な条件分岐が BankAccount から分離され、状態ごとに整理される
拡張性の向上新しい状態(例:Overdrawn、Hold など)も容易に追加可能
テスト容易性状態ごとのテストが単体で記述できる

Released under the CC-BY-4.0 license.