皆さんは「テスト」と聞くと、どんなイメージを持ちますか?「面倒くさい」「時間がかかる」という印象をお持ちかもしれません。しかし、テストはアプリケーション開発において品質を保証する最も基本的で重要なプロセスです。特に Go 言語では、テストが言語設計の中に組み込まれており、標準ライブラリだけでテストを始められる環境が整っています。
本記事ではGoのtestingパッケージを使用してテスト初めて書く方向けの説明やさまざまなテストの書き方まで広く説明します!
他のチートシート
Go/Gorm(Goそのものはこの記事をみてください)
git/gh コマンド(gitコマンド以外にもgitの概念も書いてあります)
lazygit
Docker コマンド(dockerコマンド以外にもdockerの概念の記事へのリンクもあります)
ステータスコード
TypeScript
C#/.NET/Unity
Ruby・Ruby on Rails
SQL
Vim
プルリクエスト・マークダウン記法チートシート
ファイル操作コマンドチートシート
VSCode Github Copilot拡張機能
OpenAI Assistants API
GitHub API
変数・関数(メソッド)・クラス命名規則
他のシリーズ記事
チートシート
様々な言語,フレームワーク,ライブラリなど開発技術の使用方法,基本事項,応用事例を網羅し,手引書として記載したシリーズ
git/gh,lazygit,docker,vim,typescript,プルリクエスト/マークダウン,ステータスコード,ファイル操作,OpenAI AssistantsAPI,Ruby/Ruby on Rails のチートシートがあります.以下の記事に遷移した後,各種チートシートのリンクがあります.
TypeScriptで学ぶプログラミングの世界
プログラミング言語を根本的に理解するシリーズ
情報処理技術者試験合格への道 [IP・SG・FE・AP]
情報処理技術者試験に出題されるコンピュータサイエンス用語の紹介や単語集
IAM AWS User クラウドサービスをフル活用しよう!
AWSのサービスを例にしてバックエンドとインフラ開発の手法を説明するシリーズです.
AWS UserのGCP浮気日記
GCPの様子をAWSと比較して考えてみるシリーズ
Project Gopher: Unlocking Go’s Secrets
Go言語や標準ライブラリの深掘り調査レポート
テストが必要な理由と実際のユースケース
テストを書く理由を、実際の開発シナリオで見てみましょう:
-
回帰バグの防止:
- シナリオ:ある機能の修正が別の機能を壊してしまった
- テスト対策:既存機能の自動テストを書いておけば、変更の影響範囲を即座に検知できる
-
リファクタリングの安全性確保:
- シナリオ:パフォーマンス向上のためにアルゴリズムを変更したい
- テスト対策:同じ入出力関係を保証するテストがあれば、内部実装を安心して変更できる
-
チーム開発のサポート:
- シナリオ:新しいメンバーが機能を追加する際に既存の挙動を破壊してしまう
- テスト対策:テストが「契約」として機能し、システムの期待される振る舞いを明確に示す
-
設計改善の促進:
- シナリオ:モジュールの結合度が高く、単体でのテストが困難
- テスト対策:テスト容易性を向上させることで、より良い設計(依存性の低減、責務の分離)に導かれる
-
実際のユースケースの検証:
- シナリオ:要件通りに実装したつもりが、あるエッジケースで動作しない
- テスト対策:様々なケースを網羅したテストで、実際のユースケースの検証が可能になる
Go言語におけるテストの特徴
Go 言語のテストは次のような特徴を持っています:
-
シンプルさ:Go のテストフレームワークは標準パッケージ
testing
として提供されており、追加ライブラリなしですぐに始められます -
規約駆動:ファイル名が
_test.go
で終わるソースコードがテストファイルとして認識されます -
統一されたコマンド:
go test
コマンド一つでテストを実行できます - 表駆動テスト:複数のテストケースを一度に実行できる「表駆動テスト」のアプローチが推奨されています
テストの種類
Go でよく行われる主要なテストの種類は以下の通りです:
1. ユニットテスト(単体テスト)
ユニットテストでは、関数やメソッドなど、小さな「単位」が正しく機能するかを検証します。
// calculator.go
package calculator
func Add(a, b int) int {
return a + b
}
func Multiply(a, b int) int {
return a * b
}
// calculator_test.go
package calculator
import "testing"
func TestAdd(t *testing.T) {
// 基本的な加算をテスト
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; want 5", result)
}
// 負の数の加算もテスト
result = Add(-1, 5)
if result != 4 {
t.Errorf("Add(-1, 5) = %d; want 4", result)
}
}
// 表駆動テストのアプローチ
func TestMultiply(t *testing.T) {
// テストケースをテーブル形式で定義
testCases := []struct {
a, b int
expected int
name string
}{
{2, 3, 6, "positive numbers"},
{0, 5, 0, "zero multiplier"},
{-2, 3, -6, "one negative number"},
{-2, -3, 6, "both negative numbers"},
}
for _, tc := range testCases {
// サブテストとして実行
t.Run(tc.name, func(t *testing.T) {
result := Multiply(tc.a, tc.b)
if result != tc.expected {
t.Errorf("Multiply(%d, %d) = %d; want %d",
tc.a, tc.b, result, tc.expected)
}
})
}
}
ユニットテストの適用シーン:
- 複雑なビジネスロジック(料金計算、ポイント計算など)
- エラー処理ロジック
- 境界値や特殊ケースの処理
- 正規表現や文字列操作
2. 統合テスト(Integration Test)
複数のコンポーネントが連携して正しく動作するかを検証します。例えば、データベースとの連携や API 呼び出しなど。
// user_repository.go
package repository
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) FindByID(id int) (*User, error) {
// DBから検索するロジック
}
func (r *UserRepository) Save(user *User) error {
// DBに保存するロジック
}
// user_repository_test.go
package repository_test
import (
"database/sql"
"testing"
_ "github.com/mattn/go-sqlite3"
"myapp/repository"
)
func setupTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite3", ":memory:") // インメモリDBを使用
if err != nil {
t.Fatalf("DB接続エラー: %v", err)
}
// テスト用のテーブル作成
_, err = db.Exec(`CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT UNIQUE
)`)
if err != nil {
t.Fatalf("テーブル作成エラー: %v", err)
}
return db
}
func TestUserRepository_SaveAndFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := repository.NewUserRepository(db)
// ユーザーを保存
user := &repository.User{
Name: "テストユーザー",
Email: "test@example.com",
}
err := repo.Save(user)
if err != nil {
t.Fatalf("ユーザー保存エラー: %v", err)
}
// IDが設定されていることを確認
if user.ID == 0 {
t.Error("保存後にユーザーIDが設定されていない")
}
// 保存したユーザーを検索
foundUser, err := repo.FindByID(user.ID)
if err != nil {
t.Fatalf("ユーザー検索エラー: %v", err)
}
// 検索結果が正しいか確認
if foundUser.ID != user.ID ||
foundUser.Name != user.Name ||
foundUser.Email != user.Email {
t.Errorf("検索したユーザーが一致しません。got=%+v, want=%+v", foundUser, user)
}
}
統合テストの適用シーン:
- リポジトリ層(データベースアクセス)のテスト
- 複数のサービス間の連携
- 外部APIとの連携
- キャッシュと永続化層の連携
3. エンドツーエンドテスト(E2E Test)
システム全体が実際のユーザーシナリオ通りに動作するかを検証します。例えば、Web アプリケーションでは、HTTPリクエストからの応答まで一連の流れをテストします。
// main.go (簡略化したHTTPサーバー)
package main
import (
"encoding/json"
"net/http"
)
type UserHandler struct {
userService UserService
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err := h.userService.Create(&user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
// main_test.go (E2Eテスト)
package main_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"myapp"
)
func TestCreateUserE2E(t *testing.T) {
// アプリケーションを起動
app := myapp.SetupApp()
server := httptest.NewServer(app.Router)
defer server.Close()
// テスト対象のAPIエンドポイント
url := server.URL + "/api/users"
// リクエストデータ
userData := map[string]string{
"name": "山田太郎",
"email": "yamada@example.com",
}
payload, _ := json.Marshal(userData)
// HTTPリクエストを送信
resp, err := http.Post(url, "application/json", bytes.NewBuffer(payload))
if err != nil {
t.Fatalf("リクエスト送信エラー: %v", err)
}
defer resp.Body.Close()
// ステータスコードを検証
if resp.StatusCode != http.StatusCreated {
t.Errorf("期待したステータスコード %d, 実際は %d",
http.StatusCreated, resp.StatusCode)
}
// レスポンスボディを検証
var createdUser map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&createdUser); err != nil {
t.Fatalf("レスポンスデコードエラー: %v", err)
}
// IDが設定されていることを確認
if _, exists := createdUser["id"]; !exists {
t.Error("作成されたユーザーにIDがない")
}
// 名前とメールアドレスが正しいことを確認
if createdUser["name"] != userData["name"] ||
createdUser["email"] != userData["email"] {
t.Errorf("ユーザー情報が一致しません。got=%v, want=%v",
createdUser, userData)
}
}
E2Eテストの適用シーン:
- APIエンドポイントの検証
- ユーザーシナリオの検証
- データの流れ全体の検証
- デプロイ後の検証(スモークテスト)
4. 性能テスト(Benchmark Test)
Goは標準でベンチマーク機能を提供しており、コードのパフォーマンスを測定できます。
// sort_test.go
package sort
import (
"math/rand"
"testing"
"time"
)
// ベンチマーク用の配列を生成
func generateRandomSlice(n int) []int {
rand.Seed(time.Now().UnixNano())
slice := make([]int, n)
for i := 0; i < n; i++ {
slice[i] = rand.Intn(1000)
}
return slice
}
// BenchmarkQuickSort はクイックソートのパフォーマンスを測定
func BenchmarkQuickSort(b *testing.B) {
// ベンチマークの準備
slice := generateRandomSlice(1000)
// タイマーをリセットして計測開始
b.ResetTimer()
// b.N 回ループしてパフォーマンスを測定
for i := 0; i < b.N; i++ {
// テスト対象のコード
quickSort(append([]int{}, slice...)) // コピーを渡してソート
}
}
// BenchmarkBubbleSort はバブルソートのパフォーマンスを測定
func BenchmarkBubbleSort(b *testing.B) {
slice := generateRandomSlice(1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
bubbleSort(append([]int{}, slice...))
}
}
実行方法:
# ベンチマークテストを実行
go test -bench=.
性能テストの適用シーン:
- アルゴリズムの比較
- 性能クリティカルな処理の最適化
- メモリ消費量の測定(
-benchmem
フラグ) - コード変更前後のパフォーマンス比較
テストを書くメリット
テストコードを書くことには多くのメリットがあります:
- バグの早期発見:小さな変更でも既存の機能を壊していないか自動的に検証できます
- 安全なリファクタリング:内部実装を変更しても、テストが通れば動作は保証されます
- ドキュメントとしての役割:テストコードは、関数やメソッドの使い方を示す生きたドキュメントになります
- 設計の改善:テスト可能なコードを書くために、コンポーネントの分離や責務の明確化が促進されます
- デバッグの効率化:バグが発生した際に、テストケースを追加することでデバッグと再現が容易になります
- チームの信頼向上:テストが充実しているコードベースは、チームメンバーに安心感を与えます
- 技術的負債の軽減:テストがあることで、将来のコード変更が容易になります
テストが特に役立つケース
-
境界条件の検証:
- 最小値、最大値、ゼロ、空文字など、極端な入力時の挙動
- バッファオーバーフローなどの潜在的な問題防止
-
エラー処理の検証:
- 例外ケースの正しい処理
- ユーザーフレンドリーなエラーメッセージ
-
複雑なロジックの検証:
- 条件分岐が多数あるビジネスロジック
- 金融計算、税金計算などの正確性が必要な処理
-
並行処理の検証:
- データ競合の検出
- デッドロックの防止
-
セキュリティ脆弱性の防止:
- 入力検証
- 認証・認可の正確な実装
Go言語のテスト基本構文
Go 言語での最も基本的なテストの書き方を見てみましょう:
package example
import "testing" // 標準のテストパッケージをインポート
// TestXxx という名前の関数がテスト関数として認識される
// 引数は *testing.T 型の値である必要がある
func TestExample(t *testing.T) {
got := 2 + 2
want := 4
// 期待値と実際の値が異なる場合はエラーを報告
if got != want {
t.Errorf("計算結果が間違っています: got %v, want %v", got, want)
}
}
テストファイルの配置
Go では、テスト対象のソースコードと同じディレクトリに _test.go
で終わる名前のファイルを置きます。
例えば:
-
calculator.go
(実装コード) -
calculator_test.go
(テストコード)
テストの実行方法
テストを実行する基本的なコマンドは次の通りです:
# カレントディレクトリのテストを実行
go test
# 詳細な出力を表示
go test -v
# 特定のパッケージのテストを実行
go test github.com/yourname/yourproject/path/to/package
# 全てのパッケージのテストを実行
go test ./...
# 特定のテスト関数のみを実行
go test -run TestFunctionName
テストカバレッジの測定
コードがどの程度テストされているかを知るためにカバレッジを測定することもできます:
# カバレッジレポートを生成
go test -cover
# カバレッジ情報をファイルに保存
go test -coverprofile=coverage.out
# カバレッジをブラウザで視覚的に確認
go tool cover -html=coverage.out
さまざまなテストアプローチ
表駆動テスト
Go では複数のケースを効率的にテストするために「表駆動テスト」というアプローチがよく使われます。
func TestAdd(t *testing.T) {
// テストケースの表を定義
tests := []struct {
name string
a, b int
expected int
}{
{"正の数の足し算", 2, 3, 5},
{"負の数を含む足し算", -1, 5, 4},
{"ゼロを含む足し算", 0, 10, 10},
}
// 各テストケースを実行
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("%s: add(%d, %d) = %d; want %d",
tt.name, tt.a, tt.b, got, tt.expected)
}
})
}
}
この書き方には次のメリットがあります:
- 複数のテストケースをコンパクトに記述できる
- 新しいテストケースを簡単に追加できる
- テストケースに名前がつけられるので何をテストしているか明確
サブテスト
関連するテストをグループ化するために、t.Run()
を使ってサブテストを実行できます:
func TestUser(t *testing.T) {
t.Run("有効なユーザー名", func(t *testing.T) {
valid := isValidUsername("gopher")
if !valid {
t.Error("有効なユーザー名が無効と判定されました")
}
})
t.Run("無効なユーザー名", func(t *testing.T) {
valid := isValidUsername("")
if valid {
t.Error("無効なユーザー名が有効と判定されました")
}
})
}
テストヘルパー関数
テストコードの重複を減らすためにテストヘルパー関数を作成し、t.Helper()
でマークしておくと、エラー時に実際の問題発生場所が報告されます:
func assertNoError(t *testing.T, err error) {
// これはヘルパー関数であることを明示
t.Helper()
if err != nil {
t.Fatalf("予期しないエラーが発生しました: %v", err)
}
}
func TestSomething(t *testing.T) {
result, err := doSomething()
assertNoError(t, err)
// 以下、結果のテスト...
}
gomockチートシート
の機能が正しく動作するかを検証する **ユニットテスト(単体テスト)**は、テストの基本となりますが記述する際、多くの開発者がこんな課題に直面しますよね?
- 「ユーザー登録機能をテストしたいが、実行のたびに本物のデータベースにテストデータが書き込まれるのは避けたい.」
- 「外部のAPIを利用する機能をテストしたいが、APIが停止していると、こちらのコードが正しくてもテストが失敗してしまう.」
- 「データベース接続断のようなエラー発生時の動作をテストしたいが、その状況を意図的に作り出すのが難しい.」
これらの課題は、テスト対象のコードが「自分以外の何か(外部依存)」、例えばデータベース, 外部API, ファイルシステムなどに頼っているために発生します!
この問題を解決する便利な手法が、モック (Mock) を利用したテストです!
モックとは、テスト対象のオブジェクトが依存する別のオブジェクトの振る舞いを模倣し、テスト用に制御できるようにした代替オブジェクトのこと。
モックを利用することで、外部依存を完全に切り離すことが可能になる. 例えば、データベースへアクセスする部分をモックに置き換えることで、以下のような状況を自由に作り出せます!
- データベースから特定のユーザーデータが返される状況をシミュレートする
- データベースへの接続が失敗したというエラーを意図的に発生させる
これにより、外部環境の状態に影響されることなく、テスト対象のロジックが期待通りに動作するかを独立して、かつ確実に検証できる.
ここからは、Go言語のモックライブラリとして広く利用されているuber-go/mock
(通称gomock
)について、詳しく説明します
第1章: gomock
を支える設計思想
gomock
を効果的に活用するためには、コードの「設計」が非常に重要になる. 特に、Go言語のインターフェース (Interface) を活用し、依存関係逆転の原則 (DIP: Dependency Inversion Principle) に従った設計は、モックテストを簡単に導入できます.
1.1. テスト容易性の高いアーキテクチャ
テスト容易性を高める鍵は、具象(具体的な実装)ではなく、抽象(インターフェース)に依存することである.
例えば、ビジネスロジックを担うUsecase
層が、データベースの具体的な実装を持つInfrastructure
層の構造体に直接依存していると、テスト時にこのInfrastructure
をモックに差し替えることが困難になる.
そこで、Usecase
とInfrastructure
の間に、両者が共有するインターフェースを定義する.
-
Repositoryインターフェース: データストアへの操作(例:
FindByID
)をメソッドとして定義する. これが「抽象」である. -
Usecase: この
Repository
インターフェースにのみ依存する.Usecase
は、その向こう側にデータベースがあるのか、あるいはテスト用のモックがあるのかを知る必要はない. -
Infrastructure:
Repository
インターフェースを実装(implement)する. 本番環境では、この具体的な実装がUsecase
に注入される.
この設計により、テスト時にはInfrastructure
の代わりに、同じRepository
インターフェースを実装したモックオブジェクトをUsecase
に注入できるようになる.
この関係を図にすると以下のようになる.
[ Usecase (ビジネスロジック) ]
│
└───> 依存: [ Repository Interface (抽象) ]
▲
┌─────────────────────┴─────────────────────┐
[ Infrastructure (具象) ] [ Mock Object (具象) ]
(本番用コード) (テスト用コード)
このように、インターフェースを介して依存関係を疎結合に保つことが、gomock
を活用する上での基本となる.
第2章: gomockの実践
ここでは、「ユーザーIDを指定して、ユーザー情報を取得する」というシンプルな機能を例に、gomock
を使ったテストの具体的な流れを解説する.
2.1: ドメインとインターフェースの定義
まず、アプリケーションの核となるデータ構造(ドメイン)と、データ操作の抽象(インターフェース)を定義する.
domain/entity/user.go
package entity
// User はユーザーのドメインモデル
type User struct {
ID int
Name string
}
domain/repository/user_repository.go
package repository
import entity "gomock-example/domain/entity"
// UserRepository はユーザーデータへのアクセスを抽象化するインターフェース
type UserRepository interface {
FindByID(id int) (*entity.User, error)
}
2.2: ユースケースの実装
次に、定義したインターフェースを利用してビジネスロジックを実装する.
usecase/user_interactor.go
package usecase
import (
entity "gomock-example/domain/entity"
"gomock-example/domain/repository"
)
// UserUsecase はユーザーに関するビジネスロジックを定義するインターフェース
type UserUsecase interface {
Get(id int) (*entity.User, error)
}
// userInteractor は UserUsecase の実装
type userInteractor struct {
userRepo repository.UserRepository
}
// NewUserInteractor は新しい userInteractor を生成
func NewUserInteractor(userRepo repository.UserRepository) UserUsecase {
return &userInteractor{userRepo: userRepo}
}
// Get は指定されたIDのユーザー情報を取得
func (ui *userInteractor) Get(id int) (*entity.User, error) {
user, err := ui.userRepo.FindByID(id)
if err != nil {
return nil, err
}
return user, nil
}
このuserInteractor
は、具象的なデータベース実装を知らず、UserRepository
インターフェースにのみ依存している点に注目するべきである.
2.3: mockgen
によるモック生成
gomock
が提供するmockgen
ツールを使い、UserRepository
インターフェースからモックのコードを自動生成する.
まず、mockgen
をインストールする.
go install go.uber.org/mock/mockgen@latest
次に、プロジェクトのルートディレクトリで以下のコマンドを実行する.
mockgen -source=domain/repository/user_repository.go -destination=usecase/mock_repository/mock_user_repository.go -package=mock_repository
このコマンドは、-source
で指定されたファイル内のインターフェースを元に、-destination
で指定されたパスにモックファイルを生成する.
自動生成したコードは以下のようである
usecase/mock_repository/mock_user_repository.go
// Code generated by MockGen. DO NOT EDIT.
// Source: domain/repository/user_repository.go
//
// Generated by this command:
//
// mockgen -source=domain/repository/user_repository.go -destination=usecase/mock_repository/mock_user_repository.go
//
// Package mock_repository is a generated GoMock package.
package mock_repository
import (
entity "gomock-example/domain/entity"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockUserRepository is a mock of UserRepository interface.
type MockUserRepository struct {
ctrl *gomock.Controller
recorder *MockUserRepositoryMockRecorder
isgomock struct{}
}
// MockUserRepositoryMockRecorder is the mock recorder for MockUserRepository.
type MockUserRepositoryMockRecorder struct {
mock *MockUserRepository
}
// NewMockUserRepository creates a new mock instance.
func NewMockUserRepository(ctrl *gomock.Controller) *MockUserRepository {
mock := &MockUserRepository{ctrl: ctrl}
mock.recorder = &MockUserRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockUserRepository) EXPECT() *MockUserRepositoryMockRecorder {
return m.recorder
}
// FindByID mocks base method.
func (m *MockUserRepository) FindByID(id int) (*entity.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindByID", id)
ret0, _ := ret[0].(*entity.User)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindByID indicates an expected call of FindByID.
func (mr *MockUserRepositoryMockRecorder) FindByID(id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MockUserRepository)(nil).FindByID), id)
}
2.4: テストコードの実装
生成されたモックを使い、userInteractor
のテストコードを記述する.
usecase/user_interactor_test.go
package usecase_test
import (
"errors"
"testing"
entity "gomock-example/domain/entity"
"gomock-example/usecase"
mock_repository "gomock-example/usecase/mock_repository"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
func TestUserInteractor_Get(t *testing.T) {
// ケース1: 正常系(ユーザーが見つかる場合)
t.Run("should get a user when a user exists", func(t *testing.T) {
// 1. コントローラーを初期化する. モックのライフサイクルを管理する.
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// 2. モックオブジェクトを生成する.
mockRepo := mock_repository.NewMockUserRepository(ctrl)
expectedUser := &entity.User{ID: 1, Name: "Taro"}
// 3. モックの振る舞いを定義する.
// FindByIDメソッドが引数1で呼ばれたら、expectedUserとnilを返す.
mockRepo.EXPECT().FindByID(1).Return(expectedUser, nil)
// 4. テスト対象のUsecaseを、モックを注入して生成する.
userUsecase := usecase.NewUserInteractor(mockRepo)
// 5. テスト対象メソッドを実行する.
user, err := userUsecase.Get(1)
// 6. 結果を検証する.
assert.NoError(t, err)
assert.Equal(t, expectedUser, user)
})
// ケース2: 異常系(ユーザーが見つからない場合)
t.Run("should return an error when a user does not exist", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mock_repository.NewMockUserRepository(ctrl)
// FindByIDメソッドが引数99で呼ばれたら、nilとエラーを返すよう定義する.
notFoundErr := errors.New("user not found")
mockRepo.EXPECT().FindByID(99).Return(nil, notFoundErr)
userUsecase := usecase.NewUserInteractor(mockRepo)
user, err := userUsecase.Get(99)
// エラーが返り、ユーザーはnilであることを検証する.
assert.Error(t, err)
assert.Equal(t, notFoundErr, err)
assert.Nil(t, user)
})
}
補足: なぜID=1では成功し、ID=99では失敗するのか?
上記のテストコードを見て「なぜuserUsecase.Get(1)
は成功するのに、userUsecase.Get(99)
は失敗するのか?」と疑問に思うかもしれません. これはモックの振る舞い定義によるものです.
モックの仕組み:
-
mockRepo.EXPECT().FindByID(1).Return(expectedUser, nil)
→ 「引数が1
でこのメソッドが呼ばれた場合は、expectedUser
とnil
を返す」という振る舞いを事前に定義 -
mockRepo.EXPECT().FindByID(99).Return(nil, notFoundErr)
→ 「引数が99
でこのメソッドが呼ばれた場合は、nil
とエラーを返す」という振る舞いを事前に定義
実際の処理の流れ:
**ケース1: userUsecase.Get(1) を実行**
↓
userInteractor内部で mockRepo.FindByID(1) が呼ばれる
↓
モックは事前定義に従い expectedUser, nil を返す
↓
userInteractor は正常なレスポンスを返す
**ケース2: userUsecase.Get(99) を実行**
↓
userInteractor内部で mockRepo.FindByID(99) が呼ばれる
↓
モックは事前定義に従い nil, エラー を返す
↓
userInteractor はエラーを返す
つまり、ID=1やID=99という値自体に特別な意味はありません. 重要なのは:
- テスト作成者が、モックに対してどのような振る舞いを事前に定義したか
- 実際のメソッド呼び出し時の引数が、定義した期待値と一致するか
別の例:
// こう定義すれば、ID=999でも成功させることができる
mockRepo.EXPECT().FindByID(999).Return(&entity.User{ID: 999, Name: "Alice"}, nil)
// こう定義すれば、ID=1でも失敗させることができる
mockRepo.EXPECT().FindByID(1).Return(nil, errors.New("database connection failed"))
このように、gomock
では**「特定の入力に対する出力を完全に制御できる」**ことが最大の利点です. 実際のデータベースの状態に関係なく、テストシナリオに応じて自由に成功/失敗パターンを作り出せるのです.
このように、EXPECT().Return()
を使いモックの振る舞いを定義することで、依存先のコンポーネントの状態を自由にコントロールし、テスト対象のロジックのみを純粋に検証できる.
第3章: mockgen
コマンド詳解
mockgen
は、インターフェース定義からモックの実装コードを自動生成するコマンドである.
3.1. source
モード (推奨)
Goのソースファイルを指定し、そのファイル内に定義されたインターフェースのモックを生成する、最も一般的に使用されるモードである.
mockgen -source=[ソースファイルパス] -destination=[出力ファイルパス] [オプション]
3.2. reflect
モード
Goのビルドシステムが利用するリフレクション情報を元にモックを生成するモードである. 標準ライブラリのインターフェースなど、ソースファイルが手元にない場合や、プライベートなインターフェースのモックを生成したい場合に利用する.
mockgen [インポートパス] [インターフェース名,...] -destination=[出力ファイルパス] [オプション]
3.3. よく使うオプション
オプション | 説明 |
---|---|
-source |
(source モード必須) モックの元となるインターフェースが定義されたGoソースファイル. |
-destination |
(必須) 生成されたモックコードの出力先ファイルパス. - を指定すると標準出力に書き出される. |
-package |
生成されるモックファイルのパッケージ名を指定する. 指定しない場合、出力先のディレクトリ名に_mock が付与された名前が自動的に使用される. |
-imports |
(reflect モード用) モック生成に必要なパッケージのインポートパスと名前をfoo=example.com/foo 形式で指定する. |
-build_flags |
ビルドタグなど、Goのビルドフラグを指定する. 例: -build_flags="-tags=integration" . |
-mock_names |
生成するモックの構造体名をInterfaceName=MockName のようにカスタマイズする. |
第4章: gomock
テクニック:基本マッチャー
gomock
では、メソッドに渡される引数を検証するためのマッチャー (Matchers) が豊富に用意されている.
4.1. gomock.Controller
モックオブジェクトのライフサイクル管理、期待するメソッド呼び出しの登録、そしてテスト終了時の検証を行う. 各テストの最初に生成し、defer
文を使ってFinish()
メソッドを呼び出すのが一般的な使い方である. Finish()
は、期待したメソッド呼び出しがすべて行われたか、逆に期待しない呼び出しがなかったかを検証する.
4.2. 引数マッチャー詳解
マッチャー | 説明 |
---|---|
gomock.Eq(x) / 値を直接渡す |
引数がx と等価(reflect.DeepEqual で比較)であることを検証します。値を直接渡した場合、内部でgomock.Eq が使用されます。 |
gomock.Any() |
型が一致していれば、引数の値に関わらずマッチします。最も頻繁に使用されるマッチャーの一つです。 |
gomock.Nil() |
引数がnil であることを検証します。 |
gomock.Not(m) |
他のマッチャーm の結果を否定します。gomock.Not(gomock.Nil()) は「nilでないこと」を検証します。 |
gomock.Len(i) |
引数(スライス、マップ、文字列、チャネル)の長さがi であることを検証します。 |
カスタムマッチャー |
gomock.Matcher インターフェースを実装することで、独自の検証ロジックを定義できます。 |
4.3. マッチャー使用例
4.3.1. 基本的なマッチャーパターン
以下のインターフェースを例に、各マッチャーの使用方法を解説します。
domain/repository/product_repository.go
package repository
import entity "gomock-example/domain/entity"
type ProductRepository interface {
Save(product *entity.Product) error
FindByIDs(ids []int) ([]*entity.Product, error)
UpdateStock(productID int, quantity int) error
SearchByTags(tags []string, limit int) ([]*entity.Product, error)
Delete(productID *int) error
}
基本マッチャーのテスト例
package usecase_test
import (
"testing"
entity "gomock-example/domain/entity"
mock_repository "gomock-example/usecase/mock_repository"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
func TestProductRepository_Matchers(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mock_repository.NewMockProductRepository(ctrl)
t.Run("exact value matching", func(t *testing.T) {
product := &entity.Product{ID: 1, Name: "Laptop"}
// 値を直接指定(内部で gomock.Eq が使用される)
mockRepo.EXPECT().Save(product).Return(nil)
// 明示的に gomock.Eq を使用(上記と同じ意味)
// mockRepo.EXPECT().Save(gomock.Eq(product)).Return(nil)
err := mockRepo.Save(product)
assert.NoError(t, err)
})
t.Run("any value matching", func(t *testing.T) {
// 引数の値に関係なく、型が合っていればマッチ
mockRepo.EXPECT().UpdateStock(gomock.Any(), gomock.Any()).Return(nil)
err := mockRepo.UpdateStock(999, 50)
assert.NoError(t, err)
})
t.Run("nil matching", func(t *testing.T) {
// nil値を検証
mockRepo.EXPECT().Delete(gomock.Nil()).Return(nil)
err := mockRepo.Delete(nil)
assert.NoError(t, err)
})
t.Run("not nil matching", func(t *testing.T) {
// nilでないことを検証
mockRepo.EXPECT().Delete(gomock.Not(gomock.Nil())).Return(nil)
productID := 123
err := mockRepo.Delete(&productID)
assert.NoError(t, err)
})
t.Run("length matching", func(t *testing.T) {
// スライスの長さを検証
mockRepo.EXPECT().FindByIDs(gomock.Len(3)).Return([]*entity.Product{}, nil)
ids := []int{1, 2, 3}
products, err := mockRepo.FindByIDs(ids)
assert.NoError(t, err)
assert.Empty(t, products)
})
t.Run("combined matchers", func(t *testing.T) {
// 複数のマッチャーを組み合わせ
mockRepo.EXPECT().SearchByTags(
gomock.Len(2), // tagsの長さは2
gomock.Not(gomock.Eq(0)), // limitは0以外
).Return([]*entity.Product{}, nil)
tags := []string{"electronics", "laptop"}
products, err := mockRepo.SearchByTags(tags, 10)
assert.NoError(t, err)
assert.Empty(t, products)
})
}
4.3.2. カスタムマッチャーの実装
独自の検証ロジックが必要な場合、gomock.Matcher
インターフェースを実装してカスタムマッチャーを作成できる.
カスタムマッチャーの例
package usecase_test
import (
"fmt"
"reflect"
"strings"
entity "gomock-example/domain/entity"
"go.uber.org/mock/gomock"
)
// ProductNameContainsMatcher は商品名に特定の文字列が含まれるかを検証
type ProductNameContainsMatcher struct {
substring string
}
func (m ProductNameContainsMatcher) Matches(x interface{}) bool {
product, ok := x.(*entity.Product)
if !ok {
return false
}
return strings.Contains(strings.ToLower(product.Name), strings.ToLower(m.substring))
}
func (m ProductNameContainsMatcher) String() string {
return fmt.Sprintf("product name contains %q", m.substring)
}
// ProductNameContains はカスタムマッチャーを生成するヘルパー関数
func ProductNameContains(substring string) gomock.Matcher {
return ProductNameContainsMatcher{substring: substring}
}
// PriceRangeMatcher は商品価格が指定範囲内かを検証
type PriceRangeMatcher struct {
min, max float64
}
func (m PriceRangeMatcher) Matches(x interface{}) bool {
product, ok := x.(*entity.Product)
if !ok {
return false
}
return product.Price >= m.min && product.Price <= m.max
}
func (m PriceRangeMatcher) String() string {
return fmt.Sprintf("price between %.2f and %.2f", m.min, m.max)
}
func PriceRange(min, max float64) gomock.Matcher {
return PriceRangeMatcher{min: min, max: max}
}
// カスタムマッチャーのテスト
func TestCustomMatchers(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mock_repository.NewMockProductRepository(ctrl)
t.Run("custom name matcher", func(t *testing.T) {
// 商品名に "laptop" が含まれる商品のみマッチ
mockRepo.EXPECT().Save(ProductNameContains("laptop")).Return(nil)
product := &entity.Product{ID: 1, Name: "Gaming Laptop Pro", Price: 1500.0}
err := mockRepo.Save(product)
assert.NoError(t, err)
})
t.Run("custom price range matcher", func(t *testing.T) {
// 価格が1000-2000の範囲内の商品のみマッチ
mockRepo.EXPECT().Save(PriceRange(1000.0, 2000.0)).Return(nil)
product := &entity.Product{ID: 1, Name: "Laptop", Price: 1500.0}
err := mockRepo.Save(product)
assert.NoError(t, err)
})
t.Run("combined custom matchers", func(t *testing.T) {
// 複数のカスタムマッチャーを組み合わせ
// この場合、名前に"laptop"が含まれ、価格が1000-2000の範囲内である必要がある
mockRepo.EXPECT().Save(
gomock.All(
ProductNameContains("laptop"),
PriceRange(1000.0, 2000.0),
),
).Return(nil)
product := &entity.Product{ID: 1, Name: "Gaming Laptop", Price: 1500.0}
err := mockRepo.Save(product)
assert.NoError(t, err)
})
}
4.3.3. 構造体フィールドのマッチング
構造体の特定のフィールドのみを検証したい場合のパターン.
func TestStructFieldMatching(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mock_repository.NewMockProductRepository(ctrl)
// IDは無視して、名前のみを検証
expectedProduct := &entity.Product{Name: "Laptop"}
mockRepo.EXPECT().Save(
gomock.AssignableToTypeOf(expectedProduct), // 型チェック
).Do(func(product *entity.Product) {
// Do内で詳細な検証を行う
assert.Equal(t, "Laptop", product.Name)
assert.True(t, product.Price > 0) // 価格は正の値であることを確認
}).Return(nil)
actualProduct := &entity.Product{ID: 123, Name: "Laptop", Price: 1500.0}
err := mockRepo.Save(actualProduct)
assert.NoError(t, err)
}
gomockを使ったテストの流れ
-
モック用のコントローラーを作成:
ctrl := gomock.NewController(t) defer ctrl.Finish()
-
モックオブジェクトの生成:
mockRepo := mock_repository.NewMockUserRepository(ctrl)
-
モックの振る舞いを定義:
mockRepo.EXPECT().FindByID(1).Return(&entity.User{ID: 1, Name: "Taro"}, nil)
-
テスト対象の生成:
userUsecase := usecase.NewUserInteractor(mockRepo)
-
テスト対象メソッドの実行:
user, err := userUsecase.Get(1)
-
結果の検証:
assert.NoError(t, err) assert.Equal(t, "Taro", user.Name)
テスト完全チートシート
この章では、テストに関する様々な概念や手法を簡潔にまとめたチートシートを提供します。実際のプロジェクトで参照できる実用的な情報を網羅しています。
テストの種類一覧
テストの種類 | 目的 | 特徴 | 例 (Go) |
---|---|---|---|
ユニットテスト | 最小の機能単位が正しく動作するか確認 | 高速、分離された環境、最も多く書く | func TestAdd(t *testing.T) { ... } |
統合テスト | 複数のコンポーネントの連携が正しいか確認 | 実際の連携動作を検証、外部システムとの接続を含む | func TestDatabaseOperations(t *testing.T) { ... } |
E2Eテスト | 全体のフローが期待通りに動作するか確認 | 実際のユーザーシナリオ通りに動作するか検証 | ブラウザ操作からDB更新までの一連の流れをテスト |
性能テスト | アプリケーションのパフォーマンス測定 | 処理速度、リソース使用量の検証 | func BenchmarkSort(b *testing.B) { ... } |
ロードテスト | 高負荷状態での安定性確認 | 多数のリクエストを同時に処理できるか | 大量のHTTPリクエスト送信テスト |
セキュリティテスト | セキュリティ脆弱性の検出 | 入力検証、認可、認証のチェック | SQL Injectionなどの攻撃シミュレーション |
回帰テスト | コード変更が既存機能を壊していないか自動的に検証 | 既存のテスト一式を再実行する | go test ./... |
スモークテスト | デプロイ後にシステムの基本機能が動作するか確認 | 重要機能の迅速な検証 | 主要APIエンドポイントの疎通確認 |
テストが必要な理由
- 品質担保: バグの早期発見と修正
- 回帰防止: 新機能追加や変更による既存機能への悪影響を検知
- 設計改善: テスト可能なコードを書くためのリファクタリング促進
- ドキュメント: コードの使用方法を示す生きた文書
- 開発速度向上: 長期的には手動テストより効率的
- 安全なリファクタリング: 内部実装を変えても動作が保証される
- 新規参加者の学習サポート: テストを読むことでコードの挙動を理解できる
- 継続的デリバリーの実現: 自動テストがデプロイパイプラインの基盤になる
テストカバレッジの目安
カバレッジレベル | 目安 | 推奨される対象 |
---|---|---|
低(〜50%) | 最低限の機能検証 | プロトタイプ、実験的機能 |
中(50-80%) | 一般的なプロダクション | 多くのビジネスアプリケーション |
高(80%〜) | ミッションクリティカルシステム | 金融、医療、重要インフラ系システム |
※注: カバレッジの数値だけを目標にすると、意味のないテストが増える可能性があるため注意
よくあるテストパターン
表駆動テスト(Table Driven Tests)
func TestMultiply(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"正の数の足し算", 2, 3, 6},
{"ゼロとの足し算", 0, 5, 5},
{"負の数との足し算", -1, 5, 4},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := multiply(tc.a, tc.b); got != tc.expected {
t.Errorf("multiply(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.expected)
}
})
}
}
テストヘルパー関数
func setupTestDB(t *testing.T) (*sql.DB, func()) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("DB接続エラー: %v", err)
}
// テーブル作成などのセットアップ
return db, func() {
// テスト後のクリーンアップ処理
db.Close()
}
}
func TestDatabase(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
// テストコード
}
モック(Mock)のユースケース
- 外部システム依存: データベース、API、ファイルシステムなど
- 非決定的処理: 時間、乱数生成など
- リソース制約: メモリ、CPU使用量の多い処理
- エラー条件のテスト: エラーが発生するケースの再現
- タイムアウトなどの特殊条件: 接続タイムアウトなどレアケースのテスト
gomockチートシート
コマンド | 説明 |
---|---|
mockgen -source=... |
ソースファイルからモックを生成 |
mockgen -destination=... |
指定したファイルにモックを出力 |
gomock.Any() |
任意の値とマッチ |
gomock.Eq(value) |
指定した値と等しいことを検証 |
gomock.Nil() |
nilであることを検証 |
gomock.Not(matcher) |
他のマッチャーの結果を否定 |
gomock.Len(length) |
長さが指定した値であることを検証 |
gomock.AssignableToTypeOf(type) |
指定した型に代入可能であることを検証 |
第5章: テスト実装のベストプラクティス
-
AAA (Arrange-Act-Assert)パターン:
func TestSomething(t *testing.T) { // Arrange(準備) input := "test" expected := "TEST" // Act(実行) result := toUpper(input) // Assert(検証) if result != expected { t.Errorf("got %v, want %v", result, expected) } }
-
テストの分離: 各テストは他のテストに依存しないこと
-
適切な検証: 必要な部分だけを厳密に検証し、過剰検証を避ける
-
ヘルパー関数の活用: 共通セットアップや検証ロジックはヘルパー関数として抽出
-
テスト名はドキュメント: テスト名から目的が明確に理解できるようにする
-
エラーメッセージの詳細化: 何が期待され、何が得られたかを明確に
-
テスト環境のクリーンアップ: リソースを確実に開放する
さまざまなテストケース
-
境界値テスト: 境界条件(最小値、最大値、ゼロなど)をテスト
func TestValidateAge(t *testing.T) { tests := []struct { name string age int expected bool }{ {"below minimum", -1, false}, {"minimum", 0, true}, {"normal", 30, true}, {"maximum", 120, true}, {"above maximum", 121, false}, } // ...テスト実行... }
-
ネガティブテスト: エラーケースが正しく処理されるか確認
func TestDivide(t *testing.T) { _, err := divide(10, 0) if err == nil { t.Error("ゼロ除算エラーが返されるべき") } }
-
並列テスト: 並列実行によるバグ検出
func TestConcurrent(t *testing.T) { t.Parallel() // 並列実行可能なテスト }
-
クリーンアップ確認: リソース解放が正しく行われるか
func TestResourceCleanup(t *testing.T) { resource := acquireResource() defer func() { if !resource.IsClosed() { t.Error("リソースが解放されていない") } }() // テスト処理 }
-
パフォーマンステスト: 処理時間の計測
func BenchmarkSortArray(b *testing.B) { for i := 0; i < b.N; i++ { data := generateRandomArray(1000) sortArray(data) } }
テスト駆動開発(TDD)の基本
- Red: 失敗するテストを書く
- Green: 最小限の実装でテストを通す
- Refactor: リファクタリングする(テストが通ることを確認しながら)
- 繰り返す: 次の機能に進む
テスト自動化とCIとの連携
-
継続的インテグレーション:
- GitHub Actions, CircleCI, Jenkins等でテスト自動化
- プルリクエスト時に自動テスト実行
-
テスト・カバレッジレポート:
go test -v ./... | go-junit-report > report.xml go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html
おわりに
この記事では、Go言語におけるテストの基本から、gomockを用いたモックテストの実践、さらにはテスト実装のベストプラクティスや様々なテストケースについて解説しました。
テストはソフトウェア開発において非常に重要な工程であり、適切に実施することでコードの品質を大幅に向上させることができます。特に、gomockのようなモックライブラリを活用することで、外部依存を気にせずにユニットテストを行うことができ、テストの効率と効果を高めることができます。
ぜひ、この記事で学んだ内容を参考にして、自身のプロジェクトにおけるテスト戦略を見直してみてください。そして、継続的にテストの重要性を再認識し、品質の高いソフトウェア開発を目指しましょう。
参考文献
これらのリソースは、Go言語やテストに関するさらなる知識を深めるために非常に役立ちます。興味のある方はぜひご覧ください。