or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

core-assertions.mdgbytes.mdgcustom.mdgexec.mdghttp.mdgleak.mdgmeasure.mdgstruct.mdindex.mdmatchers.mdtypes.md
tile.json

gcustom.mddocs/

gcustom Package

The gcustom package provides utilities for creating custom Gomega matchers. It simplifies the process of building type-safe, reusable matchers with custom matching logic and failure messages.

Overview

While Gomega provides a rich set of built-in matchers, you may need custom matchers for:

  • Domain-specific assertions
  • Complex validation logic
  • Reusable test patterns
  • Type-safe matching with generics

The gcustom package makes it easy to create custom matchers without implementing the full GomegaMatcher interface manually.

Core Functions

MakeMatcher

{ .api }

func MakeMatcher[T any](match func(actual T) (bool, error)) types.GomegaMatcher

Creates a simple custom matcher with just a match function. This is the easiest way to create a custom matcher when you don't need custom failure messages.

Type Parameters:

  • T - The type of value the matcher will match against

Parameters:

  • match - Function that performs the matching logic
    • actual - The value to match
    • Returns (bool, error) - success status and optional error

Returns: A types.GomegaMatcher that can be used with Gomega assertions

Example:

// Create a matcher that checks if a number is even
BeEven := gcustom.MakeMatcher(func(actual int) (bool, error) {
    return actual%2 == 0, nil
})

// Use it in tests
Expect(4).To(BeEven())
Expect(7).NotTo(BeEven())

MakeMatcherWithMessage

{ .api }

func MakeMatcherWithMessage[T any](
    match func(actual T) (bool, error),
    message func(actual T, failure bool) string,
) types.GomegaMatcher

Creates a custom matcher with custom failure messages. Use this when you want full control over how match failures are reported.

Type Parameters:

  • T - The type of value the matcher will match against

Parameters:

  • match - Function that performs the matching logic
    • actual - The value to match
    • Returns (bool, error) - success status and optional error
  • message - Function that generates failure messages
    • actual - The value that was matched
    • failure - true for normal failure, false for negated failure
    • Returns string - The failure message to display

Returns: A types.GomegaMatcher with custom messages

Example:

BeEven := gcustom.MakeMatcherWithMessage(
    func(actual int) (bool, error) {
        return actual%2 == 0, nil
    },
    func(actual int, failure bool) string {
        if failure {
            return fmt.Sprintf("Expected %d to be even", actual)
        }
        return fmt.Sprintf("Expected %d not to be even", actual)
    },
)

// Better failure messages
Expect(7).To(BeEven()) // "Expected 7 to be even"
Expect(4).NotTo(BeEven()) // "Expected 4 not to be even"

Complete Examples

Basic Custom Matchers

package matchers_test

import (
    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
    "github.com/onsi/gomega/gcustom"
)

// Simple even number matcher
var _ = Describe("Basic Matchers", func() {
    It("matches even numbers", func() {
        BeEven := gcustom.MakeMatcher(func(actual int) (bool, error) {
            return actual%2 == 0, nil
        })

        Expect(2).To(BeEven())
        Expect(4).To(BeEven())
        Expect(3).NotTo(BeEven())
    })

    It("matches positive numbers", func() {
        BePositive := gcustom.MakeMatcher(func(actual int) (bool, error) {
            return actual > 0, nil
        })

        Expect(5).To(BePositive())
        Expect(-3).NotTo(BePositive())
        Expect(0).NotTo(BePositive())
    })
})

Domain-Specific Matchers

// User validation matcher
type User struct {
    Name  string
    Email string
    Age   int
}

var _ = Describe("Domain Matchers", func() {
    It("validates user objects", func() {
        BeValidUser := gcustom.MakeMatcher(func(actual User) (bool, error) {
            if actual.Name == "" {
                return false, nil
            }
            if !strings.Contains(actual.Email, "@") {
                return false, nil
            }
            if actual.Age < 0 || actual.Age > 150 {
                return false, nil
            }
            return true, nil
        })

        validUser := User{
            Name:  "Alice",
            Email: "alice@example.com",
            Age:   30,
        }
        Expect(validUser).To(BeValidUser())

        invalidUser := User{
            Name:  "",
            Email: "invalid",
            Age:   -5,
        }
        Expect(invalidUser).NotTo(BeValidUser())
    })
})

