Description
isEmpty was rewritten in #1761 (assert: faster and simpler isEmpty using reflect.Value.IsZero, released in v1.11.0). The rewrite changed behavior for one shape that the old implementation handled: a pointer to an interface.
Since v1.11.0, isEmpty returns false for any non-nil pointer to a non-nil interface, regardless of what the interface holds. So Empty can never pass for a *SomeInterface, and NotEmpty always passes for one, even when the pointee is unambiguously empty.
Root cause, comparing the two implementations:
v1.10.0 (assert/assertions.go:717):
case reflect.Ptr:
if objValue.IsNil() {
return true
}
deref := objValue.Elem().Interface() // re-boxes through interface{}
return isEmpty(deref)
v1.12.1 (assert/assertions.go:761):
func isEmptyValue(objValue reflect.Value) bool {
if objValue.IsZero() {
return true
}
switch objValue.Kind() {
case reflect.Chan, reflect.Map, reflect.Slice:
return objValue.Len() == 0
case reflect.Ptr:
return isEmptyValue(objValue.Elem())
}
return false
}
The old code's objValue.Elem().Interface() round-trips through interface{}, and reflect.ValueOf on the result collapses the interface layer, so recursion continued into the concrete pointee. The new code walks reflect.Value directly, so objValue.Elem() on a *I yields a value with Kind() == reflect.Interface. That kind matches no case in the switch, so it falls through to return false.
This looks unintentional: #1761 was framed as a performance and simplification change, and the extensive Empty-ness cases added just before it in #1753 do not cover a pointer to an interface.
Step To Reproduce
Self-contained, no dependencies beyond testify:
package repro
import (
"testing"
"github.com/stretchr/testify/assert"
)
type Shape interface{ Area() int }
type Square struct{ Side int }
func (s *Square) Area() int { return s.Side * s.Side }
func TestEmpty_PointerToInterface(t *testing.T) {
var s Shape = &Square{}
// Unwrapped forms are Empty in every version.
assert.Empty(t, Square{}) // ok
assert.Empty(t, &Square{}) // ok
assert.Empty(t, s) // ok
// v1.10.0: passes. v1.11.0+: fails.
assert.Empty(t, &s)
}
func TestNotEmpty_PointerToInterface(t *testing.T) {
var empty any = ""
// v1.10.0: fails, correctly. v1.11.0+: passes.
assert.NotEmpty(t, &empty)
}
Under v1.11.0+ the first test fails and the second passes; under v1.10.0 it is exactly the reverse. The same holds for *any holding []int{} or 0.
Expected behavior
Empty should look through the interface layer, the way it looks through a pointer, so that Empty(&s) agrees with Empty(s). Concretely, isEmptyValue should recurse on reflect.Interface the same way it recurses on reflect.Ptr:
case reflect.Ptr, reflect.Interface:
return isEmptyValue(objValue.Elem())
That restores v1.10.0 behavior for this shape and keeps the IsZero fast path. (Elem() on a nil interface is the zero Value, but that case is already caught by the objValue.IsZero() check above.)
I applied that one-line change to a local v1.12.1 checkout: the repro above then behaves identically to v1.10.0, and go test ./... on testify itself is unchanged (only TestDirExists / TestNoDirExists fail, and they fail the same way on an unpatched v1.12.1 in my environment, so they are unrelated). Happy to send it as a PR with test cases if you agree with the direction.
Alternatively, if the new behavior is intended, it is worth calling out in the Empty doc comment, which currently says only that "pointer values are 'empty' if the pointer is nil or if the pointed value is 'empty'". That sentence reads as though Empty(&s) == Empty(s).
Actual behavior
Error: Should be empty, but was 0xc000010030
Test: TestEmpty_PointerToInterface
and NotEmpty on a pointer to an interface holding "" passes.
Version
Reproduced on v1.11.0, v1.11.1 and v1.12.1. Last good version is v1.10.0. Go 1.26.3, linux/amd64.
Description
isEmptywas rewritten in #1761 (assert: faster and simpler isEmpty using reflect.Value.IsZero, released in v1.11.0). The rewrite changed behavior for one shape that the old implementation handled: a pointer to an interface.Since v1.11.0,
isEmptyreturnsfalsefor any non-nil pointer to a non-nil interface, regardless of what the interface holds. SoEmptycan never pass for a*SomeInterface, andNotEmptyalways passes for one, even when the pointee is unambiguously empty.Root cause, comparing the two implementations:
v1.10.0 (
assert/assertions.go:717):v1.12.1 (
assert/assertions.go:761):The old code's
objValue.Elem().Interface()round-trips throughinterface{}, andreflect.ValueOfon the result collapses the interface layer, so recursion continued into the concrete pointee. The new code walksreflect.Valuedirectly, soobjValue.Elem()on a*Iyields a value withKind() == reflect.Interface. That kind matches no case in the switch, so it falls through toreturn false.This looks unintentional: #1761 was framed as a performance and simplification change, and the extensive
Empty-ness cases added just before it in #1753 do not cover a pointer to an interface.Step To Reproduce
Self-contained, no dependencies beyond testify:
Under v1.11.0+ the first test fails and the second passes; under v1.10.0 it is exactly the reverse. The same holds for
*anyholding[]int{}or0.Expected behavior
Emptyshould look through the interface layer, the way it looks through a pointer, so thatEmpty(&s)agrees withEmpty(s). Concretely,isEmptyValueshould recurse onreflect.Interfacethe same way it recurses onreflect.Ptr:That restores v1.10.0 behavior for this shape and keeps the
IsZerofast path. (Elem()on a nil interface is the zeroValue, but that case is already caught by theobjValue.IsZero()check above.)I applied that one-line change to a local v1.12.1 checkout: the repro above then behaves identically to v1.10.0, and
go test ./...on testify itself is unchanged (onlyTestDirExists/TestNoDirExistsfail, and they fail the same way on an unpatched v1.12.1 in my environment, so they are unrelated). Happy to send it as a PR with test cases if you agree with the direction.Alternatively, if the new behavior is intended, it is worth calling out in the
Emptydoc comment, which currently says only that "pointer values are 'empty' if the pointer is nil or if the pointed value is 'empty'". That sentence reads as thoughEmpty(&s) == Empty(s).Actual behavior
and
NotEmptyon a pointer to an interface holding""passes.Version
Reproduced on v1.11.0, v1.11.1 and v1.12.1. Last good version is v1.10.0. Go 1.26.3, linux/amd64.