freeeの開発情報ポータルサイト

ソフトウェアアーキテクチャに基づいた自動テスト戦略と実装ガイドライン

支出管理開発本部で事業部横断テックリードをしている @ogugu です。
広く複雑で大規模になりつつある支出管理のアーキテクチャについて、以下の連載形式でご紹介していきます。

今回は、自動テストの戦略をご紹介します。
社内展開した内容を可能な限りそのままご紹介しますので、文体についてはご了承ください。

目的

「素早く・安定した価値提供」のために、自動テストは欠かせない要素である。
最終的には、「常に最新の main がデプロイされた環境があり、そこでリグレッションテストが常に実行され、いつでもリリース可能な状態を保証すること」を目指したい。

そのためには、具体的なソフトウェアアーキテクチャやプロダクトの特徴を踏まえた上で、実効性のある自動テストを追加する必要がある。
それに向けて、開発メンバーが迷わないことを第一に、理想的なテスト戦略を提案する。

概略図

マイクロサービス内のアプリケーションレイヤーに基づくテスト戦略。domain, usecase, gateway, infra とそれぞれあるレイヤーに対して、適切なテストレイヤーを言語化している。あくまで概略図のため、詳細はこの記事の本文を読んでください。
図1 マイクロサービス内のアプリケーションレイヤーに基づくテスト戦略

フロントエンド~BFF~マイクロサービスを横断したテスト戦略。こちらもテストレイヤー毎の戦略のセクションで詳細に解説しているので、詳しくはそちらを参照ください。
図2 フロントエンド~BFF~マイクロサービスを横断したテスト戦略

図1, 2のように、単一サービス内のアプリケーションレイヤーの設計と、全体的なシステムアーキテクチャの双方を踏まえたテスト戦略を提案する。
特に、freee支出管理はドメインによって分離されたマイクロサービス構想になっているため、マイクロサービスを前提にしたテストアーキテクチャを示す。

テストレイヤー毎の使い分け

各テストレイヤーには向き不向きがあり、速度・保守性・実行コスト・信頼性・忠実性の間にトレードオフがある。 それらを理解して使い分けること。
なお、テストレイヤーの分類は Thoughtworks 社の Testing Strategies in a Microservice Architecture をベースにしている。

Speed, Maintainability, Utilization, Reliability, Fidelityの順にテストレイヤーを5段階評価した図。順に、Unit Test = (5, 5, 5, 5, 1) Integration Test = (4, 4, 4, 4, 2) Backend E2E (In-Process) = (3, 2, 3, 3, 3) Backend E2E (Out-Process) = (2, 2, 2, 2, 4) Browser E2E = (1, 1, 1, 1, 5) となっている。
Google Testing Blog: SMURF: Beyond the Test Pyramid をベースにアレンジした図

Unit Test

テスト可能な最小単位を実行し、振る舞いをテストする。
忠実性が低い一方、実行速度・安定性に優れるため、Parametrized Test のような網羅性はここで担保する。
裏を返すと、詳細な業務ロジックは domain 層の Unit Test で担保できるように、usecase 層のようなテストしづらいレイヤーへ詳細が漏れぬよう心がけること。

なお、Unit Test は、モックを使うテスト (Solitary Unit Test) とモックを使わないテスト (Sociable Unit Test) に分けられる。
基本的にはモックを使わないテストを推奨とする。モックの利用は、外部サービスに依存してテストが不安定になることを避ける目的に基づいた Client 処理部分での利用に限定したい。

Integration Test

システムコンポーネント同士の相互作用 (ネットワーク的な相互作用も含め) をテストする。
具体的には、アプリケーションレイヤー同士、マイクロサービス同士、マイクロサービスとDBを含めた外部コンテキストとの連携などが対象となる。

