Skip to content
/ golden Public

Go test utility for string- or JSON-based comparisons of arbitrary nested objects.

License

Notifications You must be signed in to change notification settings

kwk/golden

Repository files navigation

golden

Go codecov Go Report Card Go Reference

golden can be used in Go tests for string- or JSON-based comparisons of arbitrary nested objects.

Generally speaking, in testing there is a concept of a "golden file" which represents the desired document against which you match the result of an operation in a test.

1. The Why

You might ask yourself why we decided to compare objects against a desired test outcome by first converting the object into text instead of using reflect.DeepEqual(). The answer is trivial: we are humans. We figured that all existing comparisons (incl. reflect.DeepEqual()) don’t allow for a flexibly ignoring mismatches between the expected and the actual result at an arbitrary level of nesting.

Unfortunately for us, there are many types of values that are hard to match or simply unnecessary to match when comparing objects. This is where golden can help.

1.1. Time-based values

A created_at or updated_at field in some HTTP response should probably just be tested for existence and that it is a valid time. The exact time itself probably isn’t that necessary.

1.2. UUID values

When UUIDs are recurring in parts of one document (e.g. "edit": "https://www.example.com/api/person/d7a282f6-1c10-459e-bb44-55a1a6d48bdd/edit" and "id": "d7a282f6-1c10-459e-bb44-55a1a6d48bdd"), we probably want to check that the UUID is correctly repeated in the right places. But the actualy value is more or less irrelevant.

2. Usage (simple case)

For example, when you have an object like this:

johnDoe := Person{
    FirstName: "John",
    LastName:  "Doe",
    Address: Address{
        Street:  "Avenue Lane",
        Number:  3,
        Country: "North Pole",
    },
}

the golden file (in JSON format) could look like this:

{
  "FirstName": "John",
  "LastName": "Doe",
  "Address": {
    "Street": "Avenue Lane",
    "Number": 3,
    "Postcode": "",
    "Country": "North Pole"
  }
}

Then, in a test you can check if the JSON representation of johnDoe is the same as the one in the file johnDoe.golden.json by calling:

golden.Compare(t, "johnDoe.golden.json", johnDoe, golden.CompareOptions{MarshalInputAsJSON: true})

If the comparison fails, Fatal() is called on the passed the testing.T object t.

When we replace "FirstName": "John" with "FirstName": "Jane" in the golden file the test output looks like this:

demo

3. An old example

In a former project we wanted to test results of calling an HTTP JSON API endpoint. Such a message could look like this:

{
    "data": {
        "attributes": {
            "createdAt": "2017-04-21T04:38:26.777609Z",
            "last_used_workspace": "my-last-used-workspace",
            "type": "git",
            "url": "https://github.com/fabric8-services/fabric8-wit.git"
        },
        "id": "d7a282f6-1c10-459e-bb44-55a1a6d48bdd",
        "links": {
        "edit": "https:///api/codebases/d7a282f6-1c10-459e-bb44-55a1a6d48bdd/edit",
        "related": "https:///api/codebases/d7a282f6-1c10-459e-bb44-55a1a6d48bdd",
        "self": "https:///api/codebases/d7a282f6-1c10-459e-bb44-55a1a6d48bdd"
        },
        "relationships": {
            "space": {
                "data": {
                "id": "a8bee527-12d2-4aff-9823-3511c1c8e6b9",
                "type": "spaces"
                },
                "links": {
                "related": "https:///api/spaces/a8bee527-12d2-4aff-9823-3511c1c8e6b9",
                "self": "https:///api/spaces/a8bee527-12d2-4aff-9823-3511c1c8e6b9"
                }
            }
        },
        "type": "codebases"
    }
}

DISCLAIMER: The above code is probably wrong JSON-API but that doesn’t matter here ;)

As you can see, we have a time value ("2017-04-21T04:38:26.777609Z") and some UUID values (d7a282f6-1c10-459e-bb44-55a1a6d48bdd and a8bee527-12d2-4aff-9823-3511c1c8e6b9) in the document.

It would be very tough and error-prone to create an object in Go that matches the expected outcome from above with all the UUIDs and times. But it is much easier to create a golden file automatically upon request. I’ll show you in another example.

4. Create or update golden file

You can create or update (overwrite) a golden file by supplying the -update flag to the go test invocation.

You can test this by doing the following:

git clone https://github.com/kwk/golden.git
cd golden/demo
rm *.golden.json
ls
# See that golden files are gone
go test ./ -update
ls
# See that golden files have been created for you again

5. Usage (ignore time-based values)

Let’s take our Person struct from before and augment it with a silly moved-in field:

