はじめに
こんにちは!株式会社Schooで内定者インターンをしている@takishun_schooと申します!
現在私は、とあるプロジェクトのバックエンド開発に携わっており、プロジェクトではGo言語とクリーンアーキテクチャを採用しています。
今回は、そのインターン中の機能開発で得た「テスト」に関する学びを共有したいと思います。
機能開発を進める中で、自分なりに単体テストを書いてPull Requestを出したのですが、レビュー時に「この層は振る舞いを意識したテストにすべき」や、「テスト名が適切ではない」など様々な指摘を受けていました。
その一連のやり取りを通じて痛感したのは、自分はテストを書いているつもりでも、そもそも『テストで何を重視すべきか』をはっきりと理解できていなかったいうことです。
そこで本記事では、過去の自分のように「なんとなくテストを書いているけれど、何を重視して何に焦点を当てるべきなのか明確にしたい!」と悩んでいる方に向けて、テスト設計における考え方と、アーキテクチャの各層における具体的なテスト設計の方針についてまとめます!
前提:ここで言う「テスト」の定義とスコープ
具体的なテスト設計の話に入る前に、本記事で扱う「テスト」がシステム全体のどこを担保するためのものなのか、そのスコープを明確にしておきます。
本記事で扱うのは「単体テスト(ユニットテスト)」
一般的にソフトウェアテストは、以下のような層で構成されることが多く、テストピラミッドという考え方で整理されます。
・単体テスト(Unit Test): 個々のクラスや関数など、小さな単位の振る舞いを検証するテスト
・結合テスト(Integration Test): 複数のコンポーネントが連携したときに正しく動作するかを検証するテスト
・E2Eテスト: システム全体を通したユーザー操作や業務フローを検証するテスト
本記事では、この中でも最も基本となる 単体テストに焦点を当て、テスト設計の考え方を整理していきます。
単体テストの責任範囲
我々のプロジェクトにおける単体テストの最大の目的は、
各コンポーネントが担うロジックや振る舞いが正しく実装されていることを保証すること
です。具体的には以下の動作を各メソッド単位で保障する必要があります。
- ビジネスルールの検証(例:ウォッチリストの1日の登録上限を超えていないか)
- ユーザー入力のバリデーション(例:不正な文字が含まれていないか)
- 権限チェック(例:リソースにアクセスする権限があるか)
これらの責任を果たす上で、自分が数々の指摘から学んだテスト設計の結論(大原則)があります。
それは、層による例外はあるものの、基本的には『内部の実装(どう動くか)』ではなく『振る舞い(何をするか)』を意識してテストを書くべきであるということです。
なぜ「実装」ではなく「振る舞い」をテストするのか?
結論から言うと、最大の目的はリファクタリング耐性を高めることです。
1. リファクタリング性能を高める(主目的)
振る舞いを意識してテスト書くことで、リファクタリング性能が劇的に向上します。
それはなぜでしょうか?
もしメソッドの「内部でどう動くか(実装)」に依存したテストを書いてしまうと、機能要件は全く変わっていないのに、コードを綺麗に整理しただけでテストが落ちてしまいます。
言葉だけだと少し分かりにくいので、「内部実装」と「振る舞い」のテストケース名の違いを例に見てみましょう。
(例:ユーザーの会員種別が変わったかどうかを判定する関数)
❌ 内部的な処理(実装)に寄ったテストケース
キャッシュと購入データが存在し、キャッシュ時刻と購入データの更新時刻が一致しない場合、
trueと購入データの更新時刻を返す
→ ユーザー会員種別が変わったと判断するための「キャッシュの比較」という内部ロジックがテスト名に入ってしまっています。
将来キャッシュを使わなくなったら、このテストは壊れてしまいます。
✅ 振る舞いに合わせたテストケース
ユーザーの会員種別が前回取得時から変更されている場合、会員種別が変更されていることを示すフラグを
取得できる
→ 内部でどう判断しているかは問わず、ユーザーの会員種別が変わったかどうかという関数の「振る舞い(入力と出力)」だけを正しくテストできています。
このように振る舞いさえ保証しておけば、中のコードをどれだけリファクタリングしてもテストは通り続ける。この「安心感」を作ることが最大の目的です。
2. 生きた仕様書となる(副産物)
リファクタリング耐性を高めるために「振る舞い」ベースでテストを書くようになると、素晴らしい副産物が生まれます。それが、テストコード自体が最高の『生きた仕様書』になるということです。
振る舞いが日本語で簡潔に記述されたテストのリストを見れば、新しくチームに入ったメンバーでも「このユースケースにはどんなビジネスルールがあるのか」がひと目で理解できます。
さらに現代の開発においては、CursorなどのAIエディタにプロジェクトの仕様を読み込ませる(コンテキストとして与える)際にも、この振る舞いベースのテストコードが極めて有効に働きます。 AIにとっても人間にとっても優しいコードベースを作ることができるのです。
まさに一石二鳥ですね 🦆
3. 例外
ただし、この「振る舞いをテストすべき」という原則にも例外があります。
例えば、Infrastructure層(Repositoryなど)は例外的に実装に寄った書き方が適しているとされています。
なぜなら、インフラ層の責務は「特定の技術(DBや外部API)とどうやり取りするか」だからです。ここでは、「どんなSQLクエリを発行しているか」という実装そのものが技術的な仕様となります。
このように、アーキテクチャの各層の「責務」に合わせて、テストの焦点を適切にコントロールすることが重要です。
単体テストで重視したい3点
前章で触れた「振る舞いベースのテスト」を実際のコードに落とし込み、品質の高いテストを維持するために、私たちのプロジェクトでは以下の3点を重視しています。
1. リファクタリング耐性(振る舞いのテスト)
テストが壊れることを恐れずにリファクタリングできるようにします。
-
「実装詳細」ではなく「振る舞い」をテストする
メソッドが内部で「どう処理しているか」ではなく、「何をする機能として振る舞うか」に焦点を当てます -
外部依存の排除
DBなどに依存すると、テスト間の依存関係が生まれたり、実行順序によって結果が変わったりしてしまいます。外部依存はすべてMockを使用し、何度実行しても必ず同じ結果が得られる状態を作ります。
2. テスト自体の保守性(AAAパターンの徹底)
テストコード自体が複雑になり、負債化するのを防ぎます。
-
AAAパターンの分離
テストコードを「Arrange(準備)」「Act(実行)」「Assert(検証)」の3つのフェーズに明確に分け、どこで何をしているかをひと目で分かるようにします。 -
テストにロジックを含めない(if文の禁止)
テストコード内にif文やfor文などのロジックを含めるのは鉄則として禁止しています。理由は、一つのテストコードで多くのことを検証しようとし過ぎている兆候になるからです。テスト対象の挙動に合わせて適切に命名し、条件ごとのテストはテーブルドリブンテストなどを用いてシンプルに保ちます。
3. 検証(Verification)と妥当性確認(Validation)の分離と異常系
何をテストで保証するのか、そのスコープを明確にします。
-
Verification(検証)とValidation(妥当性確認)の分離
- Verification: 入力に対する正しい出力や、期待される戻り値が返ってくるかの確認
- Validation: バリデーションルール、境界値、不正な入力値が正しく処理されるかの確認
これらを分けて意識することで、テスト観点の抜け漏れを防ぎます。
-
異常系の網羅
システム全体を通した結合テストでは、特定のDBエラーなどを意図的に発生させるのが困難です。そのため、結合テストでは網羅しにくい「異常系」こそ、Mockを使って自由に状態をコントロールできる単体テストで確実に担保します。
各層のテスト方針と境界線
ここまでは、プロジェクト全体における「テストの心構え(振る舞いをテストする)」や「戦術(Mockの活用、AAAパターン)」について整理してきました。
では、この方針を実際のクリーンアーキテクチャに当てはめた場合、各層で具体的に「何をテストし、どこをMockで切り離す(境界とする)」べきなのでしょうか?
この図の中で、特定の技術との連携という実装(HOW)自体が仕様となるInfrastructure層だけが、例外的なテスト方針となります。
それ以外の3つの層(Interface, Usecase, Domain)はすべて、それぞれの責務における振る舞い(WHAT)に焦点を当ててテストを書きます。
この方針を前提として、リクエストが外側から入ってくる順に、具体的なテストの境界線を見ていきましょう。
5-1. Interface層 / Handler層
-
役割:
HTTPリクエストをGoの構造体に変換し、Usecaseの処理結果を適切なHTTPステータスコード(200, 400, 500など)とJSONで返すこと。 -
テスト対象:
ルーティング、リクエスト/レスポンスの変換、ステータスコードの制御。 -
テストの境界(分離):
Usecase層の処理はすべてMock化します。「Usecaseがエラーを返した時、正しく 400 Bad Request に変換されるか」といった翻訳の振る舞いだけをテストします。
【テストの焦点】翻訳機としての振る舞い(WHAT)
❌ 動画登録APIを叩いた時、DBに動画が保存され、ステータス200が返ること
(理由:DBへの保存はUsecase以下の責務であり、Handlerの単体テストで見るべきではない。それは結合テストの役割となる)
✅ Usecaseから登録上限エラーが返却された場合、HTTPステータス400と所定のエラーJSONが返却される
(理由:GoのエラーをHTTPレスポンスに変換するという「翻訳機」としての振る舞いに特化)
5-2. Usecase層
-
役割:
アプリケーションの要件を満たすための処理の進行。 -
テスト対象:
処理フローの分岐(成功/失敗パス)、トランザクション境界(commit/rollbackの条件)、例外やエラーの握り方・変換(ドメインエラーをどう処理するか)。 -
テストの境界(分離):
Infrastructure層(DBアクセスなど)をMock化します。DBにデータが保存されたかではなく、「DBがエラーを返した時に、正しくRollbackが呼ばれ、エラーを上位(Handler)に伝播させているか」をテストします。
【テストの焦点】ビジネスルールの振る舞い(WHAT)
❌ RepositoryのGetVideoCountが100を返し、Createメソッドが呼ばれないこと
(理由:内部のメソッド名に依存しており、リファクタリングで壊れるため脆い)
✅ ウォッチリストの登録上限に達しているユーザーが動画を登録しようとした場合、登録上限エラーが返却される
(理由:内部でどうチェックしているかは問わず、「上限のユーザーが」「登録すると」「エラーになる」というビジネス仕様が明確になっている)
5-3. Domain層
-
役割:
ビジネスの核となるルールやデータの整合性を守ること。 -
テスト対象:
値オブジェクトのバリデーション(例:IDのフォーマット違反)、状態遷移の検証。 -
テストの境界(分離):
ここは外部依存を一切持たない純粋なGoのロジックです。Mockは不要で、入力に対して正しい結果(またはエラー)が返るかだけの、最もシンプルで堅牢な単体テストになります。
【テストの焦点】ドメインルールの保護(WHAT)
❌ VideoIDの正規表現バリデーションが正しいこと
(理由:「正しいこと」は禁句。また、正規表現を使っているという実装詳細が漏れている)
✅ 無効なフォーマットの文字列で動画IDを生成しようとした場合、バリデーションエラーとなる
(理由:値オブジェクトの「生成時の振る舞い」という純粋なロジックをテストできている)
5-4. Infrastructure層 / Repository層(例外)
-
役割: データベースへの永続化処理、外部サービスとのAPI連携。
-
テスト対象:
発行されるSQLクエリの正確性、データマッピング(DBの行データ → Goの構造体)、I/Oエラーのハンドリング。 -
テストの境界(例外的な実装テスト):
第3章で触れた通り、ここは特定の技術との連携(実装)自体が仕様となります。そのため、sqlmock などを使い「期待した通りのSQL(SELECT文など)が構築され、実行されているか」という実装に寄ったテストを書きます。
【テストの焦点】技術的な実装(HOW)
❌ 動画エンティティを渡した場合、DBへの保存に成功する
(理由:インフラ層の責務は「どう技術と連携するか(実装)」。抽象的すぎるとSQLの検証などが漏れてしまう)
✅ 動画エンティティを渡した場合、watch_listテーブルに対して指定したパラメータでINSERT文が発行される
(理由:インフラ層は例外としている。どんなテーブルにどんなクエリを投げるかという「実装(HOW)」自体が仕様となるため)
5-5. 【重要】Integration Test(結合テスト)との境界線
ここまで各層をMockで切り離す単体テストを見てきましたが、最後にこれらをガチャンと繋げるのがIntegration Test(結合テスト)です。
-
目的の違い:
単体テストが「各パーツが仕様通りに動くか」を見るのに対し、結合テストは、全体として、実際のDBに正しく書き込まれ、意図した振る舞いになっているかを確認します。 -
なぜ分けるのか?:
単体テストで実際のDBまで繋いでしまうと、テストが遅くなり、エラー時の原因特定(DBが悪いのか、ロジックが悪いのか)が困難になります。「異常系や細かい分岐は単体テスト(Mock)」で叩き、「正常系の大きな流れは結合テスト(実DB)」で担保すると責務を切り分けることで、テスト全体の肥大化と負債化を防いでいます。
【実践】Goで振る舞いを保証するテストサンプル(Before / After)
ここでは、PRレビューで実際に指摘を受けやすい「実装依存のテスト」と、それをAAAパターンで改善した「振る舞いベースのテスト」を比較します。
サンプル1:Usecase層(ビジネスルールの検証)
お題:「ウォッチリストへの動画登録時、1日の上限に達していたらエラーにする」
❌ Before
// NG: どんな仕様のテストか分からず、内部の実装詳細まで検証してしまっている
func TestWatchListUsecase_CheckLimitAndSave(t *testing.T) {
mockRepo := new(MockWatchListRepository)
usecase := ProvideWatchListUsecase(mockRepo)
// 上限に達している状態をセット
mockRepo.On("GetDailyCount", "user_1").Return(10, nil)
err := usecase.RegisterVideo("user_1", "video_1")
// ❌ 内部で「GetDailyCount」という特定のメソッドが呼ばれたかまで検証している
// (リファクタリングでメソッド名や確認方法が変わると、機能は同じでもテストが壊れる)
mockRepo.AssertCalled(t, "GetDailyCount", "user_1")
assert.Error(t, err)
}
指摘ポイント:
テスト関数内にテストケース名(振る舞い)が明記されておらず、コードを読まないと何の仕様のテストか分かりません。
さらに、AssertCalled を使って「GetDailyCountが呼ばれたこと」という内部実装まで検証してしまっています。これではリファクタリング耐性が低く、脆いテストになってしまいます。
✅ After(振る舞いを意識したAAAパターン)
// OK: 振る舞いを記述し、結果(出力)だけを検証する
func TestWatchListUsecase_RegisterVideo(t *testing.T) {
// ⭕️ t.Runを使って、検証する振る舞い(仕様)を日本語で明確に記述する
t.Run("ウォッチリストの登録上限に達しているユーザーが動画を登録しようとした場合、登録上限エラーとなる", func(t *testing.T) {
// Arrange (準備)
mockRepo := new(MockWatchListRepository)
// 「上限に達している状態」をMockで表現する
mockRepo.On("GetDailyCount", "user_1").Return(10, nil)
usecase := ProvideWatchListUsecase(mockRepo)
// Act (実行)
err := usecase.RegisterVideo("user_1", "video_1")
// Assert (検証)
// ⭕️ 内部で何が呼ばれたか(AssertExpectationsなど)は検証せず、
// 「エラーが返却される」という振る舞いの結果だけを確認する
assert.Error(t, err)
assert.Equal(t, domain.ErrDailyLimitExceeded, err)
})
}
改善ポイント:
Beforeと同じテスト対象(関数名)でありながら、t.Runを使ってテストケース名を明示することで「生きた仕様書」になりました。
また、「上限に達している」という前提条件(Arrange)に対する結果(Assert)だけを見ているため、内部コードを変えても壊れません
サンプル2:Interface層(Handlerの翻訳機としての振る舞い)
お題:「Usecaseからバリデーションエラーが返ってきたら、HTTP 400を返す」
❌ Before
Handlerのテストなのに、ビジネスロジックの結果まで検証しようとしているアンチパターンです。
// NG: Handlerの責務を超えて、ドメインの結果まで見ている
func TestVideoHandler_RegisterVieo(t *testing.T) {
// ❌ 「idが空文字の時」「『動画IDは必須です』が返る」という、
// Usecase層が担うべきバリデーションルールや具体的なエラー文言(実装詳細)をテスト名にしてしまっている
t.Run("リクエストのidが空文字の場合、ステータス400と「動画IDは必須です」というメッセージが返る", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/videos", strings.NewReader(`{"id": ""}`))
rec := httptest.NewRecorder()
// 実際のUsecaseを呼んでしまっている(結合テストになっている)
handler.RegisterVideo(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
// エラーメッセージの文言までHandlerテストで担保しようとしている
assert.Contains(t, rec.Body.String(), "動画IDは必須です")
})
}
✅ After
UsecaseをMock化し、「このドメインエラーが来たら、このHTTPステータスになる」というマッピング(翻訳)だけを検証します。
// OK: UsecaseをMockし、HTTPステータスの変換(振る舞い)だけを検証する
func TestVideoHandler_RegisterVideo(t *testing.T) {
// ⭕️ 「空文字かどうか」などの実装詳細は排除し、
// Handlerの境界線である「Usecaseからのエラー」に対する「振る舞い」を記述する
t.Run("Usecaseから入力値エラーが返却された場合、HTTPステータス400が返却される", func(t *testing.T) {
// Arrange (準備)
mockUsecase := new(MockWatchListUsecase)
// Usecaseがドメインエラーを返す状態をセットアップ
mockUsecase.On("RegisterVideo", mock.Anything).Return(domain.ErrInvalidInput)
handler := NewVideoHandler(mockUsecase)
req := httptest.NewRequest(http.MethodPost, "/videos", strings.NewReader(`{"id": "invalid"}`))
rec := httptest.NewRecorder()
// Act (実行)
handler.RegisterVideo(rec, req)
// Assert (検証)
// Handlerの責務である「HTTPステータスコードへの変換」が正しいか確認
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
改善ポイント:
ドメインロジックの詳細はUsecaseテストに任せ、Handler層は「エラーを400(Bad Request)に変換できたか」という自身の責務(振る舞い)のみに集中しています。
サンプル3:Infrastructure層(例外:技術的な実装「HOW」の検証)
お題:「データベースの watch_list テーブルに動画を保存する」
ここまでの層とは異なり、インフラ層の責務は「特定の技術(DB)とどう対話するか」です。そのため、「どんなSQLが発行されるか」という実装詳細(HOW)そのものが仕様になります。
❌ Before(振る舞いに寄せすぎて、技術仕様になっていないコード)
Usecase層のように「振る舞い」だけをテストしようとして、実装の検証が甘くなっている(または実際のDBに繋いでしまっている)アンチパターンです。
// NG: どんなSQLが発行されたか(HOW)の検証が甘い
func TestWatchListRepository_Save(t *testing.T) {
// ❌ Infra層なのに「何をするか」だけ書いてあり、「どう実装されているか(SQL)」が分からない
t.Run("正常に動画を保存できること", func(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
repo := NewWatchListRepository(db)
// 正規表現の「.*」(任意の文字列)を使って、何らかのクエリが実行されたことだけをMockしている
mock.ExpectExec(".*").WillReturnResult(sqlmock.NewResult(1, 1))
err := repo.Save("user_1", "video_1")
// エラーが出ないこと(振る舞い)だけを見ており、どんなSQLが叩かれたか分からない
assert.NoError(t, err)
})
}
指摘ポイント:
インフラ層なのに「エラーが起きないこと」というざっくりとした振る舞いしか見ていません。これでは、誰かが間違えて DELETE 文に書き換えてしまってもテストが通る可能性があり、「技術的な仕様書」としての役割を果たせません。
✅ After(あえて実装・クエリ仕様に寄せたテスト)
sqlmock を使い、どのテーブルに、どんなクエリと引数を渡しているかという実装詳細(HOW)を厳密に検証します。
// OK: 発行されるSQL(実装)と引数を厳密に定義し、技術仕様書にする
func TestWatchListRepository_Save(t *testing.T) {
// ⭕️ Infra層の責務である「どんなSQLが発行されるか(実装詳細)」を明確にテスト名にする
t.Run("動画エンティティを渡した場合、watch_listテーブルに対して指定したパラメータでINSERT文が発行される", func(t *testing.T) {
// Arrange
db, mock, _ := sqlmock.New()
defer db.Close()
repo := NewWatchListRepository(db)
// 期待するSQL(実装詳細)を明確に定義する
expectedSQL := "^INSERT INTO watch_list \\(user_id, video_id\\) VALUES \\(\\?, \\?\\)$"
// 引数(user_1, video_1)が正しい位置にマッピングされるかも検証(HOWの検証)
mock.ExpectExec(expectedSQL).
WithArgs("user_1", "video_1").
WillReturnResult(sqlmock.NewResult(1, 1))
// Act
err := repo.Save("user_1", "video_1")
// Assert
assert.NoError(t, err)
// 定義した通りのSQLと引数でMockが呼ばれたかを検証
assert.NoError(t, mock.ExpectationsWereMet())
})
}
改善ポイント:
テスト名にも「watch_list テーブル」「INSERT文」という実装の言葉が堂々と入っています。これにより、このテストコードを読むだけで
「このメソッドは、こういうSQLを発行してDBに書き込むんだな」
という明確な技術仕様書として機能します。
さいごに
PRのレビューで「この層は振る舞いを意識したテストにすべき」と指摘され、見よう見まねでテスト方針を改善してきた私ですが、各層の責務(WHATとHOW)を意識してテストを書くようになった結果、自身の開発に対する理解が非常に深くなったと思っています。
最後に、この「振る舞いと適切な実装をテストする設計」を徹底したことで得られた、2つの大きな恩恵と結びの言葉で本記事を締めくくります。
実装前に「仕様の抜け漏れ」に気づける
テストを「実装の確認作業」ではなく「振る舞いの定義」として捉えるようになると、プロダクトコードを書く前にエッジケースに気づけるようになります。
「AAAパターン」に則って「前提条件(Arrange)」を並べていると、
あれ? そもそも空のリストが渡された時って、エラーを返すんだっけ? それとも空のまま正常終了するんだっけ?
といった仕様の曖昧さに自然と気づくことができます。
実装した後にバグとして見つかるのではなく、実装前に仕様のバグを潰せるようになったのは、品質を担保する上で非常に大きな成果です。
AI時代における「生きた仕様書」の価値
そして現代の開発において最大のメリットだと感じているのが、AIエディタとの相性の良さです。
AIに「GetVideoCountが100を返し、Createメソッドが呼ばれないこと」という内部実装に依存したテスト名を読ませるよりも、「ウォッチリストの登録上限に達しているユーザーが動画を登録しようとした場合、登録上限エラーとなる」というシステムの振る舞いを読ませた方が、ドメイン知識の理解度が圧倒的に高まります。
人間にもAIにも優しい「生きた仕様書」を作ることは、今後の開発において必須のスキルになっていくと確信しています。
結びに:テストは最高の投資である
正直なところ、各層の責務を理解し、Mockを準備してAAAパターンでテストを書くのは、最初はとても時間がかかります。「とりあえず動くコード」を書く方がずっと楽に感じる時期もありました。
しかし、一度この設計方針に慣れてしまえば、リファクタリングでテストが壊れるストレスから解放されます。そして何より、コードをプッシュしてCIのグリーンマーク(✅)を見た時の「機能が絶対に壊れていない」という圧倒的な安心感は、何物にも代えがたいものです。
最初は時間がかかっても、バグの減少や新機能の追加しやすさといった将来的な労力を減らせる「長期的開発速度の向上」を考えれば、質の高いテストコードを書くことはプロジェクトへの最高の投資です。
もし「単体テストで何をどこまで書けばいいか分からない」と悩んでいる方がいれば、本記事で紹介したテスト方針(各層の責務に合わせたWHATとHOWの使い分け)をぜひ参考にしてみてください!
ここまでお読みいただき、ありがとうございました!🙌
Schooでは一緒に働く仲間を募集しています!