外部連携を忠実性高くテストできる一方、外部要因によって壊れる不安定なテストになりやすい。
したがって、支出管理では Repository と DB の間の相互作用のテストに留める。
外部サービスとの相互作用は、以下に挙げる手段で担保する。

  • ① Solitary Unit Test で外部サービスに対するモックとの相互作用をテストする
  • ② infra-test (k8s Job 上で実行できるネットワーク疎通テストの社内の仕組み) によってネットワーク疎通をテストする
  • ③ Browser E2E や Backend E2E を通して、ついでに外部連携のハッピーパスをテストする

特に入出力のバリエーションを網羅するテストをしたい場合、① を活用すること。

Backend E2E

単一のバックエンドコンポーネントに対して、全体的な振る舞いをテストする。
特に、バッチ・ジョブキュー・内部 API など、UI から直接コールされず Browser E2E が適用しづらい場合に適している。
テスト対象のコードをテストコードと同一プロセスで実行するか、別プロセスで実行するかで大きく異なる。

同一プロセスで実行する Backend E2E (In-Process Backend E2E) では、テスト対象に一部モックを差し込める。 弊チームでは、外部サービスへの依存 (DB を除く)は、モックサーバーで代替する。
また、現在はそうなっていないが、 DB をオンメモリ DB に差し替えることで、テストの高速化も見込める。

一方で、テスト用の DI コードなどの仕組みを用意する必要があり、保守性や実行速度は Browser E2E ほどではないが低い。 例えば、プロダクションコード向けの DI について、外部サービスの依存を外側から注入可能な形に設計し、それをテストコード向けの DI で再利用することで、問題を軽減できる。

// プロダクションコード用の DI (google/wire を利用)
func initializeApplication(
    ctx context.Context,
) (*application, func(), error) {
    wire.Build(
        ApplicationBaseSet,
        ServerSet,
        // ...
        config.ExternalServiceSet,
        config.DatabaseSet,
    )
    return nil, nil, nil
}

// テストコード用の DI (google/wire を利用)
func InitializeAuthzServer(
    ctx context.Context,
    *config.DatabaseConfig, // テスト用のデータベースの設定を受ける
    *config.ServiceAConfig,     // テスト用の外部サービスの設定を受ける
) (*application, func(), error) {
    wire.Build(
        ApplicationBaseSet,
        ServerSet,
        // ...
        // プロダクションコード用の ExternalServiceSet と DatabaseSet は DI せず、外から受ける。
        // それ以外の依存はプロダクションコードと同じものを DI できるようにしておく。
    )
    return nil, nil, nil
}

func TestChargeAuthorization(t *testing.T) {
    // ...
    // テスト用 DB のセットアップ
    db := fixture.NewDB(t)
    // 外部サービスのモックサーバーのセットアップ
    serviceA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // ...
    }))
    serviceAConfig := &config.ServiceAConfig{
        URL: serviceA.URL,
    }
    t.Cleanup(serviceA.Close)
    // 先述の DI でアプリケーションを初期化
    s, cleanup, err := InitializeAuthzServer(ctx, db.Config, serviceAConfig)
    require.NoError(t, err)
    t.Cleanup(cleanup)

    // 決済電文の種別ごとにサブテスト関数を設ける
    t.Run("通常決済", func(t *testing.T) {
        // arrange
        company := db.CreateCompany()
        card := db.CreateCard()
        // ...
        // act
        req := &authz.AuthorizationRequest{
            TransactionCode: "20250608120000xxxx",
            TotalAmount: 3300,
            // ...
        }
        res, err := s.ChargeAuthorization(ctx, req)
        // assert
        require.NotNil(t, res)
        require.NoError(t, err)
        assert.Equal(t, "0", res.Result)
        assert.Equal(t, "0", res.ReasonCode)
        authz := fixture.GetAuthorizationByTransactionCode(t, db, req.TransactionCode)
        require.NotNil(t, a)
        assert.Equal(t, "20250608120000xxxx", authz.TransactionCode)
        assert.Equal(t, 3300, authz.TotalAmount)
        // ...
    })
    // 決済電文の種別ごとにサブテスト関数を設ける
    t.Run("決済取り消し", func(t *testing.T) {
        // ...
    })
    // ...
}

