Skip to content

テスト戦略ドキュメント - 財務会計システム

概要

本ドキュメントは、財務会計システムのテスト戦略を定義します。 システムの品質を担保するため、複数のテストレベルを組み合わせた多層的なテストアプローチを採用します。


1. テスト戦略の基本方針

1.1 テストピラミッド

本システムでは、テストピラミッド戦略を採用します。

uml diagram

テストレベル 割合 実行頻度 目的
単体テスト 70% 常時 ビジネスロジックの正確性検証
統合テスト 20% 常時 コンポーネント間連携の検証
E2E テスト 10% CI/CD ユーザーシナリオの検証

1.2 テスト原則

  1. TDD(テスト駆動開発) - テストを先に書き、実装を後で行う
  2. 独立性 - 各テストは他のテストに依存しない
  3. 再現性 - 同じ条件で常に同じ結果を返す
  4. 高速性 - 単体テストは高速に実行できる
  5. 網羅性 - 重要なビジネスロジックを網羅する

2. バックエンドテスト戦略

2.1 アーキテクチャとテスト対象

バックエンドはヘキサゴナルアーキテクチャを採用しています。

uml diagram

2.2 レイヤー別テスト方針

Domain Layer(単体テスト)

対象 テスト内容 ツール
Entity 状態遷移、ビジネスルール JUnit 5, AssertJ
ValueObject 不変性、等価性、バリデーション JUnit 5, AssertJ
DomainService ドメインロジック JUnit 5, Mockito

テスト例:仕訳エンティティ

class JournalEntryTest {
    @Test
    fun `貸借一致の場合は有効な仕訳となる`() {
        val entry = JournalEntry.create(
            date = LocalDate.of(2024, 4, 1),
            description = "売上計上",
            details = listOf(
                JournalDetail.debit(AccountCode("111"), Money(10000)),
                JournalDetail.credit(AccountCode("411"), Money(10000))
            )
        )

        assertThat(entry.isBalanced()).isTrue()
    }

    @Test
    fun `貸借不一致の場合は例外をスロする`() {
        assertThatThrownBy {
            JournalEntry.create(
                date = LocalDate.of(2024, 4, 1),
                description = "不正な仕訳",
                details = listOf(
                    JournalDetail.debit(AccountCode("111"), Money(10000)),
                    JournalDetail.credit(AccountCode("411"), Money(5000))
                )
            )
        }.isInstanceOf(UnbalancedJournalException::class.java)
    }
}

Application Layer(単体テスト + 統合テスト)

対象 テスト内容 ツール
UseCase ユースケースフロー JUnit 5, Mockito
ApplicationService トランザクション境界 Spring Test

テスト例:仕訳登録ユースケース

class CreateJournalEntryUseCaseTest {
    @Mock
    private lateinit var journalRepository: JournalRepository

    @Mock
    private lateinit var accountRepository: AccountRepository

    @InjectMocks
    private lateinit var useCase: CreateJournalEntryUseCase

    @Test
    fun `正常な仕訳を登録できる`() {
        // Given
        val command = CreateJournalEntryCommand(
            date = LocalDate.of(2024, 4, 1),
            description = "売上計上",
            details = listOf(
                DetailCommand("111", 10000, 0),
                DetailCommand("411", 0, 10000)
            )
        )

        whenever(accountRepository.existsByCode(any()))
            .thenReturn(true)
        whenever(journalRepository.save(any()))
            .thenAnswer { it.arguments[0] }

        // When
        val result = useCase.execute(command)

        // Then
        assertThat(result.status).isEqualTo(JournalStatus.DRAFT)
        verify(journalRepository).save(any())
    }
}

Infrastructure Layer(統合テスト)

対象 テスト内容 ツール
Repository Impl DB アクセス Testcontainers, Spring Test
External API 外部 API 連携 WireMock

テスト例:リポジトリ統合テスト

@DataJpaTest
@Testcontainers
class JournalRepositoryIntegrationTest {
    @Container
    val postgres = PostgreSQLContainer("postgres:15")

