Skip to content

assert.Empty regression in v1.11.0: a pointer to an interface is never empty #1955

Description

@justinhwang

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions