Skip to content

Commit

Permalink
new: make collection-related matchers Go 1.23 iterator aware
Browse files Browse the repository at this point in the history
- new internal helper package for dealing with Go 1.23 iterators
  via reflection; for Go versions before 1.23 this package provides
  the same helper functions as stubs instead, shielding both the
  matchers code base as well as their tests from any code that
  otherwise would not build on pre-iterator versions. This allows
  to keep new iterator-related matcher code and associated tests
  inline, hopefully ensuring good maintainability.
- with the exception of ContainElements and ConsistOf, the other
  iterator-aware matchers do not need to go through producing all
  collection elements first in order to work on a slice of these
  elements. Instead, they directly work on the collection elements
  individually as their iterator produces them.
- BeEmpty: iter.Seq, iter.Seq2 w/ tests
- HaveLen: iter.Seq, iter.Seq2 w/ tests
- HaveEach: iter.Seq, iter.Seq2 w/ tests
- ContainElement: iter.Seq, iter.Seq2 w/ tests
- HaveExactElements: iter.Seq, iter.Seq2 w/ tests
- ContainElements: iter.Seq, iter.Seq2 w/ tests
- ConsistOf: iter.Seq, iter.Seq2 w/ test
- HaveKey: iter.Seq2 only w/ test
- HaveKeyWithValue: iter.Seq2 only w/ test
- updated documentation.

Signed-off-by: thediveo <[email protected]>
  • Loading branch information
thediveo authored and onsi committed Nov 24, 2024
1 parent ece6872 commit 4c964c6
Show file tree
Hide file tree
Showing 25 changed files with 1,527 additions and 92 deletions.
59 changes: 47 additions & 12 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,8 @@ A number of community-supported matchers have appeared as well. A list is maint

These docs only go over the positive assertion case (`Should`), the negative case (`ShouldNot`) is simply the negation of the positive case. They also use the `Ω` notation, but - as mentioned above - the `Expect` notation is equivalent.

When using Go toolchain of version 1.23 or later, certain matchers as documented below become iterator-aware, handling iterator functions with `iter.Seq` and `iter.Seq2`-like signatures as collections in the same way as array/slice/map.

### Asserting Equivalence

#### Equal(expected interface{})
Expand Down Expand Up @@ -1114,15 +1116,15 @@ It is an error for either `ACTUAL` or `EXPECTED` to be invalid YAML.
Ω(ACTUAL).Should(BeEmpty())
```

succeeds if `ACTUAL` is, in fact, empty. `ACTUAL` must be of type `string`, `array`, `map`, `chan`, or `slice`. It is an error for it to have any other type.
succeeds if `ACTUAL` is, in fact, empty. `ACTUAL` must be of type `string`, `array`, `map`, `chan`, or `slice`. Starting with Go 1.23, `ACTUAL` can be also an iterator assignable to `iter.Seq` or `iter.Seq2`. It is an error for `ACTUAL` to have any other type.

#### HaveLen(count int)

```go
Ω(ACTUAL).Should(HaveLen(INT))
```

succeeds if the length of `ACTUAL` is `INT`. `ACTUAL` must be of type `string`, `array`, `map`, `chan`, or `slice`. It is an error for it to have any other type.
succeeds if the length of `ACTUAL` is `INT`. `ACTUAL` must be of type `string`, `array`, `map`, `chan`, or `slice`. Starting with Go 1.23, `ACTUAL` can be also an iterator assignable to `iter.Seq` or `iter.Seq2`. It is an error for `ACTUAL` to have any other type.

#### HaveCap(count int)

Expand All @@ -1145,7 +1147,7 @@ or
```


succeeds if `ACTUAL` contains an element that equals `ELEMENT`. `ACTUAL` must be an `array`, `slice`, or `map` -- anything else is an error. For `map`s `ContainElement` searches through the map's values (not keys!).
succeeds if `ACTUAL` contains an element that equals `ELEMENT`. `ACTUAL` must be an `array`, `slice`, or `map`. Starting with Go 1.23, `ACTUAL` can be also an iterator assignable to `iter.Seq` or `iter.Seq2`. It is an error for it to have any other type. For `map`s `ContainElement` searches through the map's values and not the keys. Similarly, for an iterator assignable to `iter.Seq2` `ContainElement` searches through the `v` elements of the produced (_, `v`) pairs.