別プロセスで実行する Backend E2E (Out-Process Backend E2E) では、Browser E2E と同様に外部サービスも実物を利用した忠実性の高いテストとなるが、実行速度・安定性・保守性に乏しい。
そのため、重篤度*1 が major 以上となり得る重要機能のハッピーパスに絞ること。
弊チームでは、k1LoW/runn を用いて、API サーバーの E2E なテスト (APIテスト) を実装する。

desc: 口座情報を取得できること
vars:
  user_email: ${USER_EMAIL}
runners:
  my_service:
    endpoint: ${MY_SERVICE_ENDPOINT}
force: true
steps:
  login_by_email_and_password:
    desc: ログインする
    # ...
  get_accounts:
    desc: 口座情報の取得
    my_service:
      /api/accounts:
        get:
          headers:
            # ...
          body: null
    test: |
      current.res.status == 200 &&
      current.res.body.accountNumber == string(${ACCOUNT_NUMBER}) &&
      current.res.body.accountName == string(${ACCOUNT_NAME})

Browser E2E

UI/UXや外部仕様をブラウザからテストするものを指す。 支出管理では、主に Playwright を用いた実行基盤で実装する。

ユーザーの利用環境に忠実なテストであり、システム全体を通した振る舞いをテストできる。 したがって、Browser E2E が通っていれば、ユーザー体験の提供が滞りなく行えているという自信を持てるテストになる。

一方で、実行速度と安定性に極めて乏しく、外部要因によって容易に壊れるため、保守性が低い。 かならず重篤度が major 以上となり得る重要機能のハッピーパスに絞ること。 全てを Browser E2E で担保しようとするのではなく、テストの目的を分解し、よりコストの低いテストレイヤーで担保するように考えること。

また、弊チームでは Playwright の codegen 機能を極力活用したテストコードにする。
Page Object Model は再利用性が本当に必要な箇所に限定する。 それによって、テスト失敗箇所から Recording を開始して部分的に codegen で再生成する、といったことがやりやすくなる。

Playwright では Pick Locator によって、壊れにくくユーザー操作を想起しやすいセレクターを自動生成する。 特に、ARIA Role と name (accessible name) という HTML のアクセシビリティ情報を利用するのがほぼ最適である。
逆に、classid 属性が利用される場合、HTML のセマンティクスに問題がある可能性が高いので、テストではなくUI実装を見直すこと。 例外として、複雑なテーブルの場合には限界があるため、その場合は data-testid 属性の利用を検討する。

// bad ❌: class や id 属性を指定していて、どんな操作をしているかわからない・DOMの変更に弱いテストになっている
test('カード管理画面でカードを正常に発行できること', async ({ page, user }) => {
  const cardName = cardName('VirtualCard');
  const userEmail = user.email;
  await page.locator('#card-add-button').nth(1).click();
  await page.locator('input[name="card-name"]').fill(cardName);
  await page.locator('.form-item > div > .select > .select__control > .select__indicators').click();
  await page.getByRole('option', { name: userEmail }).locator('span').click();
  await page.locator('input[name="monthly_limit"]').fill('10000');
  await page.getByRole('button', { name: '追加' }).click();
  await expect(page.locator('.notification-content')).toHaveText('カードが登録されました')
});
// good ✅: role, label, name といったセマンティクスを利用して、操作内容が伝わりやすく・壊れにくいセレクターを選択している
test('カード管理画面でカード発行・利用停止・再開を正常に行えること', async ({ page, user }) => {
  const cardName = generateCardName();
  const userEmail = user.email;
  await test.step('カードの発行', async () => {
    // good ✅: HTML のセマンティクスを活用したセレクターで、操作内容が伝わりやすく・壊れにくい
    await page.getByRole('button', { name: 'カードの追加' }).click();
    await page.getByLabel('カード名を入力').fill(cardName);
    await page.getByRole('combobox', { name: '所有者を選択' }).selectOption(userEmail);
    await page.getByLabel('1回あたりの上限額').fill('10000');
    await page.getByRole('button', { name: '追加' }).click();
    // アクセシビリティ属性の活用が難しい場合は data-testid 属性を活用する
    await expect(page.getByTestId('notification-message')).toHaveText('カードが登録されました')
  });
  // good ✅: リソースの CRUD をテストするのであれば、C をテストした流れで RUD をテストした方がかえって効率がいいケースがある
  // ただし、playwright の test.step を分けるなどの工夫はすること。
  await test.step('カードの停止', async () => {
    // ...
  });
  await test.step('カードの再開', async () => {
    // ...
  });
});

