はじめに

私の担当するバックエンドでは、機能を積み増す一方で、ビジネスロジックの中心である usecase 層にユニットテストがほぼ無い状態を長らく抱えていました。しかも500を超えるusecaseに、複数人で少しずつテストを足していく必要があります。この規模で一番の敵は「人によってテストの書き方がバラバラになること」です。書き方が揃わないと、レビューは毎回スタイルの指摘に追われ、テスト自体の信頼性も安定しません。

結論から言うと、次のように解決しました。

  • mockery v3 + testify + AAAパターンでテストの「型」を固定する
  • 書き方一式を Claude CodeのSkills(AIエージェント向けの手順書) として固定し、人が書いてもAIが書いても同じ形に揃える
  • カバレッジをCIで自動計測し、テスト未整備の箇所を可視化する

この記事は、その具体的な実装方法と、その形に決めるまでの設計判断をまとめたものです。

前提として、このバックエンドはClean Architecture + DDDをベースにした モジュラーモノリス構成のGoサービスです(構成そのものの詳しい話はこちらの記事をご確認ください)。

対象読者

  • GoでClean Architecture / DDD風のレイヤード構成を採っていて、アプリケーション層のテストをこれから増やしたい方
  • 既存コードにテストを「後から・複数人で・大量に」足す局面にいる方
  • mockery v3 + testifyでのモックテストの具体的な書き方の落としどころを知りたい方

この記事で得られること

  • usecase層を「何を境界として」テストするかの考え方(ブラックボックス検証)
  • mockery / テストデータ / 引数検証を、なぜこの形にしたか
  • AAAパターンでの具体的な書き方(引数マッチャーの読み書き分離・output比較・エラー検証)
  • テストデータ生成ヘルパーの設計(newTest{Type} + Optionパターン)
  • 書き方一式をSkillとして固定し、人もAIも同じ形に揃える運用
  • カバレッジをCIで自動可視化する仕組み

動作確認環境

種別バージョン
Go1.26.3
mockeryv3 系
testifyv1 系(mock / assert)
CICircleCI

1. 前提: usecase 層とは何か(ざっくり)

アーキテクチャの詳細は本筋ではないので前提だけ。レイヤーの依存方向はおおむね次のとおりです。

adapter(HTTP ハンドラ)
   → scenario(クライアント別の組み立て・トランザクション境界)
      → usecase(アプリケーションのユースケース) ← 今回テストする層
         → repository IF / service IF(インターフェース)
            → infra 実装(DB・外部サービス)

usecase 層は「1つの業務操作」を表す層です。リポジトリやドメインサービスをインターフェース越しに呼び、入力DTO(input)を受け取って、ドメインのルールに従い加工し、出力DTO(output)を返します。DBアクセスや外部通信の具体実装には依存しないのがポイントです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 例: 会員を作成する usecase(抜粋・簡略化)
func (uc User) CreateUser(in input.CreateUser) (output.CreateUser, error) {
    if err := in.Validate(); err != nil {
        return output.CreateUser{}, err
    }
    user := model.NewUser(/* ... in から組み立て ... */)
    created, err := uc.UserRepository.Create(user) // repository IF
    if err != nil {
        return output.CreateUser{}, err
    }
    return output.CreateUser{User: created, Events: /* ... */}, nil
}

依存がすべてインターフェースなので、そこを mock に差し替えれば DB を立てずに usecase 単体のロジックだけを検証できます。これがこの層をユニットテストする最大の動機です。


2. なぜ usecase 層をテストするのか

レイヤーが多いと「どこをテストするか」で迷いますが、usecase層は費用対効果が高い層です。

  • ビジネスロジックの中心: 入力検証の後の分岐、ドメインモデルの組み立て、状態遷移(例: 未禁止 → 禁止)など、バグると業務影響の大きいロジックが集まる。
  • 依存がすべてインターフェース: DBを立てずにmockで隔離でき、テストが速く・安定する(flakyになりにくい)。
  • リファクタの土台: ここにテストがあると、scenario層やリポジトリ実装を後から安心して変えられる。

逆に、リポジトリ実装(SQL)やHTTPハンドラは別のテスト戦略(結合テスト等)に任せ、usecase層は純粋なロジック検証に集中させます。

そしてusecaseをブラックボックスとみなし、その境界の入出力をすべて検証します。

区分対象
入るものinput DTO / mock が返すリポジトリ・サービスの戻り値
出るものoutput DTO / リポジトリ・サービスへ渡す引数 / バリデーションや自前エラー

