How Unit Tests Saved My Go Service: A Hands‑On Guide to Testing Core Modules

This article walks through a real‑world Go project, showing why adding unit tests to core modules prevents regressions, how to mock tightly‑coupled dependencies, and provides complete example test code and best‑practice tips for reliable backend development.

Code Wrench
Code Wrench
Code Wrench
How Unit Tests Saved My Go Service: A Hands‑On Guide to Testing Core Modules

1. The incident that changed my view on testing

During a routine iteration of the easyms.golang project I modified a few lines in TokenService. The change passed local tests and CI, but after deployment the login API started failing in edge cases because the affected core branch had no unit‑test coverage.

"A core module without tests is like a building without a foundation – you never know when it will collapse."

Realising this, I decided to add systematic unit tests for the core business logic.

2. The real challenge: testing highly coupled code

The example focuses on the auth‑svc package, specifically the UsernamePasswordTokenGranter struct, which implements the password grant flow. Its Grant method depends on two external interfaces:

UserDetailsService : loads user information by username.

TokenService : creates an access token after successful validation.

type UsernamePasswordTokenGranter struct {
    userDetailsService UserDetailsService
    tokenService       TokenService
}

func (t *UsernamePasswordTokenGranter) Grant(...) (*models.OAuth2Token, error) {
    userDetails, err := t.userDetailsService.LoadUserByUsername(...)
    // validate password
    if !userDetails.CheckPassword(...) { ... }
    return t.tokenService.CreateAccessToken(...)
}

The question is: how to write unit tests for such code?

3. Core principle of unit testing: isolation, not full‑process replication

Unit tests should verify only the logic of the function under test, not the entire system. We care about:

User exists / does not exist

Password correct / incorrect

Each branch returns the expected result

We deliberately ignore where the user data comes from, how the token is generated, or the actual database state.

4. Solution: use mocks to isolate dependencies

Introduce mock objects for UserDetailsService and TokenService using testify/mock. The mock simply records calls and returns pre‑configured results.

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

type mockUserDetailsService struct { mock.Mock }

func (m *mockUserDetailsService) LoadUserByUsername(username string) (*model.UserDetails, error) {
    args := m.Called(username)
    if args.Get(0) == nil { return nil, args.Error(1) }
    return args.Get(0).(*model.UserDetails), args.Error(1)
}

The essence of a mock is: "Real logic is omitted, behavior is specified by the test."

5. Writing tests as a script (Arrange‑Act‑Assert)

Example 1: successful login.

func TestUsernamePasswordTokenGranter_Grant_Success(t *testing.T) {
    // Arrange
    mockUserSvc := new(mockUserDetailsService)
    mockTokenSvc := new(mockTokenService)
    granter := NewUsernamePasswordTokenGranter("password", mockUserSvc, mockTokenSvc)

    correctUser := &model.UserDetails{Username: "testuser", Password: "password"}
    assert.NoError(t, correctUser.HashPassword())

    clientDetails := &model.ClientDetails{ClientId: "test-client", AllowedAuthorities: "read,write"}
    tokenRequest := &model.TokenRequest{Username: "testuser", Password: "password"}
    expectedToken := &model.OAuth2Token{TokenValue: "success-token", ExpiresTime: func() *time.Time { t := time.Now().Add(time.Hour); return &t }()}

    mockUserSvc.On("LoadUserByUsername", "testuser").Return(correctUser, nil)
    mockUserSvc.On("GetUserAllowedScopes", correctUser.UserId).Return([]string{"read", "write"})
    mockTokenSvc.On("CreateAccessToken", mock.MatchedBy(func(d *model.OAuth2Details) bool {
        return d.Client.ClientId == "test-client" && d.User.Username == "testuser" && (d.Scopes == "read,write" || d.Scopes == "write,read")
    })).Return(expectedToken, nil)

    // Act
    token, err := granter.Grant(context.Background(), "password", clientDetails, tokenRequest)

    // Assert
    assert.NoError(t, err)
    assert.NotNil(t, token)
    assert.Equal(t, "success-token", token.TokenValue)
    mockUserSvc.AssertExpectations(t)
    mockTokenSvc.AssertExpectations(t)
}

Example 2: user not found.

func TestUsernamePasswordTokenGranter_Grant_UserNotFound(t *testing.T) {
    mockUserSvc := new(mockUserDetailsService)
    mockTokenSvc := new(mockTokenService)
    granter := NewUsernamePasswordTokenGranter("password", mockUserSvc, mockTokenSvc)

    clientDetails := &model.ClientDetails{ClientId: "test-client"}
    tokenRequest := &model.TokenRequest{Username: "unknownuser", Password: "password"}

    mockUserSvc.On("LoadUserByUsername", "unknownuser").Return(nil, errors.New("user not found"))

    _, err := granter.Grant(context.Background(), "password", clientDetails, tokenRequest)
    assert.Error(t, err)
    assert.Equal(t, consts.ErrInvalidUsernameAndPasswordRequest, err)
    mockUserSvc.AssertCalled(t, "LoadUserByUsername", "unknownuser")
    mockTokenSvc.AssertNotCalled(t, "CreateAccessToken", mock.Anything)
}
In failure scenarios, TokenService must never be invoked.

6. Three personal changes after adding core tests

Less risky refactoring : I now rely on tests instead of gut feeling.

Design clarity : Tests expose hidden dependencies, prompting better module boundaries.

Living documentation : Test cases clearly describe expected behavior, far more reliable than stale comments.

7. Final thoughts

Writing unit tests adds short‑term effort that is invisible to managers and customers, but the long‑term benefits—greater system stability, safer refactoring, professional engineering habits, and early bug detection—far outweigh the cost.

Start adding unit tests to the core code of your project; not every file needs a test, but critical paths certainly do.

Project links

GitHub: https://github.com/louis-xie-programmer/easyms.golang

Gitee: https://gitee.com/louis_xie/easyms.golang

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Backendunit testingMocktest-driven development
Code Wrench
Written by

Code Wrench

Focuses on code debugging, performance optimization, and real-world engineering, sharing efficient development tips and pitfall guides. We break down technical challenges in a down-to-earth style, helping you craft handy tools so every line of code becomes a problem‑solving weapon. 🔧💻

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.