func TestAddMovedInField(t *testing.T) {
	// Let's augment the Person struct by
	type PersonMovedIn struct {
		Person
		MovedIn time.Time
	}

	johnDoe := PersonMovedIn{
		Person: Person{
			FirstName: "John",
			LastName:  "Doe",
			Address: Address{
				Street:  "Avenue Lane",
				Number:  3,
				Country: "North Pole",
			},
		},
		MovedIn: time.Now(),
	}

	golden.Compare(t, "movedIn.golden.json", johnDoe, golden.CompareOptions{
		MarshalInputAsJSON: true,
		DateTimeAgnostic:   true,
	})
}

Notice that we’ve turned on the DateTimeAgnostic compare option. This will do two things.

  1. create a golden file (the expected outcome) that has the time reset to 0001-01-01T00:00:00Z:

{
  "FirstName": "John",
  "LastName": "Doe",
  "Address": {
    "Street": "Avenue Lane",
    "Number": 3,
    "Postcode": "",
    "Country": "North Pole"
  },
  "MovedIn": "0001-01-01T00:00:00Z"
}
  1. modify all time values in the JSON representation of the actual value to be 0001-01-01T00:00:00Z as well.

This has two benefits:

  1. The expected document (aka golden file) looks still okay or unchanged from an API perspective as the value type for the MovedIn field is still a time.

  2. We have a fixed value to match against in one defined format. This is especially important since the format of time.Now() marshalled to JSON depends on the timezone. For me it returns "2021-03-08T12:26:54.151242279+01:00" for example.

When golden.CompareOptions.DateTimeAgnostic is true, then golden finds all RFC3339 times and RFC7232 (section 2.2) times in the expected string and replaces them with "0001-01-01T00:00:00Z" (for RFC3339) or "Mon, 01 Jan 0001 00:00:00 GMT" (for RFC7232) respectively.

6. Usage (UUID agnostic)

Suppose you have an actual result in which multiple UUIDs repeat but are different on every test run. golden will find the UUIDs for you, and replace them with numbered UUIDish strings of increasing increment.

Take the following silly example and notice that the UUIDs for x, y, and z are distinct and different on each test invokation. Yet, they are repeated in the actual struct.

func TestSillyUUIDStruct(t *testing.T) {
	// Let's augment the Person struct by
	type UUIDGroup struct {
		A, B, C, D, E, F uuid.UUID
	}

	x := uuid.NewV4()
	y := uuid.NewV4()
	z := uuid.NewV4()

	actual := UUIDGroup{y, z, x, z, x, y}

	golden.Compare(t, "sillyUuid.golden.json", actual, golden.CompareOptions{
		MarshalInputAsJSON: true,
		UUIDAgnostic:       true,
	})
}

The golden file produced by -update for this flag looks like this:

{
  "A": "00000000-0000-0000-0000-000000000001",
  "B": "00000000-0000-0000-0000-000000000002",
  "C": "00000000-0000-0000-0000-000000000003",
  "D": "00000000-0000-0000-0000-000000000002",
  "E": "00000000-0000-0000-0000-000000000003",
  "F": "00000000-0000-0000-0000-000000000001"
}

7. FAQ

7.1. What are the requirements?

The approach of this library is agnostic to the underlying object as long as it can be converted to a string or marshalled as JSON. When dealing with JSON you have the added benefit of an output document that is nicely formatted before it’s saved to disk. This is good for manual inspection for example. Of course textual comparison isn’t the fastest to compute but having requests and responses as text sitting next to your code can add quite a significant documentation value. Also, the golden files can uncover weaknesses of your API design at plain sight.

7.2. Any shortcomings?

Unless you objects implement the Stringer interface, all of the fields in your objects that you want to compare need to be publically accessible (start with an *U*ppercase letter); otherwise the json library won’t be able to access them. In the following example, the field b is not publically accessible and will not be included in the comparison because it is not exported into the golden file:

func TestIgnoredField(t *testing.T) {
	type IgnoredField struct {
		A string
		b string
	}

	actual := IgnoredField{
		A: "Hello",
		b: "world",
	}

	golden.Compare(t, "ignoredField.golden.json", actual, golden.CompareOptions{MarshalInputAsJSON: true})
}

To overcome this, you can implement a String() string method on your struct:

type StructWithPrivateField struct {
	A string
	b string
}

func (s StructWithPrivateField) String() string {
	return fmt.Sprintf("A=%q\nb=%q", s.A, s.b)
}

func TestPrivateFieldButIncludedInString(t *testing.T) {
	actual := StructWithPrivateField{
		A: "Hello",
		b: "world",
	}

  golden.Compare(t, "structWithPrivateField.golden.json", actual, golden.CompareOptions{MarshalInputAsJSON: false})
}

golden will find the String() method and call it for you automatically.

Attribution

I wrote all of the initial code except for some the IST timezone additions by @jarifibrahim and @baijum.

What others have to say about it

About

Go test utility for string- or JSON-based comparisons of arbitrary nested objects.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages