Testing
This guide covers testing patterns and practices used in Stratos development.
Test Categories
Unit Tests
Unit tests verify individual functions and methods in isolation.
Location: *_test.go files alongside source code
Run:
make test
# or
go test ./...
Integration Tests
Integration tests verify component interactions using envtest (in-memory Kubernetes API server).
Location: tests/integration/
Run:
make test-integration
# or
go test -tags=integration ./tests/integration/...
Writing Unit Tests
Basic Test Structure
func TestCalculateScaleUp(t *testing.T) {
tests := []struct {
name string
pendingPods int
nodeCapacity int
currentStandby int
expected int
}{
{
name: "single pod needs one node",
pendingPods: 1,
nodeCapacity: 10,
currentStandby: 5,
expected: 1,
},
{
name: "multiple pods need multiple nodes",
pendingPods: 25,
nodeCapacity: 10,
currentStandby: 5,
expected: 3,
},
{
name: "capped by standby availability",
pendingPods: 100,
nodeCapacity: 10,
currentStandby: 2,
expected: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := calculateScaleUp(tt.pendingPods, tt.nodeCapacity, tt.currentStandby)
if result != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, result)
}
})
}
}
Testing with Mocks
Use interfaces for dependencies to enable mocking:
// Production code
type CloudProvider interface {
StartInstance(ctx context.Context, instanceID string) error
}
type NodeManager struct {
cloud CloudProvider
}
// Test code
type mockCloudProvider struct {
startInstanceFunc func(ctx context.Context, instanceID string) error
}
func (m *mockCloudProvider) StartInstance(ctx context.Context, instanceID string) error {
if m.startInstanceFunc != nil {
return m.startInstanceFunc(ctx, instanceID)
}
return nil
}
func TestNodeManager_ScaleUp(t *testing.T) {
mock := &mockCloudProvider{
startInstanceFunc: func(ctx context.Context, instanceID string) error {
if instanceID != "i-expected" {
t.Errorf("unexpected instance ID: %s", instanceID)
}
return nil
},
}
nm := &NodeManager{cloud: mock}
err := nm.ScaleUp(context.Background(), "i-expected")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
Using the Fake Provider
The fake provider (internal/cloudprovider/fake/) supports hooks for testing:
import "github.com/stratos-sh/stratos/internal/cloudprovider/fake"
func TestWithFakeProvider(t *testing.T) {
provider := fake.NewProvider()
// Configure hooks
provider.OnLaunchInstance = func(cfg *cloudprovider.LaunchConfig) (*cloudprovider.Instance, error) {
// Custom behavior
return &cloudprovider.Instance{
ID: "i-fake-123",
State: cloudprovider.InstanceStateRunning,
}, nil
}
// Use provider in tests
instance, err := provider.LaunchInstance(context.Background(), &cloudprovider.LaunchConfig{
InstanceType: "m5.large",
})
// ...
}
Integration Tests with envtest
Setup
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
var testEnv *envtest.Environment
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controller Suite")
}
var _ = BeforeSuite(func() {
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{
filepath.Join("..", "..", "config", "crd", "bases"),
},
}
cfg, err := testEnv.Start()
Expect(err).NotTo(HaveOccurred())
// ... setup client
})
var _ = AfterSuite(func() {
err := testEnv.Stop()
Expect(err).NotTo(HaveOccurred())
})
Writing Integration Tests
var _ = Describe("NodePool Controller", func() {
Context("when creating a NodePool", func() {
It("should create standby nodes", func() {
nodePool := &stratosv1alpha1.NodePool{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pool",
},
Spec: stratosv1alpha1.NodePoolSpec{
PoolSize: 5,
MinStandby: 3,
// ...
},
}
Expect(k8sClient.Create(ctx, nodePool)).Should(Succeed())
Eventually(func() int32 {
np := &stratosv1alpha1.NodePool{}
k8sClient.Get(ctx, types.NamespacedName{Name: "test-pool"}, np)
return np.Status.Standby
}, timeout, interval).Should(Equal(int32(3)))
})
})
})
Test Patterns
Testing State Transitions
func TestValidTransitions(t *testing.T) {
tests := []struct {
from NodeState
to NodeState
valid bool
}{
{NodeStateWarmup, NodeStateStandby, true},
{NodeStateWarmup, NodeStateRunning, false},
{NodeStateStandby, NodeStateRunning, true},
{NodeStateRunning, NodeStateTerminating, true},
{NodeStateRunning, NodeStateWarmup, false},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s->%s", tt.from, tt.to), func(t *testing.T) {
result := IsValidTransition(tt.from, tt.to)
if result != tt.valid {
t.Errorf("expected %v, got %v", tt.valid, result)
}
})
}
}
Testing Error Conditions
func TestScaleUp_InsufficientStandby(t *testing.T) {
provider := fake.NewProvider()
// No standby nodes available
manager := NewNodeManager(provider)
err := manager.ScaleUp(context.Background(), 5)
if err == nil {
t.Error("expected error for insufficient standby")
}
if !errors.Is(err, ErrInsufficientStandby) {
t.Errorf("expected ErrInsufficientStandby, got %v", err)
}
}
Testing Timeouts
func TestWarmupTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
provider := fake.NewProvider()
provider.OnGetInstanceState = func(instanceID string) (cloudprovider.InstanceState, error) {
// Simulate instance that never stops
return cloudprovider.InstanceStateRunning, nil
}
err := waitForWarmup(ctx, provider, "i-123")
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("expected timeout error, got %v", err)
}
}
Test Coverage
Generate Coverage Report
make coverage
This creates an HTML coverage report at coverage.html.
View Coverage in Terminal
go test -cover ./...
Coverage Targets
- Target: 70%+ overall coverage
- Critical paths: 90%+ coverage (reconciliation, state transitions)
- Error handling: All error conditions should be tested
Continuous Integration
Tests run automatically on pull requests:
- Lint:
make lint - Unit tests:
make test - Integration tests:
make test-integration
CI Configuration
# .github/workflows/test.yaml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- run: make lint
- run: make test
- run: make test-integration
Best Practices
Test Independence
- Each test should be independent
- Don't rely on test execution order
- Clean up resources after tests
Meaningful Names
// Good
func TestScaleUp_WithInsufficientStandby_ReturnsError(t *testing.T)
// Bad
func TestScaleUp1(t *testing.T)
Test One Thing
Each test should verify one specific behavior:
// Good - single responsibility
func TestScaleUp_CalculatesCorrectNodeCount(t *testing.T) { ... }
func TestScaleUp_RespectsPoolSize(t *testing.T) { ... }
func TestScaleUp_HandlesCloudErrors(t *testing.T) { ... }
// Bad - tests multiple things
func TestScaleUp(t *testing.T) {
// Tests calculation, pool size, and errors all in one
}
Use Helpers
Extract common setup into helper functions:
func newTestNodePool(name string, poolSize, minStandby int32) *stratosv1alpha1.NodePool {
return &stratosv1alpha1.NodePool{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
Spec: stratosv1alpha1.NodePoolSpec{
PoolSize: poolSize,
MinStandby: minStandby,
// ... common defaults
},
}
}
Next Steps
- Contributing - Contribution guidelines
- Local Development - Running locally