Skip to content

Instantly share code, notes, and snippets.

@brandur
Last active July 25, 2023 21:41
Show Gist options
  • Save brandur/7b459a1ed81bfd041fabf05dc34265e3 to your computer and use it in GitHub Desktop.
Save brandur/7b459a1ed81bfd041fabf05dc34265e3 to your computer and use it in GitHub Desktop.
PartialEqual implementation
module github.com/brandur/prequire
go 1.19
require github.com/stretchr/testify v1.8.1
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
package prequire
import (
"reflect"
"github.com/stretchr/testify/require"
)
// PartialEqual takes a partially-initialized struct its expected (first
// argument) side, and compares all non-zero values on it or any substructs to
// the symmetrical values of actual (second argument). In other words, it can
// take a struct that only has values that the caller cares about, and compares
// just those, even if actual has other non-zero values.
//
// Example use:
//
// prequire.PartialEqual(t, dbsqlc.Cluster{
// Environment: sql.NullString{String: string(dbsqlc.ClusterEnvironmentProduction), Valid: true},
// Name: cluster.Name,
// }, cluster)
//
// WARNING: In Go, there's no difference between an explicit zero value versus an
// implicit one from when a field is just left out of a struct initialization,
// so there's no way for this helper to compare zero values set on the expected
// side -- they'll just be silently ignored. So watch out for use of anything
// like `false`, `nil`, `sql.NullString{}`, etc. as none of them will work.
// Recommended work around is to compare non-zero values with this assertion,
// and then assert on values expected to be zero with `require.Zero`. For API
// resources, also consider making fields pointer primitives like `*bool` or
// `*int` so that an explicit `ptrutil.Ptr(false)` or `ptrutil.Ptr(0)` can be
// checked.
func PartialEqual(t TestingT, expected, actual any) {
t.Helper()
expectedVal := reflect.ValueOf(expected)
actualVal := reflect.ValueOf(actual)
var wasPtr bool
if expectedVal.Kind() == reflect.Ptr {
if actualVal.Kind() != reflect.Ptr {
panic("expected value is a pointer; actual value should also be a pointer")
}
expectedVal = expectedVal.Elem()
actualVal = actualVal.Elem()
wasPtr = true
}
switch {
case expectedVal.Kind() != reflect.Struct:
panic("expected value must be a struct")
case !wasPtr && actualVal.Kind() == reflect.Ptr:
panic("expected value was not a pointer; actual value should also not be a pointer")
case expectedVal.Type() != actualVal.Type():
panic("expected and actual values must be the same type")
}
partialActual := buildPartialStruct(expectedVal, actualVal)
if !wasPtr {
partialActual = reflect.ValueOf(partialActual).Elem().Interface()
}
require.Equal(t, expected, partialActual, "Expected all non-zero fields on structs to be the same")
}
// Builds a partial struct, taking values from actualVal according to which
// values are non-zero in expectedVal.
func buildPartialStruct(expectedVal, actualVal reflect.Value) any {
// Creates a pointer to a new value of the given type.
partialActual := reflect.New(actualVal.Type())
for i := 0; i < expectedVal.NumField(); i++ {
expectedField := expectedVal.Field(i)
actualField := actualVal.Field(i)
if expectedField.IsZero() {
continue
}
field := partialActual.Elem().Field(i)
switch {
case expectedField.Kind() == reflect.Struct:
if !actualField.IsZero() {
s := buildPartialStruct(expectedField, actualField)
field.Set(reflect.ValueOf(s).Elem())
}
case expectedField.Kind() == reflect.Ptr && expectedField.Elem().Kind() == reflect.Struct:
if !actualField.IsNil() {
s := buildPartialStruct(expectedField.Elem(), actualField.Elem())
field.Set(reflect.ValueOf(s))
}
default:
field.Set(actualField)
}
}
return partialActual.Interface()
}
// MockT mocks TestingT (or testing.T).
//
// Copied from testify/require.
type MockT struct {
Failed bool
}
func (t *MockT) Errorf(format string, args ...interface{}) {
_, _ = format, args
}
func (t *MockT) FailNow() {
t.Failed = true
}
func (t *MockT) Helper() {}
// TestingT is an interface wrapper around *testing.T
//
// Copied from testify/require. Included so we can test failure conditions but
// if this becomes too much of a maintenance hassle, it's probably fine to just
// remove it.
type TestingT interface {
Errorf(format string, args ...interface{})
FailNow()
Helper()
}
package prequire_test
import (
"testing"
"github.com/brandur/prequire"
"github.com/stretchr/testify/require"
)
func TestPartialEqual(t *testing.T) {
type testStruct struct {
Bool bool
Int int
Pointer *string
String string
}
target := &testStruct{
Bool: false,
Int: 123,
Pointer: ptr("hello"),
String: "hello",
}
type testContainer struct {
Int int
Pointer *testStruct
Value testStruct
}
targetContainer := &testContainer{
Int: 123,
Pointer: target,
Value: *target,
}
// Here to show a hidden danger of using this assertion -- if a zero value
// is accidentally used in the expected struct (left side) then it's not
// considered for comparison, even if it was intended to be. Moreover, we
// can't detect the problem because even with reflection there's no way to
// differentiate a zero value that was set explicitly versus one that
// occurred implicitly by leaving it out.
//
// This will be dangerous for things like boolean `false`, but also `nil`
// for pointers and `0` for int, and something to watch out for.
t.Run("AccidentalNoOp", func(t *testing.T) {
prequire.PartialEqual(t,
&testStruct{Bool: false},
&testStruct{Bool: true},
)
})
t.Run("SingleField", func(t *testing.T) {
// Bool
prequire.PartialEqual(t, &testStruct{Bool: false}, target)
expectFailure(t, func(t prequire.TestingT) {
prequire.PartialEqual(t, &testStruct{Bool: true}, target)
})
// Int
prequire.PartialEqual(t, &testStruct{Int: 123}, target)
expectFailure(t, func(t prequire.TestingT) {
prequire.PartialEqual(t, &testStruct{Int: 122}, target)
})
// Pointer
prequire.PartialEqual(t, &testStruct{Pointer: ptr("hello")}, target)
expectFailure(t, func(t prequire.TestingT) {
prequire.PartialEqual(t, &testStruct{Pointer: ptr("goodbye")}, target)
})
// String
prequire.PartialEqual(t, &testStruct{String: "hello"}, target)
expectFailure(t, func(t prequire.TestingT) {
prequire.PartialEqual(t, &testStruct{String: "goodbye"}, target)
})
})
t.Run("NonPointerStruct", func(t *testing.T) {
prequire.PartialEqual(t, testStruct{String: "hello"}, *target)
expectFailure(t, func(t prequire.TestingT) {
prequire.PartialEqual(t, testStruct{String: "goodbye"}, *target)
})
})
t.Run("MultipleFields", func(t *testing.T) {
prequire.PartialEqual(t, &testStruct{Int: 123, String: "hello"}, target)
expectFailure(t, func(t prequire.TestingT) {
prequire.PartialEqual(t, &testStruct{Int: 123, String: "goodbye"}, target)
})
})
t.Run("Substruct", func(t *testing.T) {
// Differing inner field
prequire.PartialEqual(t, &testContainer{Int: 123, Pointer: &testStruct{String: "hello"}}, targetContainer)
expectFailure(t, func(t prequire.TestingT) {
prequire.PartialEqual(t, &testContainer{Int: 123, Pointer: &testStruct{String: "goodbye"}}, targetContainer)
})
// Differing outer field
prequire.PartialEqual(t, &testContainer{Int: 123, Pointer: &testStruct{String: "hello"}}, targetContainer)
expectFailure(t, func(t prequire.TestingT) {
prequire.PartialEqual(t, &testContainer{Int: 124, Pointer: &testStruct{String: "hello"}}, targetContainer)
})
// Struct value instead of pointer
prequire.PartialEqual(t, &testContainer{Int: 123, Value: testStruct{String: "hello"}}, targetContainer)
expectFailure(t, func(t prequire.TestingT) {
prequire.PartialEqual(t, &testContainer{Int: 123, Value: testStruct{String: "goodbye"}}, targetContainer)
})
// Nil and zero substructs
prequire.PartialEqual(t, &testContainer{Int: 123}, targetContainer)
expectFailure(t, func(t prequire.TestingT) {
prequire.PartialEqual(t, &testContainer{Int: 123, Pointer: &testStruct{String: "goodbye"}}, &testContainer{Int: 123})
})
})
t.Run("MisusePanics", func(t *testing.T) {
require.PanicsWithValue(t, "expected value is a pointer; actual value should also be a pointer", func() {
prequire.PartialEqual(t,
&testStruct{String: "hello"},
testStruct{String: "hello"},
)
})
require.PanicsWithValue(t, "expected value must be a struct", func() {
prequire.PartialEqual(t, "hello", "hello")
})
require.PanicsWithValue(t, "expected value was not a pointer; actual value should also not be a pointer", func() {
prequire.PartialEqual(t,
testStruct{String: "hello"},
&testStruct{String: "hello"},
)
})
require.PanicsWithValue(t, "expected and actual values must be the same type", func() {
prequire.PartialEqual(t,
&testStruct{Int: 123},
&testContainer{Int: 123},
)
})
})
}
func expectFailure(t *testing.T, f func(t prequire.TestingT)) {
t.Helper()
mockT := &prequire.MockT{}
f(mockT)
require.True(t, mockT.Failed, "Expected MockT to have failed, but it didn't")
}
// ptr returns a pointer to the given value.
func ptr[T any](v T) *T {
return &v
}
@StevenACoffman
Copy link

For comparison, I implemented your article's theoretical go-cmp version https://gist.github.com/StevenACoffman/74347e58e5e0dc4bdf0a79240557c406

I would be interested in trying it with Gomega's gstruct or https://github.com/corbym/gocrest/blob/master/has/hasstructvalues.go

@brandur
Copy link
Author

brandur commented Feb 8, 2023

Thanks Steven!

For comparison, I implemented your article's theoretical go-cmp version

Nice. OOC, which one do you think works better?

I would be interested in trying it with Gomega's gstruct or https://github.com/corbym/gocrest/blob/master/has/hasstructvalues.go

Having worked with RSpec for many years, I like DSLs but am also somewhat wary of them. Despite a lot of experience with RSpec I still finding myself having to look up matcher names and the like all the time because there's so many of them and not much easy discovery in Ruby. These look interesting though. Maybe Go is a better fit because things like IDE completion are so much better.

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