Matchers with Custom Messages

var _ = Describe("Custom Messages", func() {
    It("provides detailed failure messages", func() {
        BeInRange := func(min, max int) types.GomegaMatcher {
            return gcustom.MakeMatcherWithMessage(
                func(actual int) (bool, error) {
                    return actual >= min && actual <= max, nil
                },
                func(actual int, failure bool) string {
                    if failure {
                        return fmt.Sprintf(
                            "Expected %d to be in range [%d, %d]",
                            actual, min, max,
                        )
                    }
                    return fmt.Sprintf(
                        "Expected %d not to be in range [%d, %d]",
                        actual, min, max,
                    )
                },
            )
        }

        Expect(5).To(BeInRange(1, 10))
        Expect(15).NotTo(BeInRange(1, 10))

        // Failure message: "Expected 15 to be in range [1, 10]"
        // Expect(15).To(BeInRange(1, 10))
    })
})

String Validation Matchers

var _ = Describe("String Matchers", func() {
    It("validates email addresses", func() {
        BeValidEmail := gcustom.MakeMatcherWithMessage(
            func(actual string) (bool, error) {
                emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
                return emailRegex.MatchString(actual), nil
            },
            func(actual string, failure bool) string {
                if failure {
                    return fmt.Sprintf("Expected '%s' to be a valid email address", actual)
                }
                return fmt.Sprintf("Expected '%s' not to be a valid email address", actual)
            },
        )

        Expect("user@example.com").To(BeValidEmail())
        Expect("invalid.email").NotTo(BeValidEmail())
    })

    It("validates URLs", func() {
        BeValidURL := gcustom.MakeMatcher(func(actual string) (bool, error) {
            _, err := url.Parse(actual)
            if err != nil {
                return false, nil
            }
            return strings.HasPrefix(actual, "http://") ||
                strings.HasPrefix(actual, "https://"), nil
        })

        Expect("https://example.com").To(BeValidURL())
        Expect("not a url").NotTo(BeValidURL())
    })
})

Slice and Collection Matchers

var _ = Describe("Collection Matchers", func() {
    It("checks if slice is sorted", func() {
        BeSorted := gcustom.MakeMatcherWithMessage(
            func(actual []int) (bool, error) {
                for i := 1; i < len(actual); i++ {
                    if actual[i] < actual[i-1] {
                        return false, nil
                    }
                }
                return true, nil
            },
            func(actual []int, failure bool) string {
                if failure {
                    return fmt.Sprintf("Expected %v to be sorted", actual)
                }
                return fmt.Sprintf("Expected %v not to be sorted", actual)
            },
        )

        Expect([]int{1, 2, 3, 4, 5}).To(BeSorted())
        Expect([]int{5, 2, 3}).NotTo(BeSorted())
    })

    It("checks if slice contains duplicates", func() {
        HaveDuplicates := gcustom.MakeMatcher(func(actual []int) (bool, error) {
            seen := make(map[int]bool)
            for _, v := range actual {
                if seen[v] {
                    return true, nil
                }
                seen[v] = true
            }
            return false, nil
        })

        Expect([]int{1, 2, 2, 3}).To(HaveDuplicates())
        Expect([]int{1, 2, 3, 4}).NotTo(HaveDuplicates())
    })
})

Numeric Matchers

var _ = Describe("Numeric Matchers", func() {
    It("checks if number is prime", func() {
        BePrime := gcustom.MakeMatcherWithMessage(
            func(actual int) (bool, error) {
                if actual < 2 {
                    return false, nil
                }
                for i := 2; i*i <= actual; i++ {
                    if actual%i == 0 {
                        return false, nil
                    }
                }
                return true, nil
            },
            func(actual int, failure bool) string {
                if failure {
                    return fmt.Sprintf("Expected %d to be a prime number", actual)
                }
                return fmt.Sprintf("Expected %d not to be a prime number", actual)
            },
        )

        Expect(7).To(BePrime())
        Expect(11).To(BePrime())
        Expect(4).NotTo(BePrime())
    })

    It("checks if number is power of two", func() {
        BePowerOfTwo := gcustom.MakeMatcher(func(actual int) (bool, error) {
            return actual > 0 && (actual&(actual-1)) == 0, nil
        })

        Expect(8).To(BePowerOfTwo())
        Expect(16).To(BePowerOfTwo())
        Expect(10).NotTo(BePowerOfTwo())
    })
})