By default `ContainElement()` uses the `Equal()` matcher under the hood to assert equality between `ACTUAL`'s elements and `ELEMENT`. You can change this, however, by passing `ContainElement` a `GomegaMatcher`. For example, to check that a slice of strings has an element that matches a substring:

Expand Down Expand Up @@ -1176,6 +1178,34 @@ var findings map[int]string
}).Should(ContainElement(ContainSubstring("foo"), &findings))
```

In case of `iter.Seq` and `iter.Seq2`-like iterators, the matching contained elements can be returned in the slice referenced by the pointer.

```go
it := func(yield func(string) bool) {
for _, element := range []string{"foo", "bar", "baz"} {
if !yield(element) {
return
}
}
}
var findings []string
Ω(it).Should(ContainElement(HasPrefix("ba"), &findings))
```

Only in case of `iter.Seq2`-like iterators, the matching contained pairs can also be returned in the map referenced by the pointer. A (k, v) pair matches when it's "v" value matches.

```go
it := func(yield func(int, string) bool) {
for key, element := range []string{"foo", "bar", "baz"} {
if !yield(key, element) {
return
}
}
}
var findings map[int]string
Ω(it).Should(ContainElement(HasPrefix("ba"), &findings))
```

#### ContainElements(element ...interface{})

```go
Expand All @@ -1197,7 +1227,7 @@ By default `ContainElements()` uses `Equal()` to match the elements, however cus
Ω([]string{"Foo", "FooBar"}).Should(ContainElements(ContainSubstring("Bar"), "Foo"))
```

Actual must be an `array`, `slice` or `map`. For maps, `ContainElements` matches against the `map`'s values.
Actual must be an `array`, `slice` or `map`. Starting with Go 1.23, `ACTUAL` can be also an iterator assignable to `iter.Seq` or `iter.Seq2`. For maps, `ContainElements` matches against the `map`'s values. Similarly, for an iterator assignable to `iter.Seq2` `ContainElements` searches through the `v` elements of the produced (_, `v`) pairs.

You typically pass variadic arguments to `ContainElements` (as in the examples above). However, if you need to pass in a slice you can provided that it
is the only element passed in to `ContainElements`:
Expand All @@ -1208,6 +1238,8 @@ is the only element passed in to `ContainElements`:

Note that Go's type system does not allow you to write this as `ContainElements([]string{"FooBar", "Foo"}...)` as `[]string` and `[]interface{}` are different types - hence the need for this special rule.

Starting with Go 1.23, you can also pass in an iterator assignable to `iter.Seq` (but not `iter.Seq2`) as the only element to `ConsistOf`.

The difference between the `ContainElements` and `ConsistOf` matchers is that the latter is more restrictive because the `ConsistOf` matcher checks additionally that the `ACTUAL` elements and the elements passed into the matcher have the same length.

#### BeElementOf(elements ...interface{})
Expand Down Expand Up @@ -1263,17 +1295,18 @@ By default `ConsistOf()` uses `Equal()` to match the elements, however custom ma
Ω([]string{"Foo", "FooBar"}).Should(ConsistOf(ContainSubstring("Foo"), ContainSubstring("Foo")))
```

Actual must be an `array`, `slice` or `map`. For maps, `ConsistOf` matches against the `map`'s values.
Actual must be an `array`, `slice` or `map`. Starting with Go 1.23, `ACTUAL` can be also an iterator assignable to `iter.Seq` or `iter.Seq2`. For maps, `ConsistOf` matches against the `map`'s values. Similarly, for an iterator assignable to `iter.Seq2` `ContainElement` searches through the `v` elements of the produced (_, `v`) pairs.

You typically pass variadic arguments to `ConsistOf` (as in the examples above). However, if you need to pass in a slice you can provided that it
is the only element passed in to `ConsistOf`:
You typically pass variadic arguments to `ConsistOf` (as in the examples above). However, if you need to pass in a slice you can provided that it is the only element passed in to `ConsistOf`:

```go
Ω([]string{"Foo", "FooBar"}).Should(ConsistOf([]string{"FooBar", "Foo"}))
```

Note that Go's type system does not allow you to write this as `ConsistOf([]string{"FooBar", "Foo"}...)` as `[]string` and `[]interface{}` are different types - hence the need for this special rule.

Starting with Go 1.23, you can also pass in an iterator assignable to `iter.Seq` (but not `iter.Seq2`) as the only element to `ConsistOf`.

#### HaveExactElements(element ...interface{})

```go
Expand All @@ -1296,7 +1329,7 @@ Expect([]string{"Foo", "FooBar"}).To(HaveExactElements("Foo", ContainSubstring("
Expect([]string{"Foo", "FooBar"}).To(HaveExactElements(ContainSubstring("Foo"), ContainSubstring("Foo")))
```

Actual must be an `array` or `slice`.
`ACTUAL` must be an `array` or `slice`. Starting with Go 1.23, `ACTUAL` can be also an iterator assignable to `iter.Seq` (but not `iter.Seq2`).

You typically pass variadic arguments to `HaveExactElements` (as in the examples above). However, if you need to pass in a slice you can provided that it
is the only element passed in to `HaveExactElements`:
Expand All @@ -1313,9 +1346,9 @@ Note that Go's type system does not allow you to write this as `HaveExactElement
Ω(ACTUAL).Should(HaveEach(ELEMENT))
```

succeeds if `ACTUAL` solely consists of elements that equal `ELEMENT`. `ACTUAL` must be an `array`, `slice`, or `map` -- anything else is an error. For `map`s `HaveEach` searches through the map's values (not keys!).
succeeds if `ACTUAL` solely consists of elements that equal `ELEMENT`. `ACTUAL` must be an `array`, `slice`, or `map`. For `map`s `HaveEach` searches through the map's values, not its keys. Starting with Go 1.23, `ACTUAL` can be also an iterator assignable to `iter.Seq` or `iter.Seq2`. For `iter.Seq2` `HaveEach` searches through the `v` part of the yielded (_, `v`) pairs.

In order to avoid ambiguity it is an error for `ACTUAL` to be an empty `array`, `slice`, or `map` (or a correctly typed `nil`) -- in these cases it cannot be decided if `HaveEach` should match, or should not match. If in your test it is acceptable for `ACTUAL` to be empty, you can use `Or(BeEmpty(), HaveEach(ELEMENT))` instead.
In order to avoid ambiguity it is an error for `ACTUAL` to be an empty `array`, `slice`, or `map` (or a correctly typed `nil`) -- in these cases it cannot be decided if `HaveEach` should match, or should not match. If in your test it is acceptable for `ACTUAL` to be empty, you can use `Or(BeEmpty(), HaveEach(ELEMENT))` instead. Similar, an iterator not yielding any elements is also considered to be an error.

By default `HaveEach()` uses the `Equal()` matcher under the hood to assert equality between `ACTUAL`'s elements and `ELEMENT`. You can change this, however, by passing `HaveEach` a `GomegaMatcher`. For example, to check that a slice of strings has an element that matches a substring:

Expand All @@ -1329,7 +1362,7 @@ By default `HaveEach()` uses the `Equal()` matcher under the hood to assert equa
Ω(ACTUAL).Should(HaveKey(KEY))
```

succeeds if `ACTUAL` is a map with a key that equals `KEY`. It is an error for `ACTUAL` to not be a `map`.
succeeds if `ACTUAL` is a map with a key that equals `KEY`. Starting with Go 1.23, `ACTUAL` can be also an iterator assignable to `iter.Seq2` and `HaveKey(KEY)` then succeeds if the iterator produces a (`KEY`, `_`) pair. It is an error for `ACTUAL` to have any other type than `map` or `iter.Seq2`.

By default `HaveKey()` uses the `Equal()` matcher under the hood to assert equality between `ACTUAL`'s keys and `KEY`. You can change this, however, by passing `HaveKey` a `GomegaMatcher`. For example, to check that a map has a key that matches a regular expression:

Expand All @@ -1343,14 +1376,16 @@ By default `HaveKey()` uses the `Equal()` matcher under the hood to assert equal
Ω(ACTUAL).Should(HaveKeyWithValue(KEY, VALUE))
```

succeeds if `ACTUAL` is a map with a key that equals `KEY` mapping to a value that equals `VALUE`. It is an error for `ACTUAL` to not be a `map`.
succeeds if `ACTUAL` is a map with a key that equals `KEY` mapping to a value that equals `VALUE`. Starting with Go 1.23, `ACTUAL` can be also an iterator assignable to `iter.Seq2` and `HaveKeyWithValue(KEY)` then succeeds if the iterator produces a (`KEY`, `VALUE`) pair. It is an error for `ACTUAL` to have any other type than `map` or `iter.Seq2`.

By default `HaveKeyWithValue()` uses the `Equal()` matcher under the hood to assert equality between `ACTUAL`'s keys and `KEY` and between the associated value and `VALUE`. You can change this, however, by passing `HaveKeyWithValue` a `GomegaMatcher` for either parameter. For example, to check that a map has a key that matches a regular expression and which is also associated with a value that passes some numerical threshold:

```go
Ω(map[string]int{"Foo": 3, "BazFoo": 4}).Should(HaveKeyWithValue(MatchRegexp(`.+Foo$`), BeNumerically(">", 3)))
```

### Working with Structs

#### HaveField(field interface{}, value interface{})

```go
Expand Down
16 changes: 15 additions & 1 deletion matchers/be_empty_matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,31 @@ package matchers

import (
"fmt"
"reflect"

"github.com/onsi/gomega/format"
"github.com/onsi/gomega/matchers/internal/miter"
)

type BeEmptyMatcher struct {
}

func (matcher *BeEmptyMatcher) Match(actual interface{}) (success bool, err error) {
// short-circuit the iterator case, as we only need to see the first
// element, if any.
if miter.IsIter(actual) {
var length int
if miter.IsSeq2(actual) {
miter.IterateKV(actual, func(k, v reflect.Value) bool { length++; return false })
} else {
miter.IterateV(actual, func(v reflect.Value) bool { length++; return false })
}
return length == 0, nil
}

length, ok := lengthOf(actual)
if !ok {
return false, fmt.Errorf("BeEmpty matcher expects a string/array/map/channel/slice. Got:\n%s", format.Object(actual, 1))
return false, fmt.Errorf("BeEmpty matcher expects a string/array/map/channel/slice/iterator. Got:\n%s", format.Object(actual, 1))
}

return length == 0, nil
Expand Down
29 changes: 29 additions & 0 deletions matchers/be_empty_matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/matchers"
"github.com/onsi/gomega/matchers/internal/miter"
)

var _ = Describe("BeEmpty", func() {
Expand Down Expand Up @@ -49,4 +50,32 @@ var _ = Describe("BeEmpty", func() {
Expect(err).Should(HaveOccurred())
})
})

Context("iterators", func() {
BeforeEach(func() {
if !miter.HasIterators() {
Skip("iterators not available")
}
})

When("passed an iterator type", func() {
It("should do the right thing", func() {
Expect(emptyIter).To(BeEmpty())
Expect(emptyIter2).To(BeEmpty())

Expect(universalIter).NotTo(BeEmpty())
Expect(universalIter2).NotTo(BeEmpty())
})
})

When("passed a correctly typed nil", func() {
It("should be true", func() {
var nilIter func(func(string) bool)
Expect(nilIter).Should(BeEmpty())

var nilIter2 func(func(int, string) bool)
Expect(nilIter2).Should(BeEmpty())
})
})
})
})
32 changes: 28 additions & 4 deletions matchers/consist_of.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"reflect"

"github.com/onsi/gomega/format"
"github.com/onsi/gomega/matchers/internal/miter"
"github.com/onsi/gomega/matchers/support/goraph/bipartitegraph"
)

Expand All @@ -17,8 +18,8 @@ type ConsistOfMatcher struct {
}