アプリケーションレイヤー毎の戦略

テストすべき内容は、アプリケーションレイヤーに与えられた責務そのものと表裏一体である。 レイヤーの責務を理解して正しい設計をした上で、テストを書くこと。

フロントエンド

Page Component (画面レベルのコンポーネント)

react-router のルーティングから直結した画面レベルのコンポーネントを指す。
ここでロジックの詳細をテストしたい場合、おおむね設計が誤っている。依存するコンポーネントや扱うAPIや関心事が広く、テストが難しいはずである。
唯一テストすべき観点があるとすると、画面を含めた全体的な振る舞いの正しさである。これは、Browser E2E で画面全体の挙動のハッピーパスを確認する程度に留め、詳細なケースは別の関数・フック・小さいコンポーネントに委譲し、そちらでテストすること。

Page 以外の Component

ロジックよりも表示・操作に関する関心事がテスト対象になる。
ロジックに対するテストは、できるだけロジックをカスタムフックや純粋関数に切り出して、その Unit Test を書くことを検討する。

表示と操作をテストしたい場合は、必要に応じてインタラクションテストを追加してよい。 ただし、社内デザインシステムを利用する場合、UI/UX の挙動は先方のテストですでに担保されているため、テスト観点が重複しないように注意すること。
一方で、デザインシステムに渡す Props の計算・組み立てロジックの正しさは、自分たちでテストする必要がある。

Hooks & Functions

詳細なロジックは、出来る限りカスタムフックや純粋関数に切り出すこと。
特に、純粋関数として切り出すと、React のライフサイクルから離れてテストが書きやすいため、意識して実践すること。

バックエンド

BFF

複数の API や RPC を呼び出し、結合・加工するAPI集約の責務がメインとなる。 テストの方針としては以下が考えられる。

  • ① Browser E2E でフロントエンドと一緒に挙動を担保する
  • ② API Test (Out-Process Backend E2E) で全体挙動を担保する
  • ③ In-Process Backend E2E で全体挙動を担保する

基本的に潜在的な重篤度の高い機能は Browser E2E を用意するため、必然的に ① のケースで担保される。 潜在的な重篤度が高くない機能の場合は Browser E2E は憚られるため、② か ③ で担保することを検討する。
BFF の本質はAPI集約であることから、外部サービスをモックする ③ は実効性を損なうため、基本的には ② を推奨したい。
ただし、BFF を Rails で実装する場合は、request spec によって ③ が実装しやすいため、許容する。

なお、上記は BFF 全体の振る舞いをどう担保するかの論点である。
対して、詳細な結合・加工のロジックは、それぞれ個別のメソッドを Unit Test することを推奨する。

domain 層

domain 層は依存関係の最も内側であり、外部への依存を持たないため、テストが比較的容易である。
そのため、業務ドメインに関する計算・判定・集合操作・状態遷移は可能な限りドメインモデルに寄せ、その Unit Test で網羅性を確保する。
したがって、境界値テストなどの Parametrized Test はこのレイヤーで担保する。

// good ✅: 境界値テストを含めた網羅的なParametrized Test を domain 層で担保している
func TestWorkflow_ValidateTitle(t *testing.T) {
    t.Parallel()
    tests := []struct {
        name        string
        title       string
        expectedErr error
    }{
        {
            name:        "空文字列の場合はエラー",
            title:       "",
            expectedErr: model.TitleLengthError,
        },
        {
            name:        "1文字のタイトルは有効",
            title:       "あ",
            expectedErr: nil,
        },
        {
            name:        "255文字ちょうどのタイトルは有効",
            title:       strings.Repeat("a", 255),
            expectedErr: nil,
        },
        {
            name:        "256文字のタイトルはエラー",
            title:       strings.Repeat("a", 256),
            expectedErr: model.TitleLengthError,
        },
        {
            name:        "マルチバイト文字(日本語)を含むタイトル - 有効",
            title:       "これは日本語のタイトルです",
            expectedErr: nil,
        },
        {
            name:        "マルチバイト文字を含む、255文字ちょうどのタイトル - 有効",
            title:       "あ" + strings.Repeat("a", 254),
            expectedErr: nil,
        },
        {
            name:        "マルチバイト文字を含む、256文字のタイトル - エラー",
            title:       "あ" + strings.Repeat("a", 255),
            expectedErr: model.TitleLengthError,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            wf := &model.Workflow{
                Title: tt.title,
            }
            err := wf.ValidateTitle()
            if tt.expectedErr == nil {
                assert.NoError(t, err, "エラーは期待していませんでしたが、発生しました: %v", err)
            } else {
                require.Error(t, err, "エラーが期待されましたが、発生しませんでした")
                // エラー自体を比較するのではなく、ErrorIsでエラーの型を確認
                assert.ErrorIs(t, err, tt.expectedErr,
                    "期待されたエラータイプ %T ではなく %T が返されました", tt.expectedErr, err)
            }
        })
    }
}

usecase 層

他のレイヤーを組み合わせて機能を実現する調整ロジックが中心。 そのため、詳細なロジックではなく、その機能を通した全体的な振る舞いがテストの関心になる。
また、性質上どうしても依存が多くなり、モックを使わない Unit Test では費用対効果が薄くなる。
したがって、以下の方針をとる。

  • ① Browser E2E や Backend E2E などより上位のテストレイヤーで担保する
  • ② DB をモックせずにテスト DB に接続した Integration Test で担保する

usecase 内が比較的シンプルな場合、① を採用し、機能全体の挙動担保の一環とするのが望ましい。
一方で、usecase 内が Facade Pattern などで大きく分岐したり、細かく分岐する場合、② の方法で分岐のかたまり毎に全体的な振る舞いをテストする。

正しく設計すれば、この層には判定・計算などの詳細は書かれていないはずのため、 Parametrized Test は必要ないはず。
usecase 層のテストでそういった網羅性が必要になっている場合、設計に問題があると捉えるべきである。
また、テストケースが複数あるとしても、期待する振る舞いや前提となるデータが大きく変わることが多く、サブテスト関数を分けるスタイルが適合する。

また、usecase に限らないが、エラーハンドリングは基本的に準正常系 (= 仕様範囲"内"の異常) に焦点を絞って書くこと。
異常系 (= 仕様範囲"外"の異常) の場合、基本的には早期リターンで err を呼び出し元に伝搬し、大域的エラーハンドリングに任せるケースがほとんどのため。
逆に、異常系でも単なるエラーの伝搬以上の処理を施している場合は、必要に応じてテストケースとして追加する。

