0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go言語での実践的な単体テスト:ユーザー登録機能

0
Last updated at Posted at 2026-04-25

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 を組み込んだテーブル駆動テストにより、可読性が高く変更に強いコードを実現する。

テストの奥深さについてよく知れたと思う。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?