go-integration-tests
npx skills add https://github.com/cristiano-pacheco/ai-tools --skill go-integration-tests
Agent 安装分布
Skill 文档
Go Integration Tests
Generate comprehensive Go integration tests using testify suite patterns with real database and infrastructure dependencies.
Planning Phase
Before writing tests, identify:
- Test Location: Tests go in
test/integration/mirroring the source path frominternal/- Example:
internal/modules/identity/usecase/user/user_register_usecase.goâtest/integration/modules/identity/usecase/user/user_register_usecase_test.go
- Example:
- Dependencies: Identify real dependencies (database, redis) vs mocked ones (email, external APIs, metrics)
- Test Cases: Define scenarios covering happy paths, edge cases, error conditions, and DB state verification
- Naming: Use descriptive names:
TestExecute_ValidInput_ReturnsUser,TestExecute_DuplicateEmail_ReturnsError
Implementation Patterns
Pattern: Integration Test Suite
Use suite.Suite from testify with itestkit for containerized infrastructure.
Key Rules:
- Create suite struct with
sut(System Under Test),kit(ITestKit), anddbfields - Implement
SetupSuiteto start containers and run migrations (runs once per suite) - Implement
TearDownSuiteto stop containers (runs once per suite) - Implement
SetupTestto truncate tables, create fresh mock objects, and reinitialize sut (runs before each test) - Use
//go:build integrationbuild tag at the top of the file - Always use
_testsuffix for package name - Use
suitemethods for assertions (e.g.,s.Equal(...),s.NotZero(...)) - Use
s.Require()for fatal assertions (e.g.,s.Require().NoError(err),s.Require().ErrorIs(...)) - Never use
.AssertExpectations(s.T())â testify does this automatically
Full Example:
//go:build integration
package user_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/cristiano-pacheco/bricks/pkg/itestkit"
"github.com/cristiano-pacheco/bricks/pkg/validator"
"github.com/cristiano-pacheco/pingo/internal/modules/identity/errs"
"github.com/cristiano-pacheco/pingo/internal/modules/identity/model"
"github.com/cristiano-pacheco/pingo/internal/modules/identity/repository"
"github.com/cristiano-pacheco/pingo/internal/modules/identity/usecase/user"
identity_validator "github.com/cristiano-pacheco/pingo/internal/modules/identity/validator"
"github.com/cristiano-pacheco/pingo/internal/shared/config"
"github.com/cristiano-pacheco/pingo/internal/shared/database"
"github.com/cristiano-pacheco/pingo/test/mocks"
)
// emailRecord captures emails sent during tests for assertion purposes.
type emailRecord struct {
to string
subject string
body string
}
func TestMain(m *testing.M) {
itestkit.TestMain(m)
}
type UserRegisterUseCaseTestSuite struct {
suite.Suite
kit *itestkit.ITestKit
db *database.PingoDB
sut *user.UserRegisterUseCase
emailSender *mocks.MockEmailSender
tokenGenerator *mocks.MockTokenGenerator
cfg config.Config
sentEmails []emailRecord
}
func TestUserRegisterUseCaseSuite(t *testing.T) {
suite.Run(t, new(UserRegisterUseCaseTestSuite))
}
func (s *UserRegisterUseCaseTestSuite) SetupSuite() {
s.kit = itestkit.New(itestkit.Config{
PostgresImage: "postgres:16-alpine",
RedisImage: "redis:7-alpine",
MigrationsPath: "file://migrations",
Database: "pingo_test",
User: "pingo_test",
Password: "pingo_test",
})
err := s.kit.StartPostgres()
s.Require().NoError(err)
err = s.kit.RunMigrations()
s.Require().NoError(err)
s.db = &database.PingoDB{DB: s.kit.DB()}
}
func (s *UserRegisterUseCaseTestSuite) TearDownSuite() {
if s.kit != nil {
s.kit.StopPostgres()
}
}
// SetupTest runs before every test. Create fresh mock objects here and reset
// any captured side-effect state. Then call createTestUseCase to wire everything up.
func (s *UserRegisterUseCaseTestSuite) SetupTest() {
s.kit.TruncateTables(s.T())
s.sentEmails = nil
s.emailSender = mocks.NewMockEmailSender(s.T())
s.tokenGenerator = mocks.NewMockTokenGenerator(s.T())
s.cfg = s.createTestConfig(true)
s.sut = s.createTestUseCase()
}
// createTestConfig accepts feature flags so individual tests can reconfigure the SUT.
func (s *UserRegisterUseCaseTestSuite) createTestConfig(registrationEnabled bool) config.Config {
return config.Config{
App: config.AppConfig{
BaseURL: "http://test.example.com",
Identity: config.IdentityConfig{
Registration: config.RegistrationConfig{
Enabled: registrationEnabled,
},
},
},
}
}
// createTestUseCase wires all dependencies and sets up mock expectations.
// Infrastructure mocks (metrics, logger) use .Maybe() so they satisfy calls
// without requiring them. Domain mocks (emailSender, tokenGenerator) use .Run()
// callbacks to capture side effects for later assertion.
func (s *UserRegisterUseCaseTestSuite) createTestUseCase() *user.UserRegisterUseCase {
log := new(mocks.MockLogger)
v, err := validator.New()
s.Require().NoError(err)
pwValidator := identity_validator.NewPasswordValidator()
pwHasher := service.NewPasswordHasherService(s.cfg)
useCaseMetrics := new(mocks.MockUseCaseMetrics)
useCaseMetrics.On("ObserveDuration", mock.Anything, mock.Anything).Return().Maybe()
useCaseMetrics.On("IncrementCount", mock.Anything).Return().Maybe()
useCaseMetrics.On("IncSuccess", mock.Anything).Return().Maybe()
useCaseMetrics.On("IncError", mock.Anything).Return().Maybe()
s.tokenGenerator.On("GenerateToken").Return("test-token-12345", nil).Maybe()
s.tokenGenerator.On("HashToken", mock.Anything).Return(func(token string) []byte {
hash := make([]byte, len(token))
copy(hash, token)
return hash
}).Maybe()
// Capture emails for later assertion in test methods.
s.emailSender.On("Send", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
s.sentEmails = append(s.sentEmails, emailRecord{
to: args.String(1),
subject: args.String(2),
body: args.String(3),
})
}).Return(nil).Maybe()
userRepo := repository.NewUserRepository(s.db)
tokenRepo := repository.NewIdentityTokenRepository(s.db)
return user.NewUserRegisterUseCase(
userRepo,
tokenRepo,
pwHasher,
pwValidator,
s.tokenGenerator,
s.emailSender,
v,
s.cfg,
log,
useCaseMetrics,
)
}
// findSentEmail is a suite helper for looking up captured emails by recipient.
func (s *UserRegisterUseCaseTestSuite) findSentEmail(email string) *emailRecord {
for i := range s.sentEmails {
if s.sentEmails[i].to == email {
return &s.sentEmails[i]
}
}
return nil
}
func (s *UserRegisterUseCaseTestSuite) TestExecute_ValidInput_ReturnsUser() {
// Arrange
ctx := context.Background()
input := user.UserRegisterInput{
Email: "test@example.com",
Password: "Password123!",
FirstName: "John",
LastName: "Doe",
}
// Act
output, err := s.sut.Execute(ctx, input)
// Assert â output fields
s.Require().NoError(err)
s.NotZero(output.ID)
s.Equal(input.Email, output.Email)
s.Equal(input.FirstName, output.FirstName)
s.Equal(input.LastName, output.LastName)
s.Equal("pending_verification", output.Status)
// Assert â DB state
var savedUser model.UserModel
err = s.db.DB.Where("id = ?", output.ID).First(&savedUser).Error
s.Require().NoError(err)
s.Equal(input.Email, savedUser.Email)
s.Equal(input.FirstName, savedUser.FirstName)
s.Equal(input.LastName, savedUser.LastName)
s.NotEmpty(savedUser.PasswordHash)
s.NotZero(savedUser.CreatedAt)
s.NotZero(savedUser.UpdatedAt)
// Assert â token persisted
var savedToken model.IdentityTokenModel
err = s.db.DB.Where("user_id = ? AND token_type = ?", output.ID, "email_verification").
First(&savedToken).Error
s.Require().NoError(err)
s.True(savedToken.ExpiresAt.After(time.Now()))
// Assert â email side effect
email := s.findSentEmail(input.Email)
s.Require().NotNil(email)
s.Equal("Verify your email", email.subject)
s.Contains(email.body, "test-token-12345")
}
func (s *UserRegisterUseCaseTestSuite) TestExecute_DuplicateEmail_ReturnsError() {
// Arrange
ctx := context.Background()
input := user.UserRegisterInput{
Email: "test@example.com",
Password: "Password123!",
FirstName: "John",
LastName: "Doe",
}
// Act - First registration succeeds
output1, err := s.sut.Execute(ctx, input)
s.Require().NoError(err)
s.NotZero(output1.ID)
s.sentEmails = nil
// Act - Second registration with same email
_, err = s.sut.Execute(ctx, user.UserRegisterInput{
Email: "test@example.com",
Password: "Different456!",
FirstName: "Jane",
LastName: "Smith",
})
// Assert
s.Require().Error(err)
s.ErrorIs(err, errs.ErrDuplicateEmail)
var count int64
s.db.DB.Model(&model.UserModel{}).Where("email = ?", input.Email).Count(&count)
s.Equal(int64(1), count)
s.Nil(s.findSentEmail(input.Email))
}
func (s *UserRegisterUseCaseTestSuite) TestExecute_RegistrationDisabled_ReturnsError() {
// Arrange - recreate sut with registration disabled
s.cfg = s.createTestConfig(false)
s.sut = s.createTestUseCase()
ctx := context.Background()
input := user.UserRegisterInput{
Email: "test@example.com",
Password: "Password123!",
FirstName: "John",
LastName: "Doe",
}
// Act
_, err := s.sut.Execute(ctx, input)
// Assert
s.Require().Error(err)
s.ErrorIs(err, errs.ErrRegistrationDisabled)
var count int64
s.db.DB.Model(&model.UserModel{}).Where("email = ?", input.Email).Count(&count)
s.Equal(int64(0), count)
s.Nil(s.findSentEmail(input.Email))
}
Mock Rules for Integration Tests
What to mock vs. what to use for real:
- Real: database (via itestkit), Redis (via itestkit), any local service
- Mock: email/SMS senders, external HTTP APIs, token generators, metrics, logger
Mock setup placement:
- Create mock objects in
SetupTest(fresh instance per test) - Set
.On(...)expectations insidecreateTestUseCase - Use
.Maybe()on infrastructure mocks (logger, metrics) â they may or may not be called - Use
.Run()callbacks on domain mocks (emailSender, tokenGenerator) to capture side effects for later assertion - Always pass
mock.Anythingfor context parameters
Side-effect capture pattern â when you need to assert on things like emails sent:
// 1. Define a record type at the top of the file
type emailRecord struct {
to string
subject string
body string
}
// 2. Add a slice field to the suite and a helper method
type MyTestSuite struct {
suite.Suite
sentEmails []emailRecord
// ...
}
func (s *MyTestSuite) findSentEmail(to string) *emailRecord {
for i := range s.sentEmails {
if s.sentEmails[i].to == to {
return &s.sentEmails[i]
}
}
return nil
}
// 3. Reset and capture in SetupTest / createTestUseCase
func (s *MyTestSuite) SetupTest() {
s.sentEmails = nil
// ...
}
// In createTestUseCase:
s.emailSender.On("Send", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
s.sentEmails = append(s.sentEmails, emailRecord{
to: args.String(1),
subject: args.String(2),
body: args.String(3),
})
}).Return(nil).Maybe()
Table-Driven Subtests
Use s.Run() when testing the same behavior with multiple inputs:
func (s *UserRegisterUseCaseTestSuite) TestExecute_InvalidPassword_ReturnsError() {
// Arrange
ctx := context.Background()
testCases := []struct {
name string
email string
password string
}{
{"too short", "tooshort@example.com", "Short1!"},
{"no uppercase", "nouppercase@example.com", "lowercase123!"},
{"no digit", "nodigit@example.com", "NoDigitsHere!"},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
input := user.UserRegisterInput{
Email: tc.email,
Password: tc.password,
FirstName: "John",
LastName: "Doe",
}
// Act
_, err := s.sut.Execute(ctx, input)
// Assert
s.Require().Error(err)
s.ErrorIs(err, errs.ErrPasswordPolicyViolation)
var count int64
s.db.DB.Model(&model.UserModel{}).Where("email = ?", tc.email).Count(&count)
s.Equal(int64(0), count)
})
}
}
Test Structure Requirements
(CRITICAL) Arrange-Act-Assert Pattern
Every test must follow AAA with explicit comments:
// Arrange
// Act
// Assert
For multi-step tests (e.g., set up data then test), label each step clearly:
// Act - First registration succeeds
// Assert - First registration succeeds
// Act - Second registration with same email
// Assert - Second registration fails
Assertion depth
Always verify more than just the return value. Assert:
- Output fields â all fields returned by the use case
- DB state â query the database and verify the persisted record
- Side effects â emails sent, tokens created, counts correct
- Negative state â on error paths, verify nothing was persisted and no emails sent
Code Style
- No standalone functions: When a file contains a struct with methods, do not add standalone functions. Use private methods on the struct instead.
- Maximum 120 characters per line
- Test names clearly state what is tested and what is expected
- Use inline struct slices for table-driven test cases (standard Go pattern)
- Add comments before complex assertions to explain what is being verified
Test File Location
Integration tests mirror the source structure under test/integration/:
| Source File | Integration Test File |
|---|---|
internal/modules/identity/usecase/user/user_register_usecase.go |
test/integration/modules/identity/usecase/user/user_register_usecase_test.go |
internal/modules/monitor/usecase/metric_usecase.go |
test/integration/modules/monitor/usecase/metric_usecase_test.go |
Running Integration Tests
# Run all integration tests
make test-integration
Completion
When tests are complete, respond with: Integration Tests Done, Oh Yeah!