    @Autowired
    private lateinit var journalRepository: JournalRepository

    @Test
    fun `仕訳を保存して取得できる`() {
        // Given
        val entry = createTestJournalEntry()

        // When
        val saved = journalRepository.save(entry)
        val found = journalRepository.findById(saved.id)

        // Then
        assertThat(found).isNotNull
        assertThat(found?.description).isEqualTo(entry.description)
    }
}

API Layer(統合テスト + E2E テスト)

対象 テスト内容 ツール
Controller エンドポイント検証 MockMvc, WebTestClient
認証・認可 セキュリティ検証 Spring Security Test

テスト例:コントローラーテスト

@WebMvcTest(JournalController::class)
class JournalControllerTest {
    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockBean
    private lateinit var createJournalUseCase: CreateJournalEntryUseCase

    @Test
    @WithMockUser(roles = ["ACCOUNTANT"])
    fun `POST /api/journals で仕訳を登録できる`() {
        val request = """
            {
                "date": "2024-04-01",
                "description": "売上計上",
                "details": [
                    {"accountCode": "111", "debitAmount": 10000, "creditAmount": 0},
                    {"accountCode": "411", "debitAmount": 0, "creditAmount": 10000}
                ]
            }
        """.trimIndent()

        whenever(createJournalUseCase.execute(any()))
            .thenReturn(createTestJournalEntry())

        mockMvc.perform(
            post("/api/journals")
                .contentType(MediaType.APPLICATION_JSON)
                .content(request)
        )
            .andExpect(status().isCreated)
            .andExpect(jsonPath("$.status").value("DRAFT"))
    }
}

2.3 テストデータ管理

方式 用途 ツール
Factory Pattern テストオブジェクト生成 自作ファクトリ
Fixture Files 静的テストデータ JSON/YAML ファイル
Database Seeding 統合テスト用データ Flyway, Testcontainers

3. フロントエンドテスト戦略

3.1 アーキテクチャとテスト対象

フロントエンドは Container/View パターンを採用しています。

uml diagram

3.2 コンポーネント別テスト方針

View Components(単体テスト)

対象 テスト内容 ツール
Presentational レンダリング、イベント Vitest, Testing Library
Common 再利用コンポーネント Vitest, Testing Library

テスト例:金額入力コンポーネント

describe('MoneyInput', () => {
  it('初期値が正しく表示される', () => {
    render(<MoneyInput value={1000} onChange={() => {}} />);

    const input = screen.getByRole('textbox');
    expect(input).toHaveValue('1,000');
  });

  it('数値入力時に onChange が呼ばれる', async () => {
    const user = userEvent.setup();
    const handleChange = vi.fn();
    render(<MoneyInput value={0} onChange={handleChange} />);

    const input = screen.getByRole('textbox');
    await user.type(input, '1234');

    expect(handleChange).toHaveBeenCalledWith(1234);
  });
});

Custom Hooks(単体テスト)

対象 テスト内容 ツール
useBalanceValidation 貸借バランス計算 Vitest, renderHook
useTaxCalculation 消費税計算 Vitest, renderHook

テスト例:貸借バランスフック

describe('useBalanceValidation', () => {
  it('貸借一致の場合は isValid が true', () => {
    const details = [
      { debitAmount: 10000, creditAmount: 0 },
      { debitAmount: 0, creditAmount: 10000 },
    ];

    const { result } = renderHook(() => useBalanceValidation(details));

    expect(result.current.isValid).toBe(true);
    expect(result.current.difference).toBe(0);
  });
});

Container Components(統合テスト)

対象 テスト内容 ツール
Container API 連携、状態管理 Vitest, MSW
Page ページ全体の動作 Vitest, MSW

テスト例:仕訳入力コンテナ

