Robust Test Design and Implementation Guide in a Go × Gin × MVC Architecture

  • gin
    gin
  • golang
    golang
Published on 2023/12/04

Introduction

Designing and implementing an API itself is not that difficult.
However, raising it to a level where it has “quality that can be continuously improved” requires careful test design and architecture.

In the previous article, we built a blog post API with an MVC structure using Go × Gin.
As a sequel, this article dives into test design and implementation to evolve this application into something more robust and safe to operate.

Theme of this article

This article focuses on the following:

  • How to think about and distinguish between unit tests and integration tests
  • Test strategies for each layer: handler, service, and repository
  • How to use test libraries that work well with Gin apps, such as httptest and testify
  • Simple persistence with SQLite in a local environment and how to incorporate it into tests

You may sometimes feel that “testing = annoying.”
But having proper tests in place also accelerates development speed.
Being able to add features with confidence is a huge advantage, whether for a team or for solo development.

As a first step, let’s work together on realistic and practical test design.
We’ll introduce plenty of samples so you can learn while running the code.

Position of this article (Part 2 of the series)

This is the second article in a three-part series on API development with Go × Gin × MVC architecture.

Part Title Overview
Part1 Build an MVC-structured Blog Post Web API with Go × Gin: From Basics to Scalable Design Explains the basics of MVC and the API implementation flow. Builds CRUD with in-memory storage
👉 Part2 Robust Test Design and Implementation Guide in a Go × Gin × MVC Architecture Explains test policies and implementation methods for each layer with concrete examples (this article)
Part3 Preparing a Go × Gin App for Production: Architecture Refinement and CI Integration Practical operations design such as database introduction, environment separation, and CI/CD setup

The code created in this article is available in the following repository:

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

Differences and Choices Between Unit Tests / Integration Tests / E2E

“Tests” are essential to support application quality, but there are several types of tests. Here weorganize the differences between unit tests, integration tests, and E2E (End-to-End) tests, and clarify where and how each should be used.

Unit Tests

Target: Individual functions or methods
Purpose: Guarantee the correctness of logic in isolation

For example, you verify that the GetByID method in the service layer behaves correctly while excluding external dependencies such as DBs or external APIs.

Benefits

  • Lightweight processing and fast execution
  • Easy to isolate problems
  • Can use mocks to flexibly verify various cases

Use cases

  • Service logic
  • Helper functions
  • Validation processing

Integration Tests

Target: Processing where multiple components work together
Purpose: Verify that interactions between layers work correctly

For example, you use the actual implementations such as servicerepository, simulate HTTP requests, and verify that the internal flow of the application is correct.

Benefits

  • Can verify behavior in a way close to actual operation
  • Does not rely on mocks, which gives more confidence

Caveats

  • Test code is strongly affected by implementation changes
  • Tends to be somewhat slower

E2E Tests (End-to-End Tests)

Target: Verify the entire app in an environment close to production
Purpose: Confirm final behavior from the user’s perspective

You run tests with the server actually running, via a browser or HTTP client.
For example, E2E tests verify that a sequence like “create post → list posts → delete post” works the same way as in production.

Benefits

  • Close to the production environment, making it easier to find real bugs
  • Can detect integration errors between UI and API

Drawbacks

  • Heavy setup
  • Execution time tends to be long
  • Troubleshooting can be difficult

How to choose and balance

Test type Speed Reliability Purpose
Unit test Correctness of processing
Integration test Verifying connectivity of features
E2E test Reproducing real-world usage

In practice, the following balance is effective:

  • Unit tests: Focus on covering core logic
  • Integration tests: Cover normal and error paths per use-case path
  • E2E tests: Keep to a minimum and use for regression and CI

In the next section, we’ll introduce concrete test methods for each of handler, service, and repository while mapping them to actual code.

Basic Test Policy for handler / service / repository Layers

In a Go × Gin × MVC architecture, responsibilities are clearly separated by layer, so it’s important to think about different test approaches for each layer.
This section organize what to verify and at what granularity for each layer.

Handler layer: Test behavior at the HTTP level

Handlers receive requests, parse parameters, and delegate necessary processing to the service layer. Tests mainly verify the following:

  • Receiving and validating request parameters
  • Normal and error cases of service calls
  • Returning appropriate HTTP status codes and response bodies

By using a mock service, you can focus solely on the handler.

Service layer: Test business logic in isolation

The service layer is the core of domain logic and handles a variety of use cases. As test targets:

  • Consistency between input and output
  • Control of error cases (e.g., non-existent IDs, invalid data)
  • Branching behavior based on repository call results

Replace the repository with a mock and focus on verifying pure logic.

Repository layer: Test data retrieval and persistence

In this layer, it’s important to verify data consistency and conditional branching. In this sample we use an in-memory implementation, but the following points are common even if you later use a DB:

  • Normal and error cases for Save / FindAll / FindByID / Update / Delete
  • Whether deletions and additions are correctly reflected in the list
  • Verification of synchronization (mutex) and ordering

Mocks are unnecessary; you can test the in-memory implementation directly.

Summary of test policy

Layer Test target Use of mocks
handler Request handling and response processing Mock the service
service Correctness of domain logic Mock the repository
repository Consistency of data operations Not needed (test directly)

By clearly separating test granularity and responsibility in each layer, you can achieve a robust and maintainable test structure.

Testing the handler Layer

The handler layer plays the role of an “entry point”: it receives requests via Gin routing, parses parameters, calls the service layer, and returns responses.
The main purpose here is to verify that “the expected response is returned for a given HTTP request.”

✅ Test goals

  • For valid requests, appropriate status codes and responses are returned
  • For non-existent data or when errors occur, appropriate error responses are returned
  • Verify handler invocation via the Gin router