Error Matchers

var _ = Describe("Error Matchers", func() {
    It("matches specific error types", func() {
        BeTimeoutError := gcustom.MakeMatcherWithMessage(
            func(actual error) (bool, error) {
                if actual == nil {
                    return false, nil
                }
                return strings.Contains(actual.Error(), "timeout"), nil
            },
            func(actual error, failure bool) string {
                if failure {
                    if actual == nil {
                        return "Expected a timeout error but got nil"
                    }
                    return fmt.Sprintf(
                        "Expected '%s' to be a timeout error",
                        actual.Error(),
                    )
                }
                return fmt.Sprintf(
                    "Expected '%s' not to be a timeout error",
                    actual.Error(),
                )
            },
        )

        timeoutErr := errors.New("connection timeout")
        otherErr := errors.New("not found")

        Expect(timeoutErr).To(BeTimeoutError())
        Expect(otherErr).NotTo(BeTimeoutError())
    })
})

Struct Field Matchers

type Product struct {
    Name  string
    Price float64
    Stock int
}

var _ = Describe("Struct Matchers", func() {
    It("validates product availability", func() {
        BeAvailable := gcustom.MakeMatcherWithMessage(
            func(actual Product) (bool, error) {
                return actual.Stock > 0 && actual.Price > 0, nil
            },
            func(actual Product, failure bool) string {
                if failure {
                    return fmt.Sprintf(
                        "Expected product '%s' to be available (price: %.2f, stock: %d)",
                        actual.Name, actual.Price, actual.Stock,
                    )
                }
                return fmt.Sprintf(
                    "Expected product '%s' not to be available",
                    actual.Name,
                )
            },
        )

        available := Product{Name: "Widget", Price: 19.99, Stock: 10}
        unavailable := Product{Name: "Gadget", Price: 0, Stock: 0}

        Expect(available).To(BeAvailable())
        Expect(unavailable).NotTo(BeAvailable())
    })
})

Parameterized Matchers

var _ = Describe("Parameterized Matchers", func() {
    It("creates matcher with parameters", func() {
        // Matcher factory function
        BeDivisibleBy := func(divisor int) types.GomegaMatcher {
            return gcustom.MakeMatcherWithMessage(
                func(actual int) (bool, error) {
                    if divisor == 0 {
                        return false, errors.New("divisor cannot be zero")
                    }
                    return actual%divisor == 0, nil
                },
                func(actual int, failure bool) string {
                    if failure {
                        return fmt.Sprintf(
                            "Expected %d to be divisible by %d",
                            actual, divisor,
                        )
                    }
                    return fmt.Sprintf(
                        "Expected %d not to be divisible by %d",
                        actual, divisor,
                    )
                },
            )
        }

        Expect(10).To(BeDivisibleBy(5))
        Expect(10).To(BeDivisibleBy(2))
        Expect(10).NotTo(BeDivisibleBy(3))
    })

    It("matches strings with length constraints", func() {
        HaveLengthBetween := func(min, max int) types.GomegaMatcher {
            return gcustom.MakeMatcherWithMessage(
                func(actual string) (bool, error) {
                    length := len(actual)
                    return length >= min && length <= max, nil
                },
                func(actual string, failure bool) string {
                    if failure {
                        return fmt.Sprintf(
                            "Expected '%s' (length %d) to have length between %d and %d",
                            actual, len(actual), min, max,
                        )
                    }
                    return fmt.Sprintf(
                        "Expected '%s' (length %d) not to have length between %d and %d",
                        actual, len(actual), min, max,
                    )
                },
            )
        }

        Expect("hello").To(HaveLengthBetween(3, 10))
        Expect("hi").NotTo(HaveLengthBetween(3, 10))
    })
})

