Go × Gin × MVC構成で実践する堅牢なテスト設計と実装ガイド

  • golang
    golang
  • gin
    gin
2023/12/04に公開

はじめに

APIを設計・実装するだけなら、それほど難しくありません。
しかし、それを「継続的に育てていける品質」にまで高めるには、テストと設計の工夫が不可欠です。

前回の記事では、Go × Gin を用いて MVC構成でブログ記事投稿APIを構築しました。
今回はその続編として、このアプリケーションをより堅牢で安心して運用できる形に育てていくためのテスト設計と実装に踏み込みます。

今回のテーマ

本記事では以下を中心に解説していきます。

  • ユニットテストとインテグレーションテストの考え方と使い分け
  • handler, service, repository 各レイヤーごとのテスト戦略
  • httptesttestify など、Ginアプリと相性の良いテストライブラリの活用方法
  • ローカル環境でのSQLiteによる簡易的な永続化とそのテストへの組み込み

「テスト=面倒くさい」と感じることもあるかもしれません。
しかし、きちんとテストがあることは開発スピードの加速にもつながります。
安心して機能を追加できる状態は、チームでも個人でも大きな武器になります。

今回はその第一歩として、現実的かつ実用的なテスト設計を一緒に進めていきましょう。
コードを動かしながら学べるよう、サンプルも豊富に紹介していきます。

本記事の位置づけ(シリーズのPart2)

この記事は、全3回構成で進めている Go × Gin × MVC構成によるAPI開発シリーズ の第2弾です。

Part タイトル 概要
Part1 Go × Gin でMVC構成のブログ記事投稿用Web APIを構築する:基礎からスケーラブル設計まで MVC構成の基本とAPI実装の流れを解説。メモリ保存でCRUDを構築
👉 Part2 Go × Gin × MVC構成で実践する堅牢なテスト設計と実装ガイド 各レイヤーのテスト方針と実装方法を具体例で解説(本記事)
Part3 Go × Ginアプリを本番運用に向けて整える:構成整理とCIへの統合 データベース導入・環境分離・CI/CD構築など運用設計の実践

今回作成したコードは下記のリポジトリに載せています。

https://github.com/shinagawa-web/go-gin-blog-api

単体テスト / 統合テスト / E2E の違いと選び方

アプリケーションの品質を支えるために「テスト」は不可欠ですが、テストにはいくつかの種類があります。ここでは、単体テスト(Unit Test)、統合テスト(Integration Test)、E2Eテスト(End-to-End Test)の違いを整理し、それぞれをどこで・どのように使い分けるべきかを明らかにします。

単体テスト(Unit Test)

対象:特定の関数・メソッド単体
目的:ロジック単体の正しさを保証する

たとえば、service層の GetByID メソッドが正しい挙動をするかを、外部依存(DBや外部API)を排除して確認します。

メリット

  • 処理が軽く、高速に回せる
  • 問題の切り分けがしやすい
  • モックを使って柔軟なケースを検証できる

使いどころ

  • サービスロジック
  • ヘルパー関数
  • バリデーション処理

統合テスト(Integration Test)

対象:複数のコンポーネントが連携する処理
目的:レイヤー間の連携が正しく機能するかを検証する

たとえば、servicerepository のように実装をそのまま使って、HTTPリクエストをシミュレートし、アプリケーション内部の流れが正しいかを確認します。

メリット

  • 実際の動作に近い形で確認できる
  • モックに頼らないため安心感がある

注意点

  • 実装の変更にテストも強く影響される
  • テストの速度がやや重くなる傾向

E2Eテスト(End-to-End Test)

対象:アプリ全体を実際の環境に近い形で検証
目的:ユーザー目線で最終的な挙動を確認する

ブラウザやHTTPクライアントを通して、サーバーを立ち上げた状態でテストを実行します。
例えば「記事投稿 → 一覧取得 → 削除」の一連の流れが、本番と同じように動作するかを検証するのがE2Eです。

メリット

  • 本番と同じ環境に近いため、実際のバグを見つけやすい
  • UIやAPIの結合エラーも検出できる

デメリット

  • セットアップが重い
  • 実行時間が長くなりがち
  • トラブルシュートが難しいことも

選び方とバランス

テスト種別 スピード 信頼度 目的
単体テスト 処理の正確性
統合テスト 機能の接続確認
E2Eテスト 実利用の再現性

実践的には、以下のバランスで組み合わせると効果的です。

  • 単体テスト:コアロジックを重点的にカバー
  • 統合テスト:パス単位の正常・異常系を押さえる
  • E2Eテスト:最小限にして、リグレッションやCI用に使う

