Files
yellowjacket/.planning/codebase/TESTING.md
T
2026-02-26 16:58:25 -05:00

12 KiB

Testing Patterns

Analysis Date: 2026-02-26

Test Framework

Runner:

  • Go standard testing package
  • No external test frameworks (no testify assertions — uses raw t.Errorf/t.Fatalf)
  • golangci-lint testifylint is enabled but unused (no testify dependency)

Assertion Library:

  • Standard library only — t.Errorf, t.Fatalf, t.Fatal, t.Logf
  • Custom equality helpers in test files (e.g., slicesEqual)

Run Commands:

make test                                                        # All tests (preferred)
go test -tags webkit2_41 -race -count=1 -timeout 120s ./...      # All tests manually
go test -tags webkit2_41 ./backend/player/                       # Single package
go test -tags webkit2_41 -run TestFunctionName ./backend/player/ # Single test
go test -tags webkit2_41 -v -run TestFunctionName ./backend/...  # Verbose single test

Build Tags Requirement

Critical: All go test invocations require -tags webkit2_41. The Makefile handles this automatically. Without this tag, compilation fails because the Wails v2 framework depends on WebKit bindings.

# Correct:
go test -tags webkit2_41 ./...

# Wrong — will fail to compile:
go test ./...

The Makefile test target includes all recommended flags:

test:
    go test -tags webkit2_41 -race -count=1 -timeout 120s ./...
  • -race — Race detector enabled
  • -count=1 — Disable test caching (always run)
  • -timeout 120s — 2-minute timeout

Test File Organization

Location: Colocated with source as *_test.go in the same package:

backend/player/player.go
backend/player/player_test.go

backend/metadata/genre.go
backend/metadata/genre_test.go
backend/metadata/mp3duration.go
backend/metadata/mp3duration_test.go
backend/metadata/flacduration.go
backend/metadata/flacduration_test.go

backend/coverart/coverart.go
backend/coverart/coverart_test.go

backend/playlist/m3u.go
backend/playlist/m3u_test.go
backend/playlist/match.go
backend/playlist/match_test.go

Exception: backend/coverart/coverart_test.go uses package coverart_test (external test package) to test only the exported API.

All other test files use the same package as the source (internal tests), allowing access to unexported functions:

package metadata  // internal test — can call unexported getMP3Duration()
package playlist  // internal test — can call unexported sanitizeFilename()

Test Fixtures

Location: test_data/ at the project root.

Contents: Real audio files (MP3, FLAC) used by metadata and player tests.

Access pattern: Tests use relative paths from the package directory:

// From backend/player/player_test.go
var testQueue = []string{
    "../../test_data/music_library_test/other_music/03 PONPONPON.mp3",
    "../../test_data/music_library_test/01 Some Chords.mp3",
    "../../test_data/music_library_test/03 anything.mp3",
}

// From backend/metadata/mp3duration_test.go
root := filepath.Join("..", "..", "test_data")

Test helper functions scan the fixture directory for files of the right type:

// backend/metadata/mp3duration_test.go
func testMP3Files(t *testing.T) []string {
    t.Helper()
    root := filepath.Join("..", "..", "test_data")
    var files []string
    err := filepath.Walk(root, func(
        path string, info os.FileInfo, err error,
    ) error {
        if !info.IsDir() && filepath.Ext(path) == ".mp3" {
            files = append(files, path)
        }
        return nil
    })
    if len(files) == 0 {
        t.Skip("no .mp3 test fixtures found in test_data/")
    }
    return files
}

// backend/metadata/flacduration_test.go
func testFlacFiles(t *testing.T) []string {
    t.Helper()
    root := filepath.Join("..", "..", "test_data")
    // same pattern for .flac files
}

t.TempDir() is used for tests that write files:

dir := t.TempDir()
tmpPath := filepath.Join(dir, "multi_id3v2.mp3")
os.WriteFile(tmpPath, out, 0o644)

Hardware-Dependent Test Skipping

