Tech Topics

Testing data shapes with go-lookslike

I’d like to introduce you to a new open source go testing/schema validation library we’ve developed here at Elastic. It’s called Lookslike. Lookslike lets you match against the shape of your golang datastructures in a way similar to JSON Schema, but more powerful and more Go-like. It does a number of things that we couldn’t find in any existing go testing libs.

Let's jump straight into an example of its power:

// This library lets us check that a data-structure is either similar to, or exactly like a certain schema.
// For example we could test whether a pet is a dog or a cat, using the code below.

// A dog named rover
rover := map[string]interface{}{"name": "rover", "barks": "often", "fur_length": "long"}
// A cat named pounce
pounce := map[string]interface{}{"name": "pounce", "meows": "often", "fur_length": "short"}

// Here we define a validator
// Here we define a dog as an item that has:
// 1. a "name" key that is any non-empty string, using the builtin `IsNonEmptyString` definition
// 2. a "fur_length" key that is either "long" or "short" as a value
// 3. a "barks" key that has either "often", or "rarely" as a value
// 4. we further define this as a strict matcher, meaning that if any keys other than those
//    listed are present those should be considered errors
dogValidator := lookslike.Strict(lookslike.MustCompile(map[string]interface{}{
	"name":       isdef.IsNonEmptyString,
	"fur_length": isdef.IsAny(isdef.IsEqual("long"), isdef.IsEqual("short")),
	"barks": isdef.IsAny(isdef.IsEqual("often"), isdef.IsEqual("rarely")),
}))

result := dogValidator(rover)
fmt.Printf("Checked rover, validation status %t, errors: %v\n", result.Valid, result.Errors())
result = dogValidator(pounce)
fmt.Printf("Checked pounce, validation status %t, errors: %v\n", result.Valid, result.Errors())

Running the code above will print what is shown below.

Checked rover, validation status true, errors: []
Checked pounce, validation status false, errors: [@Path 'barks': expected this key to be present @Path 'meows': unexpected field encountered during strict validation]

Here we can see that "rover," the dog, matched as expected, and that "pounce" the cat did not, yielding two errors. One error was that there was no barks key defined; the other was that there was an extra key, meows, that was not anticipated.

Since Lookslike is usually used in a testing context, we have the testslike.Test helper that produces nicely formatted test output. You would just change the last lines from the example above to what's shown below.

testslike.Test(t, dogValidator, rover)
testslike.Test(t, dogValidator, pounce)

Composition

Being able to combine validators is a key concept in Lookslike. Let's say we wanted separate cat and dog validators, but we didn't want to redefine common fields like name and fur_length in each. Let's explore that in the example below.

pets := []map[string]interface{}{
	{"name": "rover", "barks": "often", "fur_length": "long"},
	{"name": "lucky", "barks": "rarely", "fur_length": "short"},
	{"name": "pounce", "meows": "often", "fur_length": "short"},
	{"name": "peanut", "meows": "rarely", "fur_length": "long"},
}

// We can see that all pets have the "fur_length" property, but that only cats meow, and dogs bark.
// We can concisely encode this in lookslike using lookslike.Compose.
// We can also see that both "meows" and "barks" contain the same enums of values.
// We'll start by creating a composed IsDef using the IsAny composition, which creates a new IsDef that is
// a logical 'or' of its IsDef arguments

isFrequency := isdef.IsAny(isdef.IsEqual("often"), isdef.IsEqual("rarely"))

petValidator := MustCompile(map[string]interface{}{
	"name":       isdef.IsNonEmptyString,
	"fur_length": isdef.IsAny(isdef.IsEqual("long"), isdef.IsEqual("short")),
})
dogValidator := Compose(
	petValidator,
	MustCompile(map[string]interface{}{"barks": isFrequency}),
)
catValidator := Compose(
	petValidator,
	MustCompile(map[string]interface{}{"meows": isFrequency}),
)

for _, pet := range pets {
	var petType string
	if dogValidator(pet).Valid {
		petType = "dog"
	} else if catValidator(pet).Valid {
		petType = "cat"
	}
	fmt.Printf("%s is a %s\n", pet["name"], petType)
}

// Output:
// rover is a dog
// lucky is a dog
// pounce is a cat
// peanut is a cat

Why we built Lookslike

Lookslike came out of the Heartbeat project here at Elastic. Heartbeat is the agent behind our Uptime solution; it pings endpoints then reports whether they're up or down. The ultimate output of Heartbeat is Elasticsearch documents, represented as map[string]interface{} types in our golang codebase. Testing these output documents was what created the need for this library, though it is now used elsewhere in the Beats codebase.

The challenges we faced were:

  • Some fields had data that needed to be matched imprecisely, such as monitor.duration which timed how long execution took. This could vary across runs. We wanted a way to loosely match data.
  • In any given test, many fields were shared with other tests, with only a few varying. We wanted to be able to reduce code duplication by composing different field definitions.
  • We wanted good test output showing individual field failures as individual errors, hence the testslike test helper.

Given these challenges we made the following design decisions:

  • We wanted the schema to be flexible, and we wanted it to be easy for developers to create new matchers easily.
  • We wanted all schemas to be composable and nestable, such that if you nested a document within another you could just combine schemas without duplicating a bunch of code.
  • We needed a good test helper to make test failures easy to read.

Key types

The archictecture of Lookslike revolves around two main types: Validator and IsDef. A Validator is the result of compiling a given schema. It's a function that takes an arbitrary datastructure and returns a result. An IsDef is the type used for matching an individual field. You may be wondering why there's a distinction between these things. Indeed, we may merge these types in the future. However, the main reason is that an IsDef gets extra arguments about its location in the document structure that let it perform advanced validations based on that context. Validator functions receive no extra context, but are more user-friendly to execute (they just take interface{} and validate that).

For examples of writing custom IsDefs, simply look at the source files. You can add new IsDefs to your own source to extend it.

Examples in the field

We use Lookslike extensively in Beats. You can find plenty of examples of its use with this github search.

We need your help!

If you're interested in Lookslike, submit a pull request on the repo! We could use a more comprehensive set of IsDefs in particular.

Learning more

We've made a strong effort to document Lookslike well. You can browse the docs for Lookslike on godoc.org.