Rethinking Testing Through Declarative Programming
By: Javier Fernandes
Tags: Nodejs, JavaScript, React, Testing, Programming

Expressing Tests With DSLs

Let’s say the main idea up to this point was to make tests declarative by expressing them in terms of data instead of procedural code. We declare context, inputs, and expected outputs as data. This applies to both tablelike tests and doTest styles.

Given that, we’ve found that in some domains, expressing those inputs or outputs can be very hard. It might involve a lot of code, which hurts test readability. (The whole point of being declarative is to improve the tests readability as much as possible, as in Edward Tufte’s Data-Ink ratio when applied to coding.)

Let’s see some real examples.

Example 1: A rich text editor selection and entities DSL

This first example tests the isWithinEntityMatch function, which given DraftJS text editor content, tells us if the current selection is within a given entity.

DraftJS is a framework for building rich text editors in React. We’ve used it for game dialogues. In this case, an entity is what we call a markup, like an inlined note within a dialogue, delimited by curly braces: {MOVE_CAMERA ... } . Here’s how it looks from the UI:

The problem is this text is actually a pretty complex DraftJS model object, involving ImmutableJS- and DraftJS-specific models. So we need to improve the way we create those inputs or contexts to also make them declarative and readable. We could use util functions, factories, builders, and, at the end of the spectrum, create a domain-specific language (DSL) just for our specific concern.

In our case, we came up with this tablelike test:

The text is actually a very small internal DSL using just regular strings and conventions through symbols:

... {something} … : Curly braces for entities (same as the user types)

: Curly braces for entities (same as the user types) [|] : Means the user cursor is at that specific position (the collapsed selection)

: Means the user cursor is at that specific position (the collapsed selection) [> ... >] : Declares an expanded selection. That is, the content within it is currently selected from left to right. The cursor, by definition, is on the right side.

... : Declares an expanded selection. That is, the content within it is currently selected from left to right. The cursor, by definition, is on the right side. [< … <] : The same as above, but the selection is from right to left, and the cursor is on the left side. (The selection direction is pretty important in text editors.)

The impl of expectIsWithin can be found here with the DSL parser. I’m not inlining it because it’s a lot of code for just an impl. But take a look, and imagine if each test case needed that much code to create the input. It’d be really difficult to read and maintain!

Example 2: Undo/redo logic

Another real example: a function to compute the undo-redo stack of an application. It’s a pure function in this case, a reselect selector. That’s a function that derives some data given a (redux) app state, it.

In this case, the state has a list of changes that were done. A change could be:

A regular change : For example, A (we assign names for the sake of test readability)

: For example, (we assign names for the sake of test readability) An undo : Reverts a change. We use the notation U(A) .

: Reverts a change. We use the notation . a redo: Redoes a change. We use R(A) .

Same as with the DraftJS example, there’s a long distance between these concepts when thinking about a test — like Let’s test change A, B, C; Undo C and B; and see where the cursor is — to what we actually need to code to create that scenario.

Expressing the inputs/context would involve a lot of code. So we, again, create a small, internal, string-based DSL to be able to express cases with a very compact syntax.

Test tables and DSL for building complex input objects. Every string in the array is a test case expressed in a small, simple DSL as a notation.

We actually just ended up transcribing the same notation we used on a whiteboard to investigate the problem and come up with a solution.

It reads like this:

// [latest_change ← first_change] => expected_stack [ R(B), U(B), U(C), C, B, A ] => [C, (B), A]

The user did a change, A , followed by change B and C . Then they undid C and B , but right after this, they redid B . So where are we?

[C, (B), A]

This means that A is applied, C isn’t (it’s been undone), and we’re currently at B (also applied). From this point, we can undo A or redo C

Both inputs, as well as the expected outputs, are expressed as a string DSL, but the underlying model consists of complex data structures that would’ve made the test difficult to read.

Here is the impl of parseInput , which, in turn, has its own tests. So to make better tests, we had to create a language, a parser (although this was pretty simple), and tests for that parser. Imagine if this was easier to do out of the box with testing tools ?

The DSL allows us to get rid of a lot of boilerplate code and just distill the meaningful part for the test. This makes it easier to think about missing cases, redundant cases, etc. — especially by others — in code reviews.