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 × Clean Architecture入門:Controllerテストをモックを活用して丁寧に書く方法

Posted at

はじめに

「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で、リクエストオブジェクトreqGinコンテキストに紐付けます。
  • 動作:
    • 通常のリクエスト-レスポンスの流れを再現する環境を構築します。

ハンドラを直接呼び出し

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のテスト方法を解説しました:

  • モックを作成して依存性を分離
  • Ginhttptestを使ってリクエストをモック
  • ステータスコードとレスポンス内容を検証

Clean Architectureでは、Controllerのテストを書くことが非常に重要です。この記事を参考に、あなたのプロジェクトでもControllerテストを実践してみてください!

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?