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 IsDef
s 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 IsDef
s in particular.
Learning more
We've made a strong effort to document Lookslike well. You can browse the docs for Lookslike on godoc.org.