describe('JournalEntryContainer', () => {
  it('仕訳を保存するとメッセージが表示される', async () => {
    const user = userEvent.setup();
    render(<JournalEntryContainer />);

    // フォーム入力
    await user.type(screen.getByLabelText('仕訳日付'), '2024-04-01');
    await user.type(screen.getByLabelText('摘要'), 'テスト仕訳');

    // 明細入力...

    // 保存
    await user.click(screen.getByRole('button', { name: '保存' }));

    await waitFor(() => {
      expect(screen.getByText(/登録しました/)).toBeInTheDocument();
    });
  });
});

3.3 MSW によるモック戦略

// src/test/mocks/handlers.ts
export const handlers = [
  // 勘定科目一覧
  http.get('/api/accounts', () => {
    return HttpResponse.json(mockAccounts);
  }),

  // 仕訳登録
  http.post('/api/journal-entries', async ({ request }) => {
    const body = await request.json();
    const newEntry = createMockJournalEntry(body);
    return HttpResponse.json(newEntry, { status: 201 });
  }),

  // エラーケース
  http.get('/api/accounts/:code', ({ params }) => {
    const account = mockAccounts.find(a => a.accountCode === params.code);
    if (!account) {
      return new HttpResponse(null, { status: 404 });
    }
    return HttpResponse.json(account);
  }),
];

3.4 スナップショットテスト

財務諸表など複雑な UI はスナップショットテストで回帰を検知します。

describe('BalanceSheetView', () => {
  it('勘定式でスナップショットと一致する', () => {
    const { container } = render(
      <BalanceSheetView
        data={mockBalanceSheetData}
        layout="account"
        isLoading={false}
      />
    );

    expect(container).toMatchSnapshot();
  });
});

4. E2E テスト戦略

4.1 対象シナリオ

E2E テストは実行コストが高いため、以下の優先度で対象を選定します。

優先度 対象 理由
認証フロー セキュリティの基盤
仕訳入力・承認 業務の核心機能
決算処理 取り消し困難な操作
財務諸表表示 経営判断に影響
マスタ管理 データ整合性に影響
UI の細部 単体テストでカバー

4.2 テストシナリオマッピング

ユースケース E2E テストファイル 優先度
UC-AUTH-001 ログイン auth/login.cy.ts
UC-AUTH-002 ログアウト auth/login.cy.ts
UC-MST-001 勘定科目登録 master/account.cy.ts
UC-JNL-001 仕訳入力 journal/entry.cy.ts
UC-JNL-008 仕訳承認 journal/approval.cy.ts
UC-FS-001 貸借対照表 statement/balanceSheet.cy.ts
UC-FS-002 損益計算書 statement/profitLoss.cy.ts

4.3 Cypress 設定

// cypress.config.ts
export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:5173',
    viewportWidth: 1280,
    viewportHeight: 720,
    defaultCommandTimeout: 10000,
    video: true,
    screenshotOnRunFailure: true,
    retries: {
      runMode: 2,
      openMode: 0,
    },
  },
});

4.4 カスタムコマンド

// cypress/support/commands.ts
Cypress.Commands.add('login', (email?: string, password?: string) => {
  const userEmail = email ?? Cypress.env('testUser');
  const userPassword = password ?? Cypress.env('testPassword');

  cy.session([userEmail, userPassword], () => {
    cy.visit('/login');
    cy.get('[data-testid="email-input"]').type(userEmail);
    cy.get('[data-testid="password-input"]').type(userPassword);
    cy.get('[data-testid="login-button"]').click();
    cy.url().should('not.include', '/login');
  });
});

Cypress.Commands.add('verifyDebitCreditBalance', () => {
  cy.get('[data-testid="debit-total"]').invoke('text').then((debitText) => {
    cy.get('[data-testid="credit-total"]').invoke('text').then((creditText) => {
      const debit = parseInt(debitText.replace(/[,円]/g, ''), 10);
      const credit = parseInt(creditText.replace(/[,円]/g, ''), 10);
      expect(debit).to.equal(credit);
    });
  });
});

5. テストカバレッジ目標

5.1 カバレッジ基準