Complex Validation Logic

type Order struct {
    ID        string
    Items     []string
    Total     float64
    Discount  float64
    CreatedAt time.Time
}

var _ = Describe("Complex Matchers", func() {
    It("validates complete order", func() {
        BeValidOrder := gcustom.MakeMatcherWithMessage(
            func(actual Order) (bool, error) {
                // Check ID format
                if !regexp.MustCompile(`^ORD-\d{6}$`).MatchString(actual.ID) {
                    return false, nil
                }

                // Check items
                if len(actual.Items) == 0 {
                    return false, nil
                }

                // Check totals
                if actual.Total <= 0 {
                    return false, nil
                }
                if actual.Discount < 0 || actual.Discount > actual.Total {
                    return false, nil
                }

                // Check date
                if actual.CreatedAt.After(time.Now()) {
                    return false, nil
                }

                return true, nil
            },
            func(actual Order, failure bool) string {
                if failure {
                    var reasons []string
                    if !regexp.MustCompile(`^ORD-\d{6}$`).MatchString(actual.ID) {
                        reasons = append(reasons, "invalid ID format")
                    }
                    if len(actual.Items) == 0 {
                        reasons = append(reasons, "no items")
                    }
                    if actual.Total <= 0 {
                        reasons = append(reasons, "invalid total")
                    }
                    if actual.Discount < 0 || actual.Discount > actual.Total {
                        reasons = append(reasons, "invalid discount")
                    }
                    if actual.CreatedAt.After(time.Now()) {
                        reasons = append(reasons, "future date")
                    }

                    return fmt.Sprintf(
                        "Expected order %s to be valid. Issues: %s",
                        actual.ID,
                        strings.Join(reasons, ", "),
                    )
                }
                return fmt.Sprintf("Expected order %s not to be valid", actual.ID)
            },
        )

        validOrder := Order{
            ID:        "ORD-123456",
            Items:     []string{"item1", "item2"},
            Total:     100.00,
            Discount:  10.00,
            CreatedAt: time.Now().Add(-1 * time.Hour),
        }

        invalidOrder := Order{
            ID:        "invalid",
            Items:     []string{},
            Total:     -50.00,
            Discount:  200.00,
            CreatedAt: time.Now().Add(1 * time.Hour),
        }

        Expect(validOrder).To(BeValidOrder())
        Expect(invalidOrder).NotTo(BeValidOrder())
    })
})

Reusable Matcher Library

package matchers

import (
    "github.com/onsi/gomega/gcustom"
    "github.com/onsi/gomega/types"
)

// Numeric matchers
func BeEven() types.GomegaMatcher {
    return gcustom.MakeMatcher(func(actual int) (bool, error) {
        return actual%2 == 0, nil
    })
}

func BeOdd() types.GomegaMatcher {
    return gcustom.MakeMatcher(func(actual int) (bool, error) {
        return actual%2 != 0, nil
    })
}

func BePositive() types.GomegaMatcher {
    return gcustom.MakeMatcher(func(actual int) (bool, error) {
        return actual > 0, nil
    })
}

// String matchers
func BeValidEmail() types.GomegaMatcher {
    return gcustom.MakeMatcher(func(actual string) (bool, error) {
        emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
        return emailRegex.MatchString(actual), nil
    })
}

// Usage in tests
var _ = Describe("Using matcher library", func() {
    It("uses reusable matchers", func() {
        Expect(4).To(matchers.BeEven())
        Expect(7).To(matchers.BeOdd())
        Expect(10).To(matchers.BePositive())
        Expect("user@example.com").To(matchers.BeValidEmail())
    })
})

Async Matcher Patterns

var _ = Describe("Async Matchers", func() {
    It("works with Eventually", func() {
        counter := 0

        BeGreaterThanFive := gcustom.MakeMatcher(func(actual int) (bool, error) {
            return actual > 5, nil
        })

        go func() {
            for i := 0; i < 10; i++ {
                time.Sleep(100 * time.Millisecond)
                counter++
            }
        }()

        Eventually(func() int {
            return counter
        }).Should(BeGreaterThanFive())
    })

    It("works with Consistently", func() {
        isRunning := true

        BeRunning := gcustom.MakeMatcher(func(actual bool) (bool, error) {
            return actual == true, nil
        })

        go func() {
            time.Sleep(2 * time.Second)
            isRunning = false
        }()

        Consistently(func() bool {
            return isRunning
        }, "1s", "100ms").Should(BeRunning())
    })
})