// bad ❌: usecase 層でモックを多用しており、複雑で効果の低いテストになっている
func TestCard_CreateCard(t *testing.T) {
    // bad ❌: ケースによって期待する振る舞いやデータの前提が大きく変わるため、Table Driven Test は適さない
    tests := []struct {
        name       string
        card       *domain.Card
        mockRepo   func(context.Context, *mock.MockRepository)
        error      bool
    }{
        {
            // bad ❌: 境界値テストなどの Parametrized Test をしている
            name: "正常系: カード発行枚数が100枚未満の場合、カードが作成される",
            card: &domain.Card{
                // ...
                  },
                  // bad ❌: usecase で DB をモックしている
            mockRepo: func(ctx context.Context, repo *mock.MockRepository) {
                repo.EXPECT().CountCardByCompanyID(ctx, gomock.Any()).Return(99, nil)
                repo.EXPECT().CreateCard(ctx, gomock.Any()).Return(nil)
                // ...
            },
            error: true,
        },
        {
            // bad ❌: 境界値テストなどの Parametrized Test をしている
            name: "準正常系: カード発行枚数が101枚に達した場合、ユーザーエラーになる",
            card: &domain.Card{
                // ...
                  },
            mockRepo: func(ctx context.Context, repo *mock.MockRepository) {
                repo.EXPECT().CountCardByCompanyID(ctx, gomock.Any()).Return(100, nil)
                repo.EXPECT().CreateCard(ctx, gomock.Any()).Return(nil)
                // ...
            },
            error: true,
        },
        {
            // bad ❌: 仕様範囲外のエラーで、大域的エラーハンドリングに任せているものは、あえてケースに含める必要はない
            name: "異常系: DB から予期しないエラーが返ってきた場合、システムエラーになる",
            card: &domain.Card{
                // ...
                  },
            mockRepo: func(ctx context.Context, repo *mock.MockRepository) {
                repo.EXPECT().CountCardByCompanyID(ctx, gomock.Any()).Return(0, nil)
                repo.EXPECT().CreateCard(ctx, gomock.Any()).Return(errors.New("unexpected error"))
                // ...
            },
            error: true,
        },
        // ...
    }
    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            // arrange
            repo := mock.NewMockCardRepository(ctrl)
            test.mockRepo(ctx, repo)
            // act
             u := NewCardUsecase(repo)
            res, err := u.CreateCard(ctx, test.card)
            // assert
            if test.error {
                require.Error(t, err)
            } else {
                require.NoError(t, err)
                require.NotNil(t, res)
            }
        }
    }
}
// good ✅
func TestCard_CreateCard(t *testing.T) {
    // good ✅: usecase 層ではサブテスト関数を分ける
    t.Run("正常系: カードが作成されること", func(t *testing.T) {
        // ...
        // arrange
        // good ✅: テスト用 DB を利用した Repository の生成
        db := fixture.NewDB(t)
        repo := db.Repository()
        u := NewCardUsecase(repo, tx) // good ✅: モックではなくテスト用 DB を利用した Repository を注入
        card := &domain.Card{
            // ...
        }
        // act
        res, err := u.CreateCard(ctx, card)
        // assert
        require.NoError(t, err)
        require.NotNil(t, res)
    })
    // good ✅: カード発行枚数について境界値テストはせず、1ケースに留める
    t.Run("準正常系: カード発行枚数の上限に達した場合、エラーになること", func(t *testing.T) {
        // ...
        // arrange
        // good ✅: テスト用 DB を利用した Repository の生成
        db := fixture.NewDB(t)
        repo := db.Repository()
        u := NewCardUsecase(repo, tx) // good ✅: モックではなくテスト用 DB を利用した Repository を注入
        fixture.BulkCreateCards(t, db, 100) // テスト用 DB にカードを100枚作成しておく
        card := &domain.Card{
            // ...
        }
        // act
        res, err := u.CreateCard(ctx, card)
        // assert
        require.Error(t, err)
        assert.Equal(t, "カード発行枚数の上限に達しました。", err.Error())
    })
}

infra 層の repository