func (matcher *ConsistOfMatcher) Match(actual interface{}) (success bool, err error) {
if !isArrayOrSlice(actual) && !isMap(actual) {
return false, fmt.Errorf("ConsistOf matcher expects an array/slice/map. Got:\n%s", format.Object(actual, 1))
if !isArrayOrSlice(actual) && !isMap(actual) && !miter.IsIter(actual) {
return false, fmt.Errorf("ConsistOf matcher expects an array/slice/map/iter.Seq/iter.Seq2. Got:\n%s", format.Object(actual, 1))
}

matchers := matchers(matcher.Elements)
Expand Down Expand Up @@ -60,10 +61,21 @@ func equalMatchersToElements(matchers []interface{}) (elements []interface{}) {
}

func flatten(elems []interface{}) []interface{} {
if len(elems) != 1 || !isArrayOrSlice(elems[0]) {
if len(elems) != 1 ||
!(isArrayOrSlice(elems[0]) ||
(miter.IsIter(elems[0]) && !miter.IsSeq2(elems[0]))) {
return elems
}

if miter.IsIter(elems[0]) {
flattened := []any{}
miter.IterateV(elems[0], func(v reflect.Value) bool {
flattened = append(flattened, v.Interface())
return true
})
return flattened
}

value := reflect.ValueOf(elems[0])
flattened := make([]interface{}, value.Len())
for i := 0; i < value.Len(); i++ {
Expand Down Expand Up @@ -116,7 +128,19 @@ func presentable(elems []interface{}) interface{} {
func valuesOf(actual interface{}) []interface{} {
value := reflect.ValueOf(actual)
values := []interface{}{}
if isMap(actual) {
if miter.IsIter(actual) {
if miter.IsSeq2(actual) {
miter.IterateKV(actual, func(k, v reflect.Value) bool {
values = append(values, v.Interface())
return true
})
} else {
miter.IterateV(actual, func(v reflect.Value) bool {
values = append(values, v.Interface())
return true
})
}
} else if isMap(actual) {
keys := value.MapKeys()
for i := 0; i < value.Len(); i++ {
values = append(values, value.MapIndex(keys[i]).Interface())
Expand Down
39 changes: 39 additions & 0 deletions matchers/consist_of_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package matchers_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/matchers/internal/miter"
)

var _ = Describe("ConsistOf", func() {
Expand Down Expand Up @@ -196,4 +197,42 @@ the extra elements were
})
})
})

Context("iterators", func() {
BeforeEach(func() {
if !miter.HasIterators() {
Skip("iterators not available")
}
})

Context("with an iter.Seq", func() {
It("should do the right thing", func() {
Expect(universalIter).Should(ConsistOf("foo", "bar", "baz"))
Expect(universalIter).Should(ConsistOf("foo", "bar", "baz"))
Expect(universalIter).Should(ConsistOf("baz", "bar", "foo"))
Expect(universalIter).ShouldNot(ConsistOf("baz", "bar", "foo", "foo"))
Expect(universalIter).ShouldNot(ConsistOf("baz", "foo"))
})
})

Context("with an iter.Seq2", func() {
It("should do the right thing", func() {
Expect(universalIter2).Should(ConsistOf("foo", "bar", "baz"))
Expect(universalIter2).Should(ConsistOf("foo", "bar", "baz"))
Expect(universalIter2).Should(ConsistOf("baz", "bar", "foo"))
Expect(universalIter2).ShouldNot(ConsistOf("baz", "bar", "foo", "foo"))
Expect(universalIter2).ShouldNot(ConsistOf("baz", "foo"))
})
})

When("passed exactly one argument, and that argument is an iter.Seq", func() {
It("should match against the elements of that argument", func() {
Expect(universalIter).Should(ConsistOf(universalIter))
Expect(universalIter).ShouldNot(ConsistOf(fooElements))

Expect(universalIter2).Should(ConsistOf(universalIter))
Expect(universalIter2).ShouldNot(ConsistOf(fooElements))
})
})
})
})
Loading

0 comments on commit 4c964c6

Please sign in to comment.