Best Practices

1. Type Safety with Generics

Always use specific types in your generic parameter:

// Good: Type-safe matcher
BeEven := gcustom.MakeMatcher(func(actual int) (bool, error) {
    return actual%2 == 0, nil
})

// The type system ensures this won't compile:
// Expect("string").To(BeEven()) // Compilation error!

2. Error Handling

Return errors for unexpected conditions:

BeDivisibleBy := func(divisor int) types.GomegaMatcher {
    return gcustom.MakeMatcher(func(actual int) (bool, error) {
        if divisor == 0 {
            return false, errors.New("divisor cannot be zero")
        }
        return actual%divisor == 0, nil
    })
}

3. Informative Messages

Provide context in custom messages:

// Good: Clear, informative message
BeInRange := func(min, max int) types.GomegaMatcher {
    return gcustom.MakeMatcherWithMessage(
        func(actual int) (bool, error) {
            return actual >= min && actual <= max, nil
        },
        func(actual int, failure bool) string {
            if failure {
                return fmt.Sprintf(
                    "Expected %d to be in range [%d, %d]",
                    actual, min, max,
                )
            }
            return fmt.Sprintf(
                "Expected %d not to be in range [%d, %d]",
                actual, min, max,
            )
        },
    )
}

4. Matcher Factories

Use factory functions for parameterized matchers:

// Factory function returns matcher
func HaveLengthBetween(min, max int) types.GomegaMatcher {
    return gcustom.MakeMatcher(func(actual string) (bool, error) {
        length := len(actual)
        return length >= min && length <= max, nil
    })
}

// Usage
Expect("hello").To(HaveLengthBetween(3, 10))

5. Reusable Matcher Libraries

Create shared matcher packages for your project:

// pkg/matchers/matchers.go
package matchers

// Export common matchers
func BeValidUser() types.GomegaMatcher { ... }
func BeValidOrder() types.GomegaMatcher { ... }

// Import and use
import "myproject/pkg/matchers"

Expect(user).To(matchers.BeValidUser())

6. Composition with Built-in Matchers

Combine custom matchers with Gomega's built-in matchers:

Expect(user).To(And(
    matchers.BeValidUser(),
    HaveField("Email", BeValidEmail()),
    HaveField("Age", BeNumerically(">", 0)),
))

When to Use Custom Matchers

Use Custom Matchers When:

  1. Domain-specific validation - You need to validate complex business rules
  2. Reusable patterns - The same assertion is used in multiple tests
  3. Type safety - You want compile-time type checking for assertions
  4. Better error messages - You need more informative failure messages
  5. Complex logic - The assertion logic is non-trivial

Use Built-in Matchers When:

  1. Simple comparisons - Basic equality, nil checks, etc.
  2. Standard patterns - Collection membership, string matching, etc.
  3. Quick tests - One-off assertions that won't be reused

Comparison with Manual Implementation

Using gcustom (Recommended)

BeEven := gcustom.MakeMatcher(func(actual int) (bool, error) {
    return actual%2 == 0, nil
})

Manual implementation (More verbose)

type evenMatcher struct{}

func (m *evenMatcher) Match(actual interface{}) (bool, error) {
    num, ok := actual.(int)
    if !ok {
        return false, fmt.Errorf("expected an int, got %T", actual)
    }
    return num%2 == 0, nil
}

func (m *evenMatcher) FailureMessage(actual interface{}) string {
    return format.Message(actual, "to be even")
}

func (m *evenMatcher) NegatedFailureMessage(actual interface{}) string {
    return format.Message(actual, "not to be even")
}

func BeEven() types.GomegaMatcher {
    return &evenMatcher{}
}

The gcustom approach is much more concise and provides type safety through generics!