データアクセス・永続化ロジックを Repository の Integration Test によって保証する。
ただし、社内 ORM の CRUD をそのまま利用するだけであれば、無理にテストは書かなくてよい。
どちらかというと、社内 ORM の CRUD を利用せず独自に組み立てられたクエリにフォーカスする。
また、トランザクションは usecase 層で貼ることが多いため、Unit Test よりも上位のテストレイヤーで担保すること。

// カードを柔軟に検索できる Repository の Integration Test の例
// good ✅: 柔軟なクエリ組み立てロジックの正しさをテスト DB に繋ぎ込んだ上でテストしている
func TestDBRepository_FindCard(t *testing.T) {
    // arrange
    // good ✅: テスト用 DB を利用した Repository の生成
    db := fixture.NewDB(t)
    repo := db.Repository()
    company := db.CreateCompany(domain.Company{Name: "Test Company"})
    card := db.CreateCard(company)

    tests := []struct {
        name  string
        cond  port.CardCondition
        error error
    }{
        {
            name:  "find by id",
            cond:  port.CardCondition{ID: card.ID},
            error: nil,
        },
        {
            name:  "find by company id",
            cond:  port.CardCondition{CompanyID: card.CompanyID},
            error: nil,
        },
        {
            name:  "find by name",
            cond:  port.CardCondition{Name: card.Name},
            error: nil,
        },
        // ...
        {
            name:  "not found",
            cond:  port.CardCondition{ID: fake.ID()},
            error: errors.New("カードが見つかりませんでした。"),
        },
        // ...
    }
    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            result, err := repo.FindCard(ctx, test.cond)
            if test.error != nil {
                assert.EqualError(t, test.error, err)
            } else {
                assert.NoError(t, err)
                assert.Equal(t, card.ID, result.ID)
                assert.Equal(t, card.CompanyID, result.CompanyID)
                assert.Equal(t, card.Name, result.Name)
            }
        })

    }
}

infra 層の client

Solitary Unit Test によって、外部サービスをモックした状態でどのような相互作用が発生するかをテストする。 net/http/httptest などでモックサーバーを立て、以下の観点で網羅的に検証を行う。

  • 外部サービスに対して期待されるような入力 (HTTPリクエストなど) が送信されるか
  • 外部サービスから出力された内容 (HTTPレスポンスなど) を処理して、期待された振る舞いが得られるか

一方で、実物の依存先サービスと接続した上でのテストは、以下のように別の手段で担保する。 - infra-test によるネットワーク疎通をテストする - E2E / API Test を通して外部連携のハッピーパスをついでにテストする

// eKYC サービスに対するクライアント実装のモックを使ったテスト
// good ✅: 外部サービスとの間に生まれる入出力の相互作用に焦点を当てて網羅的にテストしている
func TestKYCClient_SubmitCompanyData(t *testing.T) {
    company := domain.Company{
        Name:           "テスト株式会社",
        PrefectureCode: 2,
        StreetName1:    "青森市",
        StreetName2:    "2-2-2",
    }
    tests := []struct {
        httpStatus  int
        wantErrType errortype.ErrorType
    }{
        {http.StatusNoContent,  nil},
        {http.StatusBadRequest, nil},
        {http.StatusNotFound, errortype.BadRequest},
        {http.StatusUnprocessableEntity, errortype.BadRequest},
        {http.StatusInternalServerError, errortype.BadRequest},
    }
    for _, test := range tests {
        t.Run(fmt.Sprintf("status: %d", test.code), func(t *testing.T) {
            // arrange
            ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                // assert
                if r.Method != http.MethodPut {
                    t.Fatalf("bad method: %s", r.Method)
                }
                w.WriteHeader(test.code)
                payload, _ := io.ReadAll(r.Body)
                switch r.URL.Path {
                case "/kyc/company-data":
                    // good ✅: 外部サーバーに対してどういう入力(HTTPリクエスト)が送信されるかをテストする
                    expectedPayload := `{"company_data":{"name":"テスト株式会社","office_address":"青森県青森市 2-2-2"}}`
                    if string(payload) != expectedPayload {
                        t.Fatalf("bad payload: %s, expected: %s", payload, expectedPayload)
                    }
                }
            }))
            t.Cleanup(ts.Close)
            // act
            client := outgoing.NewKYCClient(KYCConfig(ts.URL))
            err := client.SubmitCompanyData(ctx, company)
            // assert
            // good ✅: 外部サービスからの出力をもとに client が振る舞うかを検証する
            if test.code == http.StatusNoContent {
                assert.NoError(t, err)
            } else {
                assert.Error(t, err)
                assert.IsType(t, test.et, errortype.Get(err))
            }
        })
    }
}

