Last active
July 25, 2023 21:41
-
-
Save brandur/7b459a1ed81bfd041fabf05dc34265e3 to your computer and use it in GitHub Desktop.
PartialEqual implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
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
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