特に見落としがちなのが「リポジトリへ渡す引数」です。usecaseがinputから組み立てた検索条件やドメインモデルが期待どおりかは、出力と同じくらい重要な検証対象です。


3. 主要な設計判断(何を・なぜそう決めたか)

テストの「形」を固める上で肝になったのは、主に次の3つです。それぞれ、なぜこの形にしたかを述べます。

論点採用した形理由
モックの用意mockery v3 で自動生成IF が増減するたびの mock 保守・追従コストを無くす
テストデータファクトリ関数 + Optionパターンzero value で偶然通るテストを避け、本番相当のデータで回す
引数の検証個別フィールドで比較「呼ばれた事実」だけでなく「正しい引数で呼ばれたか」まで保証する

3.1 モックは mockery v3 で自動生成する

モックは mockery v3(testify/mockベースのモック自動生成)で用意します。インターフェースにメソッドが増減するたびにmockを手で直すのは量産フェーズで破綻するので、IF定義から自動生成して保守・追従コストを無くしました。EXPECT() パターンで「この引数で呼ばれたらこれを返す」を宣言的に書け、mockはリポジトリ全体で1つの設定ファイルに集約し、コマンド一発で各モジュールへ生成。生成物はコミット対象にしています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# mockery の設定(抜粋)
template: testify
packages:
  github.com/example/lessonbackend/account/internal/domain/repository:
    config:
      dir: "account/mock/repository"
      pkgname: mockrepository
    interfaces:
      User:
      # ...

3.2 テストデータはファクトリ + Option で作る

ドメインモデルを構造体リテラルで直書きすると、フィールドが多数あるうち数個だけ埋めて残りはzero value…という状態になり、**本番と乖離した「たまたま通るテスト」**を生みがちです。「後から Age を参照する処理が増えても、zero value(0)のまま気付かず通ってしまう」といったバグ検知力の低下が心配です。

そこで、ドメインの NewXxx を必ず経由するファクトリ + Optionパターンで「変えたいフィールドだけ上書き」する形にしました(具体的な実装は §5)。

3.3 引数は個別フィールドで検証する

mockの引数を mock.Anything で素通しにすると「呼ばれたこと」しか分かりません。usecaseの主な仕事は「inputから適切な検索条件やドメインモデルを組み立てること」なので、引数の中身こそ検証対象です。そのため原則、引数は個別フィールドで比較します(詳細な書き方は §4.1)。


4. テストの書き方 ── AAAパターンで形を固定する

複数人で量産する以上、「形の固定」が最重要です。すべてのサブテストを Arrange / Act / Assert の3ブロックに固定しました。アサーションは require(失敗で即中断)と assert(継続)を使い分けず、「どちらを使うか」で迷わないよう assert に一本化しています。例外は、戻り値のポインタやスライスを間接参照して中身を比較する箇所です。assert のままだと失敗後も処理が進んでnilをたどりpanicし、本来の失敗理由が埋もれるため、そこだけ require にします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func TestUser_CreateUser(t *testing.T) {
    now := time.Date(2026, 4, 30, 12, 0, 0, 0, timezone.GetTimeZone())

    t.Run("正常系: 新規作成成功 / Create に渡す User を全項目検証", func(t *testing.T) {
        // ===== Arrange =====
        userRepo := mockrepository.NewMockUser(t)

        in := input.CreateUser{ /* 全項目入り */ }

        createdUser := newTestUser(now, func(u *model.User) { u.ID = 100; u.Name = in.Name })
        userRepo.EXPECT().
            Create(
                mock.MatchedBy(mockassert.MatchFields(t, func(u model.User, w *mockassert.Want) {
                    w.Eq("Name", u.Name, in.Name)
                    w.Custom("HashedPassword",
                        bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(in.Password)) == nil,
                        "bcrypt 対応関係")
                    /* 他項目も同様に */
                })),
            ).
            Return(createdUser, nil)

        uc := usecase.User{UserRepository: userRepo /* 他依存は nil 明示 */}

        // ===== Act =====
        out, err := uc.CreateUser(in)

        // ===== Assert: output =====
        assert.NoError(t, err)
        assert.Equal(t, output.CreateUser{User: createdUser /* ... */}, out)
    })
}

例中の mockassert.MatchFields は自作の検証ヘルパーです(詳しくは §4.1)。

ブロック役割
Arrangemock 準備・引数検証を含む動作定義・データ準備・usecase 構築
Act対象メソッド呼び出し(基本 1 行)
Assert: outputoutput 検証(引数検証は Arrange で実施済み)

以下、要点を順に説明します。

