Advanced Topics#

Deep dives into advanced patterns and techniques.

Custom Matchers#

While code generation handles most cases, sometimes you need a custom matcher.

Implementing specta.Matcher[T]#

The Matcher interface is defined as:

type Matcher[T any] interface {
    Matches(actual T) MatchResult
}

type MatchResult struct {
    Matched  bool     // Whether the match succeeded
    Message  string   // Human-readable description
    Details  []string // Additional failure details
    Expected any      // Expected value (for diffs)
    Actual   any      // Actual value (for diffs)
    Path     string   // Field path for nested failures
}

For simple matchers, you only need to set Matched and Message. The other fields enhance error output:

  • Details for multi-line failure reasons
  • Expected/Actual for structured diffs
  • Path for nested struct field errors

Using MatcherFunc for Simple Matchers#

For one-off matchers, you can use MatcherFunc instead of defining a struct:

// Simple inline matcher
containsHello := specta.MatcherFunc(func(s string) specta.MatchResult {
    if strings.Contains(s, "hello") {
        return specta.MatchResult{Matched: true}
    }
    return specta.MatchResult{
        Matched: false,
        Message: fmt.Sprintf("expected string to contain 'hello', got: %s", s),
    }
})

specta.AssertThat(t, "hello world", containsHello)

Use MatcherFunc for simple, one-off matchers. For reusable matchers, define a proper type as shown in the examples below.

Example: Custom String Matcher#

type containsAnyMatcher struct {
    substrings []string
}

func ContainsAny(substrings ...string) *containsAnyMatcher {
    return &containsAnyMatcher{substrings: substrings}
}

func (m *containsAnyMatcher) Matches(actual string) specta.MatchResult {
    for _, substr := range m.substrings {
        if strings.Contains(actual, substr) {
            return specta.MatchResult{
                Matched: true,
                Message: fmt.Sprintf("string contains '%s'", substr),
            }
        }
    }

    return specta.MatchResult{
        Matched: false,
        Message: fmt.Sprintf(
            "expected string to contain any of %v, got: %s",
            m.substrings, actual,
        ),
    }
}

Usage:

specta.AssertThat(t, message, ContainsAny("error", "warning", "failure"))

Example: Complex Struct Matcher#

// BeValidUser returns a matcher that validates multiple user fields.
// This demonstrates composing matchers - we manually extract field values
// but use matchers for the actual checks instead of if statements.
func BeValidUser() specta.Matcher[User] {
    return specta.MatcherFunc(func(actual User) specta.MatchResult {
        // Define matchers for each field
        idMatcher := specta.Not(specta.IsEmpty[string]())
        emailMatcher := specta.Contains("@")
        ageMatcher := specta.AllOf(
            specta.GreaterThanOrEqual(0),
            specta.LessThanOrEqual(150),
        )

        // Apply matchers to extracted field values
        if result := idMatcher.Matches(actual.ID); !result.Matched {
            result.Path = "ID"
            return result
        }

        if result := emailMatcher.Matches(actual.Email); !result.Matched {
            result.Path = "Email"
            return result
        }

        if result := ageMatcher.Matches(actual.Age); !result.Matched {
            result.Path = "Age"
            return result
        }

        return specta.MatchResult{
            Matched: true,
            Message: "User is valid",
        }
    })
}

Usage:

specta.AssertThat(t, user, BeValidUser())

Field() Matcher for Specific Fields#

Instead of writing a full custom matcher, use Field() to test specific fields:

// Test that user's age is positive
specta.AssertThat(t, user,
    specta.Field("Age", func(u User) int { return u.Age }, specta.GreaterThan(0)))

// Test computed values
specta.AssertThat(t, account,
    specta.Field("balance*2", func(a Account) int { return a.Balance * 2 }, specta.Equal(2000)))

// Test nested fields
specta.AssertThat(t, order,
    specta.Field("User.Email", func(o Order) string { return o.User.Email },
        specta.Contains("@example.com")))

The first parameter is just a label for error messages - you can describe what you’re testing.

Assign Field matchers to variables for reuse:

var (
    PositiveAge = specta.Field("Age", func(u User) int { return u.Age }, specta.GreaterThan(0))
    ValidEmail  = specta.Field("Email", func(u User) string { return u.Email }, specta.Contains("@"))
)

// Reuse across tests
specta.AssertThat(t, user1, PositiveAge)
specta.AssertThat(t, user2, specta.AllOf(PositiveAge, ValidEmail))

Testing Patterns#

Table-Driven Tests with Matchers#

The matcher column lets you express different expected behaviors per test case:

