This guide covers the testing infrastructure, patterns, and best practices for Zaparoo Core. For specialized topics, see the linked guides in pkg/testing/.
Zaparoo Core uses a comprehensive testing infrastructure built around:
- testify/mock: For interface mocking and assertions
- sqlmock: For database testing without SQLite dependencies
- afero: For filesystem abstraction and in-memory testing
- httptest: For WebSocket and HTTP API testing
- clockwork: For deterministic time-based testing
- rapid: For property-based testing
- Custom testing utilities: Located in
pkg/testing/
- Useful tests, not coverage metrics: High coverage means nothing if tests don't catch bugs
- Fast, isolated tests: Use mocks for external dependencies
- No hardware dependencies: All hardware interactions are mocked
- Behavior testing: Focus on what the code does, not how it does it
The testing infrastructure is organized under pkg/testing/:
pkg/testing/
├── README.md # Quick reference guide to all testing utilities
├── TIME_TESTING.md # Clockwork time-based testing guide
├── FUZZ_TESTING.md # Native Go fuzz testing guide
├── PROPERTY_TESTING.md # Rapid property-based testing guide
├── mocks/ # Interface mocks
│ ├── api_client.go # API client mock
│ ├── audio.go # Audio interface mock
│ ├── command_executor.go # Command executor mock
│ ├── kodi_client.go # Kodi client mock
│ ├── platform.go # Platform interface mock
│ ├── reader.go # Reader interface mock
│ └── websocket.go # WebSocket mocks
├── helpers/ # Testing utilities
│ ├── api.go # API testing helpers
│ ├── command.go # Command testing helpers
│ ├── db_mocks.go # Database mock interfaces and matchers
│ ├── esapi_server.go # ES API test server
│ ├── fs.go # Filesystem testing helpers
│ ├── inmemory_db.go # In-memory database for testing
│ ├── kodi_server.go # Kodi test server
│ └── validation.go # Validation helpers
├── fixtures/ # Test data
│ ├── database.go # Database fixtures and history entries
│ ├── kodi.go # Kodi test fixtures
│ ├── media.go # Sample media: SampleMedia(), NewRetroGame(), NewMediaCollection()
│ ├── playlists.go # Sample playlists: SamplePlaylists()
│ └── tokens.go # Sample tokens: SampleTokens(), NewNFCToken(), NewTokenCollection()
├── sqlmock/ # SQL mock utilities (testsqlmock.NewSQLMock())
│ └── sqlmock.go
└── examples/ # Example tests and patterns
├── api_example_test.go
├── database_example_test.go
├── filesystem_example_test.go
├── mock_usage_example_test.go
├── service_state_management_test.go
├── service_token_processing_test.go
└── service_zapscript_test.go
New to testing in Zaparoo? Start with pkg/testing/README.md for a quick reference guide to all available helpers and examples.
package mypackage
import (
"testing"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/fixtures"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMyFunction(t *testing.T) {
// Setup
mockPlatform := mocks.NewMockPlatform()
mockPlatform.SetupBasicMock()
// Test
result := MyFunction(mockPlatform)
// Verify
assert.NotNil(t, result)
mockPlatform.AssertExpectations(t)
}func TestWithFixtures(t *testing.T) {
// Get sample data
tokens := fixtures.SampleTokens()
media := fixtures.SampleMedia()
// Use in tests
assert.Len(t, tokens, 3)
assert.Equal(t, "Super Mario Bros", media[0].Name)
}func TestDatabaseOperations(t *testing.T) {
// Setup mock database
mockUserDB := helpers.NewMockUserDBI()
mockUserDB.On("AddHistory", helpers.HistoryEntryMatcher()).Return(nil)
// Test your function
err := MyDatabaseFunction(mockUserDB)
// Verify
require.NoError(t, err)
mockUserDB.AssertExpectations(t)
}Use table-driven tests for testing multiple scenarios:
func TestTokenValidation(t *testing.T) {
tests := []struct {
name string
token tokens.Token
expectErr bool
errMsg string
}{
{
name: "Valid NFC token",
token: fixtures.SampleTokens()[0],
expectErr: false,
},
{
name: "Invalid token",
token: tokens.Token{UID: ""},
expectErr: true,
errMsg: "empty UID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateToken(tt.token)
if tt.expectErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
} else {
assert.NoError(t, err)
}
})
}
}func TestComplexWorkflow(t *testing.T) {
// Setup common to all subtests
platform := mocks.NewMockPlatform()
platform.SetupBasicMock()
t.Run("Success case", func(t *testing.T) {
// Test successful workflow
})
t.Run("Error case", func(t *testing.T) {
// Test error handling
})
t.Run("Edge case", func(t *testing.T) {
// Test edge cases
})
}func TestConcurrentOperations(t *testing.T) {
const numGoroutines = 10
var wg sync.WaitGroup
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// Perform concurrent operation
result := MyConcurrentFunction(id)
assert.NotNil(t, result)
}(i)
}
wg.Wait()
}func TestPlatformIntegration(t *testing.T) {
// Create platform mock
platform := mocks.NewMockPlatform()
platform.SetupBasicMock()
// Set specific expectations
platform.On("LaunchMedia", helpers.MediaMatcher(), helpers.SystemMatcher()).Return(nil)
platform.On("SendKeyboard", "RETURN").Return(nil)
// Use in your code
MyFunction(platform)
// Verify expectations
platform.AssertExpectations(t)
// Check recorded actions
launched := platform.GetLaunchedMedia()
assert.Len(t, launched, 1)
keyPresses := platform.GetKeyboardPresses()
assert.Contains(t, keyPresses, "RETURN")
}func TestReaderOperations(t *testing.T) {
// Create reader mock
reader := mocks.NewMockReader()
reader.SetupBasicMock()
// Create a scan channel and simulate token detection
scanChan := make(chan readers.Scan, 1)
token := fixtures.SampleTokens()[0]
reader.SimulateTokenScan(scanChan, token, "mock://test")
// Receive the scan
scan := <-scanChan
require.NoError(t, scan.Error)
assert.Equal(t, token.UID, scan.Token.UID)
// For write testing, set up expectations
reader.On("Write", "test-data").Return(token, nil)
result, err := reader.Write("test-data")
require.NoError(t, err)
assert.Equal(t, token.UID, result.UID)
reader.AssertExpectations(t)
}func TestWebSocketCommunication(t *testing.T) {
// Create mock session
session := mocks.NewMockMelodySession()
session.SetupBasicMock()
// Test message sending
message := []byte(`{"method":"ping"}`)
err := session.Write(message)
require.NoError(t, err)
// Verify message was sent
sent := session.GetSentMessages()
assert.Len(t, sent, 1)
assert.Equal(t, message, sent[0])
}func TestUserOperations(t *testing.T) {
// Setup database mocks
userDB := helpers.NewMockUserDBI()
mediaDB := helpers.NewMockMediaDBI()
db := &database.Database{
UserDB: userDB,
MediaDB: mediaDB,
}
// Set expectations
userDB.On("AddHistory", helpers.HistoryEntryMatcher()).Return(nil)
mediaDB.On("GetMediaByText", "Game Name").Return(fixtures.SampleMedia()[0], nil)
// Test your function
err := ProcessToken(token, db)
// Verify
require.NoError(t, err)
userDB.AssertExpectations(t)
mediaDB.AssertExpectations(t)
}import (
testsqlmock "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/sqlmock"
)
func TestRawSQL(t *testing.T) {
// Create sqlmock
db, mock, err := testsqlmock.NewSQLMock()
require.NoError(t, err)
defer db.Close()
// Set expectations
mock.ExpectQuery("SELECT \\* FROM users WHERE id = \\?").
WithArgs(1).
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).
AddRow(1, "Test User"))
// Test your function
user, err := GetUserByID(db, 1)
// Verify
require.NoError(t, err)
assert.Equal(t, "Test User", user.Name)
assert.NoError(t, mock.ExpectationsWereMet())
}func TestFileOperations(t *testing.T) {
// Create in-memory filesystem
fs := helpers.NewMemoryFS()
// Write files directly
err := fs.WriteFile("/config/test.json", []byte(`{"setting": "value"}`), 0o644)
require.NoError(t, err)
// Or create a config file with a map
err = fs.CreateConfigFile("/config/app.json", map[string]any{
"setting": "value",
"another": "setting",
})
require.NoError(t, err)
// Read and verify
content, err := fs.ReadFile("/config/test.json")
require.NoError(t, err)
assert.Contains(t, string(content), "value")
}func TestMediaScanning(t *testing.T) {
// Create in-memory filesystem with media directories
fs := helpers.NewMemoryFS()
// Create sample media directory structure
err := fs.CreateMediaDirectory("/media/roms")
require.NoError(t, err)
// Or create custom structure
err = fs.CreateDirectoryStructure(map[string]any{
"media": map[string]any{
"games": map[string]any{
"nes": map[string]any{"mario.nes": "game-data"},
"snes": map[string]any{"zelda.sfc": "game-data"},
},
},
})
require.NoError(t, err)
// Test your function
assert.True(t, fs.FileExists("/media/games/nes/mario.nes"))
}func TestWebSocketAPI(t *testing.T) {
// Create test server
server := helpers.NewWebSocketTestServer(t, myHandler)
defer server.Close()
// Connect client
conn, err := server.CreateWebSocketClient()
require.NoError(t, err)
defer conn.Close()
// Send request
response, err := helpers.SendJSONRPCRequest(conn, "ping", nil)
require.NoError(t, err)
// Verify response
helpers.AssertJSONRPCSuccess(t, response)
assert.Equal(t, "pong", response.Result)
}func TestHTTPAPI(t *testing.T) {
// Create HTTP test helper
helper := helpers.NewHTTPTestHelper(myHandler)
defer helper.Close()
// Send request
resp, err := helper.PostJSONRPC("test_method", nil)
require.NoError(t, err)
defer resp.Body.Close()
// Verify response
assert.Equal(t, http.StatusOK, resp.StatusCode)
}func TestTokenProcessing(t *testing.T) {
// Setup complete environment
platform := mocks.NewMockPlatform()
platform.SetupBasicMock()
db := &database.Database{
UserDB: helpers.NewMockUserDBI(),
MediaDB: helpers.NewMockMediaDBI(),
}
// Set expectations for complete workflow
db.UserDB.(*helpers.MockUserDBI).On("AddHistory", helpers.HistoryEntryMatcher()).Return(nil)
db.MediaDB.(*helpers.MockMediaDBI).On("GetMediaByText", "Game").Return(fixtures.SampleMedia()[0], nil)
platform.On("LaunchMedia", helpers.MediaMatcher(), helpers.SystemMatcher()).Return(nil)
// Test complete workflow
token := fixtures.SampleTokens()[0]
err := MyTokenHandler(token, platform, db)
// Verify
require.NoError(t, err)
launched := platform.GetLaunchedMedia()
assert.Len(t, launched, 1)
}- Time-Based Testing - Deterministic time testing with clockwork (fake clocks, tickers, timeouts)
- Fuzz Testing - Native Go fuzzing for discovering edge cases in parsing and validation
- Property-Based Testing - Rapid property-based testing for verifying code invariants
- One concept per test: Each test should verify one behavior
- Clear test names: Use descriptive names that explain the scenario
- Arrange-Act-Assert: Structure tests with clear setup, action, and verification
- Use subtests: Group related tests under a parent test function
- Mock at interface boundaries: Mock external dependencies, not internal logic
- Use behavior verification: Test what the code does, not how it does it
- Setup basic mocks: Use
SetupBasicMock()for common expectations - Verify expectations: Always call
AssertExpectations(t)
- Test both success and failure paths: Every error condition should be tested
- Use specific error assertions: Check error messages and types
- Test error propagation: Verify errors are handled correctly up the call stack
- Use proper synchronization: Always use sync.WaitGroup or channels
- Test race conditions: Use proper timing controls (see Time-Based Testing)
- Verify thread safety: Ensure concurrent access doesn't corrupt state
- Always use clockwork: Never use
time.Sleep()ortime.NewTicker()in production code that needs testing - Inject clocks: Pass
clockwork.Clockas a dependency to enable fake clocks in tests - Use FakeClock in tests: Control time progression deterministically with
Advance() - Minimize sleeps: Only use very short sleeps (10ms) when absolutely necessary for goroutine synchronization
- Use BlockUntilContext: Wait for goroutines to reach blocking points instead of sleeping
# Run all tests
task test
# Run tests with race detection
go test -race ./...
# Run specific package tests
go test ./pkg/service/tokens/
# Run with coverage
go test -cover ./...
# Run specific test
go test -run TestTokenProcessing ./pkg/service/
# Run tests matching pattern
go test -run ".*Token.*" ./...
# Run tests in verbose mode
go test -v ./...Tests timeout or hang
- Check for missing
deferstatements on resources - Ensure goroutines are properly terminated
- Use context with timeout for long-running operations
Mock expectations not met
- Verify method signatures match exactly
- Check parameter matchers are appropriate
- Ensure
AssertExpectations(t)is called
Race condition failures
- Add proper synchronization
- Use atomic operations for counters
- Consider using channels for coordination
Filesystem tests fail
- Ensure proper cleanup of afero filesystem
- Check file paths use forward slashes
- Verify permissions are set correctly
// Add debugging output
t.Logf("Debug: value = %v", value)
// Use testify's debug functions
assert.Equal(t, expected, actual, "Debug message: %v", debugInfo)
// Print mock call history
for _, call := range mock.Calls {
t.Logf("Mock call: %s with args: %v", call.Method, call.Arguments)
}func BenchmarkMyFunction(b *testing.B) {
// Setup
platform := mocks.NewMockPlatform()
platform.SetupBasicMock()
b.ResetTimer()
for i := 0; i < b.N; i++ {
MyFunction(platform)
}
}- Implement the interface mock in
pkg/testing/mocks/ - Add
SetupBasicMock()method for common expectations - Add helper methods for verification and state inspection
- Create example tests showing usage patterns
- Add fixture data in
pkg/testing/fixtures/ - Provide both individual items and collections
- Include helper functions for creating variations
- Document the fixture data structure
- Add helper functions in appropriate
pkg/testing/helpers/file - Focus on reusable testing patterns
- Include comprehensive documentation
- Add example usage in
pkg/testing/examples/
For more specific examples, see the test files in pkg/testing/examples/. Each example demonstrates a complete testing pattern with detailed comments.