4.1 mock 引数の検証は「読み取り/書き込み」で書き分ける

§3.3の通り引数は原則、個別フィールドで比較します。ただし書き方を2系統に分けました。

書き込み系(Create / Update / Save) は検証フィールドが多く、不一致時にどのフィールドが食い違ったか分からないとデバッグが辛くなります。当初は素の mock.MatchedBy で書いていましたが、原因フィールドを特定できませんでした。そこで自作の小さなヘルパー mockassert.MatchFields を用意し、不一致フィールドを t.Log へ出すようにしました。

1
2
3
4
5
6
userRepo.EXPECT().Create(
    mock.MatchedBy(mockassert.MatchFields(t, func(u model.User, w *mockassert.Want) {
        w.Eq("Age", u.Age, in.Age)                    // == 比較。不一致なら got/want をログ
        w.Custom("HashedPassword", /* bool */, "bcrypt 対応関係") // == で表せない比較
    })),
).Return(createdUser, nil)

注意点としてtestifyは不一致だった呼び出しに対して MatchedBy を2回評価します(一致判定と、失敗メッセージの組み立てで1回ずつ)。そのため MatchFields のクロージャも2回走り、同じ不一致ログが2度出ます。

読み取り系(GetList 等) は素の mock.MatchedBy で十分。条件が一致しないとmockが値を返さずusecaseが早期に失敗するので、どこでズレたかすぐ分かるからです。

1
2
3
userRepo.EXPECT().GetList(mock.MatchedBy(func(c model.GetUserCondition) bool {
    return c.ID != nil && c.ID.Eq != nil && *c.ID.Eq == in.ID
})).Return(users, count, nil)

bcryptハッシュや乱数のような決定的でない値は、バイト一致ではなく「対応関係」で検証します。具体的には、bcryptは CompareHashAndPassword で照合し、乱数は生成器をテスト用に固定して完全一致を確認します。

なお「引数そのものではなく分岐が観点」のケース(例: メール重複チェックの分岐だけを見たい)では、割り切って Save 引数を mock.Anything にすることもあります。原則は個別比較、観点次第で緩める、という使い分けです。

4.2 output は構造体まるごと1発で比較

正常系の出力は、フィールドを1つずつ並べず構造体まるごと assert.Equal します。

1
2
3
4
assert.Equal(t, output.CreateUser{
    User:   createdUser,
    Events: []sharedevent.Event{event.NewUserCreated(createdUser)},
}, out)

これはGoのlint(exhaustruct:構造体の初期化で全フィールドを埋めることを強制)と組み合わせたときに効きます。outputに新フィールドが増えると、期待値側の埋め忘れが lint で即落ちるため、検証漏れを言語レベルで防げます。

4.3 エラー検証は種別で使い分ける

エラー種別検証方法
入力バリデーションassert.ErrorAs でアプリエラー型を取り、エラーコードで一致確認
リポジトリエラー伝播assert.EqualError でメッセージ完全一致
usecase 自前エラーassert.EqualError or コード値で確認

バリデーションは「どのフィールドが原因か」までは踏み込みません。それはinput層のValidateのテストの責務で、usecase側で見るのは「弾かれて依存が呼ばれないこと」だからです。

4.4 「呼ばれないこと」はmockが自動で保証する

NewMockXxx(t) で作ったmockは、想定外の呼び出しを自分で見張ります。EXPECT(「この呼び出しがあるはず」という事前宣言)を1つも登録していないと、そのmockのメソッドが呼ばれた瞬間に「そんな呼び出しは想定していない」とテストを失敗させます。

つまり、バリデーションで弾かれる入力を渡し、mockにEXPECTを何も設定しなければ、依存が呼ばれないことを追加コードなしで検証できます

1
2
3
userRepo := mockrepository.NewMockUser(t) // EXPECT を設定しない
// ... バリデーションで弾かれる input を渡す ...
// もし Create 等が呼ばれたら自動 fail

5. テストデータの作り方 ── newTest{Type} ヘルパー

§3.2で決めた「ファクトリ + Option」の実装です。ドメインの NewXxx を必ず経由し、変えたいフィールドだけOptionで上書きします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func newTestUser(now time.Time, opts ...func(*model.User)) model.User {
    u := model.NewUser(/* 本番に近い妥当な値 */)
    u.ID = 1
    u.Email = "test@example.com"
    u.CreatedAt = now
    for _, opt := range opts { opt(&u) }
    return u
}

// 使う側: 変えたい差分だけ宣言
bannedUser := newTestUser(now, func(u *model.User) {
    u.Status = sharedmodel.StatusProhibited
    u.BannedAt    = &now
})

