Matchers for Your Types#
Core matchers are great, but what about testing your own custom types? This section shows how to get fluent, composable matchers for your structs.
The Problem#
Suppose you have a User struct:
type User struct {
ID string
Name string
Email string
Age int
CreatedAt time.Time
}Testing without matchers is verbose and brittle:
func TestCreateUser(t *testing.T) {
user := CreateUser("Alice", "alice@example.com")
// Verbose and breaks when new fields are added
if user.Name != "Alice" {
t.Errorf("expected name Alice, got %s", user.Name)
}
if user.Email != "alice@example.com" {
t.Errorf("expected email alice@example.com, got %s", user.Email)
}
if user.Age <= 0 {
t.Error("expected positive age")
}
// What about ID? CreatedAt? Do we test them everywhere?
}Using basic matchers improves error messages, but isn’t composable:
func TestCreateUser(t *testing.T) {
user := CreateUser("Alice", "alice@example.com")
// Better, but can't reuse these assertions
specta.AssertThat(t, user.Name, specta.Equal("Alice"))
specta.AssertThat(t, user.Email, specta.Equal("alice@example.com"))
specta.AssertThat(t, user.Age, specta.GreaterThan(0))
}
func TestUpdateUser(t *testing.T) {
user := UpdateUser(existingUser, "Bob")
// Have to repeat the same field assertions
specta.AssertThat(t, user.Name, specta.Equal("Bob"))
specta.AssertThat(t, user.Email, specta.Equal("alice@example.com"))
// Can't express "same email as before" as a reusable matcher
}The Problem:
- Can’t build a reusable “valid user” matcher to use across tests
- Can’t compose field matchers into higher-level concepts
- Every test duplicates the same field-by-field assertions
What we want:
specta.AssertThat(t, user, UserMatches().
Name(specta.Equal("Alice")).
Email(specta.Contains("example.com")))Clean, fluent, partial matching - only assert what matters!
Option 1: Manual Custom Matchers#
You can implement the Matcher[T] interface yourself:
type UserMatcher struct {
nameMatcher specta.Matcher[string]
emailMatcher specta.Matcher[string]
}
func UserMatches() UserMatcher {
return UserMatcher{}
}
func (m UserMatcher) Name(matcher specta.Matcher[string]) UserMatcher {
m.nameMatcher = matcher
return m
}
func (m UserMatcher) Email(matcher specta.Matcher[string]) UserMatcher {
m.emailMatcher = matcher
return m
}
// Implements the Matcher[User] interface
func (m UserMatcher) Matches(actual User) specta.MatchResult {
// Only check fields that have matchers set (partial matching)
if m.nameMatcher != nil {
if result := m.nameMatcher.Matches(actual.Name); !result.Matched {
return specta.MatchResult{
Matched: false,
Message: "Name: " + result.Message,
}
}
}
if m.emailMatcher != nil {
if result := m.emailMatcher.Matches(actual.Email); !result.Matched {
return specta.MatchResult{
Matched: false,
Message: "Email: " + result.Message,
}
}
}
return specta.MatchResult{Matched: true}
}Now you can use it directly:
specta.AssertThat(t, user, UserMatches().
Name(specta.Equal("Alice")).
Email(validEmail)) // No wrapper needed - UserMatcher IS a Matcher[User]This works, but it’s tedious and error-prone. There’s a better way…
Option 2: Code Generation (Recommended)#
specta can automatically generate matchers (and factories) for your types!
Step 1: Create specta.yaml#
In your package directory, create a configuration file:
# specta.yaml
version: 1
targets:
- package: . # Current package
types:
include:
- User # Generate for User typeThe generator will introspect your User struct and generate code for all exported (public) fields automatically.
Step 2: Run the Generator#
go run github.com/james-w/specta/cmd -config specta.yamlThis generates three files in factory/ directory:
factory/spec/user_gen.go- Low-level factory builders (covered in Factories section)factory/user_gen.go- Recipe fluent API for test data (covered in Factories section)factory/user_matcher_gen.go- Matcher builders ← We’ll use this!
All files include build tags:
//go:build !ignore_testgenThis allows excluding them from linting while keeping them in your tests.
Step 3: Use Generated Matchers#
func TestCreateUser(t *testing.T) {
user := CreateUser("Alice", "alice@example.com")
// Clean, fluent, partial matching!
specta.AssertThat(t, user, UserMatches().
Name(specta.Equal("Alice")).
Email(specta.Contains("example.com")))
}Key points:
- Constructor:
UserMatches()creates a new matcher - Methods:
Name(),Email(), etc. (field names, no “With” prefix) - Direct usage: The matcher implements
Matcher[User]interface directly - Partial matching: Only specified fields are validated
Benefits:
- Only assert fields that matter for this test
- Other fields (ID, Age) are ignored
- Compose with core matchers
- Structured error messages
- Refactoring-safe: adding new fields doesn’t break tests
Constructor-Based Generation#
For types created via constructor functions (not direct struct instantiation), use constructor-based generation.
When to Use#
Use field-based (previous section) when:
- Your type is a simple struct with public fields
- Tests create instances via struct literals:
User{Name: "Alice", ...}
Use constructor-based when:
- Your type is created via a constructor function
- Constructor performs validation or initialization logic
- Example:
account := NewBankAccount("Alice", 1000)
Configuration#
version: 1
targets:
- package: .
types:
- name: BankAccount
constructor: NewBankAccount # Constructor function name
defaults: # Default values for constructor params
name:
pattern: email # Built-in pattern (email, url, uuid)
balance:
custom: specta.Int() # Custom generator expressionBuilt-in patterns: email, url, uuid
Custom generators: Any specta.Gen[T] expression (e.g., specta.Int(), specta.String(), etc.)
What Gets Generated#
For constructor-based types, the generator:
- Analyzes the constructor signature
- Generates factory code using the constructor
- Does NOT generate field-based matchers (fields may be private!)
- Requires getter-based matchers (next section)
Example: BankAccount#
Given:
type BankAccount struct {
name string // private fields
balance int
}
func NewBankAccount(name string, balance int) BankAccount {
// validation logic
return BankAccount{name: name, balance: balance}
}
func (a BankAccount) GetName() string { return a.name }
func (a BankAccount) GetBalance() int { return a.balance }Configuration:
types:
- name: BankAccount
constructor: NewBankAccount
defaults:
name:
pattern: email
balance:
custom: specta.Map(specta.Int(), func(v int64) int { return int(v) })Next: Define matchers via getter methods (next section).
Getter-Based Matchers#
For constructor-based types with private fields, define matchers based on getter methods.
Configuration#
types:
- name: BankAccount
constructor: NewBankAccount
defaults:
name:
pattern: email
balance:
custom: specta.Int()
matchers: # Define matchers via getters
- name: Name # Matcher method name
getter: GetName # Getter method name
- name: Balance
getter: GetBalanceGenerated Matcher#
The generator creates type-safe matcher methods:
// Generated code
type BankAccountMatcher struct {
nameMatcher specta.Matcher[string]
balanceMatcher specta.Matcher[int]
}
func BankAccountMatches() BankAccountMatcher {
return BankAccountMatcher{}
}
// Matcher method calls the getter
func (m BankAccountMatcher) Name(matcher specta.Matcher[string]) BankAccountMatcher {
m.nameMatcher = matcher
return m
}
func (m BankAccountMatcher) Balance(matcher specta.Matcher[int]) BankAccountMatcher {
m.balanceMatcher = matcher
return m
}
func (m BankAccountMatcher) Matcher() specta.Matcher[BankAccount] {
return specta.MatcherFunc[BankAccount](func(actual BankAccount) specta.MatchResult {
// Calls GetName(), GetBalance() on actual value
nameValue := actual.GetName()
balanceValue := actual.GetBalance()
// Evaluates matchers...
})
}Usage#
func TestBankAccount(t *testing.T) {
account := NewBankAccount("alice@example.com", 1000)
specta.AssertThat(t, account, factory.BankAccountMatches().
Name(specta.Contains("@")).
Balance(specta.GreaterThan(0)).
Matcher())
}Key difference: Matcher calls getter methods, not direct field access.
Understanding Generated Files#
Build Tags#
Generated files include:
//go:build !ignore_testgenThis allows excluding them from linting/analysis tools while keeping them in your tests.
What Gets Generated#
The generator creates three files for each type:
factory/{type}_matcher_gen.go- Matcher builders (← This section focuses here)- Contains
{Type}Matcherstruct with field matcher storage - Constructor:
{Type}Matches()creates a new matcher builder - Fluent methods: one per field, named after the field (no “With” prefix)
- Implements the
Matcher[T]interface directly viaMatches(actual T) MatchResultmethod - No wrapper needed - the struct IS the matcher, use it directly in
AssertThat - For getter-based matchers: methods call getter functions instead of accessing fields directly
- Contains
factory/spec/{type}_gen.go- Low-level factory builders- Contains
{Type}Specstruct withMaybe[T]fields - Option functions like
With{Type}{Field}() - Used internally by the Recipe API
- Covered in Test Data Factories
- Contains
factory/{type}_gen.go- Recipe fluent API- High-level builder for creating test data
- Fluent methods for setting field values
- Generates deterministic test data
- Covered in Test Data Factories and Factories + Matchers
All three files work together to provide factories (test data creation) and matchers (assertions), but this section focuses only on the matcher file.
Using Generated Matchers#
Basic Field Matching#
func TestBasicMatching(t *testing.T) {
specta.AssertThat(t, user, UserMatches().
Name(specta.Equal("Alice")).
Age(specta.GreaterThan(18)))
}Compose with Core Matchers#
func TestComposition(t *testing.T) {
specta.AssertThat(t, user, UserMatches().
Email(specta.AllOf(
specta.Contains("@"),
specta.HasSuffix(".com"),
)).
Name(specta.Not(specta.Equal(""))))
}Nested Struct Matching#
If User has a nested Address struct:
specta.AssertThat(t, user, UserMatches().
Address(AddressMatches().
City(specta.Equal("Seattle")).
ZipCode(specta.MatchesRegex(`^\d{5}$`)).
Matcher()).
Matcher())Partial Matching in Action#
// Test 1: Only care about name
specta.AssertThat(t, user, UserMatches().
Name(specta.Equal("Alice")).
Matcher())
// Test 2: Only care about email domain
specta.AssertThat(t, user, UserMatches().
Email(specta.HasSuffix("@company.com")).
Matcher())
// Test 3: Validate age and creation time
specta.AssertThat(t, user, UserMatches().
Age(specta.GreaterThan(0)).
CreatedAt(specta.Not(specta.IsNil[time.Time]())).
Matcher())Each test asserts exactly what it cares about. Adding new fields to User won’t break these tests.
Reusable Matchers#
Build higher-level matchers from the generated ones:
// Define reusable matchers for common patterns
func ValidUser() specta.Matcher[User] {
return UserMatches().
Name(specta.Not(specta.Equal(""))).
Email(specta.Contains("@")).
Age(specta.GreaterThan(0)).
Matcher()
}
func AdminUser() specta.Matcher[User] {
return UserMatches().
Name(specta.Not(specta.Equal(""))).
Email(specta.AllOf(
specta.Contains("@"),
specta.HasSuffix("@company.com"),
)).
Age(specta.GreaterThan(0)).
Matcher()
}
// Use across tests
func TestCreateUser(t *testing.T) {
user := CreateUser("Alice", "alice@company.com", 30)
specta.AssertThat(t, user, AdminUser())
}
func TestPromoteUser(t *testing.T) {
user := PromoteToAdmin(regularUser)
specta.AssertThat(t, user, AdminUser())
}When you add a new field (e.g., Status string):
- Regenerate:
go run github.com/james-w/specta/cmd -config specta.yaml - Update
ValidUser()if the new field should be validated:func ValidUser() specta.Matcher[User] { return UserMatches(). Name(specta.Not(specta.Equal(""))). Email(specta.Contains("@")). Age(specta.GreaterThan(0)). Status(specta.Equal("active")). // One update here Matcher() } - All tests using
ValidUser()now validate the new field - zero test changes needed!
Regenerating After Changes#
When you modify your types, regenerate:
go run github.com/james-w/specta/cmd -config specta.yamlThe generator:
- Parses your type definitions
- Generates type-safe matchers
- Type-checks the generated code
- Only writes if successful
Important: Commit generated files with your source changes, or CI will fail!
Next Steps#
Now you have matchers for your types! Next, learn about:
- Test Data Factories - The flip side of matchers
- Factories + Matchers Together - The complete pattern
- API Reference - Full generator configuration options