次のセクションでは、実際のコードに落とし込みながら「handler」「service」「repository」それぞれのテスト方法を具体的に紹介していきます。

handler / service / repository 層のテストの基本方針

Go × Gin × MVC構成においては、レイヤーごとに責任が明確に分かれているため、それぞれの層に対して テストのアプローチも分けて考えることが重要です。
このセクションでは、各層に対して「どのような粒度で何を検証すべきか」を整理します。

handler層:HTTPレベルでの振る舞いをテスト

handler はリクエストを受け取り、必要な処理をサービス層に委譲する役割です。テストでは主に以下を確認します。

  • リクエストパラメータの受け取りとバリデーション
  • サービス呼び出しの正常系・異常系
  • 適切なHTTPレスポンスコード・レスポンスボディの返却

モックサービスを使うことで、handlerだけにフォーカスできます。

service層:ビジネスロジックを単体でテスト

service はドメインロジックの中核を担うため、バリエーションのあるユースケースを扱います。テスト対象としては

  • 入力に対する出力の整合性
  • 異常系の制御(例:見つからないID、無効なデータ)
  • repositoryの呼び出し結果による挙動の分岐

repositoryはモックに差し替えて、純粋なロジックの確認に集中します。

repository層:データ取得・保存処理をテスト

この層では、データの整合性と条件分岐の確認が重要です。今回のサンプルではインメモリの実装を使っていますが、将来的にDBを使う場合でも次の観点は共通です。

  • Save / FIndAll / FindByID / Update / Delete の正常・異常系
  • 削除・追加が正しくリストに反映されるか
  • 同期処理(mutex)や順序性の確認

モックは不要。インメモリ実装を直接テストできます。

テスト方針まとめ

テスト対象 モック使用
handler リクエスト受付・レスポンス処理 serviceをモック化
service ドメインロジックの正当性 repositoryをモック化
repository データ操作の整合性 不要(直接テスト)

このように各層でテストの粒度と責任を明確に分離することで、堅牢かつ保守しやすいテスト構成が実現できます。

handler層のテスト

handler 層は、Ginのルーティングを通じてリクエストを受け取り、パラメータを解析し、サービス層を呼び出してレスポンスを返す「入り口」の役割を担います。
この層では「HTTPリクエストに対して期待どおりのレスポンスが返るか」を検証することが主な目的です。

✅ テストの狙い

  • 正常なリクエストに対して、適切なステータスコードとレスポンスが返るか
  • 存在しないデータやエラー発生時に、適切なエラーレスポンスが返るか
  • Ginルーターを介したハンドラ呼び出しの検証

テスト構成

handler_test/post_handler_test.go
package handler_test

