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.
While Gomega provides a rich set of built-in matchers, you may need custom matchers for:
The gcustom package makes it easy to create custom matchers without implementing the full GomegaMatcher interface manually.
{ .api }
func MakeMatcher[T any](match func(actual T) (bool, error)) types.GomegaMatcherCreates 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 againstParameters:
match - Function that performs the matching logic
actual - The value to match(bool, error) - success status and optional errorReturns: 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()){ .api }
func MakeMatcherWithMessage[T any](
match func(actual T) (bool, error),
message func(actual T, failure bool) string,
) types.GomegaMatcherCreates 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 againstParameters:
match - Function that performs the matching logic
actual - The value to match(bool, error) - success status and optional errormessage - Function that generates failure messages
actual - The value that was matchedfailure - true for normal failure, false for negated failurestring - The failure message to displayReturns: 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"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())
})
})// 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())
})
})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))
})
})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())
})
})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())
})
})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())
})
})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())
})
})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())
})
})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))
})
})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())
})
})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())
})
})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())
})
})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!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
})
}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,
)
},
)
}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))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())Combine custom matchers with Gomega's built-in matchers:
Expect(user).To(And(
matchers.BeValidUser(),
HaveField("Email", BeValidEmail()),
HaveField("Age", BeNumerically(">", 0)),
))BeEven := gcustom.MakeMatcher(func(actual int) (bool, error) {
return actual%2 == 0, nil
})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!