Test structure

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
}

Explanation of the code

type mockPostService struct {
	mock.Mock
}

By embedding mock.Mock, you can use .Called() to manage arguments and return values.

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) verifies the arguments registered in advance.
  • args.Get(0) is the return value specified by mock.On(...).Return(...).

Test case: Normal case (post is found)

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)
}

Explanation of the code

gin.SetMode(gin.TestMode)
  • Used to suppress logs and reduce noise during tests.
mockSvc := NewMockPostService()
mockSvc.On("GetByID", "1").Return(&model.Post{ ... }, nil)
  • Defines that when GetByID("1") is called, it returns a dummy Post.
h := handler.NewPostHandler(mockSvc)
r := gin.Default()
r.GET("/posts/:id", h.GetPostByID)

  • Mounts the handler on the router.
req, _ := http.NewRequest("GET", "/posts/1", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

  • Simulates actually hitting 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)
  • Verifies that the status code and response JSON match.
  • Also verifies that the mock was called with the expected arguments.

Test case: Error case (post does not exist)

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())
}

Running the tests

Let’s actually run the test code.

go test -v ./handler

Image from Gyazo

What to verify with mocks

  • Tests clearly separate the handler’s dependency on the service’s behavior
  • By using Gin’s ServeHTTP, you can verify the full request/response flow through the router

Next, we’ll directly test the logic in the service layer and verify the decision-making on data actually retrieved from the repository.
The more conditional branches there are inside a function, the more carefully you should verify it with unit tests.

Testing the service Layer

The service layer is the core part that handles business logic.
Mock external dependencies such as repositories and test only the correctness of the logic itself.

Directory structure

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

By separating into a service_test directory, you can avoid circular dependencies and test from the perspective of external usage (once you get used to it, testing inside service/ is also fine).

Test structure

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"
)

// Mock repository
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)
}

Explanation of the code

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)
}

  • For the argument id, you can set return values in tests like On("FindByID", "1")....
  • args.Get(0)*model.Post
  • args.Bool(1)true/false (whether it was found)
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
}

  • Mimics the process of saving a model
  • The returned value can be specified with 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
}
  • Mimics updating with an ID and updated data
  • Can easily reproduce both success and failure patterns
func (m *mockPostRepository) Delete(id string) bool {
	args := m.Called(id)
	return args.Bool(0)
}

  • Mimics success/failure of deleting the specified ID
func (m *mockPostRepository) FindAll() []model.Post {
	args := m.Called()
	return args.Get(0).([]model.Post)
}

  • Retrieves a list of posts. The returned slice can also be specified with On("FindAll")...

Test case: Normal case (post is found)

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)
}

Test case: Error case (post does not exist)

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)
}

Running the tests

Let’s actually run the test code.

go test -v ./service_test

Image from Gyazo

Testing the Repository Layer

In repository layer tests, you verify that CRUD operations on data are performed correctly. The following points are particularly important:

  • Saving data (Save)
  • Retrieving data (FindByID, FindAll)
  • Updating data (Update)
  • Deleting data (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)
}

Key points

  • Use assert to check that data operations behave as intended.
  • Because it’s an in-memory implementation, tests are fast and side-effect free.
  • Testing failure patterns for Update and Delete improves robustness.

Criteria for Choosing DB Mocks vs Real Databases

In tests for the repository and service layers, “DB access is involved,” so you need to choose one of the following strategies:

  • Mock the DB for tests (test doubles)
  • Start a real DB (e.g., SQLite or MySQL on Docker) for tests

Evaluation criteria

Aspect Mock (e.g., testify.Mock) Real database
Execution speed Fast Relatively slow (initialization cost)
Reproducibility of failure patterns Can flexibly simulate (can arbitrarily trigger errors) Depends on actual behavior, so sometimes hard to reproduce
Reliability Only “OK if used as expected” Can verify that it works according to the DB implementation
Schema validation Not possible (hard to notice field name or type mistakes) Possible (can also check consistency after migrations)
Need for dependency injection design Required (must use interfaces) Can work without much thought, but design tends to be sloppy
Ease of CI integration Easy (no DB required) Requires some work (start via Docker or Testcontainers, etc.)
  • Unit tests (service in isolation) → Mocks
    • Separate dependencies and focus on verifying logic behavior
    • Setting up mocks takes some effort, but offers speed and flexibility
  • Integration tests (entire implementation) → SQLite or Docker MySQL
    • Verify that the schema is intact and data is actually saved
    • With tools like github.com/ory/dockertest, you can also run them in CI

Integration Tests

Here we integrate the Service layer + Repository layer (in-memory or real implementation without mocks) and test them together.

[model][repository][service][integration test]
  • The handler (HTTP layer) is excluded; only application logic is targeted.
  • By separating from external library dependencies and HTTP routing, you can focus on correctness of processing.

Below is an example test that uses real implementations of both Service and 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)
}

No matter how much you cover with unit tests, bugs can still appear when components are combined. Integration tests serve as insurance to fill that gap. Especially in later development stages, the question “Does it really work in a configuration identical to production?” becomes increasingly important.

Conclusion

This article introduced how to build a robust testing foundation for a Web API developed with Go × Gin × MVC architecture by combining unit tests and integration tests.

By actually writing code, you should have experienced:

  • A structure where extracting dependencies makes unit testing possible
  • How to write tests using testify/mock
  • The concept of integration tests that combine real implementations

Writing tests is not only about “quality assurance,” but also an opportunity to review the soundness of your design. The patterns introduced here can be used from small-scale development and will hold up as the system grows.

Next up

In the next article, we’ll go one step further and work on architecture improvements with production operations in mind.
As preparation for integrating with CI and operating safely in production, we’ll cover configuration organization, automated test execution, and more.

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

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

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form