import (
	"go-gin-blog-api/handler"
	"go-gin-blog-api/model"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

type mockPostService struct {
	mock.Mock
}

func NewMockPostService() *mockPostService {
	return &mockPostService{}
}

func (m *mockPostService) GetByID(id string) (*model.Post, bool) {
	args := m.Called(id)
	if post := args.Get(0); post != nil {
		return post.(*model.Post), true
	}
	return nil, false
}

func (m *mockPostService) Create(post model.Post) *model.Post {
	args := m.Called(post)
	if p := args.Get(0); p != nil {
		return p.(*model.Post)
	}
	return nil
}

func (m *mockPostService) Update(id string, post model.Post) (*model.Post, bool) {
	args := m.Called(id, post)
	if p := args.Get(0); p != nil {
		return p.(*model.Post), true
	}
	return nil, false
}

func (m *mockPostService) Delete(id string) bool {
	args := m.Called(id)
	return false != args.Bool(0)
}

func (m *mockPostService) List() []model.Post {
	args := m.Called()
	if list := args.Get(0); list != nil {
		return list.([]model.Post)
	}
	return nil
}

コードの解説

type mockPostService struct {
	mock.Mock
}

mock.Mock を埋め込むことで、.Called() を使って 引数と返り値の管理ができます。

func (m *mockPostService) GetByID(id string) (*model.Post, bool) {
	args := m.Called(id)
	if post := args.Get(0); post != nil {
		return post.(*model.Post), true
	}
	return nil, false
}
  • m.Called(id) で事前に登録した引数を検証。
  • args.Get(0)mock.On(...).Return(...) で指定した返り値。

テストケース:正常系(記事が見つかる)

handler_test/post_handler_test.go
func TestGetPost_Success(t *testing.T) {
	gin.SetMode(gin.TestMode)
	mockSvc := NewMockPostService()
	mockSvc.On("GetByID", "1").Return(&model.Post{
		ID: "1", Title: "Hello", Content: "Test", Author: "Alice",
	}, nil)

	h := handler.NewPostHandler(mockSvc)

	r := gin.Default()
	r.GET("/posts/:id", h.GetPostByID)

	req, _ := http.NewRequest("GET", "/posts/1", nil)
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.JSONEq(t, `{"id":"1","title":"Hello","content":"Test","author":"Alice"}`, w.Body.String())

	mockSvc.AssertExpectations(t)
}

コードの解説

gin.SetMode(gin.TestMode)
  • ログなどを抑制して、テスト中のノイズを減らすために使います。
mockSvc := NewMockPostService()
mockSvc.On("GetByID", "1").Return(&model.Post{ ... }, nil)
  • GetByID("1") が呼ばれたら、ダミーの Post を返すように定義。
h := handler.NewPostHandler(mockSvc)
r := gin.Default()
r.GET("/posts/:id", h.GetPostByID)

  • ハンドラをルーターにマウント。
req, _ := http.NewRequest("GET", "/posts/1", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

  • 実際に GET /posts/1 を叩く動作をシミュレート。
assert.Equal(t, http.StatusOK, w.Code)
assert.JSONEq(t, `{"id":"1","title":"Hello","content":"Test","author":"Alice"}`, w.Body.String())
mockSvc.AssertExpectations(t)
  • ステータスコードとレスポンスJSONの一致を確認。
  • モックが想定通りの引数で呼び出されたかも検証。

テストケース:異常系(記事が存在しない)

handler/post_handler_test.go
func TestGetPost_NotFound(t *testing.T) {
	gin.SetMode(gin.TestMode)
	mockSvc := NewMockPostService()
	mockSvc.On("GetByID", "999").Return(nil, false)

	h := handler.NewPostHandler(mockSvc)

	r := gin.Default()
	r.GET("/posts/:id", h.GetPostByID)

	req, _ := http.NewRequest("GET", "/posts/999", nil)
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	assert.Equal(t, http.StatusNotFound, w.Code)
	assert.JSONEq(t, `{"error":"Post not found"}`, w.Body.String())
}

動作確認

テストコードを実際に動かしてみます。

go test -v ./handler

Image from Gyazo

モックで確認すべきポイント

  • handlerがserviceの振る舞いに依存していることをテストで明確に分離できている
  • Ginの ServeHTTP を使うことで、ルーターを通じた完全なリクエスト/レスポンスフローを検証できる

次は、service層のロジックを直接テストして、実際にrepositoryから取得したデータに対する判断処理を確認していきます。
関数の中で条件分岐が多い箇所ほど、単体テストで丁寧に検証しておきましょう。

service層のテスト

サービス層はビジネスロジックを担う中核部分です。
リポジトリなど外部依存をモック化して、純粋にロジックの正しさをテストしましょう。

ディレクトリ構成

go-gin-blog-api/
├── service/
│   └── post_service.go
└── service_test/
    └── post_service_test.go

service_test ディレクトリを分けると、依存関係の循環を防ぎつつ外部からの利用イメージでテスト可能になります(慣れてきたら service/ 内でもOKです)。

テスト構成

service_test/post_service_test.go
package service_test

import (
	"go-gin-blog-api/model"
	"go-gin-blog-api/service"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

// モックリポジトリ
type mockPostRepository struct {
	mock.Mock
}

func (m *mockPostRepository) FindByID(id string) (*model.Post, bool) {
	args := m.Called(id)
	if post := args.Get(0); post != nil {
		return post.(*model.Post), args.Bool(1)
	}
	return nil, args.Bool(1)
}

func (m *mockPostRepository) Save(post model.Post) *model.Post {
	args := m.Called(post)
	if p := args.Get(0); p != nil {
		return p.(*model.Post)
	}
	return nil
}

func (m *mockPostRepository) Update(id string, updated model.Post) (*model.Post, bool) {
	args := m.Called(id, updated)
	if p := args.Get(0); p != nil {
		return p.(*model.Post), args.Bool(1)
	}
	return nil, false
}

func (m *mockPostRepository) Delete(id string) bool {
	args := m.Called(id)
	return args.Bool(0)
}

func (m *mockPostRepository) FindAll() []model.Post {
	args := m.Called()
	return args.Get(0).([]model.Post)
}

コードの解説

func (m *mockPostRepository) FindByID(id string) (*model.Post, bool) {
	args := m.Called(id)
	if post := args.Get(0); post != nil {
		return post.(*model.Post), args.Bool(1)
	}
	return nil, args.Bool(1)
}

  • 引数 id に応じた戻り値をテスト時に On("FindByID", "1")... のように設定できます。
  • args.Get(0)*model.Post
  • args.Bool(1)true/false(見つかったかどうか)
func (m *mockPostRepository) Save(post model.Post) *model.Post {
	args := m.Called(post)
	if p := args.Get(0); p != nil {
		return p.(*model.Post)
	}
	return nil
}

  • モデルを保存する処理を模倣
  • 返す内容を On("Save", post)... で指定可能
func (m *mockPostRepository) Update(id string, updated model.Post) (*model.Post, bool) {
	args := m.Called(id, updated)
	if p := args.Get(0); p != nil {
		return p.(*model.Post), args.Bool(1)
	}
	return nil, false
}
  • IDと更新データで更新を模倣
  • 成功・失敗の両パターンを簡単に再現できます
func (m *mockPostRepository) Delete(id string) bool {
	args := m.Called(id)
	return args.Bool(0)
}

  • 指定IDの削除成功/失敗を模倣します
func (m *mockPostRepository) FindAll() []model.Post {
	args := m.Called()
	return args.Get(0).([]model.Post)
}

  • 投稿一覧取得。返す配列も On("FindAll")... で指定可能です

テストケース:正常系(記事が見つかる)

func TestGetByID_Success(t *testing.T) {
	mockRepo := new(mockPostRepository)
	expected := &model.Post{
		ID: "1", Title: "Gin Guide", Content: "Test content", Author: "Author1",
	}
	mockRepo.On("FindByID", "1").Return(expected, true)

	svc := service.NewPostService(mockRepo)

	post, found := svc.GetByID("1")

	assert.True(t, found)
	assert.Equal(t, expected, post)
	mockRepo.AssertExpectations(t)
}

テストケース:異常系(記事が存在しない)

func TestGetByID_NotFound(t *testing.T) {
	mockRepo := new(mockPostRepository)
	mockRepo.On("FindByID", "999").Return(nil, false)

	svc := service.NewPostService(mockRepo)

	post, found := svc.GetByID("999")

	assert.False(t, found)
	assert.Nil(t, post)
	mockRepo.AssertExpectations(t)
}

動作確認

テストコードを実際に動かしてみます。

go test -v ./service_test

Image from Gyazo

リポジトリ層のテスト

リポジトリ層のテストでは、データのCRUD操作が正しく行われているかを確認します。特に以下のような観点が重要です:

  • データの保存(Save
  • データの取得(FindByID, FindAll
  • データの更新(Update
  • データの削除(Delete
repository/post_repository_test.go
package repository_test

import (
	"go-gin-blog-api/model"
	"go-gin-blog-api/repository"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestSaveAndFindByID(t *testing.T) {
	repo := repository.NewPostRepository()
	post := model.Post{ID: "1", Title: "First", Content: "Hello", Author: "Alice"}

	repo.Save(post)
	found, ok := repo.FindByID("1")

	assert.True(t, ok)
	assert.Equal(t, "First", found.Title)
	assert.Equal(t, "Alice", found.Author)
}

func TestFindAll(t *testing.T) {
	repo := repository.NewPostRepository()
	repo.Save(model.Post{ID: "1"})
	repo.Save(model.Post{ID: "2"})

	all := repo.FindAll()
	assert.Len(t, all, 2)
}

func TestUpdate_Success(t *testing.T) {
	repo := repository.NewPostRepository()
	repo.Save(model.Post{ID: "1", Title: "Old"})

	updated := model.Post{Title: "New"}
	post, ok := repo.Update("1", updated)

	assert.True(t, ok)
	assert.Equal(t, "New", post.Title)
}

func TestUpdate_Failure(t *testing.T) {
	repo := repository.NewPostRepository()
	updated := model.Post{Title: "New"}
	post, ok := repo.Update("99", updated)

	assert.False(t, ok)
	assert.Nil(t, post)
}

func TestDelete_Success(t *testing.T) {
	repo := repository.NewPostRepository()
	repo.Save(model.Post{ID: "1"})

	ok := repo.Delete("1")
	assert.True(t, ok)

	_, found := repo.FindByID("1")
	assert.False(t, found)
}

func TestDelete_Failure(t *testing.T) {
	repo := repository.NewPostRepository()

	ok := repo.Delete("999")
	assert.False(t, ok)
}

ポイント

  • assert を使って意図通りのデータ操作が行われているかチェック。
  • インメモリ実装なので、副作用なく高速にテストできる。
  • UpdateDelete の 失敗パターン もテストしておくことで、堅牢さがアップ。

DBモック or 実データベースを選ぶ観点

リポジトリ層やサービス層のテストでは「DBアクセスが絡む」ため、以下のどちらかの戦略を取る必要があります。

  • DBをモック化してテスト(テストダブル)
  • 実際のDB(例えばSQLiteやDocker上のMySQL)を起動してテスト

判断軸となる観点

観点 モック(testify.Mockなど) 実データベース
実行速度 高速 比較的遅い(初期化コストあり)
失敗パターンの再現性 柔軟にシミュレート可能(エラーも任意で発生させられる) 実際の挙動に依存するため再現しづらいことも
信頼性 あくまで"想定通りに使われていれば"OK DBの実装通りに動作するかを検証できる
スキーマ検証 できない(フィールド名や型ミスに気づきにくい) できる(マイグレーション後の整合性チェックも可能)
依存性注入の設計力 要求される(インターフェース化が必須) あまり意識しなくても動くが設計が疎かになりやすい
CI導入のしやすさ 容易(DB不要) 工夫が必要(DockerやTestcontainersなどで起動)

併用戦略がおすすめ

  • ユニットテスト(サービス単体) → モック
    • 依存を分離し、ロジックの動作確認に集中
    • モックの組み立ては少し手間だが、速度と柔軟性に優れる
  • インテグレーションテスト(実装全体) → SQLite or Docker MySQL
    • スキーマが壊れていないか?本当に保存されるか?などを確認
    • github.com/ory/dockertest などを使えばCIでも実行可能

統合テスト(Integration Test)

以下のように Service層 + Repository層(インメモリ or モックなしの実装) を統合してテストします。

[model][repository][service][統合テスト]
  • Handler(=HTTP層)は含めず、アプリケーションロジックだけを対象にします。
  • 外部ライブラリへの依存やHTTPルーティングを切り離すことで、処理の正確さにフォーカスできます。

ServiceとRepositoryの両方を本物の実装で使ったテストコード例です。

integration/post_integration_test.go
package integration_test

import (
	"go-gin-blog-api/model"
	"go-gin-blog-api/repository"
	"go-gin-blog-api/service"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestIntegration_PostServiceLifecycle(t *testing.T) {
	repo := repository.NewPostRepository()
	svc := service.NewPostService(repo)

	// Create
	post := model.Post{
		ID:      "200",
		Title:   "Test Lifecycle",
		Content: "This post will be updated and deleted.",
		Author:  "TestBot",
	}
	created := svc.Create(post)

	// GetByID
	got, found := svc.GetByID("200")
	assert.True(t, found)
	assert.Equal(t, created, got)

	// Update
	updatedPost := model.Post{
		Title:   "Updated Title",
		Content: "Updated content",
	}
	updated, ok := svc.Update("200", updatedPost)
	assert.True(t, ok)
	assert.Equal(t, "Updated Title", updated.Title)
	assert.Equal(t, "Updated content", updated.Content)

	// List
	all := svc.List()
	assert.Len(t, all, 1)
	assert.Equal(t, "Updated Title", all[0].Title)

	// Delete
	deleted := svc.Delete("200")
	assert.True(t, deleted)

	// Confirm Deletion
	_, found = svc.GetByID("200")
	assert.False(t, found)
}

ユニットテストでどれだけカバーしても、組み合わせたときにバグが出ることはあります。統合テストはそのギャップを埋めるための保険になります。特に、開発後期になるにつれ、「本番環境と同様の構成でしっかり動くか?」という信頼性が重要になってきます。

おわりに

本記事では、Go × Gin × MVC構成で開発したWeb APIに対して、単体テスト・統合テストを組み合わせながら、堅牢なテスト基盤を整えていく流れを紹介しました。

実際に手を動かしながら、

  • 依存の切り出しによってユニットテストが可能になる構成
  • testify/mock を活用したテストの記述方法
  • 本物の実装を組み合わせた統合テストの考え方

を体感できたかと思います。

テストを書くという行為は「品質保証」だけでなく、設計の健全性を見直す機会にもなります。今回紹介したパターンは小規模な開発から始められ、将来的な規模拡大にも耐えられる構成です。

次回予告

次の記事では、さらに一歩進めて本番運用を意識した構成改善に取り組みます。
CIへの統合や本番環境でも安心して運用できるための準備として、設定の整理・自動テスト実行などを扱っていく予定です。

https://shinagawa-web.com/blogs/go-gin-production-ready

Xでシェア
Facebookでシェア
LinkedInでシェア

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する技術ブログ

弊社の技術支援サービス

お問い合わせ

経営と現場をつなぐ“共創型”の技術支援。
成果に直結するチーム・技術・プロセスを共に整えます。

お問い合わせ