Integration Tests (Audio Device + Wails Runtime)

The player test requires both a Wails runtime context and an audio output device. It skips unless explicitly opted in:

// backend/player/player_test.go
func TestPlayer(t *testing.T) {
    if os.Getenv("YELLOWJACKET_INTEGRATION") == "" {
        t.Skip(
            "skipping: integration test requires Wails runtime and audio device " +
            "(set YELLOWJACKET_INTEGRATION=1 to run)",
        )
    }
    // ...
}

To run integration tests:

YELLOWJACKET_INTEGRATION=1 go test -tags webkit2_41 -v ./backend/player/

Fixture-Dependent Tests

Tests that need audio fixtures skip gracefully when none are found:

if len(files) == 0 {
    t.Skip("no .mp3 test fixtures found in test_data/")
}

Test Structure Patterns

Table-Driven Tests

The predominant pattern across the codebase. Use a slice of anonymous structs with t.Run subtests:

// backend/metadata/genre_test.go
func TestParseGenres(t *testing.T) {
    t.Parallel()

    tests := []struct {
        name string
        raw  string
        want []string
    }{
        {
            name: "single genre",
            raw:  "Rock",
            want: []string{"Rock"},
        },
        {
            name: "semicolon separated",
            raw:  "Rock; Electronic",
            want: []string{"Rock", "Electronic"},
        },
        // ...
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()

            got := ParseGenres(tt.raw)
            if !slicesEqual(got, tt.want) {
                t.Errorf(
                    "ParseGenres(%q) = %v, want %v",
                    tt.raw, got, tt.want,
                )
            }
        })
    }
}

Parallel Tests

Use t.Parallel() at both the suite and subtest level. All unit tests use parallel execution:

func TestSanitizeFilename(t *testing.T) {
    t.Parallel()       // top-level parallel

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()   // subtest parallel
            // ...
        })
    }
}

File-Iteration Tests

For tests that iterate over real fixture files, use t.Run with the filename:

// backend/metadata/mp3duration_test.go
func TestGetMP3Duration_MatchesBeepDecode(t *testing.T) {
    for _, path := range testMP3Files(t) {
        t.Run(filepath.Base(path), func(t *testing.T) {
            // compare fast parser vs full decode
            refMS, err := GetTrackLengthMillis(path)
            // ...
            if diffMS > toleranceMS {
                t.Errorf(
                    "duration mismatch: beep=%dms fast=%dms "+
                        "(diff %dms exceeds %dms tolerance)",
                    refMS, fastMS, diffMS, toleranceMS,
                )
            }
        })
    }
}

Integration Test Pattern

The player integration test creates a real player instance and exercises it:

// backend/player/player_test.go
func TestPlayer(t *testing.T) {
    if os.Getenv("YELLOWJACKET_INTEGRATION") == "" {
        t.Skip("skipping: integration test requires ...")
    }

    p := NewPlayer(slog.Default(), nil)

    if err := p.InitSpeaker(); err != nil {
        t.Fatalf("could not initialize speaker: %s", err.Error())
    }

    p.SetContext(t.Context())

    for _, track := range testQueue {
        if err := p.LoadFile(track); err != nil {
            t.Fatalf("could not load file %s: %s", track, err.Error())
        }
        if err := p.Play(); err != nil {
            t.Fatalf("could not play file %s: %s", track, err.Error())
        }
    }
}

Mocking

No mocking framework is used. The codebase relies on:

  1. Interfaces for injection: The TrackLoader interface in backend/queue/queue.go allows the queue to work with any player implementation:
type TrackLoader interface {
    LoadFile(filePath string) error
    Play() error
    IsPlaying() bool
    CurrentPositionSeconds() (int, error)
    UnloadTrack()
}
  1. nil dependencies: Tests pass nil for dependencies not needed:
p := NewPlayer(slog.Default(), nil)  // nil database
  1. Real implementations: Most tests exercise real code against test fixtures rather than mocks.

  2. Callback injection: Cross-cutting behavior uses function callbacks rather than interface mocks:

