Test Assertions in Go with gjson


When writing unit tests, a lot of the assertions get duplicated across multiple test packages. This has a weird side-effect that when api responses change over time, folks tend to forget to add the assertion for the new field. Over time, when that response key gets removed and the tests still pass, the consuming client(s) end up crashing.

Having a cleaner overview of key/value http response types in your Go tests will aid in making it easier to update and review for missing keys when api responses change. Below is an example of a typical api response that contains pagination elements and a list of models from the tags table.

Usage

expected := map[string]any{
	"data.0.id":    tag.ID,
	"data.0.name":  tag.Name,
	"status":       "ok",
	"total":        30,
	"per_page":     20,
	"current_page": 1,
	"last_page":    2,
	"order":        "desc",
	"sort":         "created_at",
}

testutils.AssertResponseBody(t, body, expected)

Using require and gjson allows us to assert a map of expected values. When a gjson.Get() happens on a string, it will map the values to a gjson.Result that we can use to base our assertions from. We can use the require.<assertion> to check against the actual expected types in this regard.

AssertResponseBody()

// AssertResponseBody asserts that the http response body, or a set of bytes 
// is equal to the expected set of items
func AssertResponseBody(t *testing.T, body []byte, expected map[string]any) {
	t.Helper()

	for key, e := range expected {
		actual := gjson.Get(string(body), key)

		// We expect that the json keys would exist in the body. 
		// This will fail when someone adds/removes a key in any response
		require.True(
			t, 
			actual.Exists(), 
			fmt.Sprintf("failed to assert that [%s] exists in response body", key)
		)

		expectedMsg := fmt.Sprintf(
			"failed to assert that [%s] is equal to expected value [%s]", 
			key, 
			e
		)

		switch actual.Type {
		case gjson.String:
			require.Equal(t, e, actual.String(), expectedMsg)
		case gjson.Number:
			require.Equal(t, e, int(actual.Int()), expectedMsg)
		case gjson.False:
		case gjson.True:
			require.Equal(t, e, actual.Bool())
		case gjson.JSON:
			switch e := e.(type) {
			case []string:
				elems := []string{"[\"", strings.Join(e, "\",\""), "\"]"}

				require.Equal(t, strings.Join(elems, ""), actual.String())
			default:
				require.Equal(t, e, actual.Raw)
			}
		case gjson.Null:
			fallthrough
		default:
			// Any other types
			require.Equal(t, e, actual.Value())
		}
	}
}

AssertResponseBody takes the following parameters:

  • t *testing.T: This takes the current Go test context and allows us to propogate any errors back to the test runner. We also set the t.Helper() on our utility function to be excluded from any coverage reports.

  • body []byte: The body byte slice is a plain reader ex: (body, err := io.ReadAll(resp.Body)) that is being set when the http response is written from inside the test. For more information see the http/httptest package in the Go standard library.

  • expected map[string]any: This map sets up a readable list of expected values with the key:string being the gjson string path and the value:any being the expected value to assert the test against.

Next we iterate over the expected map to match each actual value to it's expectated value. The actual value is found using the key on the reader bytes ex: actual := gjson.Get(string(body), key) that will return a gjson.Result. The internal type mapping will happen inside gjson but we are able to simply call actual.<Type> to fetch the mapped value.

Resources