Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/doc-site/api/equality.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ func main() {
|--|--|
| [`assertions.Empty(t T, object any, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Empty) | internal implementation |

**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Empty](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L74)
**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Empty](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L73)
{{% /tab %}}
{{< /tabs >}}

Expand Down Expand Up @@ -891,7 +891,7 @@ func main() {
|--|--|
| [`assertions.Nil(t T, object any, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Nil) | internal implementation |

**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Nil](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L21)
**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Nil](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L20)
{{% /tab %}}
{{< /tabs >}}

Expand Down Expand Up @@ -1004,7 +1004,7 @@ func main() {
|--|--|
| [`assertions.NotEmpty(t T, object any, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NotEmpty) | internal implementation |

**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotEmpty](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L100)
**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotEmpty](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L99)
{{% /tab %}}
{{< /tabs >}}

Expand Down Expand Up @@ -1455,7 +1455,7 @@ func main() {
|--|--|
| [`assertions.NotNil(t T, object any, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NotNil) | internal implementation |

**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotNil](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L43)
**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotNil](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L42)
{{% /tab %}}
{{< /tabs >}}

Expand Down
33 changes: 24 additions & 9 deletions docs/doc-site/usage/TRACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ We continue to monitor and selectively adopt changes from the upstream repositor
- ✅ [#1840] - JSON/YAML `Redactor` pattern (dynamic input redaction, inspired by Insta)
- ✅ [#1859] - Channel assertions (`Blocked` / `NotBlocked`)
- ✅ [#1860] - `ErrorAsType` / `NotErrorAsType` (go1.26+, adapted with a typed `*E` target)
- ✅ [#1940] - `ErrorNotContains` (the opposite of `ErrorContains`)
- ✅ [#1940], [#1942] - `ErrorNotContains` (the opposite of `ErrorContains`)
- ✅ [#1908] - `Contains` with a rune or byte element
- ✅ [#1874], [#1875] - quoted string values in `Empty` / `NotEmpty` failures
- ✅ [#1931], [#1899] - `InEpsilonSlice` keeps the caller's message
- ✅ [#1898] - `InDeltaMapValues` names the failing key

### Superseded by Our Implementation
- ✅ [#1801] - Error message on large collections for `Len`
Expand All @@ -34,7 +38,7 @@ We continue to monitor and selectively adopt changes from the upstream repositor
[#1830]: https://github.com/stretchr/testify/pull/1830
[#1824]: https://github.com/stretchr/testify/pull/1824

**Review frequency**: Quarterly (next review: May 2026)
**Review frequency**: Quarterly (last review: August 2026, next review: November 2026)

---
[#1223]: https://github.com/stretchr/testify/pull/1223
Expand Down Expand Up @@ -87,7 +91,11 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif
| [#1859] | Issue | Channel assertions | ✅ Adapted |
| [#1860] | Issue (PR [#1861]) | `ErrorAsType[E]` for Go 1.26+ | ✅ Adapted - implemented as `ErrorAsType` / `NotErrorAsType` with a typed `*E` target and a `bool` return (not the upstream `(E, bool)` shape), guarded by `//go:build go1.26`. First user of the codegen go-version guard. |
| [#1915] | Issue | Stack overflow on recursive walk | ✅ Fixed (detected and fixed independently) |
| [#1940] | Issue/PR | `ErrorNotContains` assertion | ✅ Adapted - implemented as `ErrorNotContains` in the error domain, the opposite of `ErrorContains`: a nil error fails, and so does an error whose message contains the substring. |
| [#1874], [#1875] | PR | Quote string values in empty assertion failures | ✅ Adapted - `Empty`/`NotEmpty` render a string value with `%q`, so whitespace and the empty string stay visible. `NotEmpty` also gained the truncation every other failure message has. |
| [#1898] | PR | Include the map key in `InDeltaMapValues` error message | ✅ Adapted - the per-key call carries `at key <k>`, alongside the caller's own message. |
| [#1899], [#1931] | PR | Pass the custom message through `InEpsilonSlice` | ✅ Adapted - the index context no longer replaces the caller's message; both are joined. |
| [#1908] | PR | `Contains` is not rune-safe on Unicode strings | ✅ Adapted - `Contains(t, "héllo", 'é')` compared against the literal `"<int32 Value>"`. Dispatch is now on the element kind (string, rune, byte, defined string type). The invalid-UTF-8 half of the upstream report is deliberately left as byte semantics. |
| [#1940], [#1942] | Issue/PR | `ErrorNotContains` assertion | ✅ Adapted - implemented as `ErrorNotContains` in the error domain, the opposite of `ErrorContains`: a nil error fails, and so does an error whose message contains the substring. |

[#994]: https://github.com/stretchr/testify/pull/994
[#1232]: https://github.com/stretchr/testify/pull/1232
Expand All @@ -100,15 +108,22 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif
[#1797]: https://github.com/stretchr/testify/pull/1797
[#1816]: https://github.com/stretchr/testify/issues/1816
[#1826]: https://github.com/stretchr/testify/issues/1826
[#1829]: https://github.com/stretchr/testify/issues/1829
[#1087]: https://github.com/stretchr/testify/issues/1087
[#1606]: https://github.com/stretchr/testify/pull/1606
[#1839]: https://github.com/stretchr/testify/pull/1839
[#1840]: https://github.com/stretchr/testify/issues/1840
[#1848]: https://github.com/stretchr/testify/pull/1848
[#1859]: https://github.com/stretchr/testify/pull/1859
[#1915]: https://github.com/stretchr/testify/issues/1915
[#1874]: https://github.com/stretchr/testify/pull/1874
[#1875]: https://github.com/stretchr/testify/pull/1875
[#1898]: https://github.com/stretchr/testify/pull/1898
[#1899]: https://github.com/stretchr/testify/pull/1899
[#1908]: https://github.com/stretchr/testify/pull/1908
[#1931]: https://github.com/stretchr/testify/pull/1931
[#1937]: https://github.com/stretchr/testify/pull/1937
[#1940]: https://github.com/stretchr/testify/pull/1940
[#1942]: https://github.com/stretchr/testify/pull/1942

### Superseded by Our Implementation

Expand Down Expand Up @@ -136,16 +151,16 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif
| [#1147] | Issue | General discussion about generics adoption | ℹ️ Marked "Not Planned" upstream - We implemented our own generics approach ({{% siteparam "metrics.generics" %}} functions) |
| [#1308] | PR | Comprehensive refactor with generic type parameters | ℹ️ Draft for v2.0.0 upstream - We took a different approach with the same objective |
| [#1591], [#1601] | PR + Issue | `NoFieldIsZero` recursive zero-value assertion | ⛔ **Won't do** - Considered and prototyped (2026-04-26). Same conclusion as upstream maintainers: semantics is too ambiguous (map keys, []byte, pointer targets, unexported fields, cycles, time.Time-style smart-zero types) and overlaps too heavily with [Equal](...) for legitimate use cases. Each pitfall fix adds a knob; full version is a struct validator, not an assertion. |
| [#1776] | Issue | `require` doc-comment examples call functions that return no value | ℹ️ Same defect in our generated API pages, found and fixed independently: the signature tables listed a `bool` return for every `require` row. Nothing to adopt. |
| [#1862] | Issue | `CollectT` redesign / `testing.TB` interop | ⛔ **Won't do (for now)** - Studied in depth (2026-04-17). All four design options (interface widening, embedding `*testing.T`, opt-in `CollectTB` wrapper, `CollectT`-as-interface) carry visible costs; Go's `testing.TB.private()` blocks any clean proxy. Workaround for affected users is a 3-line per-helper adapter. Revisit if traction warrants the breaking churn. |

[#1147]: https://github.com/stretchr/testify/issues/1147
[#1776]: https://github.com/stretchr/testify/issues/1776
[#1308]: https://github.com/stretchr/testify/pull/1308
[#1591]: https://github.com/stretchr/testify/pull/1591
[#1576]: https://github.com/stretchr/testify/pull/1576
[#1801]: https://github.com/stretchr/testify/pull/1801
[#1819]: https://github.com/stretchr/testify/pull/1819
[#1845]: https://github.com/stretchr/testify/pull/1845
[#1859]: https://github.com/stretchr/testify/pull/1859
[#1860]: https://github.com/stretchr/testify/pull/1860
[#1861]: https://github.com/stretchr/testify/pull/1861
[#1862]: https://github.com/stretchr/testify/pull/1862
Expand All @@ -156,11 +171,11 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif

| Category | Count |
|----------|-------|
| **Implemented/Merged** | 30 |
| **Implemented/Merged** | 34 |
| **Superseded** | 6 |
| **Monitoring** | 2 |
| **Informational** | 4 |
| **Total Processed** | 42 |
| **Informational** | 5 |
| **Total Processed** | 47 |

**Note**: This fork maintains an active relationship with upstream, regularly reviewing new PRs and issues. The quarterly review process ensures we stay informed about upstream developments while maintaining our architectural independence.

Expand Down
29 changes: 27 additions & 2 deletions internal/assertions/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,32 @@ func isNotSubsetList(t T, list, subset any, subsetList reflect.Value, msgAndArgs
// return (false, false) if impossible.
// return (true, false) if element was not found.
// return (true, true) if element was found.
// stringContainsElement reports whether str contains element, where element is either a
// substring or a single character.
//
// A character reaches us as a rune or a byte, and [reflect.Value.String] renders those as
// "<int32 Value>" and "<uint8 Value>" rather than as the character, so a plain
// strings.Contains on that rendering never matches: Contains(t, "héllo", 'é') has to be
// dispatched on the element's kind instead.
func stringContainsElement(str string, element any) bool {
switch e := element.(type) {
case string:
return strings.Contains(str, e)
case rune: // int32
return strings.ContainsRune(str, e)
case byte: // uint8
return strings.IndexByte(str, e) >= 0
}

// a named string type, e.g. type Doc string
if elementValue := reflect.ValueOf(element); elementValue.Kind() == reflect.String {
return strings.Contains(str, elementValue.String())
}

// anything else cannot occur in a string
return false
}

func containsElement(list any, element any) (ok, found bool) {
listValue := reflect.ValueOf(list)
listType := reflect.TypeOf(list)
Expand All @@ -852,8 +878,7 @@ func containsElement(list any, element any) (ok, found bool) {
}()

if listKind == reflect.String {
elementValue := reflect.ValueOf(element)
return true, strings.Contains(listValue.String(), elementValue.String())
return true, stringContainsElement(listValue.String(), element)
}

if listKind == reflect.Map {
Expand Down
11 changes: 11 additions & 0 deletions internal/assertions/collection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,9 @@ const (
testStringFoo = "Foo"
)

// namedString exercises the reflect fallback for a string element of a defined type.
type namedString string

func unifiedContainsCases() iter.Seq[containsTestCase] {
list := []string{testStringFoo, testStringBar}
complexList := []*containsStruct{
Expand All @@ -483,6 +486,14 @@ func unifiedContainsCases() iter.Seq[containsTestCase] {
// String contains
{"string/contains", func() (any, any) { return "Hello World", "Hello" }, crContains, false},
{"string/not-contains", func() (any, any) { return "Hello World", "Salut" }, crNotContains, false},
// A single character reaches Contains as a rune or a byte, never as a string.
// Reflection-only: the generic variants constrain the element to Text.
{"string/contains-rune", func() (any, any) { return "héllo", 'é' }, crContains, true},
{"string/not-contains-rune", func() (any, any) { return "héllo", 'z' }, crNotContains, true},
{"string/contains-byte", func() (any, any) { return "Hello World", byte('W') }, crContains, true},
{"string/not-contains-byte", func() (any, any) { return "Hello World", byte('z') }, crNotContains, true},
{"string/contains-named-string", func() (any, any) { return "Hello World", namedString("World") }, crContains, true},
{"string/not-contains-other-kind", func() (any, any) { return "Hello World", 42 }, crNotContains, true},

// Slice contains
{"slice-string/contains", func() (any, any) { return list, testStringBar }, crContains, false},
Expand Down
5 changes: 2 additions & 3 deletions internal/assertions/equal_unary.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package assertions

import (
"fmt"
"reflect"
)

Expand Down Expand Up @@ -79,7 +78,7 @@ func Empty(t T, object any, msgAndArgs ...any) bool {
if h, ok := t.(H); ok {
h.Helper()
}
Fail(t, "Should be empty, but was "+truncatingFormat("%v", object), msgAndArgs...)
Fail(t, "Should be empty, but was "+truncatingValue(object), msgAndArgs...)
}

return pass
Expand All @@ -104,7 +103,7 @@ func NotEmpty(t T, object any, msgAndArgs ...any) bool {
if h, ok := t.(H); ok {
h.Helper()
}
Fail(t, fmt.Sprintf("Should NOT be empty, but was %v", object), msgAndArgs...)
Fail(t, "Should NOT be empty, but was "+truncatingValue(object), msgAndArgs...)
}

return pass
Expand Down
15 changes: 13 additions & 2 deletions internal/assertions/equal_unary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,18 @@ func equalUnaryFailCases() iter.Seq[failCase] {
{
name: "Empty/non-empty-string",
assertion: func(t T) bool { return Empty(t, "something") },
wantError: "Should be empty, but was something",
wantError: `Should be empty, but was "something"`,
},
{
// without the quotes these two report a message that looks like it lost its value
name: "Empty/whitespace-string",
assertion: func(t T) bool { return Empty(t, " ") },
wantError: `Should be empty, but was " "`,
},
{
name: "NotEmpty/empty-string",
assertion: func(t T) bool { return NotEmpty(t, "") },
wantError: `Should NOT be empty, but was ""`,
},
{
name: "Empty/non-nil-error",
Expand Down Expand Up @@ -206,7 +217,7 @@ func equalUnaryFailCases() iter.Seq[failCase] {
{
name: "Empty/aliased-string",
assertion: func(t T) bool { return Empty(t, TString("abc")) },
wantError: "Should be empty, but was abc",
wantError: `Should be empty, but was "abc"`,
},
{
name: "Empty/ptr-to-non-nil",
Expand Down
29 changes: 29 additions & 0 deletions internal/assertions/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bufio"
"bytes"
"fmt"
"reflect"
"strings"
)

Expand All @@ -29,6 +30,34 @@ func truncatingFormat(format string, data any) string {
return value
}

// truncatingValue formats a value for a failure message, quoting it when it is a string.
//
// Without the quotes a string made of whitespace, or an empty one, leaves nothing on screen:
// "Should be empty, but was" followed by two spaces reads as a message with its value missing.
func truncatingValue(data any) string {
if reflect.ValueOf(data).Kind() == reflect.String {
return truncatingFormat("%q", data)
}

return truncatingFormat("%v", data)
}

// withContext prepends positional context to the caller's message, for an assertion that
// delegates to another one element by element.
//
// InEpsilonSlice checks each element with InEpsilon, and the failure comes from InEpsilon,
// which knows the two values but not which element they came from. Passing the context
// through msgAndArgs keeps the index in the message without dropping what the caller asked
// to see.
func withContext(context string, msgAndArgs []any) []any {
caller := messageFromMsgAndArgs(msgAndArgs...)
if caller == "" {
return []any{context}
}

return []any{context + ": " + caller}
}

// Aligns the provided message so that all lines after the first line start at the same location as the first line.
//
// Assumes that the first line starts at the correct location (after carriage return, tab, label, spacer and tab).
Expand Down
12 changes: 9 additions & 3 deletions internal/assertions/number.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ func InDeltaSlice(t T, expected, actual any, delta float64, msgAndArgs ...any) b
}

for i := range lenActual {
result := InDelta(t, actualSlice.Index(i).Interface(), expectedSlice.Index(i).Interface(), delta, msgAndArgs...)
result := InDelta(t, expectedSlice.Index(i).Interface(), actualSlice.Index(i).Interface(), delta, msgAndArgs...)
if !result {
return result
}
Expand Down Expand Up @@ -409,7 +409,7 @@ func InDeltaMapValues(t T, expected, actual any, delta float64, msgAndArgs ...an
ev.Interface(),
av.Interface(),
delta,
msgAndArgs...,
withContext(fmt.Sprintf("at key %v", k), msgAndArgs)...,
) {
return false
}
Expand Down Expand Up @@ -453,7 +453,13 @@ func InEpsilonSlice(t T, expected, actual any, epsilon float64, msgAndArgs ...an
}

for i := range expectedLen {
if !InEpsilon(t, expectedSlice.Index(i).Interface(), actualSlice.Index(i).Interface(), epsilon, "at index %d", i) {
if !InEpsilon(
t,
expectedSlice.Index(i).Interface(),
actualSlice.Index(i).Interface(),
epsilon,
withContext(fmt.Sprintf("at index %d", i), msgAndArgs)...,
) {
return false
}
}
Expand Down
Loading
Loading