Core Matchers#

Matchers are the foundation of specta. They test values and provide detailed, structured failure messages.

Assertion Functions#

Before diving into specific matchers, it’s important to understand the two assertion functions specta provides.

AssertThat - Continue on Failure#

AssertThat reports failures but allows the test to continue executing:

func TestUserValidation(t *testing.T) {
    user := createUser()

    // All three assertions will run, even if the first fails
    specta.AssertThat(t, user.Name, specta.Equal("Alice"))
    specta.AssertThat(t, user.Email, specta.Contains("@"))
    specta.AssertThat(t, user.Age, specta.GreaterThan(18))

    // You'll see all failures in a single test run
}

Output when multiple assertions fail:

user.Name: expected "Alice" but got "Bob"
user.Email: expected string to contain "@" but got "invalid"

This is useful when you want to see all validation failures at once, making it faster to fix multiple issues.

RequireThat - Stop on Failure#

RequireThat stops test execution immediately if the assertion fails:

func TestUserProcessing(t *testing.T) {
    user := findUser(id)

    // Guard: ensure user exists before accessing fields
    specta.RequireThat(t, user, specta.IsNotNil[User]())

    // Safe - test stopped above if user was nil
    specta.AssertThat(t, user.Name, specta.Equal("Alice"))
    specta.AssertThat(t, user.Email, specta.Contains("@"))
}

Without RequireThat, if user is nil, the test would panic at user.Name with an unhelpful error. With RequireThat, you get a clear assertion failure and the test stops cleanly.

When to Use Each#

Use RequireThat for:

  1. Nil checks before dereferencing:

    specta.RequireThat(t, configPtr, specta.IsNotNil[Config]())
    _ = configPtr.Setting  // Safe - test stopped if nil
  2. Prerequisites for setup:

    specta.RequireThat(t, len(items), specta.GreaterThan(0))
    _ = items[0]  // Safe - won't panic on empty slice
  3. Critical preconditions:

    specta.RequireThat(t, db.Ping(), specta.NoErr())
    // Don't continue if database is unavailable

Use AssertThat for:

  1. Independent field validations:

    specta.AssertThat(t, user.Name, specta.Equal("Alice"))
    specta.AssertThat(t, user.Age, specta.GreaterThan(18))
    // Want to see both failures if both are wrong
  2. Multiple unrelated checks:

    specta.AssertThat(t, response.Status, specta.Equal(200))
    specta.AssertThat(t, response.Body, specta.Contains("success"))
  3. Default choice: When in doubt, use AssertThat

Technical Details#

Both functions provide identical error messages. The only difference is whether t.Fatalf() is called after reporting the error:

  • AssertThat calls t.Errorf() - marks test as failed but continues
  • RequireThat calls t.Errorf() then t.Fatalf("") - marks failed and stops

This follows the same pattern as testify’s assert vs require packages.

Value Matchers#

Equality#

Test if values are equal:

specta.AssertThat(t, value, specta.Equal(42))
specta.AssertThat(t, user, specta.DeepEqual(expectedUser))
specta.AssertThat(t, value, specta.Is(42))  // Alias for Equal

Use Equal for comparable types, DeepEqual for structs/slices/any type.

Comparison#

Compare numeric values:

specta.AssertThat(t, age, specta.GreaterThan(18))
specta.AssertThat(t, score, specta.LessThan(100))
specta.AssertThat(t, age, specta.GreaterThanOrEqual(21))
specta.AssertThat(t, score, specta.LessThanOrEqual(100))

Works with all numeric types (int, float, uint variants).

Zero Value#

Check if a value is its type’s zero value:

specta.AssertThat(t, zeroInt, specta.IsZero[int]())
specta.AssertThat(t, name, specta.Not(specta.IsZero[string]()))

Boolean Matchers#

Test boolean values:

specta.AssertThat(t, active, specta.IsTrue())
specta.AssertThat(t, verified, specta.IsFalse())

Simpler and clearer than Equal(true) or Equal(false).

String Matchers#

Substring Matching#

specta.AssertThat(t, message, specta.Contains("error"))
specta.AssertThat(t, filename, specta.HasPrefix("test_"))
specta.AssertThat(t, email, specta.HasSuffix(".com"))

Pattern Matching#

Match against regular expressions:

// Compile pattern on each call
specta.AssertThat(t, email, specta.MatchesRegex(`^[a-z]+@[a-z]+\.[a-z]+$`))

// Use pre-compiled pattern (more efficient for repeated use)
specta.AssertThat(t, email, specta.MatchesPattern(emailPattern))

Empty Strings#

specta.AssertThat(t, name, specta.Not(specta.Equal("")))

