I’ve written about Property-Based Testing for .NET previously. It’s a way of writing unit tests with random (but constrained) inputs. This means your tests are run multiple times with different inputs and your code is tested more thoroughly. You might even find bugs you didn’t know were there.
As I’m working quite a bit with TypeScript these days, I decided to look into property-based Testing with TypeScript. At my current client, we use Mocha for our unit tests, so let’s see if we can expand our tests to become property-based tests.
Available Libraries
There seem to be two popular options for property-based testing with JavaScript/TypeScript: JSVerify and fast-check. Of these two, it seems fast-check is more actively maintained. It’s also written in TypeScript so it has type support built-in. Those are two very valid reasons to choose fast-check.
Our Code
We need a piece of code to test first. I’m going to use this piece of code that parses cookies. The cookies can come in the form of a string or an array or strings:
export function parseCookies(cookieHeader: string): any {
const list: any = {};
(cookieHeader as string).split(";").forEach((cookie: string) => {
const parts = cookie.split("=");
list[parts.shift()] = parts.shift();
});
return list;
}
This piece of code should allow us to parse “username=John Doe; expires=Thu, 18 Dec 2013 12:00:00 UTC;” into an object like:
{
username: "John Doe",
expires: "Thu, 18 Dec 2013 12:00:00 UTC"
}
Writing Our Fast-Check Test
Installing fast-check is as easy as running:
npm i fast-check -D
Now this is what a traditional Mocha test would look like:
describe("parseCookies", () => {
it("should return the parsed cookies when given a cookie string", () => {
const cookies = "username=John Doe; expires=Thu, 18 Dec 2013 12:00:00 UTC;";
const result = parseCookies(cookies);
expect(result.username).to.equal("John Doe");
expect(result.expires).to.equal("Thu, 18 Dec 2013 12:00:00 UTC");
});
});
Let’s now rewrite this using fast-check.
We need to import it first, so we’ll add this to the top of our file:
import * as fc from 'fast-check';
Then, our test will now look like this:
it("should return the parsed cookies when given a cookie string", () => {
fc.assert(
fc.property(
fc.base64String().filter(s => /[A-Za-z0-9]/.test(s)),
fc.string().filter(s => /\S/.test(s)),
fc.base64String().filter(s => /[A-Za-z0-9]/.test(s)),
fc.string().filter(s => /\S/.test(s)),
(key1, value1, key2, value2) => {
fc.pre(key1 !== key2);
const cookies = `${key1}=${value1}; ${key2}=${value2}`;
const result = parseCookies(cookies);
const expected = {} as any;
expected[key1] = value1;
expected[key2] = value2;
expect(result).to.deep.equal(expected);
})
);
});
We’re doing some things in the beginning of our test to set up the input values. The “fc.assert” and “fc.property” calls are necessary to trigger fast-check. But the interesting bits are these:
- fc. base64String ().filter…: This gets us a basic string with only alphanumeric characters which we’ll use as the keys of our cookies.
- fc.string().filter…: This generates a random string that can’t be only whitespace. We’ll use these as the values of our cookies.
- fc.pre…: We want to stop our test prematurely if the keys of both cookies are the same. In that case, fast-check will generate a new set of inputs and this test won’t count.
After that, we’re constructing our cookie string, parsing it, and verifying the results.
Immediately, we find a bug:
expected { AAAA: '!', ' AAAB': '!' } to deeply equal { AAAA: '!', AAAB: '!' }
Our cookie string contains a space after each “;”, but we’re not taking this into account when assigning the properties of our resulting object.
To fix this, we can change our code. Where we assign the cookie values to our result object, we should “trim” the keys:
list[parts.shift().trim()] = parts.shift();
Now when we run our tests, we’re confronted with a new edge-case. Fast-check will give us the inputs it used:
Counterexample: ["AAAA","!","AAAB","="]
So in this case, the cookie value was an equals sign. This won’t work as a cookie value, so we’ll change our test to URI encode the values:
const cookies = `${key1}=${encodeURIComponent(value1)}; ${key2}=${encodeURIComponent(value2)}`;
Now our test will fail again:
expected { AAAA: '!', AAAB: '%22' } to deeply equal { AAAA: '!', AAAB: '"' }
So we need to URI decode the cookie values in our code:
list[parts.shift().trim()] = decodeURIComponent(parts.shift());
And with that, our test passes!
Far-fetched?
Aren’t these really far-fetched edge-cases? Well, the real code we use in production is slightly different, but we actually encountered the URI encode/decode issue in production.
We weren’t using fast-check (yet) and our tests contained the edge-cases we could think of. Fast-check would have helped us find other edge-cases. We did encounter such a bug in production. It was easily recreated in a “classic” unit test and subsequently fixed. But with fast-check, we could have avoided it entirely.
Another Tool In Our Toolbox
As I mentioned in my other post on property-based testing, you can and should still use regular unit tests. But property-based tests are a powerful addition to the set of tools we already know and love.
I still need to use them more than I do now. They really help us find edge-cases that we didn’t think of. Property-based tests stress our production code much more than traditional unit tests, giving us more robust code.