skill-testing-standards
Testing standards and best practices for Meridian services
Install
mkdir -p .claude/skills/skill-testing-standards && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/14432" && unzip -o skill.zip -d .claude/skills/skill-testing-standards && rm skill.zipInstalls to .claude/skills/skill-testing-standards
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Testing standards and best practices for Meridian servicesAbout this skill
Testing Standards
This document defines testing standards for Meridian services.
Core Principles
- No
time.Sleep- Use theawaitpackage for async assertions - Defensive testing - Test happy paths AND unhappy paths (see ADR-0008)
- Table-driven tests - Use Go table-driven patterns with rationale
- Isolation - Tests must not depend on each other
- Fast feedback - Unit tests should complete in milliseconds
Async Testing: Use await Not time.Sleep
NEVER use time.Sleep in tests. It creates flaky tests - too short causes failures, too long wastes CI time.
Use shared/platform/await instead. It polls until conditions are met or timeout.
import "github.com/meridianhub/meridian/shared/platform/await"
// BAD - arbitrary sleep, flaky and slow
time.Sleep(2 * time.Second)
assert.Equal(t, "COMPLETED", order.Status)
// GOOD - polls until condition met or timeout
err := await.Until(func() bool {
return order.Status == "COMPLETED"
})
require.NoError(t, err)
// With custom timeout and poll interval
err := await.New().
AtMost(5 * time.Second).
PollInterval(50 * time.Millisecond).
Until(func() bool {
return repo.FindByID(ctx, id) != nil
})
// Wait for an operation to succeed (no error)
err := await.UntilNoError(func() error {
return client.HealthCheck()
})
// With context cancellation
err := await.New().
WithContext(ctx).
AtMost(10 * time.Second).
Until(condition)
Defaults: 10s timeout, 100ms poll interval.
For advanced matchers and more expressive assertions, consider gomega.Eventually().
Database Integration Tests: Use Testcontainers
Use shared/platform/testdb/ for PostgreSQL integration tests. Each test gets an isolated container.
import "github.com/meridianhub/meridian/internal/<service>/repository/testhelpers"
func TestRepository(t *testing.T) {
tc := testhelpers.SetupTestContainer(t)
defer tc.Cleanup(t) // CRITICAL - always defer cleanup
// Use tc.Repo for repository operations
err := tc.Repo.Create(ctx, entity)
require.NoError(t, err)
// Or use tc.Pool for direct SQL
var count int
err = tc.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM table").Scan(&count)
require.NoError(t, err)
}
See Testcontainers Usage Guide for detailed patterns.
Table-Driven Tests with Rationale
Every test case must include a rationale field explaining WHY it matters:
func TestDeposit(t *testing.T) {
tests := []struct {
name string
amount int64
wantErr bool
rationale string // WHY this test case matters
}{
// Happy path
{
name: "valid deposit",
amount: 100,
wantErr: false,
rationale: "Standard valid deposit should succeed",
},
// Edge cases
{
name: "minimum valid amount",
amount: 1,
wantErr: false,
rationale: "Even 1 cent is a valid deposit",
},
// Unhappy paths
{
name: "zero amount rejected",
amount: 0,
wantErr: true,
rationale: "Zero deposits are meaningless operations",
},
{
name: "negative amount rejected",
amount: -100,
wantErr: true,
rationale: "Negative deposits don't make sense (use withdraw)",
},
// Defensive: values that shouldn't happen but might
{
name: "overflow detection",
amount: math.MaxInt64,
wantErr: true,
rationale: "Must detect arithmetic overflow to prevent corruption",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := account.Deposit(tt.amount)
if tt.wantErr {
assert.Error(t, err, tt.rationale)
} else {
assert.NoError(t, err, tt.rationale)
}
})
}
}
Defensive Testing Categories
Apply these categories to all tests (see ADR-0008):
For Numeric Inputs
- Valid values (happy path)
- Zero
- Negative values
- Maximum/minimum boundaries
- Overflow conditions
For String Inputs
- Valid formatted strings
- Empty strings
- Whitespace-only strings
- Invalid formats
- Very long strings
For State Transitions
- Valid transitions
- Invalid transitions (must be rejected)
- Idempotent operations
- Concurrent state changes
For Error Handling
- Every error path has a test
- State is not modified on error
- Resources are cleaned up on error
- Errors wrap correctly (
errors.Is/errors.Aswork)
Test Assertions: Use testify
Use github.com/stretchr/testify for assertions:
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Use require for setup - fails fast
tc := testhelpers.SetupTestContainer(t)
require.NotNil(t, tc.Pool, "database pool required")
// Use assert for verifications - continues on failure
assert.Equal(t, expected, actual)
assert.NoError(t, err)
assert.ErrorIs(t, err, ErrNotFound)
assert.Contains(t, logs, "expected message")
require vs assert:
require- Stops test immediately on failure (use for setup/preconditions)assert- Records failure but continues (use for verifications)
Test Naming
Use descriptive names that explain the scenario:
// GOOD - describes scenario and expected outcome
func TestDeposit_ZeroAmount_ReturnsError(t *testing.T)
func TestFindByID_NonExistent_ReturnsNotFoundError(t *testing.T)
func TestTransfer_InsufficientFunds_RollsBackTransaction(t *testing.T)
// BAD - vague names
func TestDeposit1(t *testing.T)
func TestError(t *testing.T)
func TestIt(t *testing.T)
Parallel Tests
Run independent tests in parallel for faster feedback:
func TestConcurrentOperations(t *testing.T) {
t.Parallel() // Mark test as safe for parallel execution
tc := testhelpers.SetupTestContainer(t)
defer tc.Cleanup(t)
// Each parallel test gets its own container
}
When NOT to parallelize:
- Tests sharing global state
- Tests with fixed port bindings
- Tests modifying environment variables
Mocking Guidelines
Minimize mocking. Prefer real implementations where practical:
- Use testcontainers for database tests (real PostgreSQL)
- Use in-memory implementations for simple interfaces
- Mock only external services that can't be containerized
- Never mock the thing you're testing
When mocking is necessary, use github.com/stretchr/testify/mock:
type MockClient struct {
mock.Mock
}
func (m *MockClient) Send(ctx context.Context, msg Message) error {
args := m.Called(ctx, msg)
return args.Error(0)
}
// In test
client := new(MockClient)
client.On("Send", mock.Anything, expectedMsg).Return(nil)
Test Organization
service/
├── domain/
│ ├── account.go
│ └── account_test.go # Unit tests (same package)
├── repository/
│ ├── postgres_repository.go
│ ├── postgres_repository_test.go # Integration tests
│ └── testhelpers/
│ └── container.go # Shared test setup
└── handler/
├── grpc_handler.go
└── grpc_handler_test.go # Handler tests
CI Considerations
- Unit tests run on every commit
- Integration tests (testcontainers) run on PR
- Tests must pass before merge
- Flaky tests must be fixed immediately (not skipped)
Related Documentation
- ADR-0008: Defensive Testing Standards - Testing philosophy
- Testcontainers Usage Guide - Database test setup
- await package - Async polling utility