Collection Matchers#

Size and Emptiness#

specta.AssertThat(t, list, specta.HasSize[string](3))
specta.AssertThat(t, emptyList, specta.IsEmpty[string]())
specta.AssertThat(t, list, specta.IsNotEmpty[string]())

Membership#

specta.AssertThat(t, list, specta.ContainsElement("apple"))
specta.AssertThat(t, list, specta.ContainsAllElements("apple", "banana", "cherry"))
specta.AssertThat(t, list, specta.ContainsAnyElement("apple", "durian"))

Element Matching#

Match patterns across collection elements:

// All elements must match
specta.AssertThat(t, numbers, specta.Every(specta.GreaterThan(0)))

// At least one element must match
specta.AssertThat(t, numbers, specta.Any(specta.GreaterThan(5)))

// No elements should match
specta.AssertThat(t, numbers, specta.None(specta.LessThan(0)))

Map Matchers#

Test map contents:

// Check for key existence
specta.AssertThat(t, userMap, specta.HasKey[string, int]("alice"))

// Check for value existence
specta.AssertThat(t, userMap, specta.HasValue[string, int](30))

// Check for specific key-value pair
specta.AssertThat(t, userMap, specta.HasEntry[string, int]("alice", 30))

// Check map size
specta.AssertThat(t, userMap, specta.MapHasSize[string, int](2))
specta.AssertThat(t, emptyMap, specta.MapHasSize[string, int](0))

Pointer Matchers#

Nil Checks#

specta.AssertThat(t, ptr, specta.IsNil[YourType]())
specta.AssertThat(t, nonNilPtr, specta.IsNotNil[YourType]())

Value Matching#

Match the value a pointer points to:

value := 42
ptr := &value

specta.AssertThat(t, ptr, specta.PointsTo(specta.Equal(42)))
specta.AssertThat(t, ptr, specta.PointsTo(specta.GreaterThan(40)))

Error Matchers#

Basic Error Checks#

specta.AssertThat(t, err, specta.IsError())
specta.AssertThat(t, nilErr, specta.NoErr())

Error Messages#

Check error message content:

specta.AssertThat(t, err, specta.ErrorContains("operation failed"))
specta.AssertThat(t, err, specta.ErrorContains("failed"))

Error Chain Matching#

Use Go’s error chain utilities:

// Check if error is in the error chain (uses errors.Is)
specta.AssertThat(t, wrappedErr, specta.ErrorIs(baseErr))

Time Matchers#

Time Comparison#

specta.AssertThat(t, future, specta.After(now))
specta.AssertThat(t, past, specta.Before(now))
specta.AssertThat(t, now, specta.Between(past, future))

Time Tolerance#

Check if times are close enough (useful for testing timestamps with slight variations):

// future is 30 minutes from now, check it's within 1 hour
specta.AssertThat(t, future, specta.WithinDuration(now, oneHour))

Field Extraction#

Extract and match computed or derived values:

// Match on a computed field
specta.AssertThat(t, user,
    specta.Field("FullName",
        func(u User) string { return u.Name },
        specta.Equal("Alice")))

// Match on method result
specta.AssertThat(t, user,
    specta.Field("Age",
        func(u User) int { return u.Age },
        specta.GreaterThan(18)))

// Combine with composition
specta.AssertThat(t, user,
    specta.AllOf(
        specta.Field("Name", func(u User) string { return u.Name }, specta.Contains("A")),
        specta.Field("Age", func(u User) int { return u.Age }, specta.GreaterThan(18)),
    ))

Useful for:

  • Testing unexported fields (via getter methods)
  • Computed properties
  • Complex validation logic

Composing Matchers#

AllOf (AND logic)#

All matchers must pass:

specta.AssertThat(t, email, specta.AllOf(
    specta.Contains("@"),
    specta.HasSuffix(".com"),
    specta.HasPrefix("user"),
))

AnyOf (OR logic)#

At least one matcher must pass:

specta.AssertThat(t, status, specta.AnyOf(
    specta.Equal("active"),
    specta.Equal("pending"),
    specta.Equal("processing"),
))

Not (Negation)#

Inverts a matcher:

specta.AssertThat(t, name, specta.Not(specta.Equal("")))
specta.AssertThat(t, list, specta.Not(specta.ContainsElement("forbidden")))

Understanding Matcher Errors#

When a matcher fails, specta provides structured error messages with visual indicators:

Value does not match:
  ✗ AllOf:
    ✓ Contains("@")
    ✗ HasSuffix(".com")
      Expected: string ending with ".com"
      Got:      "user@example.org"
    ✓ HasPrefix("user")

