Robust Test Design and Implementation Guide in a Go × Gin × MVC Architecture
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, andrepository - How to use test libraries that work well with Gin apps, such as
httptestandtestify - 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:
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 service → repository, 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
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 bymock.On(...).Return(...).
Test case: Normal case (post is found)
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 dummyPost.
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)
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
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
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.Postargs.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
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)
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
UpdateandDeleteimproves 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.) |
Using both is recommended
- 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.
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.
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
Complete Cache Strategy Guide: Maximizing Performance with CDN, Redis, and API Optimization
2024/03/07Getting Started with Building Web Servers Using Go × Echo: Learn Simple, High‑Performance API Development in the Shortest Time
2023/12/03Go × Gin Advanced: Practical Techniques and Scalable API Design
2023/11/29Go × Gin Basics: A Thorough Guide to Building a High-speed API Server
2023/11/23Article & Like API with Go + Gin + GORM (Part 1): First, an "implementation-first controller" with all-in-one CRUD
2025/07/13Building an MVC-Structured Blog Post Web API with Go × Gin: From Basics to Scalable Design
2023/12/03Bringing a Go + Gin App Up to Production Quality: From Configuration and Structure to CI
2023/12/06Released a string slice utility for Go: “strlistutils”
2025/06/19

