Randomized Testing in JavaScript Without Lifting a Finger

Gabe Scholz
10 min readMar 13, 2017

--

tl;dr babel-plugin-transform-flow-to-gen is a Babel transform for using Flow annotations to generate randomized test inputs.

This post is about getting more value out of your JavaScript testing suite. It’s a post about having a sense of confidence that your tests will not only guard against regression bugs, but find new ones. It’s a post about testing properties of a program rather than checking for undefined is not a function.

Here, we cover the concept of randomized testing, why you should care about it, and how to introduce it into a JavaScript codebase using a Babel transform developed at Unbounce.

What is randomized testing?

Randomized testing (a subset of property-based testing) is a concept popularized by a library called QuickCheck. If you haven’t heard of it, I invite you to take a moment to familiarize yourself. Originally from Haskell and ported to many different languages, QuickCheck uses function input type annotations to generate randomized arguments. The developer defines a property that should hold for all inputs of a given type. QuickCheck will make hundreds of passes against the property in a single test run to ensure that it holds for every case.

You might say, “Well, I use Math.random to generate random inputs for my tests. Look at all this random,” which is missing the point because the “random” in randomized testing is a little more deliberate than that. Even if I know the type of input a function expects, there are different kinds of ways that I can test for that type. These are usually referred to as edge cases. You will not see much value out of generating random numbers if you don’t try different kinds of numbers: 1, 0, -1, 2, 3, 500.

As an example, let’s say you have a function which takes a array of strings as its only argument. QuickCheck would attempt to generate test input for a wide range of cases. Not only that, it will go out of its way to try many edge cases. The first thing it would try is an empty array. And then an array with one string. And then an array with one empty string. And then an array with two strings. And then… It deliberately tries to break your test hundreds of times in a single test run.

A traditional approach to unit testing can only be counted on to verify that specific inputs result in specific outputs. It does not make any guarantees about all inputs and certainly does not say much about the property which the test is attempting to verify.

More impressive, however, QuickCheck also implements a feature called “shrinking”. If it generates inputs that cause the test to fail, it will then attempt to reduce the input to a minimum failing case so that you can more easily fix your code. Yes, that’s right. Even after a failure it will continue to run tests to see whether it can produce a smaller, simpler failing input.

If you’re interested in learning more on randomized and property-based testing, this talk by Jessica Kerr is a good place to start.

Randomized testing in JavaScript

When surveying the landscape of randomized testing libraries we settled on TestCheck.js because it implements shrinking. In short, TestCheck is similar to QuickCheck but for JavaScript. The following is an example of how TestCheck might expose bad code.

This is going to fail!

As this fails when either a or b is a negative integer, testcheck.check might return something like the following.

Smallest failing case was [0, -1]

It’s a really awesome tool. There is one problem with TestCheck though: the developer needs to manually create and maintain input generators. Given a complex enough domain model, this is a huge pain and prone to error. Here’s what a “Person” generator on an e-commerce application might look like.

You can imagine how much this sucks to try and maintain

So unlike Haskell, where wiring can be generated automatically from type annotations, JavaScript is a dynamic language and wiring must be manually maintained as part of your test suite. This is where Flow comes in!

Gradual type checking in JavaScript with Flow

Introducing Flow into a JavaScript project allows developers to gradually add type information. Flow is a pre-runtime check that completes a static analysis on your code. With types throughout your application, we can be sure that a function that requires an array of strings is only ever called with an array of strings. Flow will raise an exception in the following case.

Flow is not pleased.

One of my favorite things about statically-typed programming languages is that I can stop caring about the input and output types of my functions: static analysis has got my back. Compilation will fail if I’ve made a mistake. My test code is cleaner too. I can devote everything to testing behavior and not type checks.

So much of JavaScript unit testing is ineffective against preventing bugs. “Did you make sure to test that this function always returns an array?”, the code reviewer will say. Rather than testing against a specification, we tend to focus on testing the things that a type checker would give us for free.

Most of that “kind” of problem tends to fade away with comprehensive type checking a la Flow. With end-to-end type coverage, you can be reasonably certain that anything you would unit test is always going to return the correct type of outputs as long as they receive the correct type of inputs. In this way we can do away with checking for types or presence. Runtime schema checks at the boundaries of the program are good enough to further lock down those guarantees.

Of course, Flow is only a pre-runtime check and the developer should still write tests. However, given sophisticated enough input, unit testing can be ineffective for testing against edge cases. Instead developers will usually add a few regression tests to verify that their code won’t totally break on modification and leave it at that.

And now the snake oil

As a team we had been enjoying the benefits of very strict type coverage with Flow. We were also very confident in our randomized testing suite. However, we felt a significant amount of pain trying to keep our TestCheck generators up to date with type annotations. We began to question whether we might just create generators from the types themselves. And so our child was born.

flow-to-gen for Babel

babel-plugin-transform-flow-to-gen is a Babel transform that turns your Flow type annotations into TestCheck generators. After all, Flow can only conclude that your input types are correct. It cannot conclude that the program will run correctly.

We begin our treatise with an overview of how to integrate flow-to-gen with a test suite, then explain how it’s working under the hood, and finally cover some of its other advantages.

Getting started

It’s best to make sure that Babel only uses the transform in test mode. Set up your config to look something like this.

npm i babel-plugin-transform-flow-to-gen --save-dev// oryarn add babel-plugin-transform-flow-to-gen --dev
Some sorta’ Babel config

Integrating with a test suite

In order to make use of proper shrinking in your tests flow-to-gen should be used in conjunction with TestCheck and all of its supporting libraries. In the examples below we use jasmine-check to integrate with Jest. (Jest works with jasmine-check because it is written on top of Jasmine.)