func TestProcessOrder(t *testing.T) {
    tests := []struct {
        name    string
        order   Order
        matcher specta.Matcher[OrderResult]
    }{
        {
            name:    "standard order",
            order:   Order{Items: 3, Total: 50.00},
            matcher: MatchOrderResult().WithStatus(specta.Equal("confirmed")),
        },
        {
            name:    "large order gets discount",
            order:   Order{Items: 10, Total: 500.00},
            matcher: MatchOrderResult().
                WithDiscount(specta.GreaterThan(0.0)).
                WithStatus(specta.Equal("confirmed")),
        },
        {
            name:    "empty order rejected",
            order:   Order{Items: 0},
            matcher: MatchOrderResult().WithStatus(specta.Equal("rejected")),
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := ProcessOrder(tt.order)
            specta.AssertThat(t, result, tt.matcher)
        })
    }
}

Reusable Matcher Compositions#

Define matchers as package-level variables:

var (
    // Email matchers
    ValidEmail = specta.AllOf(
        specta.Contains("@"),
        specta.MatchesRegex(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`),
    )

    CompanyEmail = specta.AllOf(
        ValidEmail,
        specta.HasSuffix("@company.com"),
    )

    // User matchers
    ActiveUser = MatchUser().
        WithStatus(specta.Equal("active"))

    AdminUser = MatchUser().
        WithRole(specta.Equal("admin")).
        WithPermissions(specta.ContainsAllElements[string]("read", "write", "delete"))
)

Usage:

func TestUserCreation(t *testing.T) {
    user := CreateUser("alice@company.com")

    specta.AssertThat(t, user.Email, CompanyEmail)
    specta.AssertThat(t, user, ActiveUser)
}

Error Testing Patterns#

func TestErrorConditions(t *testing.T) {
    tests := []struct {
        name    string
        input   User
        wantErr specta.Matcher[error]
    }{
        {
            name:    "missing email",
            input:   User{Name: "Alice"},
            wantErr: specta.IsError(),  // Expect an error
        },
        {
            name:    "invalid age",
            input:   User{Name: "Alice", Email: "alice@example.com", Age: -1},
            wantErr: specta.IsError(),  // Expect an error
        },
        {
            name:    "valid user",
            input:   User{Name: "Alice", Email: "alice@example.com", Age: 30},
            wantErr: specta.NoErr(),  // No error expected
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.input)
            specta.AssertThat(t, err, tt.wantErr)
        })
    }
}

For more specific error checks beyond presence, use ErrorContains, ErrorIs, or ErrorAs:

// Check error message
specta.AssertThat(t, err, specta.ErrorContains("invalid"))

// Check error identity (errors.Is)
specta.AssertThat(t, err, specta.ErrorIs(ErrNotFound))

// Check error type (errors.As)
var validationErr *ValidationError
specta.AssertThat(t, err, specta.ErrorAs(&validationErr))

Integration with Standard Testing#

Using with testing.T#

specta works seamlessly with testing.T:

func TestSomething(t *testing.T) {
    // Regular assertions
    if got != want {
        t.Errorf("got %v, want %v", got, want)
    }

    // specta matchers
    specta.AssertThat(t, value, specta.Equal(expected))

    // Mix and match as needed
}

Subtests Organization#

func TestUserWorkflow(t *testing.T) {
    p := specta.New()

    t.Run("creation", func(t *testing.T) {
        user := factory.NewUser(p).Build()
        specta.AssertThat(t, user.ID, specta.Not(specta.IsEmpty[string]()))
    })

    t.Run("validation", func(t *testing.T) {
        user := factory.NewUser(p).Build()
        err := ValidateUser(user)
        specta.AssertThat(t, err, specta.NoErr())
    })
}

Coverage Considerations#

specta matchers count toward test coverage:

go test -v -race -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

Generated code (*_gen.go) is excluded from coverage by build tags.

Code Generation Advanced#

Extending Generated Code#

The generator uses embedded templates that cover common use cases. You can extend generated matchers and factories with custom code in the same package - see Combining with Factory Recipes for examples of adding custom matchers and factory methods alongside generated code.

Type Checking#

Generated code is type-checked before writing. For example, the generator ensures that methods like WithName compile correctly:

func (m *UserMatcher) WithName(matcher specta.Matcher[string]) *UserMatcher {
    // ...
}

If generation fails, it’s usually because:

  • Type in YAML doesn’t match source
  • Import paths are incorrect
  • Unsupported type (fix: add to generator)
  • Custom defaults or extensions are out of sync with generated code

Best Practices Summary#

  1. Use generated matchers for custom types (don’t write manually)
  2. Extract common matchers as package variables
  3. Partial matching - only assert what each test cares about
  4. Deterministic factories - use Primitives for reproducibility
  5. Table-driven tests - combine with matchers for clarity
  6. Regenerate after changes - keep generated code in sync
  7. Commit generated files - required for CI

Next Steps#