Kratos Testing Skill
Purpose
Generate comprehensive, table-driven tests for all layers of a Kratos microservice (repository, business logic, service) using testify/assert and testify/mock libraries.
Essential Patterns
1. Repository Layer Tests
File: `internal/data/repo/{entity}_test.go`
Test Setup Pattern
package repo
import (
"context"
"testing"
"{service}/internal/biz/domain"
"{service}/internal/data/model"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Setup in-memory database
func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Errorf("Failed to open in-memory SQLite database: %v", err)
}
if err := db.AutoMigrate(&model.{Entity}{}, &model.{RelatedEntity}{}); err != nil {
t.Errorf("Failed to migrate test tables: %v", err)
}
return db
}
// Cleanup function
func cleanupDB(db *gorm.DB) {
db.Exec("DELETE FROM {related_entities}")
db.Exec("DELETE FROM {entities}")
}
// Helper: valid domain object
func validDomain{Entity}() *domain.{Entity} {
return &domain.{Entity}{
Field1: "value1",
Field2: 123,
// ... all required fields
}
}
// Helper: valid GORM entity
func validEntity{Entity}() *model.{Entity} {
return &model.{Entity}{
Field1: "value1",
Field2: 123,
// ... all required fields
}
}
Table-Driven Test Pattern
func TestCreate(t *testing.T) {
tests := []struct {
name string
input *domain.{Entity}
setup func(*gorm.DB)
wantErr bool
checkError func(*testing.T, error)
checkResult func(*testing.T, *domain.{Entity})
}{
{
name: "success with nested data",
input: validDomain{Entity}(),
setup: func(db *gorm.DB) {},
wantErr: false,
checkResult: func(t *testing.T, result *domain.{Entity}) {
assert.NotNil(t, result)
assert.NotZero(t, result.ID)
assert.Equal(t, "value1", result.Field1)
},
},
{
name: "duplicate entry error",
input: validDomain{Entity}(),
setup: func(db *gorm.DB) {
// Pre-create entity to cause duplicate
entity := validEntity{Entity}()
db.Create(entity)
},
wantErr: true,
checkError: func(t *testing.T, err error) {
assert.ErrorIs(t, err, domain.ErrDataDuplicateEntry)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := setupTestDB(t)
defer cleanupDB(db)
tt.setup(db)
logger := log.NewStdLogger(os.Stdout)
tx := &mockTransaction{}
repo := New{Entity}Repo(db, tx, logger)
result, err := repo.Create(context.Background(), tt.input)
if tt.wantErr {
assert.Error(t, err)
if tt.checkError != nil {
tt.checkError(t, err)
}
} else {
assert.NoError(t, err)
if tt.checkResult != nil {
tt.checkResult(t, result)
}
}
})
}
}
List/Pagination Tests Pattern
func TestList{Entities}(t *testing.T) {
tests := []struct {
name string
offset uint64
limit uint32
filter map[string]interface{}
setup func(*gorm.DB)
wantErr bool
checkResult func(*testing.T, []*domain.{Entity}, *pagination.Meta)
}{
{
name: "success - first page with next page",
offset: 0,
limit: 5,
filter: map[string]interface{}{"project_id": uint64(1)},
setup: func(db *gorm.DB) {
for i := 0; i < 10; i++ {
entity := validEntity{Entity}()
entity.UID = fmt.Sprintf("uid-%d", i)
db.Create(entity)
}
},
wantErr: false,
checkResult: func(t *testing.T, results []*biz.{Entity}, meta *pagination.Meta) {
assert.Len(t, results, 5)
assert.Equal(t, uint64(10), meta.TotalCount)
assert.True(t, meta.HasNextPage)
assert.False(t, meta.HasPreviousPage)
},
},
{
name: "success - filter with pagination",
offset: 0,
limit: 10,
filter: map[string]interface{}{"project_id": uint64(1), "label": "test"},
setup: func(db *gorm.DB) {
// Create entities with matching filter
for i := 0; i < 3; i++ {
entity := validEntity{Entity}()
entity.Label = "test"
db.Create(entity)
}
// Create entities not matching filter
for i := 0; i < 5; i++ {
entity := validEntity{Entity}()
entity.Label = "other"
db.Create(entity)
}
},
wantErr: false,
checkResult: func(t *testing.T, results []*biz.{Entity}, meta *pagination.Meta) {
assert.Len(t, results, 3)
assert.Equal(t, uint64(3), meta.TotalCount) // Only filtered count
for _, r := range results {
assert.Equal(t, "test", r.Label)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := setupTestDB(t)
defer cleanupDB(db)
tt.setup(db)
logger := log.NewStdLogger(os.Stdout)
tx := &mockTransaction{}
repo := New{Entity}Repo(db, tx, logger)
results, meta, err := repo.List{Entities}(context.Background(), tt.offset, tt.limit, tt.filter)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
if tt.checkResult != nil {
tt.checkResult(t, results, meta)
}
}
})
}
}
2. Business Layer Tests (Use Case)
File: `internal/biz/{entity}/usecase_test.go` (new structure) Old File: `internal/biz/{entity}_test.go` (legacy structure)
Mock Repository Pattern
// Package {entity} provides tests for use cases managing {entities}.
package {entity}
import (
"context"
"testing"
"platform/pagination"
"{service}/internal/biz/domain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Mock repository
type Mock{Entity}Repo struct {
mock.Mock
}
func (m *Mock{Entity}Repo) Create(ctx context.Context, entity *domain.{Entity}) (*domain.{Entity}, error) {
args := m.Called(ctx, entity)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.{Entity}), args.Error(1)
}
func (m *Mock{Entity}Repo) FindByID(ctx context.Context, id uint64) (*domain.{Entity}, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.{Entity}), args.Error(1)
}
func (m *Mock{Entity}Repo) List{Entities}(ctx context.Context, offset uint64, limit uint32, filter map[string]interface{}) ([]*domain.{Entity}, *pagination.Meta, error) {
args := m.Called(ctx, offset, limit, filter)
if args.Get(0) == nil {
return nil, nil, args.Error(2)
}
if args.Get(1) == nil {
return args.Get(0).([]*domain.{Entity}), nil, args.Error(2)
}
return args.Get(0).([]*domain.{Entity}), args.Get(1).(*pagination.Meta), args.Error(2)
}
// Test helper - legacy (without event publishing)
func setup{Entity}UseCase(mockRepo *Mock{Entity}Repo) domain.{Entity}UseCase {
logger := log.NewStdLogger(os.Stdout)
validator := NewValidator()
mockTx := new(MockTransaction)
mockTx.On("InTx", mock.Anything, mock.Anything).Return(nil).Maybe()
return NewUseCase(mockRepo, validator, mockTx, logger)
}
func valid{Entity}() *domain.{Entity} {
return &domain.{Entity}{
Field1: "value1",
Field2: 123,
}
}
Use Case Test Pattern
func TestCreate{Entity}(t *testing.T) {
tests := []struct {
name string
input *domain.{Entity}
mockSetup func(*Mock{Entity}Repo, context.Context, *domain.{Entity})
wantErr bool
errContains string
checkResult func(*testing.T, *domain.{Entity})
}{
{
name: "success",
input: valid{Entity}(),
mockSetup: func(repo *Mock{Entity}Repo, ctx context.Context, input *{Entity}) {
output := valid{Entity}()
output.Id = 1
repo.On("Create", ctx, input).Return(output, nil)
},
wantErr: false,
checkResult: func(t *testing.T, result *domain.{Entity}) {
assert.NotZero(t, result.ID)
assert.Equal(t, "value1", result.Field1)
},
},
{
name: "validation error - missing required field",
input: &{Entity}{
Field2: 123,
// Field1 missing
},
mockSetup: func(repo *Mock{Entity}Repo, ctx context.Context, input *{Entity}) {},
wantErr: true,
errContains: "Field1",
},
{
name: "repository error",
input: valid{Entity}(),
mockSetup: func(repo *Mock{Entity}Repo, ctx context.Context, input *{Entity}) {
repo.On("Create", ctx, input).Return(nil, errors.New("database error"))
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := new(Mock{Entity}Repo)
uc := setup{Entity}UseCase(mockRepo)
ctx := context.Background()
tt.mockSetup(mockRepo, ctx, tt.input)
result, err := uc.Create{Entity}(ctx, tt.input)
if tt.wantErr {
assert.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
} else {
assert.NoError(t, err)
if tt.checkResult != nil {
tt.checkResult(t, result)
}
}
mockRepo.AssertExpectations(t)
})
}
}
Filter Passthrough Tests
CRITICAL: Test that use case correctly passes filters to repository
func TestList{Entities}(t *testing.T) {
tests := []struct {
name string
params *pagination.OffsetPaginationParams
filter map[string]interface{}
mockSetup func(*Mock{Entity}Repo, context.Context, uint64, uint32, map[string]interface{})
wantErr bool
checkResult func(*testing.T, []*{Entity}, *pagination.Meta)
}{
{
name: "success - passes filter to repository",
params: &pagination.OffsetPaginationParams{
Offset: 0,
Limit: 10,
},
filter: map[string]interface{}{"project_id": uint64(1)},
mockSetup: func(repo *Mock{Entity}Repo, ctx context.Context, offset uint64, limit uint32, filter map[string]interface{}) {
entities := []*domain.{Entity}{valid{Entity}()}
meta := &pagination.Meta{TotalCount: 1, Offset: 0, Limit: 10}
// Verify exact filter is passed through
repo.On("List{Entities}", ctx, offset, limit, filter).Return(entities, meta, nil)
},
wantErr: false,
},
{
name: "success - passes nil filter to repository",
params: &pagination.OffsetPaginationParams{Offset: 0, Limit: 10},
filter: nil,
mockSetup: func(repo *Mock{Entity}Repo, ctx context.Context, offset uint64, limit uint32, filter map[string]interface{}) {
entities := []*{Entity}{valid{Entity}()}
meta := &pagination.Meta{TotalCount: 1}
repo.On("List{Entities}", ctx, offset, limit, filter).Return(entities, meta, nil)
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := new(Mock{Entity}Repo)
uc := setup{Entity}UseCase(mockRepo)
ctx := context.Background()
tt.mockSetup(mockRepo, ctx, tt.params.Offset, tt.params.Limit, tt.filter)
results, meta, err := uc.List{Entities}(ctx, tt.params, tt.filter)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
if tt.checkResult != nil {
tt.checkResult(t, results, meta)
}
}
mockRepo.AssertExpectations(t)
})
}
}
Event Publishing Tests Pattern
When: Use cases integrate event publishing for domain events
Mock Setup:
// Mock event publisher
type MockPublisher struct {
mock.Mock
}
func (m *MockPublisher) PublishSymbolCreated(ctx context.Context, symbol *domain.Symbol) error {
args := m.Called(ctx, symbol)
return args.Error(0)
}
func (m *MockPublisher) PublishSymbolUpdated(ctx context.Context, symbol *domain.Symbol) error {
args := m.Called(ctx, symbol)
return args.Error(0)
}
func (m *MockPublisher) PublishSymbolDeleted(ctx context.Context, symbol *domain.Symbol) error {
args := m.Called(ctx, symbol)
return args.Error(0)
}
// Mock transaction
type MockTransaction struct {
mock.Mock
}
func (m *MockTransaction) InTx(ctx context.Context, fn func(ctx context.Context) error) error {
args := m.Called(ctx, fn)
if args.Error(0) != nil {
return args.Error(0)
}
// Execute callback immediately without real transaction
return fn(ctx)
}
// Test setup with all dependencies
type testDeps struct {
repo *MockSymbolRepo
pub *MockPublisher
tx *MockTransaction
uc domain.SymbolUseCase
}
func setupSymbolUseCaseWithDeps() *testDeps {
logger := log.NewStdLogger(os.Stdout)
v := NewValidator()
mockRepo := new(MockSymbolRepo)
mockPub := new(MockPublisher)
mockTx := new(MockTransaction)
// Default transaction behavior - executes callback
mockTx.On("InTx", mock.Anything, mock.Anything).Return(nil).Maybe()
uc := NewUseCase(mockRepo, v, mockTx, mockPub, logger)
return &testDeps{
repo: mockRepo,
pub: mockPub,
tx: mockTx,
uc: uc,
}
}
Test Pattern - Event Publishing Success:
func TestCreateSymbol_PublishesEvent(t *testing.T) {
tests := []struct {
name string
symbol *domain.Symbol
repoReturn *domain.Symbol
publishErr error
wantErr bool
wantEventCall bool
checkEventData func(*testing.T, *domain.Symbol)
}{
{
name: "publishes SymbolCreated event on success",
symbol: validSymbol(),
repoReturn: func() *domain.Symbol {
s := validSymbol()
s.ID = 1
return s
}(),
publishErr: nil,
wantErr: false,
wantEventCall: true,
checkEventData: func(t *testing.T, published *domain.Symbol) {
assert.Equal(t, uint64(1), published.ID)
assert.Equal(t, "Test Symbol", published.Label)
},
},
{
name: "rolls back transaction on publish failure",
symbol: validSymbol(),
repoReturn: func() *domain.Symbol {
s := validSymbol()
s.ID = 1
return s
}(),
publishErr: errors.New("publish failed"),
wantErr: true,
wantEventCall: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
deps := setupSymbolUseCaseWithDeps()
ctx := context.Background()
// Setup repo mock
deps.repo.On("Create", ctx, mock.AnythingOfType("*domain.Symbol")).Return(tt.repoReturn, nil)
// Setup publisher mock - capture published symbol
var publishedSymbol *domain.Symbol
deps.pub.On("PublishSymbolCreated", ctx, mock.AnythingOfType("*domain.Symbol")).
Run(func(args mock.Arguments) {
publishedSymbol = args.Get(1).(*domain.Symbol)
}).
Return(tt.publishErr)
result, err := deps.uc.CreateSymbol(ctx, tt.symbol)
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.NotNil(t, result)
}
if tt.wantEventCall {
deps.pub.AssertCalled(t, "PublishSymbolCreated", ctx, mock.AnythingOfType("*domain.Symbol"))
if tt.checkEventData != nil && publishedSymbol != nil {
tt.checkEventData(t, publishedSymbol)
}
}
deps.repo.AssertExpectations(t)
deps.pub.AssertExpectations(t)
})
}
}
Test Pattern - Event Not Published on Failure:
func TestEventPublishing_NotCalledOnValidationFailure(t *testing.T) {
t.Run("CreateSymbol does not publish event on validation failure", func(t *testing.T) {
deps := setupSymbolUseCaseWithDeps()
ctx := context.Background()
invalidSymbol := &domain.Symbol{
Label: "", // Invalid - missing required field
}
_, err := deps.uc.CreateSymbol(ctx, invalidSymbol)
assert.Error(t, err)
deps.pub.AssertNotCalled(t, "PublishSymbolCreated", mock.Anything, mock.Anything)
})
t.Run("CreateSymbol does not publish event on repo failure", func(t *testing.T) {
deps := setupSymbolUseCaseWithDeps()
ctx := context.Background()
symbol := validSymbol()
deps.repo.On("Create", ctx, mock.AnythingOfType("*domain.Symbol")).
Return(nil, errors.New("database error"))
_, err := deps.uc.CreateSymbol(ctx, symbol)
assert.Error(t, err)
deps.pub.AssertNotCalled(t, "PublishSymbolCreated", mock.Anything, mock.Anything)
})
}
Delete with Event Publishing:
func TestDeleteSymbol_PublishesEvent(t *testing.T) {
tests := []struct {
name string
symbolID uint64
foundSymbol *domain.Symbol
publishErr error
wantErr bool
checkEventData func(*testing.T, *domain.Symbol)
}{
{
name: "publishes SymbolDeleted event on success",
symbolID: 1,
foundSymbol: func() *domain.Symbol {
s := validSymbol()
s.ID = 1
return s
}(),
publishErr: nil,
wantErr: false,
checkEventData: func(t *testing.T, published *domain.Symbol) {
assert.Equal(t, uint64(1), published.ID)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
deps := setupSymbolUseCaseWithDeps()
ctx := context.Background()
// Must find symbol before delete (for event payload)
deps.repo.On("FindByID", ctx, tt.symbolID).Return(tt.foundSymbol, nil)
deps.repo.On("Delete", ctx, tt.symbolID).Return(nil)
// Capture published symbol
var publishedSymbol *domain.Symbol
deps.pub.On("PublishSymbolDeleted", ctx, mock.AnythingOfType("*domain.Symbol")).
Run(func(args mock.Arguments) {
publishedSymbol = args.Get(1).(*domain.Symbol)
}).
Return(tt.publishErr)
err := deps.uc.DeleteSymbol(ctx, tt.symbolID)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
if tt.checkEventData != nil && publishedSymbol != nil {
tt.checkEventData(t, publishedSymbol)
}
deps.repo.AssertExpectations(t)
deps.pub.AssertExpectations(t)
})
}
}
Key Testing Principles:
- Test event publishing happens - Verify
PublishXxxEventis called - Test event data correctness - Capture and assert event payload
- Test transaction rollback - Verify failure prevents commit
- Test event not published on validation/repo failure - Use
AssertNotCalled - Use testDeps pattern - Return all mocks for granular assertions
3. Service Layer Tests
File: `internal/service/{entity}_test.go`
Service Mock Pattern
type mock{Entity}UseCase struct {
mock.Mock
}
func (uc *mock{Entity}UseCase) List{Entities}(ctx context.Context, params *pagination.OffsetPaginationParams, filter map[string]interface{}) ([]*biz.{Entity}, *pagination.Meta, error) {
args := uc.Called(ctx, params, filter)
if args.Get(0) == nil {
return nil, nil, args.Error(2)
}
if args.Get(1) == nil {
return args.Get(0).([]*biz.{Entity}), nil, args.Error(2)
}
return args.Get(0).([]*biz.{Entity}), args.Get(1).(*pagination.Meta), args.Error(2)
}
Critical Testing Rules
1. Mock Signatures MUST Match Interfaces Exactly
// Interface in biz/interfaces.go
List{Entities}(ctx context.Context, offset uint64, limit uint32, filter map[string]interface{}) ([]*{Entity}, *pagination.Meta, error)
// Mock MUST have identical signature
func (m *Mock{Entity}Repo) List{Entities}(ctx context.Context, offset uint64, limit uint32, filter map[string]interface{}) ([]*{Entity}, *pagination.Meta, error) {
args := m.Called(ctx, offset, limit, filter) // Pass ALL parameters
// ...
}
2. Test All Filter Scenarios
- ✅ Valid filter with single field
- ✅ Valid filter with multiple fields
- ✅ Nil filter
- ✅ Empty filter (`map[string]interface{}{}`)
- ✅ Filter with pagination
3. Test All Error Paths
- ✅ Validation errors
- ✅ Repository errors
- ✅ Not found errors
- ✅ Duplicate entry errors
4. Always Assert Mock Expectations
mockRepo.AssertExpectations(t) // Verify all expected calls happened
Validation Checklist
- [ ] Test file exists alongside implementation
- [ ] All public methods have test coverage
- [ ] Table-driven tests used for multiple scenarios
- [ ] Success and error cases both tested
- [ ] Mock signatures exactly match interface signatures
- [ ] Filter passthrough tested (if applicable)
- [ ] Pagination metadata validated
- [ ] Mock expectations asserted (`AssertExpectations`)
- [ ] Helper functions for valid test data
- [ ] Setup/cleanup functions for repo tests
- [ ] Context propagated to all calls
Anti-Patterns
❌ DON'T:
- Have mock signatures different from interfaces
- Forget to call `AssertExpectations(t)`
- Skip error case testing
- Test only happy paths
- Hardcode test data in test cases
- Use production database for tests
✅ DO:
- Match mock signatures exactly to interfaces
- Always assert expectations
- Test both success and error paths
- Use table-driven tests
- Create helper functions for test data
- Use in-memory database for repo tests
Success Criteria
Tests MUST:
- Pass with `go test ./internal/...`
- Achieve >85% coverage for the layer
- Run quickly (<5s for unit tests)
- Be deterministic (no flaky tests)
- Clean up resources (database, mocks)
- Verify all mock expectations
- Test all public methods
微信扫一扫