// Injected callback avoids queue→player circular dependency:
p.SetPlaybackFinishedHandler(handler func())

// Hook-based coordination:
l.SetRescanHooks(library.RescanHooks{
    PreClear: yj.queue.Clear,
    PostScan: yj.playlist.RestoreAllPlaylists,
})

Test Helpers

Custom Equality Functions

Since no assertion library is used, test files include local equality helpers:

// backend/metadata/genre_test.go
func slicesEqual(a, b []string) bool {
    if len(a) == 0 && len(b) == 0 {
        return true
    }
    if len(a) != len(b) {
        return false
    }
    for i := range a {
        if a[i] != b[i] {
            return false
        }
    }
    return true
}

// backend/playlist/match_test.go
func stringSliceEqual(a, b []string) bool {
    // identical implementation
}

Test File Builders

The buildID3v2Header helper in backend/metadata/flacduration_test.go creates synthetic audio file structures for testing:

func buildID3v2Header(payloadSize int) []byte {
    header := []byte{
        'I', 'D', '3', // signature
        3, 0,           // version 2.3.0
        0,              // flags
        0, 0, 0, 0,     // size (syncsafe, filled below)
    }
    header[6] = byte((payloadSize >> 21) & 0x7F)
    header[7] = byte((payloadSize >> 14) & 0x7F)
    header[8] = byte((payloadSize >> 7) & 0x7F)
    header[9] = byte(payloadSize & 0x7F)
    return header
}

t.Helper() Usage

Test helper functions call t.Helper() so failure line numbers point to the caller:

func testMP3Files(t *testing.T) []string {
    t.Helper()
    // ...
}

t.Context() Usage

Integration tests use t.Context() for the test context (enforced by usetesting linter):

p.SetContext(t.Context())

//nolint Annotations

Tests use //nolint:mnd for magic numbers in test data construction:

//nolint:mnd // synthetic tag construction.
tag1Size := 1024
tag2Size := 2048

//nolint:mnd // expected offset after first tag.
expectedFirst := int64(10 + 100)

//nolint:mnd // byte values from manual FLAC spec packing.
var si [streamInfoLength]byte
si[10] = 0x0A

Error Assertion Patterns

Fatal vs Error

  • t.Fatalf for setup failures that prevent the test from continuing
  • t.Errorf for check failures that should be reported but allow remaining checks to run
// Setup failure — stop immediately:
f, err := os.Open(path)
if err != nil {
    t.Fatalf("open: %v", err)
}

// Assertion failure — continue checking other fields:
if got != tt.want {
    t.Errorf(
        "SizedFilename(%q, %q) = %q, want %q",
        tt.filename, tt.suffix, got, tt.want,
    )
}

Error Expectation

Tests that expect errors check for nil/non-nil:

func TestWriteM3U8EmptyDir(t *testing.T) {
    t.Parallel()

    err := writeM3U8("", 1, "test", nil)
    if err == nil {
        t.Fatal("expected error for empty dir path")
    }
}

Frontend Type Checking

No frontend test framework is configured. TypeScript correctness is verified via type checking:

cd frontend && pnpm exec tsc --noEmit

This validates all TypeScript files against the strict tsconfig.json settings without producing output files.

Test Coverage

Requirements: No enforced coverage target.

Coverage command:

go test -tags webkit2_41 -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

Test Types Summary

Unit Tests:

  • All tests in backend/metadata/, backend/coverart/, backend/playlist/
  • Test pure functions with table-driven patterns
  • Use t.Parallel() for concurrent execution
  • No external dependencies (except test fixtures)

Integration Tests:

  • backend/player/player_test.go
  • Requires audio hardware and Wails runtime
  • Gated behind YELLOWJACKET_INTEGRATION=1 env var
  • Not run in CI

E2E Tests:

  • Not implemented

Frontend Tests:

  • Not implemented (type checking only via tsc --noEmit)

Testing analysis: 2026-02-26