gateway 層

バックエンドコンポーネントの入り口であり、以下の責務を持つ。

  • ① 入出力の変換と、入力の Sanity Check を実施すること
  • ② 検証・変換した入力を usecase に渡して、業務ロジック本体を実行すること

特に、REST API, gRPC, Job, Worker のハンドラーは、詳細なロジックを実装してもテストしづらい。
① は、mapper や validator のような構造体・関数に分離し、そこに Parametrized Test を書けばいい。
② は、ほとんど機能全体の振る舞い担保になってくるため、Browser E2E や Backend E2E で担保すればいい。

// bad ❌
func (s *Server) IssueCard(
    ctx context.Context,
    req *proto.IssueCardRequest,
) (*proto.IssueCardResponse, error) {
    // bad ❌: 入力の変換と sanity check をたくさんベタ書きしている
    card := &domain.Card{
        CompanyID: req.CompanyId,
        Name:      req.Name,
        // ...
    }
    if card.CompanyID == "" {
        return nil, status.Error(codes.InvalidArgument, "Company ID is required")
    }
    if card.Name == "" {
        return nil, status.Error(codes.InvalidArgument, "Card name is required")
    }
    // ... (mony more sanity checks & conversions)
    // bad ❌: 詳細な業務ロジックがたくさんベタ書きされている
    count, err := s.repo.CountCardByCompanyID(ctx, card.CompanyID)
    if err != nil {
        return nil, status.Error(codes.Internal, "Failed to count cards")
    }
    if count >= 100 {
        return nil, status.Error(codes.ResourceExhausted, "Card limit reached for company")
    }
    // ... (many more business logic)
    resp := &proto.IssueCardResponse{
        CardId: card.ID,
        // ...
    }
    return resp, nil
}
// good ✅
func (s *Server) IssueCard(
    ctx context.Context,
    req *proto.IssueCardRequest,
) (*proto.IssueCardResponse, error) {
    // good ✅: 入力の変換と sanity check を mapper に委譲して、そこでテストする
    card, err := mapper.ToCardModel(req)
    if err != nil {
        return nil, err
    }
    // good ✅: 詳細なロジックは実行せず、usecase を呼び出すだけ
    err = s.usecase.IssueCard(ctx, card)
    if err != nil {
        return nil, err
    }
    // good ✅: 出力の変換を mapper に委譲して、そこでテストする
    return mapper.ToCardResponse(card), nil
}

Pub/Sub による連携

Pub/Sub による連携は、Browser E2E などで担保すると、非同期処理の完了を wait する必要があり、実行速度や安定性に問題が生じる。
そのため、Publisher 側と Subscriber 側の全体的な振る舞いは、それぞれに対して Backend E2E あるいは usecase 層の Integration Test で担保する。
特に Publisher 側では、Outbox Message テーブルに対する Transactional Enqueue が正しく実施されることをテストできれば良い。

*1:重篤度とは、freeeにおける障害やバグの事象のひどさをあらわし、critical / major / normal / minor の分類が存在する。詳しくは右記を参照: ハッピーの重篤度でみんなで品質の目線合わせをするぞ! - freee Developers Hub