ルールはシンプルに保ちました。

  • 1ドメインモデルにつき1関数newTestBannedUser のような派生は作らない。状態差分はOptionで表現)
  • モジュールに1ファイル(testhelper_test.go)へ集約
  • 時刻は引数で受け取る(パッケージ変数に持たせない)

このヘルパーのおかげで、テスト本体には「そのテストで何を変えたか」だけが残り、意図が読み取りやすくなります。

なお最後の「時刻は引数で受け取る」には経緯があります。当初は「テスト共通の現在時刻」をパッケージ変数で持たせていました。しかし並列実行や可読性で不利なうえ、time.Now() はモノトニッククロックを含むため assert.Equal で「同じ瞬間なのに不一致」になる罠がありました。そこで各テスト関数内で固定値を直書きし、ヘルパーへは引数で渡す形に統一しています。


6. 書き方を「Skill」として固定する

ここまでのルールは、散文の規約ドキュメントに書くこともできます。しかし500以上のusecaseを複数人、さらに AIコーディングエージェントも書き手として書く前提だと、散文の規約は「読まれない・解釈がブレる・チェックされない」で形骸化します。

そこで、テストの書き方一式を Skill として固定しました。SkillはAIエージェント(Claude Code)が特定タスクを実行するための手順書で、呼び出すとその手順・ルールに沿って成果物を生成します。

重要なのは「同じ入力から同じ形の出力が出ること」だという点です。例えば、次のように対象を指示します。

1
「user.go のテストを書いて」

Skillは、次の手順を踏みます。

  • 対象のソースと依存インターフェースを読む
  • 足りないmockを生成する
  • テストデータのヘルパーを用意する
  • AAA・マッチャー・エラー検証のルールに沿って書く
  • テストとlintを通す

こうして**§4〜§5で説明したのと同じ形のテスト**を出力します。書き手が人かAIエージェントかを問わず、出力は同じ形に揃います。これが量産フェーズで一番効いた点でした。

形がSkillとCI(後述)で担保されるので、人間のレビューは「スタイルの逸脱探し」から「テストしているロジックが妥当か」に集中できます。


7. カバレッジをCIで可視化する

テスト進捗をCIに計測させ、結果をリポジトリ内のドキュメントへ自動反映する形にしました。developブランチへのマージ時に、lint / test / buildが通ったあとカバレッジを計測し、次のような形で残します。

  • モジュール別のカバレッジ表
  • ファイル別の詳細表
  • カバレッジが著しく低いファイルは「テスト未整備の可能性」として警告リストに自動掲出

計測対象はusecase層に絞っています。自動生成の入力DTOやレスポンスDTOまで含めると分母が膨らみ、率が実態より低く出てしまうためです。

計測して変化があればカバレッジ表を書き換え、自動コミットしてリポジトリへ反映します。この際、自動コミットのpushがCIを起こし無限ループに陥るのを防ぐためにコミットメッセージへ [skip ci] を付けます。

これで実装漏れなどが、リポジトリのREADMEを見るだけで分かるようになりました。


8. 導入して変わったこと

  • カバレッジ: usecase層はほぼ0の状態から、主要モジュールでおおむね 7〜10割程度まで引き上がりました。
  • 未整備箇所が一目で分かる: カバレッジの低いファイルが自動で警告リストに載るので、「次にどこを埋めるか」を探す手間が消えました。
  • レビューが軽くなった: 形を担保できているぶん、指摘は「スタイル」から「ロジックの妥当性」へ移りました。
  • 人もAIも同じ形: 誰が(何が)書いても同じ構造のテストになり、読み手の負荷が下がりました。

9. まとめ

  • usecase層は依存がすべてインターフェースなので、mockで隔離してロジック単体を速く・安定してテストできる、費用対効果の高い層。
  • モック・テストデータ・引数検証の3つは、量産で破綻しない形(mockery / ファクトリ+Option / 個別比較)に固めた。
  • 書き方はAAAで固定し、引数マッチャーの読み書き分離・outputまるごと比較・エラー検証の使い分けを具体ルールにした。
  • 書き方一式を Skill として固定し、CIのカバレッジ可視化と合わせて「人もAIも同じ形」を担保した。
  • ルールは一度で固まらない。共有時刻変数の廃止・assert 統一・デバッグ補助の追加など、運用で見直し続けた。

「テストをどう書くか」以上に「どうすれば大勢で書いてもブレないか」に投資したのが、今回の肝でした。

関連リンク