レイヤー ライン 分岐 関数
Domain Layer 90% 85% 90%
Application Layer 85% 80% 85%
Infrastructure Layer 70% 65% 70%
API Layer 80% 75% 80%
Frontend Components 80% 70% 80%

5.2 除外対象

以下はカバレッジ計測から除外します。

  • 自動生成コード(OpenAPI generated types)
  • テストコード自体
  • 設定ファイル
  • 型定義ファイル(.d.ts)

6. テスト環境

6.1 環境構成

uml diagram

6.2 テストデータベース

環境 データベース 用途
ローカル Docker PostgreSQL 開発時の統合テスト
CI Testcontainers CI パイプライン
ステージング 専用 PostgreSQL E2E テスト

7. CI/CD パイプライン

7.1 パイプライン構成

uml diagram

7.2 GitHub Actions ワークフロー

name: Test Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup
        uses: actions/setup-java@v4
        with:
          java-version: '21'
      - name: Run Unit Tests
        run: ./gradlew test
      - name: Upload Coverage
        uses: codecov/codecov-action@v4

  integration-test:
    needs: unit-test
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: accounting_test
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - name: Run Integration Tests
        run: ./gradlew integrationTest

  e2e-test:
    needs: integration-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Start Application
        run: docker-compose up -d
      - name: Run E2E Tests
        uses: cypress-io/github-action@v6
        with:
          wait-on: 'http://localhost:5173'
      - name: Upload Artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: cypress-screenshots
          path: cypress/screenshots

8. テスト実行コマンド

8.1 バックエンド

# 全テスト実行
./gradlew test

# 単体テストのみ
./gradlew test --tests '*Test'

# 統合テストのみ
./gradlew integrationTest

# カバレッジレポート生成
./gradlew jacocoTestReport

8.2 フロントエンド

# 全テスト実行
npm test

# ウォッチモード
npm run test:watch

# カバレッジレポート生成
npm run test:coverage

# E2E テスト(インタラクティブ)
npm run cypress:open

# E2E テスト(ヘッドレス)
npm run cypress:run

9. テスト品質指標

9.1 モニタリング指標

指標 目標 計測方法
テスト成功率 100% CI/CD
カバレッジ 80%+ Jacoco, V8
テスト実行時間 < 10分 CI/CD
Flaky テスト率 < 1% CI/CD 履歴

9.2 改善プロセス

uml diagram


10. トレーサビリティマトリクス

10.1 ユースケースとテストの対応

ユースケース ID ユースケース名 単体テスト 統合テスト E2E テスト
UC-AUTH-001 システムにログインする
UC-AUTH-002 システムからログアウトする
UC-AUTH-003 ユーザーを登録する
UC-MST-001 勘定科目を登録する
UC-MST-002 勘定科目を編集する
UC-MST-003 勘定科目を削除する
UC-MST-004 勘定科目一覧を表示する -
UC-JNL-001 仕訳を入力する
UC-JNL-002 仕訳を編集する
UC-JNL-003 仕訳を削除する
UC-JNL-004 仕訳一覧を表示する
UC-JNL-005 仕訳を検索する
UC-JNL-006 自動仕訳を生成する -
UC-JNL-007 仕訳の承認を申請する
UC-JNL-008 仕訳を承認する
UC-JNL-009 仕訳を差し戻す
UC-JNL-010 仕訳を確定する
UC-LDG-001 総勘定元帳を照会する -
UC-LDG-002 補助元帳を照会する -
UC-LDG-003 日次残高を照会する -
UC-LDG-004 月次残高を照会する -
UC-LDG-005 残高試算表を表示する -
UC-FS-001 貸借対照表を表示する
UC-FS-002 損益計算書を表示する
UC-FS-003 財務分析を表示する -
UC-SYS-001 監査ログを照会する -
UC-SYS-002 データをダウンロードする

10.2 重要ビジネスルールのテストカバレッジ

