はじめに
「Clean Architecture」を採用したGoアプリケーションでControllerをテストする際、「どのようにモックを使って依存性を制御するのか」や「レスポンスをどう検証するのか」に戸惑う方も多いはずです。この記事では、実践的な例を通じて Ginを使ったControllerのテスト方法を解説します。
なお、こちらは以下の記事の続きになります。
Clean ArchitectureのControllerの役割
Controllerは以下の役割を担います:
- リクエストを受け取り、適切なユースケースを呼び出す。
- ユースケースの結果を整形し、レスポンスとして返す。
たとえば、以下の ArticleController をテストします:
backend/adapter/controller/article.go
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/jijimama/newspaper-app/usecase"
)
// ArticleController は記事関連のHTTPリクエストを処理するコントローラー。
// ユースケースに処理を依頼し、その結果を整形して返す。
type ArticleController struct {
articleUseCase usecase.ArticleUseCase // ユースケースを通じてビジネスロジックを実行
}
// NewArticleController は ArticleController を作成するファクトリ関数。
func NewArticleController(articleUseCase usecase.ArticleUseCase) *ArticleController {
return &ArticleController{
articleUseCase: articleUseCase,
}
}
// GetArticles は /articles エンドポイントへのGETリクエストを処理。
// コントローラーは、ユースケースを使ってビジネスロジックを実行し、結果をレスポンスとして返す。
func (ctrl *ArticleController) GetArticles(c *gin.Context) {
// ユースケースを呼び出して記事一覧を取得
articles, err := ctrl.articleUseCase.GetArticles(c.Request.Context())
if err != nil {
// エラーが発生した場合は500エラーを返す
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch articles"})
return
}
c.JSON(http.StatusOK, articles)
}
テストコードの全体像
最終的なテストコードは以下のようになります:
backend/adapter/controller/article_test.go
package controller
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/jijimama/newspaper-app/domain"
)
// MockArticleUseCase は ArticleUseCase をモック化したもの
type MockArticleUseCase struct {
mock.Mock
}
// NewMockArticleUseCase は MockArticleUseCase の新しいインスタンスを作成する
func NewMockArticleUseCase() *MockArticleUseCase {
return &MockArticleUseCase{}
}
// GetArticles はユースケースのモックメソッド
func (m *MockArticleUseCase) GetArticles(ctx context.Context) ([]*domain.Article, error) {
args := m.Called(ctx)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*domain.Article), args.Error(1)
}
// ArticleControllerSuite は ArticleController のテストスイート
type ArticleControllerSuite struct {
suite.Suite
mockUseCase *MockArticleUseCase
articleController *ArticleController
}
// TestArticleControllerTestSuite はテストスイートを実行する
func TestArticleControllerTestSuite(t *testing.T) {
suite.Run(t, new(ArticleControllerSuite))
}
// SetupTest は各テストの前に実行されるセットアップメソッド
func (suite *ArticleControllerSuite) SetupTest() {
suite.mockUseCase = NewMockArticleUseCase()
suite.articleController = NewArticleController(suite.mockUseCase)
}
// TestGetArticles は GetArticles メソッドのテスト
func (suite *ArticleControllerSuite) TestGetArticles() {
// モックの返却値を設定
articles := []*domain.Article{
{
Year: 2023,
Month: 12,
Day: 1,
Content: "Article Content 1",
Newspaper: "Daily News",
ColumnName: "Opinion",
},
{
Year: 2023,
Month: 12,
Day: 2,
Content: "Article Content 2",
Newspaper: "Daily News",
ColumnName: "Sports",
},
}
suite.mockUseCase.On("GetArticles", mock.Anything).Return(articles, nil)
// HTTPリクエストを作成
req, _ := http.NewRequest(http.MethodGet, "/articles", nil)
w := httptest.NewRecorder()
ginContext, _ := gin.CreateTestContext(w)
ginContext.Request = req
// ハンドラメソッドを直接呼び出し
suite.articleController.GetArticles(ginContext)
// レスポンスを確認
suite.Assert().Equal(http.StatusOK, w.Code)
// レスポンスボディを読み取る
bodyBytes, _ := io.ReadAll(w.Body)
var articlesResponse []domain.Article
err := json.Unmarshal(bodyBytes, &articlesResponse)
suite.Assert().Nil(err)
// レスポンスの内容を検証
suite.Assert().Equal(2, len(articlesResponse))
suite.Assert().Equal(2023, articlesResponse[0].Year)
suite.Assert().Equal(12, articlesResponse[0].Month)
suite.Assert().Equal(1, articlesResponse[0].Day)
suite.Assert().Equal("Article Content 1", articlesResponse[0].Content)
suite.Assert().Equal("Daily News", articlesResponse[0].Newspaper)
suite.Assert().Equal("Opinion", articlesResponse[0].ColumnName)
suite.Assert().Equal(2023, articlesResponse[1].Year)
suite.Assert().Equal(12, articlesResponse[1].Month)
suite.Assert().Equal(2, articlesResponse[1].Day)
suite.Assert().Equal("Article Content 2", articlesResponse[1].Content)
suite.Assert().Equal("Daily News", articlesResponse[1].Newspaper)
suite.Assert().Equal("Sports", articlesResponse[1].ColumnName)
}
Mockの作成
まず、ArticleUseCase
をモック化します。モックを使うことで、ユースケースに依存せずControllerの挙動のみをテストできます。
// MockArticleUseCase は ArticleUseCase をモック化したもの
type MockArticleUseCase struct {
mock.Mock
}
// NewMockArticleUseCase は MockArticleUseCase の新しいインスタンスを作成する
func NewMockArticleUseCase() *MockArticleUseCase {
return &MockArticleUseCase{}
}
// GetArticles はユースケースのモックメソッド
func (m *MockArticleUseCase) GetArticles(ctx context.Context) ([]*domain.Article, error) {
args := m.Called(ctx)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*domain.Article), args.Error(1)
}
テストスイートの作成
testify/suite を使ってテストスイートを定義します。これにより、初期化処理が簡潔になります。
// ArticleControllerSuite は ArticleController のテストスイート
type ArticleControllerSuite struct {
suite.Suite
mockUseCase *MockArticleUseCase
articleController *ArticleController
}
// SetupTest は各テストの前に実行されるセットアップメソッド
func (suite *ArticleControllerSuite) SetupTest() {
suite.mockUseCase = NewMockArticleUseCase()
suite.articleController = NewArticleController(suite.mockUseCase)
}
HTTPリクエストのモックと期待値の設定
次に、記事の一覧を取得するモックデータを設定します。
// TestGetArticles は GetArticles メソッドのテスト
func (suite *ArticleControllerSuite) TestGetArticles() {
// モックの返却値を設定
articles := []*domain.Article{
{
Year: 2023,
Month: 12,
Day: 1,
Content: "Article Content 1",
Newspaper: "Daily News",
ColumnName: "Opinion",
},
{
Year: 2023,
Month: 12,
Day: 2,
Content: "Article Content 2",
Newspaper: "Daily News",
ColumnName: "Sports",
},
}
suite.mockUseCase.On("GetArticles", mock.Anything).Return(articles, nil)
HTTPリクエストを作成
req, _ := http.NewRequest(http.MethodGet, "/articles", nil)
- 目的: テスト用のHTTPリクエストを作成します。
-
内容:
-
http.NewRequest
を使って、新しいHTTPリクエストを生成します。 - 第一引数:
http.MethodGet
はHTTPメソッドを指定します(この場合はGET
)。 - 第二引数: リクエストするURLパス(
/articles
)を指定します。 - 第三引数: リクエストボディを指定します(
GET
メソッドでは通常不要なのでnil
)。
-
-
動作:
- 実際の環境ではブラウザやクライアントが送信するリクエストを再現します。
レスポンスの記録器を作成
w := httptest.NewRecorder()
- 目的: テスト環境でレスポンスを受け取るための「記録器」を作成します。
-
内容:
-
httptest.NewRecorder()
は、HTTPレスポンスをキャプチャするためのモックオブジェクトを作成します。 -
w
は実際のサーバーがクライアントに返すレスポンス(ステータスコードやボディ)を記録します。
-
-
動作:
- 実際のレスポンスではなく、記録器にデータが保存されます。
Ginのテストコンテキストを作成
ginContext, _ := gin.CreateTestContext(w)
ginContext.Request = req
-
目的:
Gin
用のテストコンテキストを作成し、リクエストを割り当てます。 -
内容:
-
gin.CreateTestContext(w)
でテスト用のGin
コンテキストを作成します。 - 引数
w
には、レスポンスを記録するhttptest.ResponseRecorder
を渡します。 -
ginContext.Request = req
で、リクエストオブジェクトreq
をGin
コンテキストに紐付けます。
-
-
動作:
- 通常のリクエスト-レスポンスの流れを再現する環境を構築します。
ハンドラを直接呼び出し
suite.articleController.GetArticles(ginContext)
-
目的: テスト対象の
Controller
メソッドを呼び出します。 -
内容:
-
suite.articleController.GetArticles
で、/articles
エンドポイントに対応するController
のメソッドを実行します。 - 引数に
Gin
のテストコンテキストginContext
を渡します。
-
-
動作:
- 実際のサーバー起動なしに、
Controller
の動作をテストします。 - モックされた
articleUseCase
が呼び出され、期待するレスポンスを返します。
- 実際のサーバー起動なしに、
ステータスコードの確認
suite.Assert().Equal(http.StatusOK, w.Code)
- 目的: レスポンスのHTTPステータスコードを確認します。
-
内容:
-
w.Code
には、Controller
が設定したステータスコードが記録されています。 -
suite.Assert().Equal
で、期待するステータスコード(http.StatusOK
=200)と一致するかを確認します。
-
-
動作:
- ステータスコードが期待値と異なる場合、テストが失敗します。
レスポンスボディを確認
bodyBytes, _ := io.ReadAll(w.Body)
- 目的: 記録されたレスポンスボディを取得します。
-
内容:
-
w.Body
には、Controller
が返したレスポンスボディ(JSONなど)が保存されています。 -
io.ReadAll
を使って、レスポンスボディ全体を[]byte
形式で読み取ります。
-
-
動作:
- レスポンスデータを後の検証に使える形に変換します。
レスポンスのJSONをデコード
var articlesResponse []domain.Article
err := json.Unmarshal(bodyBytes, &articlesResponse)
suite.Assert().Nil(err)
- 目的: レスポンスボディを構造体に変換し、解析可能な形にします。
-
内容:
-
json.Unmarshal
を使って、レスポンスボディ(JSON形式)をarticlesResponse
(Goの構造体スライス)にデコードします。 -
suite.Assert().Nil(err)
で、デコード時にエラーが発生していないことを確認します。
-
-
動作:
- 正しい形式のJSONレスポンスであることを確認します。
まとめ
今回の記事では、以下のステップを通してControllerのテスト方法を解説しました:
- モックを作成して依存性を分離
-
Gin
のhttptest
を使ってリクエストをモック - ステータスコードとレスポンス内容を検証
Clean Architectureでは、Controllerのテストを書くことが非常に重要です。この記事を参考に、あなたのプロジェクトでもControllerテストを実践してみてください!