Go言語での実践的な単体テスト:ユーザー登録機能
1. はじめに
本記事では、Go言語によるバックエンド開発において、ユーザー作成機能(user.Create)の単体テストを題材とし、テスト設計からテーブル駆動テストへのリファクタリングの過程についてまとめた。
2. テスト設計:何を検証すべきか
user.Create の役割は「usernameとpasswordを受け取り、usernameとハッシュ化したpasswordを保存する」ことである。この責務を全うするため、以下の観点でテストケースを設計した。
① 入力バリデーション(異常系)
- ユーザー名が空、またはスペースを含む場合に適切にエラーを返すか。
- パスワードが空、または強度が不足している(長さ7未満など)場合にエラーを返すか。
② 依存コンポーネントとの連携(正常系・異常系)
- 正常系: usernameとハッシュ化されたpasswordが、適切にリポジトリ(DB)に渡されているか。
- 異常系: ハッシュ生成関数やリポジトリがエラーを返した際、そのエラーを呼び出し元へ伝播できるか。
3. アーキテクチャ:ハッシュ処理を別パッケージにした理由
最初はuserパッケージのプライベート関数として定義していたが、別パッケージで定義することにした。理由は2つ。
- 再利用: ログイン処理でも同様のハッシュ処理を行う。用いるハッシュ関数に対して同じパラメータを使用しないといけないので、ハッシュ処理は一か所で管理すべき。
- テストを簡単に: ソルトの影響で毎回ランダムになるhashの値をテストしづらい。プライベート関数だとDIができないため。
テスト容易性の例
プライベート関数のとき
Create(username, password) {
hash = generateHash(password);
repository.Save(hash);
// このhashの値が毎回ランダムなので、Saveの引数が適切なhashかどうかが分からない。
}
外部に切り分けて、DIするとき
Create(username, password) {
hash = hash.GenerateHash(password); // スタブで間接入力を設定できる。
repository.Save(hash);
// スタブで設定した値とSaveに渡された引数hashが一致するかどうかを検証できる。
}
4. リファクタリング:setupMocks によるテーブル駆動テストの洗練
可読性を上げる、冗長性をなくす、変更に強くする、という目的のために、リファクタリングを行った。
当初は各テストケースごとにモックの振る舞いを手動で定義していたが、setupMocks 関数を用いたテーブル駆動テストへリファクタリングした。
Before
func TestCreate(t *testing.T) {
t.Run("正常系:有効なユーザー作成", func(t *testing.T) {
hashMock := &HashGeneratorMock{}
repoMock := &RepositoryMock{}
hashMock.Test(t)
repoMock.Test(t)
// モックの振る舞いを手動で定義
hashMock.On("GenerateFromPassword", "testPassword").Return("hashed_val", nil)
repoMock.On("Save", user.User{Username: "testuser", PasswordHash: "hashed_val"}).Return(nil)
srv := user.NewService(repoMock, hashMock)
err := srv.Create("testuser", "testPassword")
assert.NoError(t, err)
hashMock.AssertExpectations(t)
repoMock.AssertExpectations(t)
})
t.Run("異常系:ユーザー名が空", func(t *testing.T) {
hashMock := &HashGeneratorMock{}
repoMock := &RepositoryMock{}
hashMock.Test(t)
repoMock.Test(t)
// バリデーションで止まるため、モックは呼ばれない想定
// (モックのセットアップは不要だが、初期化やサービス生成のボイラープレートは同じように必要)
srv := user.NewService(repoMock, hashMock)
err := srv.Create("", "password123")
assert.EqualError(t, err, "username is required")
hashMock.AssertNotCalled(t, "GenerateFromPassword", mock.Anything)
repoMock.AssertNotCalled(t, "Save", mock.Anything)
})
// ... 以降、ハッシュ失敗やDB失敗のテストケースを追加するたびに、
// モックの初期化や srv := user.NewService(...) などの同じ記述をコピー&ペーストし続ける必要がある。
}
After
func TestCreate(t *testing.T) {
tests := []struct {
name string
username string
password string
setupMocks func(repo *RepositoryMock, hash *HashGeneratorMock)
expectedErr error
}{
{
name: "正常系:有効なユーザー作成",
username: "testuser",
password: "testPassword",
setupMocks: func(repo *RepositoryMock, hash *HashGeneratorMock) {
hash.On("GenerateFromPassword", "testPassword").Return("hashed_val", nil)
repo.On("Save", user.User{Username: "testuser", PasswordHash: "hashed_val"}).Return(nil)
},
expectedErr: nil,
},
{
name: "異常系:ユーザー名が空",
username: "",
password: "password123",
setupMocks: nil, // バリデーションで止まるためモックは呼ばれない
expectedErr: errors.New("username is required"),
},
// ... ハッシュ失敗、DB失敗などのケースを定義
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hashMock := &HashGeneratorMock{}
repoMock := &RepositoryMock{}
hashMock.Test(t)
repoMock.Test(t)
if tc.setupMocks != nil {
tc.setupMocks(repoMock, hashMock)
}
srv := user.NewService(repoMock, hashMock)
err := srv.Create(tc.username, tc.password)
assert.ErrorIs(t, err, tc.expectedErr)
hashMock.AssertExpectations(t)
repoMock.AssertExpectations(t)
})
}
}
5. ライブラリの活用:testify/mock
車輪の再発明を防ぐため、testify/mock を活用している。主なAPIの役割は以下の通りである。
-
On("Method", args...): 特定の引数でメソッドが呼び出されることを期待する。 -
Return(vals...): その呼び出しに対して返す値を定義する。 -
AssertExpectations(t):Onで定義した期待通りの呼び出しが実際に行われたかを検証する。 -
Test(t): モック内部でエラーが発生した際、パニックを起こさず適切にテストを失敗させる。
6. おわりに
単にアサーションを通すだけでなく、実務に耐えうる保守性の高いテストを追求した結果、以下の学びを得た。
- テスト設計: 実装前に境界値やエラー伝播のパターンを洗い出し、「何を検証すべきか」を明確にする。
- 依存関係の隔離: DI(依存性の注入)を活用し、外部依存を切り離してテスト対象のロジックのみを検証する。
- 保守性の向上: setupMocks を組み込んだテーブル駆動テストにより、可読性が高く変更に強いコードを実現する。
テストの奥深さについてよく知れたと思う。