ビジネスルール テスト内容 テストレベル
貸借一致検証 借方合計 = 貸方合計 単体, E2E
仕訳ステータス遷移 下書き→承認待ち→承認済み→確定 単体
承認済み仕訳の編集不可 ステータス検証 単体, 統合
月次締め後の仕訳入力制限 日付検証 単体, E2E
勘定科目削除制約 使用中科目の削除不可 単体, 統合
消費税計算 税込/税抜変換、端数処理 単体
財務諸表計算 資産 = 負債 + 純資産 単体, 統合

11. 参考資料

11.1 関連ドキュメント

11.2 使用ツール・ライブラリ

用途 バックエンド フロントエンド
テストフレームワーク JUnit 5 Vitest
モック Mockito MSW
アサーション AssertJ @testing-library
E2E - Cypress
カバレッジ Jacoco V8 Coverage
コンテナ Testcontainers -

12. E2E テスト安定性ガイドライン

本プロジェクトの E2E テスト(Cypress + MSW)で発見・確立した安定性パターンをまとめる。React の制御コンポーネントと Cypress の DOM 操作の相互作用により、タイミング問題が発生しやすい。以下のパターンを標準として適用する。

12.1 基本原則

  1. データロード完了を待ってから操作する — テーブル行やフォーム要素の存在を確認してから操作
  2. 要素は毎回 cy.get() で取得する — 変数に格納しない(stale element 回避)
  3. 明示的なタイムアウトを設定する — デフォルト 10 秒、ページロード系は 15 秒
  4. CI 環境のリトライを活用するretries: { runMode: 2 } で CI のみリトライ

12.2 タイムアウト階層

用途 タイムアウト
標準要素取得 10,000ms cy.get('.btn', { timeout: 10000 })
ページ・テーブルロード 15,000ms cy.get('table tbody tr', { timeout: 15000 })
フィルタ結果即時確認 5,000ms cy.get('tr', { timeout: 5000 }).first()
React レンダリング待機 300-1,000ms cy.wait(300) — 最後の手段として使用

12.3 パターン 1: データロード待機パターン

テーブルや一覧データが API から読み込まれた後に操作する。

// ❌ NG: データロード前に操作
cy.get('.pagination__select').select('10');

// ✅ OK: テーブル行の存在を確認してから操作
cy.get('table tbody tr', { timeout: 15000 }).should('have.length.at.least', 1);
cy.get('.pagination__select', { timeout: 10000 }).should('be.visible');
cy.get('.pagination__select').select('10');

適用場面: ページネーション操作、フィルタ操作、テーブル行クリック

12.4 パターン 2: ネイティブ DOM API パターン

React の制御コンポーネント(<select value={state}>)では、Cypress の cy.select() が React の再レンダリングと競合して DOM detach エラーを起こすことがある。ネイティブ DOM API で直接操作すると安定する。

// ❌ NG: React 再レンダリング中に cy.select() で DOM detach
cy.get('.pagination__select').select('10');

// ✅ OK: ネイティブセッター + dispatchEvent で React を通す
cy.get('.pagination__select', { timeout: 10000 })
  .should('have.value', '20')
  .then(($el) => {
    const select = $el[0] as HTMLSelectElement;
    const nativeSetter = Object.getOwnPropertyDescriptor(
      HTMLSelectElement.prototype,
      'value'
    )?.set;
    nativeSetter?.call(select, '10');
    select.dispatchEvent(new Event('change', { bubbles: true }));
  });
cy.get('.pagination__select', { timeout: 10000 }).should('have.value', '10');

適用場面: ページサイズ変更、動的オプションの <select>

12.5 パターン 3: 要素再取得パターン

Cypress では DOM 要素を変数に保持すると、React の再レンダリングで要素が detach される。毎回 cy.get() で最新の DOM 要素を取得する。

// ❌ NG: 変数に格納(stale element になるリスク)
const $select = cy.get('.pagination__select');
$select.select('10');
$select.should('have.value', '10');

// ✅ OK: 毎回 cy.get() で再取得
cy.get('.pagination__select').select('10');
cy.get('.pagination__select').should('have.value', '10');