Symbols:

  • - Matcher passed
  • - Matcher failed
  • ~ - Matcher not evaluated (due to short-circuit logic)

Building Reusable Matchers#

Building reusable matchers lets you compose test assertions and create domain-specific validation logic that you can use across your test suite.

Simple Value Matchers#

Extract common patterns for primitive types:

import "github.com/james-w/specta"

// Define reusable matchers
var (
    validEmail = specta.AllOf(
        specta.Contains("@"),
        specta.MatchesRegex(`^[^@]+@[^@]+\.[^@]+$`),
    )

    positiveInteger = specta.GreaterThan(0)
)

// Use in tests
func TestUser(t *testing.T) {
    specta.AssertThat(t, user.Email, validEmail)
    specta.AssertThat(t, user.Age, positiveInteger)
}

Custom Type Matchers#

You can implement the Matcher[T] interface to create matchers for your own types:

// User type from your application
type User struct {
    Name  string
    Email string
    Age   int
}

// UserMatcher allows partial matching on User fields
type UserMatcher struct {
    nameMatcher  specta.Matcher[string]
    emailMatcher specta.Matcher[string]
    ageMatcher   specta.Matcher[int]
}

// UserMatches creates a new matcher builder for User
func UserMatches() UserMatcher {
    return UserMatcher{}
}

// Name sets the matcher for the Name field
func (m UserMatcher) Name(matcher specta.Matcher[string]) UserMatcher {
    m.nameMatcher = matcher
    return m
}

// Email sets the matcher for the Email field
func (m UserMatcher) Email(matcher specta.Matcher[string]) UserMatcher {
    m.emailMatcher = matcher
    return m
}

// Age sets the matcher for the Age field
func (m UserMatcher) Age(matcher specta.Matcher[int]) UserMatcher {
    m.ageMatcher = matcher
    return m
}

// Matches 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,
            }
        }
    }

    if m.ageMatcher != nil {
        if result := m.ageMatcher.Matches(actual.Age); !result.Matched {
            return specta.MatchResult{
                Matched: false,
                Message: "Age: " + result.Message,
            }
        }
    }

    return specta.MatchResult{Matched: true}
}

Now you can use it to create reusable, composable matchers:

func TestUserCreation(t *testing.T) {
    user := CreateUser("Alice", "alice@example.com", 30)

    // Partial matching - only assert what matters
    specta.AssertThat(t, user, UserMatches().
        Name(specta.Equal("Alice")).
        Email(validEmail))  // Compose with reusable matcher!
}

func TestUserValidation(t *testing.T) {
    user := ValidateUser(input)

    // Different test, different assertions
    specta.AssertThat(t, user, UserMatches().
        Email(validEmail).
        Age(positiveInteger))
}

Key benefits:

  • Partial matching: Only specified fields are validated
  • Composable: Mix custom matchers with built-in ones
  • Reusable: Define once, use across all User tests
  • Type-safe: Compile-time checking for field types

Building a Matcher Library#

As your codebase grows, you’ll want a library of reusable matchers:

// matchers/user.go
package matchers

import "github.com/james-w/specta"

// Common user matchers
var (
    ValidEmail = specta.AllOf(
        specta.Contains("@"),
        specta.MatchesRegex(`^[^@]+@[^@]+\.[^@]+$`),
    )

    Adult = specta.GreaterThanOrEqual(18)

    ValidUser = UserMatches().
        Email(ValidEmail).
        Age(Adult)

    AdminUser = UserMatches().
        Email(specta.HasSuffix("@company.com")).
        Age(Adult)
)

// Use across tests
func TestCreateUser(t *testing.T) {
    user := CreateUser(input)
    specta.AssertThat(t, user, matchers.ValidUser)
}

func TestPromoteToAdmin(t *testing.T) {
    admin := PromoteToAdmin(user)
    specta.AssertThat(t, admin, matchers.AdminUser)
}

This pattern—building reusable, composable matchers for your domain types—is powerful. But there’s a problem…

Why Code Generation?#

The manual approach works, but it’s tedious and error-prone:

For each type, you need to write:

  1. A matcher struct with optional field matchers
  2. A constructor function
  3. Fluent builder methods for each field (with correct return type)
  4. The Matches() implementation with partial matching logic
  5. Proper error messages for each field

For a 5-field struct, that’s ~50-70 lines of boilerplate — all mechanical, repetitive code.

And when you add a field to your type:

  • Update the matcher struct
  • Add a new builder method
  • Update the Matches() logic

There’s a better way: automatic code generation.

The next section shows how specta can generate all this boilerplate for you, keeping your matchers in sync with your types automatically.

Next Steps#

Now that you understand how matchers work—including how to build them manually—learn how to: