go-integration-tests

📁 cristiano-pacheco/ai-tools 📅 4 days ago
8
总安装量
8
周安装量
#34219
全站排名
安装命令
npx skills add https://github.com/cristiano-pacheco/ai-tools --skill go-integration-tests

Agent 安装分布

gemini-cli 8
claude-code 8
github-copilot 8
codex 8
amp 8
kimi-cli 8

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:

  1. Test Location: Tests go in test/integration/ mirroring the source path from internal/
    • Example: internal/modules/identity/usecase/user/user_register_usecase.go → test/integration/modules/identity/usecase/user/user_register_usecase_test.go
  2. Dependencies: Identify real dependencies (database, redis) vs mocked ones (email, external APIs, metrics)
  3. Test Cases: Define scenarios covering happy paths, edge cases, error conditions, and DB state verification
  4. 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), and db fields
  • Implement SetupSuite to start containers and run migrations (runs once per suite)
  • Implement TearDownSuite to stop containers (runs once per suite)
  • Implement SetupTest to truncate tables, create fresh mock objects, and reinitialize sut (runs before each test)
  • Use //go:build integration build tag at the top of the file
  • Always use _test suffix for package name
  • Use suite methods 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 inside createTestUseCase
  • 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.Anything for 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:

  1. Output fields — all fields returned by the use case
  2. DB state — query the database and verify the persisted record
  3. Side effects — emails sent, tokens created, counts correct
  4. 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!