Behind the Scenes: Testing a Formula Editor With Parameterized Tests and ASCII UI

Evan Jones

First, a little background

At Interana, we've built an analytics stack from the ground up that's tailored to behavioral analysis. This includes a custom database, query language, and UI. One of the more involved aspects of the UI is providing a way for users to select columns and enter calculations when building queries. We've built our own UI control for this, called the "expression field," which offers an editing experience similar to composing formulas in a spreadsheet.

By default, the expression field operates like a typical autocomplete field, accepting a single selection. But when the user starts the text with an equal sign (=), it transitions into an advanced mode for editing formulas (think spreadsheets). In this advanced mode, completions are contextual based on cursor position, and operate on the level of tokens, rather than filling the whole field.

These features lead to a relatively large testing burden. The various functions of the expression field, like presenting and accepting completions, have to be verified against different combinations of input text, cursor position, and available items. Our challenge was to write a comprehensive set of unit tests while keeping the test code readable and maintainable.

Enter parameterized tests

Knowing there was a large state space to test, parameterized tests seemed a natural fit. We use the Jest test framework and, luckily, they added a parameterized tests feature shortly before we started this project. Without this feature, you end up with a lot of repetitive test cases. You can refactor your tests a bit, but you'll still end up with a low signal-to-noise ratio:

it('returns items for the start of a formula', () => {
 const items = getItems({text: '=', selectionStart: 1});
 expect(items.map(({name}) => name)).toEqual(['ROUND', 'SQRT']);
});

it('returns items for the start of a formula with existing text', () => {
 const items = getItems({text: '=foo', selectionStart: 1});
 expect(items.map(({name}) => name)).toEqual(['ROUND', 'SQRT']);
});

it('returns items for a number literal inside a function', () => {
 const items = getItems({text: '=foo(42', selectionStart: 7});
 expect(items.map(({name}) => name)).toEqual(['42', 'ROUND', 'SQRT']);
});

Parameterized tests offer a way to enumerate the various inputs and desired outputs, running them all against a single test function. As an added bonus, Jest's parameterized tests have a mode that leverages ES6 template literals and the Prettier code formatter to produce nicely aligned test “tables”:

it.each`
 text         | position | expectedItems
 ${'='}       | ${1} | ${['ROUND', 'SQRT']}
 ${'=foo'}    | ${1} | ${['ROUND', 'SQRT']}
 $['=foo(42'} | ${7}     | ${['42', 'ROUND', 'SQRT']}
`)('returns items for $text', ({text, position, expectedItems}) => {
 const items = getItems({text, selectionStart: position});
 expect(items.map(({name}) => name).toEqual(expectedItems);
});

Further clean up with ASCII “diagrams”

Parameterized tests cut down on the repetition of test cases, but there's still a verbosity and cognitive burden in tests dealing with input cursor positions. Consider the following potential mock of a DOM input with a text selection:

{value: "foo bar", selectionStart: 2, selectionEnd: 5}

Reading and writing something like this requires you to mentally translate the positions and determine that it's saying the sequence "o b" is selected.

We opted to write a little utility that converts from an ASCII representation of a DOM input to such a mock object. Specifically, it interprets one or two pipe characters as representing cursor position(s). So, the above mock input could be written as:

fo|o b|ar

A regular insertion cursor can just be represented as a single bar:

fo|
foo|
fo|o

Combined with parameterized tests, this makes for a very succinct, readable set of test cases:

it.each`
 inputWithCursor | expectedItems
 ${'=|'}         | ${['ROUND', 'SQRT']}
 ${'=|foo'}      | ${['ROUND', 'SQRT']}
 $['=foo(42|'}   | ${['42', 'ROUND', 'SQRT']}
`)('returns items for $text', ({inputWithCursor, expectedItems}) => {
 const mockInput = asciiToMockInput(inputWithCursor);
 expect(getItems(mockInput).map(({name}) => name).toEqual(expectedItems);
});

Keeping it clean

The final thing that makes this work really well is that in the Interana front-end codebase we are heavily invested in a pattern of immutable view models. We try to keep logic out of UI components and in model classes and helper functions as much as possible. The vast majority of the expression field's functionality is expressed in terms of pure functions that transform state; the UI component itself is just a thin layer on top of this. As a result, most of the tests are written in this parameterized, tabular style describing inputs and outputs.

More test coverage! Smaller space!

Taken together, parameterized tests, ASCII representations of UI, and pure functions allowed us to fit a lot more test coverage in a smaller space. The way test cases are expressed is declarative and obvious. In code review, gaps in behavior were easier to spot. As we debugged and tweaked the behavior of the expression field, it was also easy to adjust and extend the test cases.

 

Did this pique your interest? We're hiring! Drop us a line.

Previous article Blog Summary Next article