適用場面: 全ての DOM 操作・アサーション

12.6 パターン 4: カスタムコマンドでの待機集約

共通の操作フローはカスタムコマンドに集約し、待機ロジックを一元管理する。

// cypress/support/e2e.ts
Cypress.Commands.add('visitJournalEntryList', () => {
  cy.visit('/journal/entries');
  cy.get('[data-testid="journal-entry-list"]', { timeout: 15000 }).should('be.visible');
  cy.get('table tbody tr', { timeout: 15000 }).should('have.length.at.least', 1);
});

Cypress.Commands.add('selectAccountOption', (selectId: string, index: number = 1) => {
  cy.get(`#${selectId} option`, { timeout: 15000 }).should('have.length.greaterThan', 1);
  cy.get(`#${selectId}`).select(index);
});

適用場面: ログイン、ページ遷移、勘定科目選択など頻出操作

12.7 パターン 5: リトライ・リカバリパターン

React の stale closure によりフィルタ操作が反映されないことがある。操作後に結果を確認し、期待と異なれば再実行する。

const executeFilter = () => {
  cy.get('#filter-status').select(status);
  cy.wait(300); // React re-render 待機
  cy.contains('button', '検索').click();
};

executeFilter();

// 結果が正しくない場合は再実行
cy.get('table tbody tr', { timeout: 5000 }).first().then(($row) => {
  if (!$row.text().includes(expectedText)) {
    executeFilter();
  }
});

適用場面: フィルタ・検索操作で結果が不安定な場合

12.8 パターン 6: テスト設定ファクトリパターン

帳票系テスト(元帳・残高・財務諸表)では、共通の設定・ヘルパーをファクトリ関数で提供する。

// cypress/support/ledgerTestConfig.ts
export const createLedgerTestConfig = (options: LedgerTestOptions) => ({
  visitPage: () => { cy.visit(options.path); waitForPageLoad(options); },
  clickSearch: () => { cy.contains('button', '照会').click(); cy.wait(1000); },
  verifyTable: () => {
    cy.get(options.tableSelector, { timeout: 15000 }).should('be.visible');
  },
});

適用場面: 類似構造のテスト(帳票系、マスタ一覧系)

12.9 MSW 環境での注意事項

本プロジェクトでは E2E テストに MSW(Mock Service Worker)を使用しているため、cy.intercept() による API モックは使用できない。代わりに以下のアプローチを採用する。

項目 cy.intercept() MSW
API 待機 cy.wait('@alias') DOM アサーションで待機
レスポンス制御 cy.intercept().as() handlers.ts で定義
エラーシミュレーション cy.intercept(500) MSW ハンドラで制御
// ❌ NG: MSW 環境では cy.intercept は使えない
cy.intercept('GET', '/api/accounts').as('getAccounts');
cy.wait('@getAccounts');

// ✅ OK: DOM アサーションで API 完了を待機
cy.get('table tbody tr', { timeout: 15000 }).should('have.length.at.least', 1);

12.10 Cypress 設定(cypress.config.ts)

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    defaultCommandTimeout: 10000,
    retries: {
      runMode: 2,  // CI 環境で最大 2 回リトライ
      openMode: 0, // ローカルではリトライなし
    },
    screenshotOnRunFailure: true,
  },
});

12.11 チェックリスト

新しい E2E テストを作成する際に確認する項目:

  • ページ遷移後、data-testid または主要要素の表示を待機しているか
  • テーブル操作前にデータロード完了(have.length.at.least)を確認しているか
  • <select> 操作で DOM detach が発生する場合、ネイティブ DOM API を使用しているか
  • DOM 要素を変数に格納せず、毎回 cy.get() で取得しているか
  • タイムアウト値はタイムアウト階層に従っているか
  • 共通操作はカスタムコマンドを使用しているか
  • cy.wait() は最後の手段として使用し、可能な限り DOM アサーションで待機しているか