jasmine-check enhances Jest by adding an check.it function that is similar to it but also accepts a list of generators. check.it uses the generators to execute your test callback with randomized values. The following example is similar to what we’ve seen before, but now running through the test suite.

Similar to the testcheck.js example above

Jest will run the callback for hundreds of different values. And once a or b is randomized as a negative one, the test suite will blow up.

Using flow-to-gen

Functions are a huge center piece of any JavaScript codebase and thus we should make testing them as easy as possible. Given a function that has all typed inputs, flow-to-gen will attach an asGenerator static member that generates tuples of randomized test arguments for the function.

In the example below, we test a function setName by verifying that for any random Person and string, we will copy the person and give her a new randomized name. (I always wanted to name my daughter “x5jdA”.)

The setName.asGenerator function generates tuples of [Person, string]. A single run of the test suite will verify that this property holds true for one hundred random cases.

Under the hood

As previously mentioned, the flow-to-gen runtime is mostly a wrapper around TestCheck. Therefore, all of the generated code can be used along with TestCheck or jasmine-check.

Using type aliases

The beauty of flow-to-gen is that if you already have types, you do not need to modify any of your existing code to get going right away. You can treat your type aliases as if they compiled to functions. Generating sample information is easily done with the sample and sampleOne helpers which can be imported from babel-plugin-transform-flow-to-gen/api.

Generating some samples with type aliases

The flow-to-gen API also bundles with type helper functions (which are mostly wrappers around TestCheck generators) for creating your own runtime generators. There are a bunch of them which I’m not going to enumerate here, but the three we see in the example below are types.plainObject for plain objects, types.string for strings, and types.number for integers.

Generating some samples with type helpers

Given all of this new information, I could have written the previous test as the following.

An example where arguments are disclosed as part of the test.

You may be wondering why Person needs to be a function. Why can’t Person be the generator itself? The reason is generics (related to parameterized types) which we will discuss next.

Generics

For our purposes, a generic can be thought of as an argument to a type definition. They can be used to reduce the amount of duplication in type annotations. Types with generics are an abstraction over an annotation.

Generators, both ones created by flow-to-gen and provided by the API, are composable. In the example below, Dog() and dogGen produce equivalent results. Because Animal has a generic value, we have to provide it with a generator as an argument.

It’s also perfectly fine to use your own generators as arguments to generators.

Generators on generators on generators.

The same principle also applies to function annotations that use generics.

Neato!

Other benefits

Generating valid data

Sometimes it’s not enough to just generate a type alias with any random inputs. For example, consider that you might have a User type whose id is strictly in the form of a v4 UUID and perhaps your program makes a lot of assumptions about the shape of said UUID. The type string is simply not enough. In such a case, you can forcefully override randomized input with your own. The $Gen type allows you to do this. It’s a cute little trick to pass along information to the transform without breaking Flow.

Locking down your type definitions

An additional and originally unforeseen benefit of adding flow-to-gen to our test suite has been that sometimes the problem is with the type aliases (not poorly written production code or bad tests). Our types allowed for objects that we would never expect to see on a “valid” record. We only figured this out after inspecting some of the data generated from our type aliases!

For example, consider a scenario where a function input type is an array of strings (Array<string> or string[]), but it is common knowledge amongst the team that this array would always be exactly three elements long. Under certain conditions — which TestCheck will test for — our function may blow up. Instead we should define the input as a triple of strings ([string, string, string]). It’s easy to overlook such subtleties when the entire team “just knows” that something like this should be true. The change moves that knowledge to the codebase without the need for documentation.

Unit tests break with changes to type aliases

Your tests are generated from type aliases, so they may break when type aliases change. If you were expecting a particular property to hold, but it doesn’t, it could be a sign that your type definitions are not exactly as you intended them to be.

UI Fuzz Testing

If React component props are written in Flow, we can use them to render components with a higher degree of randomness and ensure that our UI doesn’t blow up on edge cases.

Generating garbage for any, Object, or mixed types

In order to encourage you not to use these loose types, flow-to-gen generates nonsense garbage whenever these types are used. If your function truly accepts any then I’m happy for you. Enjoy your garbage. That said, you were probably just being lazy.

Automatic mocks with Jest

If you are running Jest as your test suite, flow-to-gen will automatically generate Jest mock functions in place of function types.

Gross OO code, but it works.

The tradeoff

Using randomized testing works amazingly well for code that simply cannot have bugs. For us, this approach has found an unbelievable number of flaws in mission critical code. I would never consider going back to the old way of doing things.

But the feedback loop is very slow. For example, we have one very important function that tries at least 600 different sets of inputs on a single test run. With Jest, we can split the work out among processes, but it still takes about 12 seconds on my beast of a MacBook Pro. And there aren’t even network calls or database reads/writes.

This sucks if you’re using TDD. Fortunately enough, however, we don’t have to go all in. jasmine-check only creates the check.it function, but leaves it unchanged. In this way, we’re allowed to decide which parts of our code need a more robust form of inspection.

Where to go from here

We’re using flow-to-gen to transform our type aliases into test generators, but there’s still a lot of work to be done. Take ES6 classes for example. We have no support for that. Flow global types. We have no support for those. React components. Not really. Recursive types. Those blow up. The list goes on. These are problems that we have not yet explored, but we would love to hear your ideas on how we can solve them.

Check it out at https://github.com/unbounce/babel-plugin-transform-flow-to-gen.

Thank you to Ray Huang, Roman Gonzalez, Emily Mears, Niall Lennon and Tavis Rudd for taking time to provide feedback on this blog post.

--

--