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:
Detailsfor multi-line failure reasonsExpected/Actualfor structured diffsPathfor 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.outGenerated 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#
- Use generated matchers for custom types (don’t write manually)
- Extract common matchers as package variables
- Partial matching - only assert what each test cares about
- Deterministic factories - use
Primitivesfor reproducibility - Table-driven tests - combine with matchers for clarity
- Regenerate after changes - keep generated code in sync
- Commit generated files - required for CI
Next Steps#
- API Reference - Complete API documentation
- Examples - Real-world patterns
- GitHub Repository - Source code and issues