Initial commit
This commit is contained in:
255
skills/testing/SKILL.md
Normal file
255
skills/testing/SKILL.md
Normal file
@@ -0,0 +1,255 @@
|
||||
---
|
||||
name: testing
|
||||
description: Automatically invoked to write tests for new types, or use as testing expert advisor for guidance and recommendations. Covers unit, integration, and system tests with emphasis on in-memory dependencies. Use when creating leaf types, after refactoring, during implementation, or when testing advice is needed. Ensures 100% coverage on leaf types with public API testing.
|
||||
---
|
||||
|
||||
# Testing Principles
|
||||
|
||||
Principles and patterns for writing effective Go tests.
|
||||
|
||||
## When to Use
|
||||
|
||||
### Automatic Invocation (Proactive)
|
||||
- **Automatically invoked** by @linter-driven-development during Phase 2 (Implementation)
|
||||
- **Automatically invoked** by @refactoring when new isolated types are created
|
||||
- **Automatically invoked** by @code-designing after designing new types
|
||||
- **After creating new leaf types** - Types that should have 100% unit test coverage
|
||||
- **After extracting functions** during refactoring that create testable units
|
||||
|
||||
### Manual Invocation
|
||||
- User explicitly requests tests to be written
|
||||
- User asks for testing advice, recommendations, or "what to do"
|
||||
- When testing strategy is unclear (table-driven vs testify suites)
|
||||
- When choosing between dependency levels (in-memory vs binary vs test-containers)
|
||||
- When adding tests to existing untested code
|
||||
- When user needs testing expert guidance or consultation
|
||||
|
||||
**IMPORTANT**: This skill writes tests autonomously based on the code structure and type design, and also serves as a testing expert advisor
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
**Test only the public API**
|
||||
- Use `pkg_test` package name
|
||||
- Test types through their constructors
|
||||
- No testing private methods/functions
|
||||
|
||||
**Prefer real implementations over mocks**
|
||||
- Use in-memory implementations (fastest, no external deps)
|
||||
- Use HTTP test servers (httptest)
|
||||
- Use temp files/directories
|
||||
- Test with actual dependencies when beneficial
|
||||
|
||||
**Coverage targets**
|
||||
- Leaf types: 100% unit test coverage
|
||||
- Orchestrating types: Integration tests
|
||||
- Critical workflows: System tests
|
||||
|
||||
## Test Pyramid
|
||||
|
||||
Three levels of testing, each serving a specific purpose:
|
||||
|
||||
**Unit Tests** (Base of pyramid - most tests here)
|
||||
- Test leaf types in isolation
|
||||
- Fast, focused, no external dependencies
|
||||
- 100% coverage target for leaf types
|
||||
- Use `pkg_test` package, test public API only
|
||||
|
||||
**Integration Tests** (Middle - fewer than unit)
|
||||
- Test seams between components
|
||||
- Test workflows across package boundaries
|
||||
- Use real or in-memory implementations
|
||||
- Verify components work together correctly
|
||||
|
||||
**System Tests** (Top - fewest tests)
|
||||
- Black box testing from `tests/` folder
|
||||
- Test entire system via CLI/API
|
||||
- Test critical end-to-end workflows
|
||||
- **Strive for independence in Go** (minimize external deps)
|
||||
|
||||
## Reusable Test Infrastructure
|
||||
|
||||
Build shared test infrastructure in `internal/testutils/`:
|
||||
- In-memory mock servers with DSL (HTTP, DB, file system)
|
||||
- Reusable across all test levels
|
||||
- Test the infrastructure itself!
|
||||
- Can expose as CLI tools for manual testing
|
||||
|
||||
**Dependency Priority** (minimize external dependencies):
|
||||
1. **In-memory** (preferred): Pure Go, httptest, in-memory DB
|
||||
2. **Binary**: Standalone executable via exec.Command
|
||||
3. **Test-containers**: Programmatic Docker from Go
|
||||
4. **Docker-compose**: Last resort, manual testing only
|
||||
|
||||
Goal: System tests should be **independent in Go** when possible.
|
||||
|
||||
See reference.md for comprehensive testutils patterns and DSL examples.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Unit Tests Workflow
|
||||
|
||||
**Purpose**: Test leaf types in isolation, 100% coverage target
|
||||
|
||||
1. **Identify leaf types** - Self-contained types with logic
|
||||
2. **Choose structure** - Table-driven (simple) or testify suites (complex setup)
|
||||
3. **Write in pkg_test package** - Test public API only
|
||||
4. **Use in-memory implementations** - From testutils or local implementations
|
||||
5. **Avoid pitfalls** - No time.Sleep, no conditionals in cases, no private method tests
|
||||
|
||||
**Test structure:**
|
||||
- Table-driven: Separate success/error test functions (complexity = 1)
|
||||
- Testify suites: Only for complex infrastructure setup (HTTP servers, DBs)
|
||||
- Always use named struct fields (linter reorders fields)
|
||||
|
||||
See reference.md for detailed patterns and examples.
|
||||
|
||||
### Integration Tests Workflow
|
||||
|
||||
**Purpose**: Test seams between components, verify they work together
|
||||
|
||||
1. **Identify integration points** - Where packages/components interact
|
||||
2. **Choose dependencies** - Prefer: in-memory > binary > test-containers
|
||||
3. **Write tests** - In `pkg_test` or `integration_test.go` with build tags
|
||||
4. **Test workflows** - Cover happy path and error scenarios across boundaries
|
||||
5. **Use real or testutils implementations** - Avoid heavy mocking
|
||||
|
||||
**File organization:**
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package user_test
|
||||
|
||||
// Test Service + Repository + real/mock dependencies
|
||||
```
|
||||
|
||||
See reference.md for integration test patterns with dependencies.
|
||||
|
||||
### System Tests Workflow
|
||||
|
||||
**Purpose**: Black box test entire system, critical end-to-end workflows
|
||||
|
||||
1. **Place in tests/ folder** - At project root, separate from packages
|
||||
2. **Test via CLI/API** - exec.Command for CLI, HTTP client for APIs
|
||||
3. **Minimize external deps** - Prefer: in-memory mocks > binary > test-containers
|
||||
4. **Strive for Go independence** - Pure Go tests, no Docker when possible
|
||||
5. **Test critical workflows** - User journeys, not every edge case
|
||||
|
||||
**Example structure:**
|
||||
```go
|
||||
// tests/cli_test.go
|
||||
func TestCLI_UserWorkflow(t *testing.T) {
|
||||
mockAPI := testutils.NewMockServer().
|
||||
OnGET("/users/1").RespondJSON(200, user).
|
||||
Build() // In-memory httptest.Server
|
||||
defer mockAPI.Close()
|
||||
|
||||
cmd := exec.Command("./myapp", "get-user", "1",
|
||||
"--api-url", mockAPI.URL())
|
||||
output, err := cmd.CombinedOutput()
|
||||
// Assert on output
|
||||
}
|
||||
```
|
||||
|
||||
See reference.md for comprehensive system test patterns.
|
||||
|
||||
## Key Test Patterns
|
||||
|
||||
**Table-Driven Tests:**
|
||||
- Separate success and error test functions (complexity = 1)
|
||||
- Always use named struct fields (linter reorders fields)
|
||||
- No wantErr bool pattern (adds conditionals)
|
||||
|
||||
**Testify Suites:**
|
||||
- Only for complex infrastructure (HTTP servers, DBs, OpenTelemetry)
|
||||
- SetupSuite/TearDownSuite for expensive shared setup
|
||||
- SetupTest/TearDownTest for per-test isolation
|
||||
|
||||
**Synchronization:**
|
||||
- Never use time.Sleep (flaky, slow)
|
||||
- Use channels with select/timeout for async operations
|
||||
- Use sync.WaitGroup for concurrent operations
|
||||
|
||||
See reference.md for complete patterns with code examples.
|
||||
|
||||
## Output Format
|
||||
|
||||
After writing tests:
|
||||
|
||||
```
|
||||
✅ TESTING COMPLETE
|
||||
|
||||
📊 Unit Tests:
|
||||
- user/user_id_test.go: 100% (4 test cases)
|
||||
- user/email_test.go: 100% (6 test cases)
|
||||
- user/service_test.go: 100% (8 test cases)
|
||||
|
||||
🔗 Integration Tests:
|
||||
- user/integration_test.go: 3 workflows tested
|
||||
- Dependencies: In-memory DB, httptest mock server
|
||||
|
||||
🎯 System Tests:
|
||||
- tests/cli_test.go: 2 end-to-end workflows
|
||||
- tests/api_test.go: 1 full API workflow
|
||||
- Infrastructure: In-memory mocks (pure Go, no Docker)
|
||||
|
||||
Test Infrastructure:
|
||||
- internal/testutils/httpserver: In-memory mock API with DSL
|
||||
- internal/testutils/mockdb: In-memory database mock
|
||||
|
||||
Test Execution:
|
||||
$ go test ./... # All tests
|
||||
$ go test -tags=integration ./... # Include integration tests
|
||||
$ go test ./tests/... # System tests only
|
||||
|
||||
✅ All tests pass
|
||||
✅ 100% coverage on leaf types
|
||||
✅ No external dependencies required
|
||||
|
||||
Next Steps:
|
||||
1. Run linter: task lintwithfix
|
||||
2. If linter fails → use @refactoring skill
|
||||
3. If linter passes → use @pre-commit-review skill
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
See reference.md for:
|
||||
- Table-driven test patterns
|
||||
- Testify suite guidelines
|
||||
- Real implementations over mocks
|
||||
- Synchronization techniques
|
||||
- Coverage strategies
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Unit Tests
|
||||
- [ ] All unit tests in pkg_test package
|
||||
- [ ] Testing public API only (no private methods)
|
||||
- [ ] Table-driven tests use named struct fields
|
||||
- [ ] No conditionals in test cases (complexity = 1)
|
||||
- [ ] Using in-memory implementations from testutils
|
||||
- [ ] No time.Sleep (using channels/waitgroups)
|
||||
- [ ] Leaf types have 100% coverage
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Test seams between components
|
||||
- [ ] Use in-memory or binary dependencies (avoid Docker)
|
||||
- [ ] Build tags for optional execution (`//go:build integration`)
|
||||
- [ ] Cover happy path and error scenarios across boundaries
|
||||
- [ ] Real or testutils implementations (minimal mocking)
|
||||
|
||||
### System Tests
|
||||
- [ ] Located in tests/ folder at project root
|
||||
- [ ] Black box testing via CLI/API
|
||||
- [ ] Uses in-memory testutils mocks (pure Go)
|
||||
- [ ] No external dependencies (no Docker required)
|
||||
- [ ] Tests critical end-to-end workflows
|
||||
- [ ] Fast execution, runs in CI without setup
|
||||
|
||||
### Test Infrastructure
|
||||
- [ ] Reusable mocks in internal/testutils/
|
||||
- [ ] Test infrastructure has its own tests
|
||||
- [ ] DSL provides readable test setup
|
||||
- [ ] Can be exposed as CLI for manual testing
|
||||
|
||||
See reference.md for complete testing guidelines and examples.
|
||||
318
skills/testing/examples/grpc-bufconn.md
Normal file
318
skills/testing/examples/grpc-bufconn.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# gRPC Testing with bufconn and Rich Client Mocks
|
||||
|
||||
## When to Use This Example
|
||||
|
||||
Use this when:
|
||||
- Testing gRPC servers
|
||||
- Need bidirectional streaming tests
|
||||
- Want in-memory gRPC (no network I/O)
|
||||
- Testing server-client interactions
|
||||
- Need rich DSL for readable tests
|
||||
|
||||
**Dependency Level**: Level 1 (In-Memory) - Uses `bufconn` for in-memory gRPC connections
|
||||
|
||||
**Key Insight**: When testing a **gRPC server**, mock the **clients** that connect to it. When testing a **gRPC client**, mock the **server**.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Rich gRPC Client Mock with DSL
|
||||
|
||||
When your **System Under Test (SUT) is a gRPC server**, create rich client mocks:
|
||||
|
||||
```go
|
||||
// internal/testutils/grpc_client_mock.go
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
pb "myproject/grpc_api/gen/go/traces/v1"
|
||||
)
|
||||
|
||||
// TaskmonOnCluster is a rich gRPC client mock with DSL for testing gRPC servers.
|
||||
// It connects to your gRPC server and provides helper methods for assertions.
|
||||
type TaskmonOnCluster struct {
|
||||
clusterRoutingKey string
|
||||
taskmonID string
|
||||
stream pb.RemoteTracesService_StreamTracesClient
|
||||
mu sync.RWMutex
|
||||
receivedQuery *pb.TracesQuery
|
||||
receivedQueriesPayloads []string
|
||||
}
|
||||
|
||||
// OpenTaskmonToWekaHomeStream creates a gRPC client mock that connects to your server.
|
||||
// This is the constructor for the mock - returns a rich DSL object.
|
||||
func OpenTaskmonToWekaHomeStream(
|
||||
ctx context.Context,
|
||||
client pb.RemoteTracesServiceClient,
|
||||
clusterRoutingKey, taskmonID string,
|
||||
) (*TaskmonOnCluster, error) {
|
||||
// Inject metadata (like session tokens) into context
|
||||
md := metadata.Pairs("X-Taskmon-session-token", clusterRoutingKey)
|
||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||
|
||||
// Open streaming connection to the server (your SUT)
|
||||
stream, err := client.StreamTraces(ctx, grpc.Header(&md))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TaskmonOnCluster{
|
||||
stream: stream,
|
||||
clusterRoutingKey: clusterRoutingKey,
|
||||
taskmonID: taskmonID,
|
||||
receivedQueriesPayloads: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SessionToken returns the session token (useful for assertions)
|
||||
func (m *TaskmonOnCluster) SessionToken() string {
|
||||
return m.clusterRoutingKey
|
||||
}
|
||||
|
||||
// Close closes the stream (idempotent)
|
||||
func (m *TaskmonOnCluster) Close() {
|
||||
if m.stream == nil {
|
||||
return
|
||||
}
|
||||
m.stream.CloseSend()
|
||||
}
|
||||
|
||||
// ListenToStreamAndAssert is a helper that listens to server messages and asserts.
|
||||
// This makes tests read like documentation!
|
||||
func (m *TaskmonOnCluster) ListenToStreamAndAssert(
|
||||
t *testing.T,
|
||||
expectedQueryPayload,
|
||||
resultPayload string,
|
||||
) {
|
||||
for {
|
||||
query, err := m.stream.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err, "Failed to receive query from server")
|
||||
|
||||
// Store received data (thread-safe)
|
||||
m.mu.Lock()
|
||||
m.receivedQuery = query
|
||||
m.receivedQueriesPayloads = append(m.receivedQueriesPayloads, string(query.TracesQueryPayload))
|
||||
m.mu.Unlock()
|
||||
|
||||
// Assert expected payload
|
||||
require.Equal(t, expectedQueryPayload, string(query.TracesQueryPayload))
|
||||
|
||||
// Send response back to server
|
||||
response := &pb.TracesFromServer{
|
||||
TraceServerRoute: query.TraceServerRoute,
|
||||
TracesPayload: []byte(resultPayload),
|
||||
MessageId: query.MessageId,
|
||||
}
|
||||
err = m.stream.Send(response)
|
||||
require.NoError(t, err, "Failed to send response")
|
||||
}
|
||||
}
|
||||
|
||||
// LastReceivedQuery returns the last received query (thread-safe)
|
||||
func (m *TaskmonOnCluster) LastReceivedQuery() *pb.TracesQuery {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.receivedQuery
|
||||
}
|
||||
|
||||
// ReceivedQueriesPayloads returns all received payloads (thread-safe)
|
||||
func (m *TaskmonOnCluster) ReceivedQueriesPayloads() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.receivedQueriesPayloads
|
||||
}
|
||||
```
|
||||
|
||||
## Usage in Integration Tests
|
||||
|
||||
### Complete Test Suite Example
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
|
||||
pb "myproject/grpc_api/gen/go/traces/v1"
|
||||
"myproject/internal/remotetraces"
|
||||
"myproject/internal/testutils"
|
||||
)
|
||||
|
||||
type RemoteTracesTestSuite struct {
|
||||
suite.Suite
|
||||
lis *bufconn.Listener // In-memory gRPC connection
|
||||
ctx context.Context
|
||||
natsServer *nserver.Server // In-memory NATS
|
||||
}
|
||||
|
||||
func (suite *RemoteTracesTestSuite) SetupSuite() {
|
||||
suite.ctx = context.Background()
|
||||
|
||||
// Start in-memory NATS server (Level 1)
|
||||
natsServer, err := testutils.RunNATsServer()
|
||||
suite.Require().NoError(err)
|
||||
suite.natsServer = natsServer
|
||||
|
||||
// Connect to NATS
|
||||
natsAddress := "nats://" + natsServer.Addr().String()
|
||||
nc, err := natsremotetraces.ConnectToRemoteTracesSession(suite.ctx, natsAddress, 2, 2, 10)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// ** System Under Test: gRPC Server **
|
||||
// Use bufconn for in-memory gRPC (no network I/O!)
|
||||
suite.lis = bufconn.Listen(1024 * 1024)
|
||||
s := grpc.NewServer()
|
||||
|
||||
// Your gRPC server implementation
|
||||
remoteTracesServer := remotetraces.NewGRPCServer(nc, 10, 10, time.Second)
|
||||
pb.RegisterRemoteTracesServiceServer(s, remoteTracesServer)
|
||||
|
||||
go func() {
|
||||
if err := s.Serve(suite.lis); err != nil {
|
||||
suite.NoError(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (suite *RemoteTracesTestSuite) bufDialer(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return suite.lis.DialContext(ctx)
|
||||
}
|
||||
|
||||
func (suite *RemoteTracesTestSuite) TestStreamTraces() {
|
||||
// Create gRPC client (connects to your server)
|
||||
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||
grpc.WithContextDialer(suite.bufDialer),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
suite.Require().NoError(err)
|
||||
defer conn.Close()
|
||||
|
||||
client := pb.NewRemoteTracesServiceClient(conn)
|
||||
|
||||
// Create rich gRPC client mock (testutils DSL!)
|
||||
clusterRoutingKey := "test-cluster-123"
|
||||
taskmonMock, err := testutils.OpenTaskmonToWekaHomeStream(
|
||||
suite.ctx, client, clusterRoutingKey, "taskmon-1")
|
||||
suite.Require().NoError(err)
|
||||
defer taskmonMock.Close()
|
||||
|
||||
expectedQuery := "fetch_traces_query"
|
||||
expectedResult := "traces_result_data"
|
||||
|
||||
// Start listening (this makes the test readable!)
|
||||
go taskmonMock.ListenToStreamAndAssert(suite.T(), expectedQuery, expectedResult)
|
||||
|
||||
// Send query to server (via NATS or HTTP API)
|
||||
// ... your test logic here ...
|
||||
|
||||
// Assert using helper methods
|
||||
suite.Eventually(func() bool {
|
||||
return taskmonMock.LastReceivedQuery() != nil &&
|
||||
string(taskmonMock.LastReceivedQuery().TracesQueryPayload) == expectedQuery
|
||||
}, 5*time.Second, 500*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestRemoteTracesTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(RemoteTracesTestSuite))
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Pattern is Excellent
|
||||
|
||||
1. **Rich DSL** - `OpenTaskmonToWekaHomeStream()` returns friendly object with helper methods
|
||||
2. **Helper Methods** - `ListenToStreamAndAssert()`, `LastReceivedQuery()`, `ReceivedQueriesPayloads()`
|
||||
3. **Thread-Safe** - Mutex protects shared state for concurrent access
|
||||
4. **Readable Tests** - Tests read like documentation, clear intent
|
||||
5. **In-Memory** - Uses `bufconn` (no network I/O, pure Go)
|
||||
6. **Reusable** - Same mock for unit, integration, and system tests
|
||||
7. **Event-Driven** - Can add channels for connection events if needed
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
### Testing Direction
|
||||
|
||||
- **Testing a server?** → Mock the **clients** that connect to it
|
||||
- **Testing a client?** → Mock the **server** it connects to
|
||||
|
||||
### DSL Benefits
|
||||
|
||||
- Use rich DSL objects with helper methods
|
||||
- Make tests read like documentation
|
||||
- Hide complexity behind clean interfaces
|
||||
- Provide thread-safe state tracking
|
||||
- Enable fluent assertions
|
||||
|
||||
### In-Memory with bufconn
|
||||
|
||||
`bufconn` provides an in-memory, full-duplex network connection:
|
||||
- No network I/O overhead
|
||||
- No port allocation needed
|
||||
- Faster than TCP loopback
|
||||
- Perfect for CI/CD
|
||||
- Deterministic behavior
|
||||
|
||||
## Benefits
|
||||
|
||||
- **No Docker required** - Pure Go, works anywhere
|
||||
- **No binary downloads** - Everything in-memory
|
||||
- **No network I/O** - Unless testing actual network code
|
||||
- **Perfect for CI/CD** - Fast, reliable, no external dependencies
|
||||
- **Lightning fast** - Microsecond startup time
|
||||
- **Thread-safe** - Concurrent test execution safe
|
||||
|
||||
## Alternative: Testing gRPC Clients
|
||||
|
||||
If you're testing a **gRPC client**, mock the **server** instead:
|
||||
|
||||
```go
|
||||
// internal/testutils/grpc_server_mock.go
|
||||
type MockGRPCServer struct {
|
||||
pb.UnimplementedRemoteTracesServiceServer
|
||||
mu sync.Mutex
|
||||
receivedQueries []*pb.TracesQuery
|
||||
}
|
||||
|
||||
func (m *MockGRPCServer) StreamTraces(stream pb.RemoteTracesService_StreamTracesServer) error {
|
||||
// Mock server implementation
|
||||
// Store received queries, send responses
|
||||
// ...
|
||||
return nil
|
||||
}
|
||||
|
||||
// Usage
|
||||
server := testutils.NewMockGRPCServer()
|
||||
lis := bufconn.Listen(1024 * 1024)
|
||||
s := grpc.NewServer()
|
||||
pb.RegisterRemoteTracesServiceServer(s, server)
|
||||
// ... test your client against this mock server
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **bufconn is Level 1** - In-memory, no external dependencies
|
||||
2. **Mock the opposite end** - Server → mock clients, Client → mock server
|
||||
3. **Rich DSL makes tests readable** - Helper methods, clear intent
|
||||
4. **Thread-safe state tracking** - Use mutexes for concurrent access
|
||||
5. **Reusable across test levels** - Same infrastructure everywhere
|
||||
6. **Check for official test harnesses first** - Many libraries provide them (like NATS)
|
||||
281
skills/testing/examples/httptest-dsl.md
Normal file
281
skills/testing/examples/httptest-dsl.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# HTTP Test Server with DSL Pattern
|
||||
|
||||
## When to Use This Example
|
||||
|
||||
Use this when:
|
||||
- Testing HTTP clients or APIs
|
||||
- Need simple, readable HTTP mocking
|
||||
- Want to avoid complex mock frameworks
|
||||
- Testing REST APIs, webhooks, or HTTP integrations
|
||||
|
||||
**Dependency Level**: Level 1 (In-Memory) - Uses stdlib `httptest.Server`
|
||||
|
||||
## Basic httptest.Server Pattern
|
||||
|
||||
### Simple HTTP Mock
|
||||
|
||||
```go
|
||||
func TestAPIClient(t *testing.T) {
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Mock API response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Use real HTTP client with test server URL
|
||||
client := NewAPIClient(server.URL)
|
||||
result, err := client.GetStatus()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ok", result.Status)
|
||||
}
|
||||
```
|
||||
|
||||
## DSL Pattern for Readable Tests
|
||||
|
||||
### Without DSL (Verbose)
|
||||
|
||||
```go
|
||||
func TestUserAPI(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/users/1" {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": "1", "name": "Alice"})
|
||||
} else if r.Method == "POST" && r.URL.Path == "/users" {
|
||||
// ... more complex logic
|
||||
} else {
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
// ... test
|
||||
}
|
||||
```
|
||||
|
||||
### With DSL (Readable)
|
||||
|
||||
```go
|
||||
func TestUserAPI(t *testing.T) {
|
||||
mockAPI := httpserver.New().
|
||||
OnGET("/users/1").
|
||||
RespondJSON(200, User{ID: "1", Name: "Alice"}).
|
||||
OnPOST("/users").
|
||||
WithBodyMatcher(hasRequiredFields).
|
||||
RespondJSON(201, User{ID: "2", Name: "Bob"}).
|
||||
Build()
|
||||
defer mockAPI.Close()
|
||||
|
||||
// Test reads like documentation!
|
||||
client := NewAPIClient(mockAPI.URL())
|
||||
user, err := client.GetUser("1")
|
||||
// ... assertions
|
||||
}
|
||||
```
|
||||
|
||||
## Implementing the DSL
|
||||
|
||||
### Basic DSL Structure
|
||||
|
||||
```go
|
||||
// internal/testutils/httpserver/server.go
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
type MockServer struct {
|
||||
routes map[string]map[string]mockRoute // method -> path -> handler
|
||||
server *httptest.Server
|
||||
}
|
||||
|
||||
type mockRoute struct {
|
||||
statusCode int
|
||||
response any
|
||||
matcher func(*http.Request) bool
|
||||
}
|
||||
|
||||
func New() *MockServerBuilder {
|
||||
return &MockServerBuilder{
|
||||
routes: make(map[string]map[string]mockRoute),
|
||||
}
|
||||
}
|
||||
|
||||
type MockServerBuilder struct {
|
||||
routes map[string]map[string]mockRoute
|
||||
}
|
||||
|
||||
func (b *MockServerBuilder) OnGET(path string) *RouteBuilder {
|
||||
return &RouteBuilder{
|
||||
builder: b,
|
||||
method: "GET",
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *MockServerBuilder) OnPOST(path string) *RouteBuilder {
|
||||
return &RouteBuilder{
|
||||
builder: b,
|
||||
method: "POST",
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
type RouteBuilder struct {
|
||||
builder *MockServerBuilder
|
||||
method string
|
||||
path string
|
||||
statusCode int
|
||||
response any
|
||||
matcher func(*http.Request) bool
|
||||
}
|
||||
|
||||
func (r *RouteBuilder) RespondJSON(statusCode int, response any) *MockServerBuilder {
|
||||
if r.builder.routes[r.method] == nil {
|
||||
r.builder.routes[r.method] = make(map[string]mockRoute)
|
||||
}
|
||||
r.builder.routes[r.method][r.path] = mockRoute{
|
||||
statusCode: statusCode,
|
||||
response: response,
|
||||
matcher: r.matcher,
|
||||
}
|
||||
return r.builder
|
||||
}
|
||||
|
||||
func (r *RouteBuilder) WithBodyMatcher(matcher func(*http.Request) bool) *RouteBuilder {
|
||||
r.matcher = matcher
|
||||
return r
|
||||
}
|
||||
|
||||
func (b *MockServerBuilder) Build() *MockServer {
|
||||
mock := &MockServer{routes: b.routes}
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
methodRoutes, ok := mock.routes[r.Method]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
route, ok := methodRoutes[r.URL.Path]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if route.matcher != nil && !route.matcher(r) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(route.statusCode)
|
||||
json.NewEncoder(w).Encode(route.response)
|
||||
})
|
||||
|
||||
mock.server = httptest.NewServer(handler)
|
||||
return mock
|
||||
}
|
||||
|
||||
func (m *MockServer) URL() string {
|
||||
return m.server.URL
|
||||
}
|
||||
|
||||
func (m *MockServer) Close() {
|
||||
m.server.Close()
|
||||
}
|
||||
```
|
||||
|
||||
## Simple In-Memory Patterns
|
||||
|
||||
### In-Memory Repository
|
||||
|
||||
```go
|
||||
// user/inmem.go
|
||||
package user
|
||||
|
||||
type InMemoryRepository struct {
|
||||
mu sync.RWMutex
|
||||
users map[UserID]User
|
||||
}
|
||||
|
||||
func NewInMemoryRepository() *InMemoryRepository {
|
||||
return &InMemoryRepository{
|
||||
users: make(map[UserID]User),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) Save(ctx context.Context, u User) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.users[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) Get(ctx context.Context, id UserID) (*User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
u, ok := r.users[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Test Email Sender
|
||||
|
||||
```go
|
||||
// user/test_emailer.go
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type TestEmailer struct {
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
}
|
||||
|
||||
func NewTestEmailer() *TestEmailer {
|
||||
return &TestEmailer{}
|
||||
}
|
||||
|
||||
func (e *TestEmailer) Send(to Email, subject, body string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
fmt.Fprintf(&e.buffer, "To: %s\nSubject: %s\n%s\n\n", to, subject, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *TestEmailer) SentEmails() string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.buffer.String()
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Simple** - Built on stdlib, no external dependencies
|
||||
- **Readable** - DSL makes tests self-documenting
|
||||
- **Fast** - In-memory, microsecond startup
|
||||
- **Flexible** - Easy to extend with new methods
|
||||
- **Reusable** - Same pattern for all HTTP testing
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Start with httptest.Server** - Simple and powerful
|
||||
2. **Add DSL for readability** - When tests get complex
|
||||
3. **Keep implementations simple** - In-memory maps, buffers
|
||||
4. **Thread-safe** - Use mutexes for concurrent access
|
||||
5. **Test your test infrastructure** - It's production code
|
||||
248
skills/testing/examples/integration-patterns.md
Normal file
248
skills/testing/examples/integration-patterns.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Integration Test Patterns
|
||||
|
||||
## Purpose
|
||||
|
||||
Integration tests verify that components work together correctly. They test the seams between packages, ensure proper data flow, and validate that integrated components behave as expected.
|
||||
|
||||
**When to Write**: After unit testing individual components, test how they interact.
|
||||
|
||||
## File Organization
|
||||
|
||||
### Option 1: In Package with Build Tags (Preferred)
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"myproject/internal/testutils"
|
||||
)
|
||||
|
||||
func TestUserService_Integration(t *testing.T) {
|
||||
// Integration test
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Separate Package
|
||||
|
||||
```
|
||||
user/
|
||||
├── user.go
|
||||
├── user_test.go # Unit tests
|
||||
└── integration/
|
||||
└── user_integration_test.go # Integration tests
|
||||
```
|
||||
|
||||
## Pattern 1: Service + Repository (In-Memory)
|
||||
|
||||
**Use when**: Testing service logic with data persistence
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"myproject/user"
|
||||
)
|
||||
|
||||
func TestUserService_CreateAndRetrieve(t *testing.T) {
|
||||
// Setup: In-memory repository (Level 1)
|
||||
repo := user.NewInMemoryRepository()
|
||||
svc := user.NewUserService(repo, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
userID, _ := user.NewUserID("usr_123")
|
||||
email, _ := user.NewEmail("alice@example.com")
|
||||
newUser := user.User{
|
||||
ID: userID,
|
||||
Name: "Alice",
|
||||
Email: email,
|
||||
}
|
||||
|
||||
err := svc.CreateUser(ctx, newUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve user
|
||||
retrieved, err := svc.GetUser(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Alice", retrieved.Name)
|
||||
require.Equal(t, email, retrieved.Email)
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 2: Testing with Real External Service
|
||||
|
||||
**Use when**: Need to test against real service behavior (Victoria Metrics, NATS, etc.)
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package metrics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"myproject/internal/testutils"
|
||||
"myproject/metrics"
|
||||
)
|
||||
|
||||
func TestMetricsIngest_WithVictoriaMetrics(t *testing.T) {
|
||||
// Start real Victoria Metrics (Level 2 - binary)
|
||||
vmServer, err := testutils.RunVictoriaMetricsServer()
|
||||
require.NoError(t, err)
|
||||
defer vmServer.Shutdown()
|
||||
|
||||
// Create service with real dependency
|
||||
svc := metrics.NewIngester(vmServer.WriteURL())
|
||||
|
||||
// Test ingestion
|
||||
err = svc.IngestMetric(context.Background(), "test_metric", 42.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Force flush and verify
|
||||
vmServer.ForceFlush(context.Background())
|
||||
results, err := testutils.QueryVictoriaMetrics(vmServer.QueryURL(), "test_metric")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 3: Multi-Component Workflow
|
||||
|
||||
**Use when**: Testing complete workflows across multiple components
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package workflow_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"myproject/internal/testutils"
|
||||
"myproject/user"
|
||||
"myproject/notification"
|
||||
)
|
||||
|
||||
type UserWorkflowSuite struct {
|
||||
suite.Suite
|
||||
userRepo *user.InMemoryRepository
|
||||
emailer *user.TestEmailer
|
||||
natsServer *nserver.Server
|
||||
userService *user.UserService
|
||||
notifSvc *notification.NotificationService
|
||||
}
|
||||
|
||||
func (s *UserWorkflowSuite) SetupSuite() {
|
||||
// Setup in-memory NATS (Level 1)
|
||||
natsServer, err := testutils.RunNATsServer()
|
||||
s.Require().NoError(err)
|
||||
s.natsServer = natsServer
|
||||
|
||||
// Setup components
|
||||
s.userRepo = user.NewInMemoryRepository()
|
||||
s.emailer = user.NewTestEmailer()
|
||||
s.userService = user.NewUserService(s.userRepo, s.emailer)
|
||||
|
||||
natsAddr := "nats://" + natsServer.Addr().String()
|
||||
s.notifSvc = notification.NewService(natsAddr)
|
||||
}
|
||||
|
||||
func (s *UserWorkflowSuite) TearDownSuite() {
|
||||
s.natsServer.Shutdown()
|
||||
}
|
||||
|
||||
func (s *UserWorkflowSuite) TestCreateUser_TriggersNotification() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Subscribe to notifications
|
||||
received := make(chan string, 1)
|
||||
s.notifSvc.Subscribe("user.created", func(msg string) {
|
||||
received <- msg
|
||||
})
|
||||
|
||||
// Create user
|
||||
userID, _ := user.NewUserID("usr_123")
|
||||
email, _ := user.NewEmail("alice@example.com")
|
||||
newUser := user.User{ID: userID, Name: "Alice", Email: email}
|
||||
|
||||
err := s.userService.CreateUser(ctx, newUser)
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Verify notification sent
|
||||
select {
|
||||
case msg := <-received:
|
||||
s.Contains(msg, "Alice")
|
||||
case <-time.After(2 * time.Second):
|
||||
s.Fail("timeout waiting for notification")
|
||||
}
|
||||
|
||||
// Verify email sent
|
||||
emails := s.emailer.SentEmails()
|
||||
s.Contains(emails, "alice@example.com")
|
||||
}
|
||||
|
||||
func TestUserWorkflowSuite(t *testing.T) {
|
||||
suite.Run(t, new(UserWorkflowSuite))
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Priority
|
||||
|
||||
1. **Level 1: In-Memory** (Preferred) - httptest, in-memory maps, NATS harness
|
||||
2. **Level 2: Binary** (When needed) - Victoria Metrics, standalone services
|
||||
3. **Level 3: Test-containers** (Last resort) - Docker containers, slow startup
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO:
|
||||
- Test seams between components
|
||||
- Use in-memory implementations when possible
|
||||
- Test happy path and error scenarios
|
||||
- Use testify suites for complex setup
|
||||
- Focus on data flow and integration points
|
||||
|
||||
### DON'T:
|
||||
- Don't test business logic (that's unit tests)
|
||||
- Don't use heavy mocking (use real implementations)
|
||||
- Don't require Docker unless absolutely necessary
|
||||
- Don't duplicate unit test coverage
|
||||
- Don't skip cleanup (always defer)
|
||||
|
||||
## Running Integration Tests
|
||||
|
||||
```bash
|
||||
# Skip integration tests (default)
|
||||
go test ./...
|
||||
|
||||
# Run with integration tests
|
||||
go test -tags=integration ./...
|
||||
|
||||
# Run only integration tests
|
||||
go test -tags=integration ./... -run Integration
|
||||
|
||||
# With coverage
|
||||
go test -tags=integration -coverprofile=coverage.out ./...
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Test component interactions** - Not individual units
|
||||
2. **Prefer real implementations** - Over mocks when possible
|
||||
3. **Use build tags** - Keep unit tests fast
|
||||
4. **Reuse testutils** - Same infrastructure across tests
|
||||
5. **Test workflows** - Not just individual operations
|
||||
263
skills/testing/examples/jsonrpc-mock.md
Normal file
263
skills/testing/examples/jsonrpc-mock.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# JSON-RPC Server Mock with DSL
|
||||
|
||||
## When to Use This Example
|
||||
|
||||
Use this when:
|
||||
- Testing JSON-RPC clients
|
||||
- Need to mock JSON-RPC server responses
|
||||
- Want configurable mock behavior per method
|
||||
- Need to track and assert on received requests
|
||||
- Testing with OpenTelemetry trace propagation
|
||||
|
||||
**Dependency Level**: Level 1 (In-Memory) - Uses `httptest.Server` for in-memory HTTP
|
||||
|
||||
**Key Insight**: When testing a **JSON-RPC client**, mock the **server** it calls. Use rich DSL for readable test setup.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Rich JSON-RPC Server Mock
|
||||
|
||||
```go
|
||||
// internal/testutils/jrpc_server_mock.go
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/gorilla/rpc/v2/json2"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var ErrMethodNotFound = errors.New("method not found")
|
||||
|
||||
// TraceQuery holds received JSON-RPC queries for assertions
|
||||
type TraceQuery struct {
|
||||
Method string
|
||||
Params string
|
||||
}
|
||||
|
||||
// JrpcTraceServerMock is a rich JSON-RPC server mock with DSL.
|
||||
// Uses httptest.Server for in-memory HTTP (Level 1).
|
||||
type JrpcTraceServerMock struct {
|
||||
tracer trace.Tracer
|
||||
server *httptest.Server
|
||||
mockResponses map[string]any // method -> response
|
||||
queriesReceived []TraceQuery // for assertions
|
||||
}
|
||||
|
||||
// StartJrpcTraceServerMock starts an in-memory JSON-RPC server.
|
||||
// Returns a rich DSL object for configuring mock responses.
|
||||
func StartJrpcTraceServerMock() *JrpcTraceServerMock {
|
||||
mock := &JrpcTraceServerMock{
|
||||
mockResponses: make(map[string]any),
|
||||
tracer: otel.Tracer("trace-server-mock"),
|
||||
}
|
||||
|
||||
mux := mock.createHTTPHandlers()
|
||||
mock.server = httptest.NewServer(mux)
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// AddMockResponse configures the mock to return a response for a method.
|
||||
// This is the DSL - chain multiple calls for different methods!
|
||||
func (m *JrpcTraceServerMock) AddMockResponse(method string, response any) {
|
||||
m.mockResponses[method] = response
|
||||
}
|
||||
|
||||
// GetQueriesReceived returns all queries received (for assertions)
|
||||
func (m *JrpcTraceServerMock) GetQueriesReceived() []TraceQuery {
|
||||
return m.queriesReceived
|
||||
}
|
||||
|
||||
// Close shuts down the server (idempotent)
|
||||
func (m *JrpcTraceServerMock) Close() {
|
||||
m.server.Close()
|
||||
}
|
||||
|
||||
// Address returns the server address (for client configuration)
|
||||
func (m *JrpcTraceServerMock) Address() string {
|
||||
return m.server.Listener.Addr().String()
|
||||
}
|
||||
|
||||
func (m *JrpcTraceServerMock) createHTTPHandlers() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
codec := json2.NewCodec()
|
||||
|
||||
mux.HandleFunc("/reader", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract OpenTelemetry context for realistic testing
|
||||
reqCtx := r.Context()
|
||||
reqCtx = otel.GetTextMapPropagator().Extract(reqCtx, propagation.HeaderCarrier(r.Header))
|
||||
reqCtx, span := m.tracer.Start(reqCtx, "jrpc-trace-server",
|
||||
trace.WithSpanKind(trace.SpanKindServer))
|
||||
defer span.End()
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
receivedReq := codec.NewRequest(r)
|
||||
method, err := receivedReq.Method()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have a mock response configured
|
||||
if response, exists := m.mockResponses[method]; exists {
|
||||
args := struct{}{}
|
||||
if err := receivedReq.ReadRequest(&args); err != nil {
|
||||
receivedReq.WriteError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Store query for assertions
|
||||
m.queriesReceived = append(m.queriesReceived, TraceQuery{
|
||||
Method: method,
|
||||
Params: fmt.Sprintf("%+v", args),
|
||||
})
|
||||
|
||||
// Write mock response
|
||||
receivedReq.WriteResponse(w, response)
|
||||
return
|
||||
}
|
||||
|
||||
// Method not configured
|
||||
params := []string{}
|
||||
receivedReq.ReadRequest(¶ms)
|
||||
receivedReq.WriteError(w, http.StatusBadRequest, ErrMethodNotFound)
|
||||
})
|
||||
|
||||
return mux
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Setup in Test Suite
|
||||
|
||||
```go
|
||||
func (suite *TaskmonTestSuite) SetupSuite() {
|
||||
// Start in-memory JSON-RPC server mock (Level 1)
|
||||
suite.jrpcServerMock = testutils.StartJrpcTraceServerMock()
|
||||
|
||||
// Configure mock responses using DSL
|
||||
suite.jrpcServerMock.AddMockResponse("protocol", struct {
|
||||
Version string `json:"version"`
|
||||
Date string `json:"date"`
|
||||
}{
|
||||
Version: "3.18.0",
|
||||
Date: "Sep-04-2018",
|
||||
})
|
||||
|
||||
suite.jrpcServerMock.AddMockResponse("get_traces", struct {
|
||||
Traces []string `json:"traces"`
|
||||
}{
|
||||
Traces: []string{"trace1", "trace2"},
|
||||
})
|
||||
|
||||
// Configure your client to use the mock server
|
||||
client := jrpc.NewClient(suite.jrpcServerMock.Address() + "/reader")
|
||||
}
|
||||
|
||||
func (suite *TaskmonTestSuite) TearDownSuite() {
|
||||
suite.jrpcServerMock.Close()
|
||||
}
|
||||
```
|
||||
|
||||
### Test with Assertions
|
||||
|
||||
```go
|
||||
func (suite *TaskmonTestSuite) TestProtocolVersion() {
|
||||
// Call your code that makes JSON-RPC requests
|
||||
version, err := suite.taskmon.GetProtocolVersion()
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal("3.18.0", version.Version)
|
||||
|
||||
// Assert on received queries
|
||||
queries := suite.jrpcServerMock.GetQueriesReceived()
|
||||
suite.Require().Len(queries, 1)
|
||||
suite.Equal("protocol", queries[0].Method)
|
||||
}
|
||||
|
||||
func (suite *TaskmonTestSuite) TestGetTraces() {
|
||||
// Call your code
|
||||
traces, err := suite.taskmon.GetTraces()
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal([]string{"trace1", "trace2"}, traces)
|
||||
|
||||
// Verify the right method was called
|
||||
queries := suite.jrpcServerMock.GetQueriesReceived()
|
||||
suite.Require().Len(queries, 2) // protocol + get_traces
|
||||
suite.Equal("get_traces", queries[1].Method)
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Pattern is Excellent
|
||||
|
||||
1. **Rich DSL** - `AddMockResponse()` for easy, readable configuration
|
||||
2. **Readable Setup** - Tests are self-documenting, clear intent
|
||||
3. **In-Memory** - Uses `httptest.Server` (Level 1, no network I/O)
|
||||
4. **Query Tracking** - `GetQueriesReceived()` for assertions on what was called
|
||||
5. **OpenTelemetry Integration** - Realistic trace propagation for observability testing
|
||||
6. **Idempotent Cleanup** - Safe to call `Close()` multiple times
|
||||
7. **Flexible** - Configure any method/response combination dynamically
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
### DSL for Configuration
|
||||
|
||||
Mock setup should read like configuration:
|
||||
```go
|
||||
mock.AddMockResponse("method_name", expectedResponse)
|
||||
mock.AddMockResponse("another_method", anotherResponse)
|
||||
```
|
||||
|
||||
### Query Tracking for Assertions
|
||||
|
||||
Always track what was received:
|
||||
- Method names called
|
||||
- Parameters passed
|
||||
- Order of calls
|
||||
- Number of calls
|
||||
|
||||
### Built on httptest.Server
|
||||
|
||||
httptest.Server provides:
|
||||
- In-memory HTTP (no network I/O)
|
||||
- Automatic address allocation
|
||||
- Clean lifecycle management
|
||||
- Standard library, no dependencies
|
||||
|
||||
## Pattern Comparison
|
||||
|
||||
| Pattern | Use When |
|
||||
|---------|----------|
|
||||
| **httptest.Server** | Simple HTTP mocking |
|
||||
| **NATS test harness** | Need real NATS (pub/sub) |
|
||||
| **gRPC client mock** | Testing gRPC **server** |
|
||||
| **JSON-RPC server mock** | Testing JSON-RPC **client** |
|
||||
|
||||
## Benefits
|
||||
|
||||
- **In-Memory** - No network I/O, pure Go
|
||||
- **Fast** - Microsecond startup time
|
||||
- **Configurable** - Dynamic response configuration per test
|
||||
- **Trackable** - Full visibility into received requests
|
||||
- **OpenTelemetry-aware** - Realistic trace propagation
|
||||
- **Reusable** - Same infrastructure across test levels
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Mock servers should have rich DSL** - Makes setup readable
|
||||
2. **Track received requests** - Essential for assertions
|
||||
3. **Use httptest.Server** - Perfect for HTTP-based protocols
|
||||
4. **Make setup read like configuration** - Self-documenting tests
|
||||
5. **Support trace propagation** - Realistic observability testing
|
||||
6. **Idempotent cleanup** - Safe resource management
|
||||
175
skills/testing/examples/nats-in-memory.md
Normal file
175
skills/testing/examples/nats-in-memory.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# NATS In-Memory Test Server
|
||||
|
||||
## When to Use This Example
|
||||
|
||||
Use this when:
|
||||
- Testing message queue integrations with NATS
|
||||
- Need pub/sub functionality in tests
|
||||
- Want fast, in-memory NATS server (no Docker, no binary)
|
||||
- Testing event-driven architectures
|
||||
|
||||
**Dependency Level**: Level 1 (In-Memory) - Pure Go, official test harness
|
||||
|
||||
## Implementation
|
||||
|
||||
### Setup Test Infrastructure
|
||||
|
||||
Many official SDKs provide test harnesses. Here's NATS:
|
||||
|
||||
```go
|
||||
// internal/testutils/nats.go
|
||||
package testutils
|
||||
|
||||
import (
|
||||
nserver "github.com/nats-io/nats-server/v2/server"
|
||||
natsserver "github.com/nats-io/nats-server/v2/test"
|
||||
"github.com/projectdiscovery/freeport"
|
||||
)
|
||||
|
||||
// RunNATsServer runs a NATS server in-memory for testing.
|
||||
// Uses the official NATS SDK test harness - no binary download needed!
|
||||
func RunNATsServer() (*nserver.Server, error) {
|
||||
opts := natsserver.DefaultTestOptions
|
||||
|
||||
// Allocate free port to prevent conflicts in parallel tests
|
||||
tcpPort, err := freeport.GetFreePort("127.0.0.1", freeport.TCP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts.Port = tcpPort.Port
|
||||
|
||||
// Start NATS server in-memory (pure Go!)
|
||||
return natsserver.RunServer(&opts), nil
|
||||
}
|
||||
|
||||
// RunNATsServerWithJetStream runs NATS with JetStream enabled
|
||||
func RunNATsServerWithJetStream() (*nserver.Server, error) {
|
||||
opts := natsserver.DefaultTestOptions
|
||||
|
||||
tcpPort, err := freeport.GetFreePort("127.0.0.1", freeport.TCP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts.Port = tcpPort.Port
|
||||
opts.JetStream = true
|
||||
|
||||
return natsserver.RunServer(&opts), nil
|
||||
}
|
||||
```
|
||||
|
||||
## Usage in Integration Tests
|
||||
|
||||
### Basic Pub/Sub Test
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/require"
|
||||
"myproject/internal/testutils"
|
||||
)
|
||||
|
||||
func TestNATSPubSub_Integration(t *testing.T) {
|
||||
// Start NATS server in-memory (Level 1 - pure Go!)
|
||||
natsServer, err := testutils.RunNATsServer()
|
||||
require.NoError(t, err)
|
||||
defer natsServer.Shutdown()
|
||||
|
||||
// Connect to in-memory NATS
|
||||
natsAddress := "nats://" + natsServer.Addr().String()
|
||||
nc, err := nats.Connect(natsAddress)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
// Test pub/sub
|
||||
received := make(chan string, 1)
|
||||
_, err = nc.Subscribe("test.subject", func(msg *nats.Msg) {
|
||||
received <- string(msg.Data)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Publish message
|
||||
err = nc.Publish("test.subject", []byte("hello"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for message
|
||||
select {
|
||||
case msg := <-received:
|
||||
require.Equal(t, "hello", msg)
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for message")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Real-World Usage Example (gRPC + NATS)
|
||||
|
||||
```go
|
||||
// tests/gointegration/remote_traces_test.go
|
||||
type RemoteTracesTestSuite struct {
|
||||
suite.Suite
|
||||
natsServer *nserver.Server
|
||||
natsAddress string
|
||||
nc *nats.Conn
|
||||
// ... other fields
|
||||
}
|
||||
|
||||
func (suite *RemoteTracesTestSuite) SetupSuite() {
|
||||
// Start NATS server in-memory
|
||||
natsServer, err := testutils.RunNATsServer()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
suite.natsServer = natsServer
|
||||
suite.natsAddress = "nats://" + natsServer.Addr().String()
|
||||
|
||||
// Connect application to in-memory NATS
|
||||
suite.nc, err = natsremotetraces.ConnectToRemoteTracesSession(
|
||||
suite.ctx, suite.natsAddress, numWorkers, numWorkers, channelSize)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Start gRPC server with NATS backend
|
||||
// ... rest of setup
|
||||
}
|
||||
|
||||
func (suite *RemoteTracesTestSuite) TearDownSuite() {
|
||||
suite.nc.Close()
|
||||
suite.natsServer.Shutdown() // Clean shutdown
|
||||
}
|
||||
|
||||
func (suite *RemoteTracesTestSuite) TestMessageFlow() {
|
||||
// Test your application logic that uses NATS
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Why This is Excellent
|
||||
|
||||
- **Pure Go** - NATS server imported as library (no binary download)
|
||||
- **Official** - Uses NATS SDK's official test harness
|
||||
- **Fast** - Starts in microseconds
|
||||
- **Reliable** - Same behavior as production NATS
|
||||
- **Portable** - Works anywhere Go runs
|
||||
- **No Docker** - No external dependencies
|
||||
- **Parallel-Safe** - Free port allocation prevents conflicts
|
||||
|
||||
## Other Libraries with Test Harnesses
|
||||
|
||||
- **Redis**: `github.com/alicebob/miniredis` - Pure Go in-memory Redis
|
||||
- **NATS**: `github.com/nats-io/nats-server/v2/test` (shown above)
|
||||
- **PostgreSQL**: `github.com/jackc/pgx/v5/pgxpool` with pgx mock
|
||||
- **MongoDB**: `github.com/tryvium-travels/memongo` - In-memory MongoDB
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Check for official test harnesses first** - Many popular libraries provide them
|
||||
2. **Use free port allocation** - Prevents conflicts in parallel tests
|
||||
3. **Clean shutdown** - Always call `Shutdown()` in teardown
|
||||
4. **Reusable infrastructure** - Same setup for unit, integration, and system tests
|
||||
288
skills/testing/examples/system-patterns.md
Normal file
288
skills/testing/examples/system-patterns.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# System Test Patterns
|
||||
|
||||
## Purpose
|
||||
|
||||
System tests are black-box tests that verify the entire application works correctly from an external perspective. They test via CLI or API, simulating real user interactions.
|
||||
|
||||
**Location**: `tests/` directory at project root (separate from package code)
|
||||
|
||||
## Principles
|
||||
|
||||
### Black Box Testing
|
||||
- Test only via public interfaces (CLI, API)
|
||||
- No access to internal packages
|
||||
- Simulate real user behavior
|
||||
- Test critical workflows end-to-end
|
||||
|
||||
### Independence in Go
|
||||
- Strive for pure Go tests (no Docker required)
|
||||
- Use in-memory mocks from `testutils`
|
||||
- Binary dependencies when needed
|
||||
- Avoid docker-compose in CI
|
||||
|
||||
## CLI Testing Patterns
|
||||
|
||||
### Pattern 1: Simple Command Execution
|
||||
|
||||
```go
|
||||
// tests/cli_test.go
|
||||
package tests
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCLI_Version(t *testing.T) {
|
||||
// Execute CLI command
|
||||
cmd := exec.Command("./myapp", "version")
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(output), "myapp version")
|
||||
}
|
||||
|
||||
func TestCLI_Help(t *testing.T) {
|
||||
cmd := exec.Command("./myapp", "--help")
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(output), "Usage:")
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: CLI with In-Memory Mocks
|
||||
|
||||
```go
|
||||
// tests/cli_metrics_test.go
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"myproject/internal/testutils"
|
||||
)
|
||||
|
||||
func TestCLI_MetricsIngest(t *testing.T) {
|
||||
// Start Victoria Metrics (Level 2 - binary)
|
||||
vmServer, err := testutils.RunVictoriaMetricsServer()
|
||||
require.NoError(t, err)
|
||||
defer vmServer.Shutdown()
|
||||
|
||||
// Test CLI against real Victoria Metrics
|
||||
cmd := exec.Command("./myapp", "ingest",
|
||||
"--metrics-url", vmServer.WriteURL(),
|
||||
"--metric-name", "cli_test_metric",
|
||||
"--value", "100")
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(output), "Metric ingested successfully")
|
||||
|
||||
// Verify with helpers
|
||||
vmServer.ForceFlush(context.Background())
|
||||
results, err := testutils.QueryVictoriaMetrics(vmServer.QueryURL(), "cli_test_metric")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: CLI with File System
|
||||
|
||||
```go
|
||||
// tests/cli_config_test.go
|
||||
package tests
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCLI_ConfigFile(t *testing.T) {
|
||||
// Create temp directory
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
|
||||
// Write config file
|
||||
configContent := `
|
||||
server:
|
||||
port: 8080
|
||||
host: localhost
|
||||
`
|
||||
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test CLI with config file
|
||||
cmd := exec.Command("./myapp", "start", "--config", configPath, "--dry-run")
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(output), "Server would start on localhost:8080")
|
||||
}
|
||||
```
|
||||
|
||||
## API Testing Patterns
|
||||
|
||||
### Pattern 1: HTTP API with In-Memory Mocks
|
||||
|
||||
```go
|
||||
// tests/api_test.go
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"myproject/internal/testutils"
|
||||
)
|
||||
|
||||
func TestAPI_UserWorkflow(t *testing.T) {
|
||||
// Start in-memory NATS (Level 1)
|
||||
natsServer, err := testutils.RunNATsServer()
|
||||
require.NoError(t, err)
|
||||
defer natsServer.Shutdown()
|
||||
|
||||
natsAddr := "nats://" + natsServer.Addr().String()
|
||||
|
||||
// Start API server
|
||||
cmd := exec.Command("./myapp", "serve",
|
||||
"--port", "0", // Random free port
|
||||
"--nats-url", natsAddr)
|
||||
|
||||
// Start in background
|
||||
err = cmd.Start()
|
||||
require.NoError(t, err)
|
||||
defer cmd.Process.Kill()
|
||||
|
||||
// Wait for API to be ready
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Get actual port (from logs or endpoint)
|
||||
apiURL := "http://localhost:8080" // Or parse from logs
|
||||
|
||||
// Test API workflow
|
||||
// 1. Create user
|
||||
createReq := map[string]string{
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com",
|
||||
}
|
||||
body, _ := json.Marshal(createReq)
|
||||
|
||||
resp, err := http.Post(apiURL+"/users", "application/json", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var createResp map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&createResp)
|
||||
userID := createResp["id"]
|
||||
|
||||
// 2. Retrieve user
|
||||
resp, err = http.Get(apiURL + "/users/" + userID)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var user map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&user)
|
||||
require.Equal(t, "Alice", user["name"])
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture for Independence
|
||||
|
||||
### Dependency Injection Pattern
|
||||
|
||||
Design your application to accept dependency URLs:
|
||||
|
||||
```go
|
||||
// cmd/myapp/main.go
|
||||
func main() {
|
||||
// Allow overriding dependencies via flags
|
||||
natsURL := flag.String("nats-url", "nats://localhost:4222", "NATS server URL")
|
||||
metricsURL := flag.String("metrics-url", "http://localhost:8428", "Metrics server URL")
|
||||
flag.Parse()
|
||||
|
||||
// Use provided URLs (allows in-memory mocks in tests)
|
||||
app := app.New(*natsURL, *metricsURL)
|
||||
app.Run()
|
||||
}
|
||||
```
|
||||
|
||||
### Test with In-Memory Dependencies
|
||||
|
||||
```go
|
||||
// tests/app_test.go
|
||||
func TestApp_WithMocks(t *testing.T) {
|
||||
// Start all mocks
|
||||
natsServer, _ := testutils.RunNATsServer()
|
||||
defer natsServer.Shutdown()
|
||||
|
||||
vmServer, _ := testutils.RunVictoriaMetricsServer()
|
||||
defer vmServer.Shutdown()
|
||||
|
||||
// Test app with mocked dependencies (pure Go, no Docker!)
|
||||
cmd := exec.Command("./myapp", "serve",
|
||||
"--nats-url", "nats://"+natsServer.Addr().String(),
|
||||
"--metrics-url", vmServer.WriteURL())
|
||||
|
||||
// ... test application
|
||||
}
|
||||
```
|
||||
|
||||
## Running System Tests
|
||||
|
||||
```bash
|
||||
# Build application first
|
||||
go build -o myapp ./cmd/myapp
|
||||
|
||||
# Run system tests
|
||||
go test -v ./tests/...
|
||||
|
||||
# With coverage
|
||||
go test -v -coverprofile=coverage.out ./tests/...
|
||||
|
||||
# Specific test
|
||||
go test -v ./tests/... -run TestCLI_MetricsIngest
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO:
|
||||
- Test via CLI/API only (black box)
|
||||
- Use in-memory mocks from testutils
|
||||
- Test critical end-to-end workflows
|
||||
- Build binary before running tests
|
||||
- Use temp directories for file operations
|
||||
|
||||
### DON'T:
|
||||
- Don't import internal packages
|
||||
- Don't test every edge case (that's unit/integration tests)
|
||||
- Don't require Docker in CI
|
||||
- Don't use sleep for timing (use polling/channels)
|
||||
- Don't skip cleanup
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Black box only** - Test via public interfaces
|
||||
2. **Independent in Go** - No Docker required
|
||||
3. **Use testutils mocks** - Reuse infrastructure
|
||||
4. **Test critical paths** - Not every scenario
|
||||
5. **Fast execution** - Should run quickly in CI
|
||||
260
skills/testing/examples/test-organization.md
Normal file
260
skills/testing/examples/test-organization.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Test Organization and File Structure
|
||||
|
||||
## File Organization
|
||||
|
||||
### Basic Structure
|
||||
|
||||
```
|
||||
user/
|
||||
├── user.go
|
||||
├── user_test.go # Unit tests for user.go
|
||||
├── service.go
|
||||
├── service_test.go # Unit tests for service.go
|
||||
├── repository.go
|
||||
└── repository_test.go # Unit tests for repository.go
|
||||
```
|
||||
|
||||
### With Integration and System Tests
|
||||
|
||||
```
|
||||
project/
|
||||
├── user/
|
||||
│ ├── user.go
|
||||
│ ├── user_test.go # Unit tests (pkg_test)
|
||||
│ ├── service.go
|
||||
│ ├── service_test.go # Unit tests (pkg_test)
|
||||
│ └── integration_test.go # Integration tests with //go:build integration
|
||||
├── internal/
|
||||
│ └── testutils/ # Reusable test infrastructure
|
||||
│ ├── nats.go # In-memory NATS server
|
||||
│ ├── victoria.go # Victoria Metrics binary management
|
||||
│ └── httpserver/ # HTTP mock DSL
|
||||
│ ├── server.go
|
||||
│ └── server_test.go # Test the infrastructure!
|
||||
└── tests/ # System tests (black box)
|
||||
├── cli_test.go # CLI testing via exec.Command
|
||||
└── api_test.go # API testing via HTTP client
|
||||
```
|
||||
|
||||
## Package Naming
|
||||
|
||||
### Use `pkg_test` for Unit Tests
|
||||
|
||||
```go
|
||||
// ✅ External package - tests public API only
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/yourorg/project/user"
|
||||
)
|
||||
|
||||
func TestService_CreateUser(t *testing.T) {
|
||||
// Test through public API
|
||||
svc, _ := user.NewUserService(repo, notifier)
|
||||
err := svc.CreateUser(ctx, testUser)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Same Package Testing
|
||||
|
||||
```go
|
||||
// ❌ Same package - can test private methods (don't do this)
|
||||
package user
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInternalValidation(t *testing.T) {
|
||||
// Testing private function - bad practice
|
||||
result := validateEmailInternal("test@example.com")
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Build Tags for Integration Tests
|
||||
|
||||
### Using Build Tags
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"myproject/internal/testutils"
|
||||
)
|
||||
|
||||
func TestUserService_Integration(t *testing.T) {
|
||||
// Integration test with real dependencies
|
||||
natsServer, _ := testutils.RunNATsServer()
|
||||
defer natsServer.Shutdown()
|
||||
|
||||
// Test with real NATS
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run only unit tests (default - no build tags)
|
||||
go test ./...
|
||||
|
||||
# Run unit + integration tests
|
||||
go test -tags=integration ./...
|
||||
|
||||
# Run specific package integration tests
|
||||
go test -tags=integration ./user
|
||||
|
||||
# Run system tests only
|
||||
go test ./tests/...
|
||||
|
||||
# Run all tests
|
||||
go test -tags=integration ./...
|
||||
```
|
||||
|
||||
## Makefile/Taskfile Integration
|
||||
|
||||
### Taskfile.yml Example
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
test:
|
||||
desc: Run unit tests
|
||||
cmds:
|
||||
- go test -v -race ./...
|
||||
|
||||
test:integration:
|
||||
desc: Run integration tests
|
||||
cmds:
|
||||
- go test -v -race -tags=integration ./...
|
||||
|
||||
test:system:
|
||||
desc: Run system tests
|
||||
cmds:
|
||||
- go test -v -race ./tests/...
|
||||
|
||||
test:all:
|
||||
desc: Run all tests
|
||||
cmds:
|
||||
- task: test:integration
|
||||
- task: test:system
|
||||
|
||||
test:coverage:
|
||||
desc: Run tests with coverage
|
||||
cmds:
|
||||
- go test -v -race -coverprofile=coverage.out ./...
|
||||
- go tool cover -html=coverage.out -o coverage.html
|
||||
```
|
||||
|
||||
### Makefile Example
|
||||
|
||||
```makefile
|
||||
.PHONY: test test-integration test-system test-all coverage
|
||||
|
||||
test:
|
||||
go test -v -race ./...
|
||||
|
||||
test-integration:
|
||||
go test -v -race -tags=integration ./...
|
||||
|
||||
test-system:
|
||||
go test -v -race ./tests/...
|
||||
|
||||
test-all: test-integration test-system
|
||||
|
||||
coverage:
|
||||
go test -v -race -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
```
|
||||
|
||||
## Test File Naming
|
||||
|
||||
### Unit Tests
|
||||
- `*_test.go` - Standard test files
|
||||
- Located next to the code being tested
|
||||
- Use `pkg_test` package name
|
||||
|
||||
### Integration Tests
|
||||
- `integration_test.go` or `*_integration_test.go`
|
||||
- Use `//go:build integration` tag
|
||||
- Can be in same directory or separate `integration/` folder
|
||||
- Use `pkg_test` package name
|
||||
|
||||
### System Tests
|
||||
- `*_test.go` in `tests/` directory at project root
|
||||
- No build tags needed (separate directory)
|
||||
- Use `tests` or `main_test` package name
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.21'
|
||||
- name: Run unit tests
|
||||
run: go test -v -race ./...
|
||||
|
||||
integration-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.21'
|
||||
- name: Run integration tests
|
||||
run: go test -v -race -tags=integration ./...
|
||||
|
||||
system-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.21'
|
||||
- name: Build application
|
||||
run: go build -o myapp ./cmd/myapp
|
||||
- name: Run system tests
|
||||
run: go test -v -race ./tests/...
|
||||
```
|
||||
|
||||
## testutils Package Structure
|
||||
|
||||
```
|
||||
internal/testutils/
|
||||
├── nats.go # NATS in-memory server helpers
|
||||
├── victoria.go # Victoria Metrics binary management
|
||||
├── prometheus.go # Prometheus payload helpers
|
||||
├── grpc_client_mock.go # gRPC client mock with DSL
|
||||
├── jrpc_server_mock.go # JSON-RPC server mock with DSL
|
||||
└── httpserver/ # HTTP mock server with DSL
|
||||
├── server.go
|
||||
├── server_test.go # Test the infrastructure!
|
||||
├── dsl.go
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Co-locate unit tests** - Next to the code being tested
|
||||
2. **Use pkg_test package** - Forces public API testing
|
||||
3. **Build tags for integration** - Keep unit tests fast by default
|
||||
4. **Separate system tests** - In `tests/` directory
|
||||
5. **Test your test infrastructure** - Treat testutils as production code
|
||||
6. **Reusable infrastructure** - Share across all test levels
|
||||
566
skills/testing/examples/victoria-metrics.md
Normal file
566
skills/testing/examples/victoria-metrics.md
Normal file
@@ -0,0 +1,566 @@
|
||||
# Victoria Metrics Binary Test Server
|
||||
|
||||
## When to Use This Example
|
||||
|
||||
Use this when:
|
||||
- Testing Prometheus Remote Write integrations
|
||||
- Need real Victoria Metrics for testing metrics ingestion
|
||||
- Testing PromQL queries
|
||||
- Want production-like behavior without Docker
|
||||
- Testing metrics pipelines end-to-end
|
||||
|
||||
**Dependency Level**: Level 2 (Binary) - Standalone executable via `exec.Command`
|
||||
|
||||
**Why Binary Instead of In-Memory:**
|
||||
- Victoria Metrics is complex; reimplementing as in-memory mock isn't practical
|
||||
- Need real PromQL engine behavior
|
||||
- Need actual data persistence and querying
|
||||
- Binary startup is fast (< 1 second) and requires no Docker
|
||||
|
||||
## Implementation
|
||||
|
||||
### Victoria Server Infrastructure
|
||||
|
||||
This example shows how to download, manage, and run Victoria Metrics binary for testing:
|
||||
|
||||
```go
|
||||
// internal/testutils/victoria.go
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/freeport"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultVictoriaMetricsVersion = "v1.128.0"
|
||||
VictoriaMetricsVersionEnvVar = "TEST_VICTORIA_METRICS_VERSION"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrVictoriaMetricsNotHealthy = errors.New("victoria metrics did not become healthy")
|
||||
ErrDownloadFailed = errors.New("download failed")
|
||||
|
||||
// binaryDownloadMu protects concurrent downloads (prevent race conditions)
|
||||
binaryDownloadMu sync.Mutex
|
||||
)
|
||||
|
||||
// VictoriaServer represents a running Victoria Metrics test instance
|
||||
type VictoriaServer struct {
|
||||
cmd *exec.Cmd
|
||||
port int
|
||||
dataPath string
|
||||
writeURL string
|
||||
queryURL string
|
||||
version string
|
||||
binaryPath string
|
||||
shutdownOnce sync.Once
|
||||
shutdownErr error
|
||||
}
|
||||
|
||||
// WriteURL returns the URL for writing metrics (Prometheus Remote Write endpoint)
|
||||
func (vs *VictoriaServer) WriteURL() string {
|
||||
return vs.writeURL
|
||||
}
|
||||
|
||||
// QueryURL returns the URL for querying metrics (Prometheus-compatible query endpoint)
|
||||
func (vs *VictoriaServer) QueryURL() string {
|
||||
return vs.queryURL
|
||||
}
|
||||
|
||||
// Port returns the port Victoria Metrics is listening on
|
||||
func (vs *VictoriaServer) Port() int {
|
||||
return vs.port
|
||||
}
|
||||
|
||||
// ForceFlush forces Victoria Metrics to flush buffered samples from memory to disk,
|
||||
// making them immediately queryable. This is useful for testing to avoid waiting
|
||||
// for the automatic flush cycle.
|
||||
func (vs *VictoriaServer) ForceFlush(ctx context.Context) error {
|
||||
url := fmt.Sprintf("http://localhost:%d/internal/force_flush", vs.port)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create force flush request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to force flush: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("force flush failed: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown stops Victoria Metrics and cleans up resources.
|
||||
// Safe to call multiple times (idempotent).
|
||||
func (vs *VictoriaServer) Shutdown() error {
|
||||
vs.shutdownOnce.Do(func() {
|
||||
if vs.cmd == nil || vs.cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Send interrupt signal for graceful shutdown
|
||||
if err := vs.cmd.Process.Signal(os.Interrupt); err != nil {
|
||||
vs.shutdownErr = err
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for process to exit (with timeout)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- vs.cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
vs.cmd.Process.Kill()
|
||||
vs.shutdownErr = errors.New("shutdown timeout")
|
||||
case err := <-done:
|
||||
if err != nil && err.Error() != "signal: interrupt" {
|
||||
vs.shutdownErr = err
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup data directory
|
||||
if vs.dataPath != "" {
|
||||
os.RemoveAll(vs.dataPath)
|
||||
}
|
||||
})
|
||||
return vs.shutdownErr
|
||||
}
|
||||
|
||||
// RunVictoriaMetricsServer starts a Victoria Metrics instance for testing.
|
||||
// It downloads the binary if needed, starts the server, and waits for it to be healthy.
|
||||
func RunVictoriaMetricsServer() (*VictoriaServer, error) {
|
||||
version := getVictoriaMetricsVersion()
|
||||
|
||||
// Ensure binary exists (downloads if missing)
|
||||
binaryPath, err := ensureVictoriaBinary(version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get free port (prevents conflicts in parallel tests)
|
||||
freePort, err := freeport.GetFreePort("127.0.0.1", freeport.TCP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get free port: %w", err)
|
||||
}
|
||||
port := freePort.Port
|
||||
|
||||
// Create temporary data directory
|
||||
dataPath, err := os.MkdirTemp("", "victoria-metrics-test-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
|
||||
// Start Victoria Metrics
|
||||
cmd := exec.Command(
|
||||
binaryPath,
|
||||
fmt.Sprintf("-httpListenAddr=:%d", port),
|
||||
"-storageDataPath="+dataPath,
|
||||
"-retentionPeriod=1d",
|
||||
"-inmemoryDataFlushInterval=1ms", // Force immediate data flush for testing
|
||||
)
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.RemoveAll(dataPath)
|
||||
return nil, fmt.Errorf("failed to start victoria metrics: %w", err)
|
||||
}
|
||||
|
||||
baseURL := fmt.Sprintf("http://localhost:%d", port)
|
||||
server := &VictoriaServer{
|
||||
cmd: cmd,
|
||||
port: port,
|
||||
dataPath: dataPath,
|
||||
writeURL: baseURL + "/api/v1/write",
|
||||
queryURL: baseURL + "/api/v1/query",
|
||||
version: version,
|
||||
binaryPath: binaryPath,
|
||||
}
|
||||
|
||||
// Wait for server to become healthy
|
||||
if err := waitForHealth(baseURL); err != nil {
|
||||
server.Shutdown()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func getVictoriaMetricsVersion() string {
|
||||
if version := os.Getenv(VictoriaMetricsVersionEnvVar); version != "" {
|
||||
return version
|
||||
}
|
||||
return DefaultVictoriaMetricsVersion
|
||||
}
|
||||
|
||||
// ensureVictoriaBinary ensures the Victoria Metrics binary exists, downloading if necessary.
|
||||
// Thread-safe with double-check locking to prevent race conditions.
|
||||
func ensureVictoriaBinary(version string) (string, error) {
|
||||
binaryName := fmt.Sprintf("victoria-metrics-%s-%s-%s", version, runtime.GOOS, getVMArch())
|
||||
binaryPath := filepath.Join(".bin", binaryName)
|
||||
|
||||
// Quick check without lock (optimization)
|
||||
if _, err := os.Stat(binaryPath); err == nil {
|
||||
return binaryPath, nil
|
||||
}
|
||||
|
||||
// Acquire lock to prevent concurrent downloads
|
||||
binaryDownloadMu.Lock()
|
||||
defer binaryDownloadMu.Unlock()
|
||||
|
||||
// Double-check after acquiring lock (another goroutine might have downloaded it)
|
||||
if _, err := os.Stat(binaryPath); err == nil {
|
||||
return binaryPath, nil
|
||||
}
|
||||
|
||||
// Create .bin directory
|
||||
if err := os.MkdirAll(".bin", 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create .bin directory: %w", err)
|
||||
}
|
||||
|
||||
// Download to temporary location with unique name
|
||||
tempPath := fmt.Sprintf("%s.tmp.%d", binaryPath, os.Getpid())
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
downloadURL := fmt.Sprintf(
|
||||
"https://github.com/VictoriaMetrics/VictoriaMetrics/releases/download/%s/victoria-metrics-%s-%s-%s.tar.gz",
|
||||
version, runtime.GOOS, getVMArch(), version,
|
||||
)
|
||||
|
||||
if err := downloadAndExtract(downloadURL, tempPath); err != nil {
|
||||
return "", fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tempPath, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to make binary executable: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename - only one goroutine succeeds if multiple try
|
||||
if err := os.Rename(tempPath, binaryPath); err != nil {
|
||||
// If rename fails, check if another goroutine succeeded
|
||||
if _, statErr := os.Stat(binaryPath); statErr == nil {
|
||||
return binaryPath, nil // Another goroutine won the race
|
||||
}
|
||||
return "", fmt.Errorf("failed to rename binary: %w", err)
|
||||
}
|
||||
|
||||
return binaryPath, nil
|
||||
}
|
||||
|
||||
func getVMArch() string {
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return "amd64"
|
||||
case "arm64":
|
||||
return "arm64"
|
||||
default:
|
||||
return runtime.GOARCH
|
||||
}
|
||||
}
|
||||
|
||||
func waitForHealth(baseURL string) error {
|
||||
healthURL := baseURL + "/health"
|
||||
maxRetries := 30
|
||||
retryInterval := time.Second
|
||||
|
||||
ctx := context.Background()
|
||||
for range maxRetries {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
|
||||
if err != nil {
|
||||
time.Sleep(retryInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
statusOK := resp.StatusCode == http.StatusOK
|
||||
resp.Body.Close()
|
||||
if statusOK {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(retryInterval)
|
||||
}
|
||||
|
||||
return ErrVictoriaMetricsNotHealthy
|
||||
}
|
||||
```
|
||||
|
||||
### Helper Functions for Prometheus/Victoria Metrics Testing
|
||||
|
||||
Add practical helpers that make tests clear and maintainable:
|
||||
|
||||
```go
|
||||
// internal/testutils/prometheus.go
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"github.com/golang/snappy"
|
||||
"github.com/prometheus/prometheus/prompb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrQueryFailed = errors.New("victoria metrics query failed")
|
||||
ErrQueryNonSuccess = errors.New("query returned non-success status")
|
||||
)
|
||||
|
||||
// CreatePrometheusPayload creates a valid Prometheus Remote Write payload
|
||||
// with a sample metric. The payload is protobuf-encoded and snappy-compressed,
|
||||
// ready to be sent to Victoria Metrics' /api/v1/write endpoint.
|
||||
func CreatePrometheusPayload(metricName string, value float64, labels map[string]string) ([]byte, error) {
|
||||
// Create timestamp (current time in milliseconds)
|
||||
timestampMs := time.Now().UnixMilli()
|
||||
|
||||
// Build label pairs
|
||||
labelPairs := make([]prompb.Label, 0, len(labels)+1)
|
||||
labelPairs = append(labelPairs, prompb.Label{
|
||||
Name: "__name__",
|
||||
Value: metricName,
|
||||
})
|
||||
for name, val := range labels {
|
||||
labelPairs = append(labelPairs, prompb.Label{
|
||||
Name: name,
|
||||
Value: val,
|
||||
})
|
||||
}
|
||||
|
||||
// Create a single time series with one sample
|
||||
timeseries := []prompb.TimeSeries{
|
||||
{
|
||||
Labels: labelPairs,
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: value,
|
||||
Timestamp: timestampMs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create WriteRequest
|
||||
writeRequest := &prompb.WriteRequest{
|
||||
Timeseries: timeseries,
|
||||
}
|
||||
|
||||
// Marshal to protobuf
|
||||
data, err := proto.Marshal(writeRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal protobuf: %w", err)
|
||||
}
|
||||
|
||||
// Compress with snappy
|
||||
compressed := snappy.Encode(nil, data)
|
||||
|
||||
return compressed, nil
|
||||
}
|
||||
|
||||
// VMQueryResult represents a single result from a Victoria Metrics query.
|
||||
type VMQueryResult struct {
|
||||
Metric map[string]string // label name -> label value
|
||||
Value []any // [timestamp, value_string]
|
||||
}
|
||||
|
||||
// VMQueryResponse represents the full Victoria Metrics API response.
|
||||
type VMQueryResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
ResultType string `json:"result_type"`
|
||||
Result []VMQueryResult `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// QueryVictoriaMetrics executes a PromQL query against Victoria Metrics.
|
||||
// The query is performed via the /api/v1/query endpoint with time buffer
|
||||
// for clock skew and delayed indexing.
|
||||
func QueryVictoriaMetrics(queryURL, query string) ([]VMQueryResult, error) {
|
||||
// Query with current time + 1 minute to catch any clock skew or delayed indexing
|
||||
currentTime := time.Now().Add(1 * time.Minute)
|
||||
fullURL := fmt.Sprintf("%s?query=%s&time=%d", queryURL, url.QueryEscape(query), currentTime.Unix())
|
||||
|
||||
// Execute HTTP request
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute query: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %s", ErrQueryFailed, resp.Status)
|
||||
}
|
||||
|
||||
// Read response body
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
var queryResp VMQueryResponse
|
||||
if err := json.Unmarshal(bodyBytes, &queryResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if queryResp.Status != "success" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrQueryNonSuccess, queryResp.Status)
|
||||
}
|
||||
|
||||
return queryResp.Data.Result, nil
|
||||
}
|
||||
|
||||
// AssertLabelExists checks if at least one result contains a label with the given name and value.
|
||||
// Fails the test if the label is not found.
|
||||
func AssertLabelExists(t *testing.T, results []VMQueryResult, labelName, labelValue string) {
|
||||
t.Helper()
|
||||
|
||||
for _, result := range results {
|
||||
if val, exists := result.Metric[labelName]; exists && val == labelValue {
|
||||
return // Found it!
|
||||
}
|
||||
}
|
||||
|
||||
// Label not found - fail with helpful message
|
||||
require.Fail(t, "Label not found",
|
||||
"Expected to find label %s=%s in query results, but it was not present",
|
||||
labelName, labelValue)
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Integration Test
|
||||
|
||||
```go
|
||||
// internal/api/stats/prometheus_ingest_test.go
|
||||
func TestPrometheusIngest_WithVictoriaMetrics(t *testing.T) {
|
||||
// Start real Victoria Metrics server (Level 2)
|
||||
vmServer, err := testutils.RunVictoriaMetricsServer()
|
||||
require.NoError(t, err)
|
||||
defer vmServer.Shutdown()
|
||||
|
||||
// Create valid Prometheus payload using helper
|
||||
payload, err := testutils.CreatePrometheusPayload("test_metric", 42.0, map[string]string{
|
||||
"service": "api",
|
||||
"env": "test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send to Victoria Metrics
|
||||
req := httptest.NewRequest(http.MethodPost, vmServer.WriteURL(), bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/x-protobuf")
|
||||
req.Header.Set("Content-Encoding", "snappy")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
||||
// Force flush to make data queryable immediately
|
||||
err = vmServer.ForceFlush(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Query using helper
|
||||
results, err := testutils.QueryVictoriaMetrics(vmServer.QueryURL(), `test_metric{service="api"}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
|
||||
// Assert using helper
|
||||
testutils.AssertLabelExists(t, results, "env", "test")
|
||||
}
|
||||
```
|
||||
|
||||
### System Test
|
||||
|
||||
```go
|
||||
// tests/prometheus_ingestion_test.go
|
||||
func TestE2E_PrometheusIngestion(t *testing.T) {
|
||||
// Same Victoria Metrics infrastructure!
|
||||
vmServer, err := testutils.RunVictoriaMetricsServer()
|
||||
require.NoError(t, err)
|
||||
defer vmServer.Shutdown()
|
||||
|
||||
// Test CLI against real Victoria Metrics
|
||||
cmd := exec.Command("./myapp", "ingest",
|
||||
"--metrics-url", vmServer.WriteURL(),
|
||||
"--metric-name", "cli_test_metric",
|
||||
"--value", "100")
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(output), "Metric ingested successfully")
|
||||
|
||||
// Verify with helpers
|
||||
vmServer.ForceFlush(context.Background())
|
||||
results, err := testutils.QueryVictoriaMetrics(vmServer.QueryURL(), "cli_test_metric")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Binary download with OS/arch detection** - Works on macOS/Linux, amd64/arm64
|
||||
- **Thread-safe download** - Mutex + double-check locking prevents race conditions
|
||||
- **Free port allocation** - Prevents conflicts in parallel tests
|
||||
- **Idempotent shutdown** - Safe to call multiple times with `sync.Once`
|
||||
- **Resource cleanup** - Proper temp directory and process cleanup
|
||||
- **Helper functions** - `ForceFlush()` for immediate data availability
|
||||
- **Prometheus helpers** - Create payloads, query, assert on results
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Production-like testing** - Testing against REAL Victoria Metrics, not mocks
|
||||
- **Reusable** - Same `testutils` infrastructure for unit, integration, and system tests
|
||||
- **Readable** - Helper functions make tests read like documentation
|
||||
- **No Docker** - No Docker required, works in any environment
|
||||
- **Fast** - Binary starts in < 1 second
|
||||
- **Portable** - Works anywhere Go runs
|
||||
- **Maintainable** - Changes to test infrastructure are centralized
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Binary level is good for complex services** - When in-memory is too complex
|
||||
2. **Download management is critical** - Thread-safe, cached, version-controlled
|
||||
3. **Helper functions make tests readable** - DSL for common operations
|
||||
4. **Reuse across test levels** - Same infrastructure for unit, integration, system
|
||||
5. **Force flush is essential** - Make data immediately queryable in tests
|
||||
689
skills/testing/reference.md
Normal file
689
skills/testing/reference.md
Normal file
@@ -0,0 +1,689 @@
|
||||
# Testing Reference
|
||||
|
||||
Complete guide to Go testing principles and patterns.
|
||||
|
||||
## Core Testing Principles
|
||||
|
||||
### 1. Test Only Public API
|
||||
- **Use `pkg_test` package name** - Forces external perspective
|
||||
- **Test types via constructors** - No direct struct initialization
|
||||
- **No testing private methods** - If you need to test it, make it public or rethink design
|
||||
|
||||
```go
|
||||
// ✅ Good
|
||||
package user_test
|
||||
|
||||
import "github.com/yourorg/project/user"
|
||||
|
||||
func TestService_CreateUser(t *testing.T) {
|
||||
svc, _ := user.NewUserService(repo, notifier)
|
||||
err := svc.CreateUser(ctx, testUser)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Avoid Mocks - Use Real Implementations
|
||||
|
||||
Instead of mocks, use:
|
||||
- **HTTP test servers** (`httptest` package)
|
||||
- **Temp files/directories** (`os.CreateTemp`, `os.MkdirTemp`)
|
||||
- **In-memory databases** (SQLite in-memory, or custom implementations)
|
||||
- **Test implementations** (TestEmailer that writes to buffer)
|
||||
|
||||
**Benefits:**
|
||||
- Tests are more reliable
|
||||
- Tests verify actual behavior
|
||||
- Easier to maintain
|
||||
|
||||
### 3. Coverage Strategy
|
||||
|
||||
**Leaf Types** (self-contained):
|
||||
- **Target**: 100% unit test coverage
|
||||
- **Why**: Core logic must be bulletproof
|
||||
|
||||
**Orchestrating Types** (coordinate others):
|
||||
- **Target**: Integration test coverage
|
||||
- **Why**: Test seams between components
|
||||
|
||||
**Goal**: Most logic in leaf types (easier to test and maintain)
|
||||
|
||||
---
|
||||
|
||||
## Table-Driven Tests
|
||||
|
||||
### When to Use
|
||||
- Each test case has **cyclomatic complexity = 1**
|
||||
- No conditionals inside t.Run()
|
||||
- Simple, focused testing scenarios
|
||||
|
||||
### ❌ Anti-Pattern: wantErr bool
|
||||
|
||||
**DO NOT** use `wantErr bool` pattern - it violates complexity = 1 rule:
|
||||
|
||||
```go
|
||||
// ❌ BAD - Has conditionals (complexity > 1)
|
||||
func TestNewUserID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want UserID
|
||||
wantErr bool // ❌ Anti-pattern
|
||||
}{
|
||||
{name: "valid ID", input: "usr_123", want: UserID("usr_123"), wantErr: false},
|
||||
{name: "empty ID", input: "", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NewUserID(tt.input)
|
||||
if tt.wantErr { // ❌ Conditional
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct Pattern: Separate Functions
|
||||
|
||||
**Always separate success and error cases:**
|
||||
|
||||
```go
|
||||
// ✅ Success cases - Complexity = 1
|
||||
func TestNewUserID_Success(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want UserID
|
||||
}{
|
||||
{name: "valid ID", input: "usr_123", want: UserID("usr_123")},
|
||||
{name: "with numbers", input: "usr_456", want: UserID("usr_456")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NewUserID(tt.input)
|
||||
require.NoError(t, err) // ✅ No conditionals
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Error cases - Complexity = 1
|
||||
func TestNewUserID_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{name: "empty ID", input: ""},
|
||||
{name: "whitespace only", input: " "},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewUserID(tt.input)
|
||||
assert.Error(t, err) // ✅ No conditionals
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Critical Rule: Named Struct Fields
|
||||
|
||||
**ALWAYS use named struct fields** - Linter reorders fields, breaking unnamed initialization:
|
||||
|
||||
```go
|
||||
// ❌ BAD - Breaks when linter reorders fields
|
||||
tests := []struct {
|
||||
name string
|
||||
input int
|
||||
want string
|
||||
}{
|
||||
{"test1", 42, "result"}, // Will break
|
||||
}
|
||||
|
||||
// ✅ GOOD - Works regardless of field order
|
||||
tests := []struct {
|
||||
name string
|
||||
input int
|
||||
want string
|
||||
}{
|
||||
{name: "test1", input: 42, want: "result"}, // Always works
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testify Suites
|
||||
|
||||
### When to Use
|
||||
|
||||
ONLY for complex test infrastructure setup:
|
||||
- Mock HTTP servers
|
||||
- Database connections
|
||||
- OpenTelemetry testing setup
|
||||
- Temporary files/directories needing cleanup
|
||||
- Shared expensive setup/teardown
|
||||
|
||||
### When NOT to Use
|
||||
- Simple unit tests (use table-driven instead)
|
||||
- Tests without complex setup
|
||||
|
||||
### Pattern
|
||||
|
||||
```go
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type ServiceSuite struct {
|
||||
suite.Suite
|
||||
server *httptest.Server
|
||||
svc *user.UserService
|
||||
}
|
||||
|
||||
func (s *ServiceSuite) SetupSuite() {
|
||||
s.server = httptest.NewServer(testHandler)
|
||||
}
|
||||
|
||||
func (s *ServiceSuite) TearDownSuite() {
|
||||
s.server.Close()
|
||||
}
|
||||
|
||||
func (s *ServiceSuite) SetupTest() {
|
||||
s.svc = user.NewUserService(s.server.URL)
|
||||
}
|
||||
|
||||
func (s *ServiceSuite) TestCreateUser() {
|
||||
err := s.svc.CreateUser(ctx, testUser)
|
||||
s.NoError(err)
|
||||
}
|
||||
|
||||
func TestServiceSuite(t *testing.T) {
|
||||
suite.Run(t, new(ServiceSuite))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Synchronization in Tests
|
||||
|
||||
### Never Use time.Sleep
|
||||
|
||||
Use channels or WaitGroups instead.
|
||||
|
||||
### Use Channels
|
||||
|
||||
```go
|
||||
func TestAsyncOperation(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
doAsyncWork()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Success
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use WaitGroups
|
||||
|
||||
```go
|
||||
func TestConcurrentOperations(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
results := make([]string, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
results[index] = doWork(index)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Assert on results
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Organization
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
user/
|
||||
├── user.go
|
||||
├── user_test.go # Tests for user.go (pkg_test)
|
||||
├── service.go
|
||||
├── service_test.go # Tests for service.go (pkg_test)
|
||||
```
|
||||
|
||||
### Package Naming
|
||||
|
||||
```go
|
||||
// ✅ External package - tests public API only
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/yourorg/project/user"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real Implementation Patterns
|
||||
|
||||
### In-Memory Repository
|
||||
|
||||
```go
|
||||
package user
|
||||
|
||||
type InMemoryRepository struct {
|
||||
mu sync.RWMutex
|
||||
users map[UserID]User
|
||||
}
|
||||
|
||||
func NewInMemoryRepository() *InMemoryRepository {
|
||||
return &InMemoryRepository{
|
||||
users: make(map[UserID]User),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) Save(ctx context.Context, u User) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.users[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) Get(ctx context.Context, id UserID) (*User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
u, ok := r.users[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Test Email Sender
|
||||
|
||||
```go
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type TestEmailer struct {
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
}
|
||||
|
||||
func NewTestEmailer() *TestEmailer {
|
||||
return &TestEmailer{}
|
||||
}
|
||||
|
||||
func (e *TestEmailer) Send(to Email, subject, body string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
fmt.Fprintf(&e.buffer, "To: %s\nSubject: %s\n%s\n\n", to, subject, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *TestEmailer) SentEmails() string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.buffer.String()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testable Examples (GoDoc Examples)
|
||||
|
||||
### When to Add
|
||||
- Non-trivial types
|
||||
- Types with validation
|
||||
- Common usage patterns
|
||||
|
||||
### Pattern
|
||||
|
||||
```go
|
||||
// Example_UserID demonstrates basic usage.
|
||||
func Example_UserID() {
|
||||
id, _ := user.NewUserID("usr_123")
|
||||
fmt.Println(id)
|
||||
// Output: usr_123
|
||||
}
|
||||
|
||||
// Example_UserID_validation shows validation behavior.
|
||||
func Example_UserID_validation() {
|
||||
_, err := user.NewUserID("")
|
||||
fmt.Println(err != nil)
|
||||
// Output: true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Before Considering Tests Complete
|
||||
|
||||
**Structure:**
|
||||
- [ ] Tests in `pkg_test` package
|
||||
- [ ] Testing public API only
|
||||
- [ ] Table-driven tests use named fields
|
||||
- [ ] No conditionals in test cases
|
||||
|
||||
**Implementation:**
|
||||
- [ ] Using real implementations, not mocks
|
||||
- [ ] No time.Sleep (using channels/waitgroups)
|
||||
- [ ] Testify suites only for complex setup
|
||||
|
||||
**Coverage:**
|
||||
- [ ] Leaf types: 100% unit test coverage
|
||||
- [ ] Orchestrating types: Integration tests
|
||||
- [ ] Happy path, edge cases, error cases covered
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**The Golden Rule**: Cyclomatic complexity = 1 in all test cases
|
||||
|
||||
**Test Structure Choices:**
|
||||
- **Table-driven tests**: Simple, focused scenarios
|
||||
- **Testify suites**: Complex infrastructure setup only
|
||||
|
||||
**Test Philosophy:**
|
||||
- Test only public API (`pkg_test` package)
|
||||
- Use real implementations, not mocks
|
||||
- Leaf types: 100% coverage
|
||||
- Orchestrating types: Integration tests
|
||||
|
||||
**Common Pitfalls to Avoid:**
|
||||
- ❌ Testing private methods
|
||||
- ❌ Heavy mocking
|
||||
- ❌ time.Sleep in tests
|
||||
- ❌ Conditionals in test cases
|
||||
- ❌ Unnamed struct fields in table tests
|
||||
|
||||
---
|
||||
|
||||
# Example Files - Reusable Testing Patterns
|
||||
|
||||
The following example files contain **transferable patterns** that apply to many scenarios, not just the specific technologies shown. Claude should read these files based on the **pattern needed**, not the specific technology mentioned.
|
||||
|
||||
## Pattern 1: In-Memory Test Harness (Level 1)
|
||||
|
||||
**File**: `examples/nats-in-memory.md`
|
||||
|
||||
**Pattern**: Using official test harnesses from Go libraries
|
||||
|
||||
**When to read:**
|
||||
- Need to test with ANY service that provides an official Go test harness
|
||||
- Testing message queues, databases, caches, or any service with in-memory test mode
|
||||
- Want to avoid Docker but need realistic service behavior
|
||||
|
||||
**Applies to:**
|
||||
- **NATS** (shown in example) - Message queue with official test harness
|
||||
- **Redis** - `github.com/alicebob/miniredis` pure Go in-memory Redis
|
||||
- **MongoDB** - `github.com/tryvium-travels/memongo` in-memory MongoDB
|
||||
- **PostgreSQL** - `github.com/jackc/pgx/v5` with pgx mock
|
||||
- **Any Go library with test package** - Check if dependency has `/test` package
|
||||
|
||||
**Key techniques to adapt:**
|
||||
- Wrapping official harness with clean API
|
||||
- Free port allocation for parallel tests
|
||||
- Clean lifecycle management (Setup/Teardown)
|
||||
- Thread-safe initialization
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Binary Dependency Management (Level 2)
|
||||
|
||||
**File**: `examples/victoria-metrics.md`
|
||||
|
||||
**Pattern**: Download, manage, and run ANY standalone binary for testing
|
||||
|
||||
**When to read:**
|
||||
- Need to test against ANY external binary executable
|
||||
- No in-memory option available
|
||||
- Want production-like testing without Docker
|
||||
|
||||
**Applies to:**
|
||||
- **Victoria Metrics** (shown in example) - Metrics database
|
||||
- **Prometheus** - Metrics and alerting
|
||||
- **Grafana** - Dashboards and visualization
|
||||
- **Any database binaries** - PostgreSQL, MySQL, Redis, etc.
|
||||
- **Any CLI tools** - Language servers, formatters, linters
|
||||
- **Custom binaries** - Your own services or third-party tools
|
||||
|
||||
**Key techniques to adapt:**
|
||||
- OS/ARCH detection (`runtime.GOOS`, `runtime.GOARCH`)
|
||||
- Thread-safe binary downloads with double-check locking
|
||||
- Health check polling with retries
|
||||
- Graceful shutdown with `sync.Once`
|
||||
- Free port allocation
|
||||
- Temp directory management
|
||||
- Version management via environment variables
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Mock Server with Generic DSL (Level 1)
|
||||
|
||||
**File**: `examples/jsonrpc-mock.md`
|
||||
|
||||
**Pattern**: Building generic mock servers with configurable responses using `AddMockResponse()`
|
||||
|
||||
**When to read:**
|
||||
- Need to mock ANY request/response protocol
|
||||
- Want readable test setup with DSL
|
||||
- Testing clients that call external APIs
|
||||
|
||||
**Applies to:**
|
||||
- **JSON-RPC** (shown in example) - RPC over HTTP
|
||||
- **REST APIs** - Use same pattern with route matching
|
||||
- **GraphQL** - Configure response per query
|
||||
- **gRPC** - Adapt for protobuf messages
|
||||
- **WebSocket** - Mock message responses
|
||||
- **Any HTTP-based protocol** - SOAP, XML-RPC, custom protocols
|
||||
|
||||
**Key techniques to adapt:**
|
||||
- Generic `AddMockResponse(identifier, response)` pattern
|
||||
- Using `httptest.Server` as foundation
|
||||
- Query/request tracking for assertions
|
||||
- Configuration-based response mapping
|
||||
- Thread-safe response storage
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Bidirectional Streaming with Rich DSL (Level 1)
|
||||
|
||||
**File**: `examples/grpc-bufconn.md`
|
||||
|
||||
**Pattern**: In-memory bidirectional communication with rich client/server mocks
|
||||
|
||||
**When to read:**
|
||||
- Testing ANY bidirectional streaming protocol
|
||||
- Need full-duplex communication in tests
|
||||
- Want to avoid network I/O
|
||||
|
||||
**Applies to:**
|
||||
- **gRPC** (shown in example) - Uses bufconn for in-memory
|
||||
- **WebSockets** - Adapt bufconn pattern
|
||||
- **TCP streams** - Custom protocols over TCP
|
||||
- **Unix sockets** - Inter-process communication
|
||||
- **Any streaming protocol** - Server-Sent Events, HTTP/2 streams
|
||||
|
||||
**Key techniques to adapt:**
|
||||
- `bufconn` for in-memory connections (gRPC-specific, but concept applies)
|
||||
- Rich mock objects with helper methods
|
||||
- Thread-safe state tracking with mutexes
|
||||
- Assertion helpers (`ListenToStreamAndAssert()`)
|
||||
- When testing **server** → mock the **clients**
|
||||
- When testing **client** → mock the **server**
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5: HTTP DSL and Builder Pattern (Level 1)
|
||||
|
||||
**File**: `examples/httptest-dsl.md`
|
||||
|
||||
**Pattern**: Building readable test infrastructure with DSL wrappers over stdlib
|
||||
|
||||
**When to read:**
|
||||
- Want to wrap ANY test infrastructure with clean DSL
|
||||
- Need fluent, readable test setup
|
||||
- Building reusable test utilities
|
||||
|
||||
**Applies to:**
|
||||
- **HTTP mocking** (shown in example) - httptest.Server wrapper
|
||||
- **Any test infrastructure** - Databases, queues, file systems
|
||||
- **Test data builders** - Fluent APIs for creating test data
|
||||
- **Custom test harnesses** - Wrapping complex setups
|
||||
|
||||
**Key techniques to adapt:**
|
||||
- Builder pattern with method chaining
|
||||
- Fluent API design (`OnGET().RespondJSON()`)
|
||||
- Separating configuration from execution
|
||||
- Type-safe builders with Go generics
|
||||
- Hiding complexity behind clean interfaces
|
||||
|
||||
---
|
||||
|
||||
## Pattern 6: Test Organization and Structure
|
||||
|
||||
**File**: `examples/test-organization.md`
|
||||
|
||||
**When to read:**
|
||||
- Setting up test structure for new projects
|
||||
- Adding build tags for integration tests
|
||||
- Configuring CI/CD for tests
|
||||
- Creating testutils package structure
|
||||
|
||||
**Universal patterns** (not technology-specific):
|
||||
- File organization (`pkg_test` package naming)
|
||||
- Build tags (`//go:build integration`)
|
||||
- Makefile/Taskfile structure
|
||||
- CI/CD configuration
|
||||
- testutils package layout
|
||||
|
||||
---
|
||||
|
||||
## Pattern 7: Integration Test Workflows
|
||||
|
||||
**File**: `examples/integration-patterns.md`
|
||||
|
||||
**When to read:**
|
||||
- Testing component interactions across package boundaries
|
||||
- Need patterns for Service + Repository testing
|
||||
- Testing workflows that span multiple components
|
||||
|
||||
**Universal patterns:**
|
||||
- Pattern 1: Service + Repository with in-memory deps
|
||||
- Pattern 2: Testing with real external services
|
||||
- Pattern 3: Multi-component workflow with testify suites
|
||||
- Dependency priority (in-memory > binary > test-containers)
|
||||
|
||||
---
|
||||
|
||||
## Pattern 8: System Test (Black Box)
|
||||
|
||||
**File**: `examples/system-patterns.md`
|
||||
|
||||
**When to read:**
|
||||
- Writing black-box end-to-end tests
|
||||
- Testing via CLI or API
|
||||
- Need tests that work without Docker
|
||||
|
||||
**Universal patterns:**
|
||||
- CLI testing with `exec.Command`
|
||||
- API testing with HTTP client
|
||||
- Dependency injection architecture
|
||||
- Pure Go testing (no Docker)
|
||||
|
||||
---
|
||||
|
||||
## How Claude Should Use These Files
|
||||
|
||||
### Pattern-Based Reading Rules
|
||||
|
||||
**When user needs to test with external dependencies:**
|
||||
|
||||
1. **Has official Go test harness?** → Read `nats-in-memory.md`
|
||||
- "Test with Redis/MongoDB/PostgreSQL/NATS"
|
||||
- "Avoid Docker but need real service"
|
||||
- Look for inspiration on wrapping official harnesses
|
||||
|
||||
2. **Need to download/run binary?** → Read `victoria-metrics.md`
|
||||
- "Test with Prometheus/Grafana/any binary"
|
||||
- "Manage binary dependencies"
|
||||
- Learn OS/ARCH detection, download patterns, health checks
|
||||
|
||||
3. **Need to mock request/response?** → Read `jsonrpc-mock.md`
|
||||
- "Mock REST/GraphQL/RPC/any HTTP API"
|
||||
- "Build mock with DSL"
|
||||
- Learn generic `AddMockResponse()` pattern
|
||||
|
||||
4. **Need bidirectional streaming?** → Read `grpc-bufconn.md`
|
||||
- "Test gRPC/WebSocket/streaming protocol"
|
||||
- "In-memory bidirectional communication"
|
||||
- Learn rich mock patterns, thread-safe state
|
||||
|
||||
5. **Want readable test DSL?** → Read `httptest-dsl.md`
|
||||
- "Build fluent test API"
|
||||
- "Wrap test infrastructure"
|
||||
- Learn builder pattern, method chaining
|
||||
|
||||
**When user asks about test structure:**
|
||||
- "How should I organize tests?" → Read `test-organization.md`
|
||||
- "How do I write integration tests?" → Read `integration-patterns.md`
|
||||
- "How do I write system tests?" → Read `system-patterns.md`
|
||||
|
||||
### Key Principle
|
||||
|
||||
**Examples show specific technologies (NATS, Victoria Metrics, JSON-RPC) but teach transferable patterns.**
|
||||
|
||||
Claude should:
|
||||
1. Identify the **pattern needed** (harness, binary, mock DSL, etc.)
|
||||
2. Read the **example file** that demonstrates that pattern
|
||||
3. **Adapt the techniques** to the user's specific technology
|
||||
4. Use the example as a **template**, not a literal solution
|
||||
|
||||
### Default Behavior (No Example Needed)
|
||||
|
||||
For simple scenarios, use the core patterns in this file:
|
||||
- Basic table-driven tests → Use patterns from this file
|
||||
- Simple testify suites → Use patterns from this file
|
||||
- Basic synchronization → Use patterns from this file
|
||||
- Simple in-memory implementations → Use InMemoryRepository/TestEmailer from this file
|
||||
|
||||
**Read example files when patterns/techniques are needed, not just for specific tech.**
|
||||
|
||||
---
|
||||
|
||||
## Final Notes
|
||||
|
||||
This reference provides core testing principles and patterns. For detailed implementations and complete examples, refer to the example files listed above. Each example file is self-contained and can be read independently based on your testing needs.
|
||||
Reference in New Issue
Block a user