The test module

Idiomatic unit testing for ECMAScript modules, no installation or transpilation required. Use it entirely client-side with a modern web browser like Firefox, Chrome, Safari or Edge, or server-side with Deno or Node.js.

Anatomy of a test suite module

A test suite is a module that imports the unit test boilerplate API from the test module:

import { suite, result } from 'https://m.bednarz.dev/test.js';

The suite function is a higher-order function that returns a test function for the scope of the current test suite module. Its argument is the ECMAScript 2020 import.meta object.

const test = suite(import.meta);

The identifier for the the test function will likely be the most used one in the test suite and is user defined. If brevity is your thing, you can name it t, for example.

Now call the result function with the test function as argument and export the returned Promise as default.

export default result(test);

That was all the test API boilerplate code required to create a test suite. A useful test suite would have imported something from a module under test as well.

Time to run the first test:

test('The truth is out there', true);

After the exported test suite result resolves, a test suite summary is printed to the browser console or STDOUT as a side effect.

This page concatenates the text content of the above code examples and dynamically imports the resulting test module source text as an object URL. If you read this in a browser, you should see the following in the console:

- [Blob reference] (1 passed)

Note that the above test suite summary is just a side effect. To do something with the resolved result object, you need to pass it to a reporter.

The test function

The test function has two parameters, the test description and the test case, and returns itself.

  1. The test description is a string that enables human consumers to identifiy the test case in conjunction with the test suite module URL. It should be unique in the context of its test suite for cognitive benefits, but technically it does not have to be.
  2. The test case is a synchronous assertion, an asynchronous assertion or a callable assertion.

Test case definitions

callable assertion
A function that returns a synchronous assertion or an asynchronous assertion.
asynchronous assertion
A Promise that resolves a synchronous assertion.
synchronous assertion
A boolean expression or ordered pair.
boolean expression
An expression that must evaluate to a boolean. The test case passes if it is true.
ordered pair
An array with two test primitives representing the actual and the expected outcome, in that order. The test passes if they are strictly equal.
test primitive
A string literal, number literal, boolean true or false, null or undefined.

Use an ordered pair assertion if you care about the actual and expected values being available in failure feedback and reports.

Examples of test function calls with passing test cases

test('The truth is out there', true);
test('The truth is out there', [42, 42]);
test('The truth is out there', Promise.resolve(true));
test('The truth is out there', Promise.resolve([42, 42]));
test('The truth is out there', () => true);
test('The truth is out there', () => [42, 42]);
test('The truth is out there', () => Promise.resolve(true));
test('The truth is out there', () => Promise.resolve([42, 42]));

This is all you need to know about the test function. Read on for syntactic sugar or skip ahead for runtime environment info.

Chained tests

There is nothing wrong with using one statement per test as shown in the previous section. It is explicit and readable. If a callable assertion has multiple statements, there is just one level of indentation and it is easy to see where the test starts and ends.

Since the test function returns itself, however, the previous tests could be rewritten as:

test
  ('The truth is out there', true)
  ('The truth is out there', [42, 42])
  ('The truth is out there', Promise.resolve(true))
  ('The truth is out there', Promise.resolve([42, 42]))
  ('The truth is out there', () => true)
  ('The truth is out there', () => [42, 42])
  ('The truth is out there', () => Promise.resolve(true))
  ('The truth is out there', () => Promise.resolve([42, 42]))
  ;

This is a matter of personal preference. As a rule of thumb, one test per statement is better for complex test cases that have multiple lines and nested functions, while chained tests work best with oneliners. The important thing here is: your code, your tests, your choice.

Note: if your coding style requires a semicolon after a statement, putting it on a line of its own makes for cleaner version control diffing when you add or remove tests.

Scoped tests

One advantage of pyramid of doom test suite APIs in the style of Mocha or Jasmine is that there is little redundancy in test descriptions because those are nested or concatenated together down the call stack, at the expense of creating monolithic hairballs of code.

ECMAScript modules often export multiple functions, and functions are the primary subject of unit testing, so it makes sense to group multiple tests for a particular function together.

Calling the test function with another function object as argument returns a function with the same signature as the original test function. The only difference is that the test description is automatically prefixed based on the function argument’s name property:

import { suite, result } from 'https://m.bednarz.dev/test.js';
import { foo, bar } from './fubar.js';

const test = suite(import.meta);

export default result(test);

test(foo)
  ('is true', foo())
  ('is numeric', [Number(foo()), 1])
  ;

test(bar)
  ('is true', bar())
  ('is numeric', [Number(bar()), 1])
  ;

The resulting test case descriptions are:

If you don’t chain scoped tests, repeated calls create a new scoped test function every time:

test(foo)
  ('is true', foo());
test(foo)
  ('is numeric', [Number(foo()), 1]);

In that case, it is better to assign it once and call it by reference.

const test_foo = test(foo);
test_foo('is true', foo());
test_foo('is numeric', [Number(foo()), 1]);

If many functions are imported from the module under test, each can be contained in block statement scope, and the identifier can even be reused:

{
   const t = test(foo);
   t('is true', foo());
} {
   const t = test(bar);
   t('is true', bar());
}

What about …

Spies?

The idiomatic ECMAScript >= 2015 answer is using a Proxy’s apply handler.

Setup and teardown?

Shared initialization and cleanup functions have the same problems as global and/or magic test API identifiers. They contaminate the environment and can cause side effects that are difficult to reason about. Besides, they are completely at odds with the purpose of unit testing in the first place.

Environments

Note: the test-io module at https://m.bednarz.dev/test-io.js provides environment agnostic utilities for test runners and reporters. It is consumed by the web browser and Deno utilities below.

Web browser

You can import a test suite module in an HTML page and inspect its summary in the console. Or you could dynamically import it from the console:

import('https://m.bednarz.dev/test.test.js');

With a bit more effort, you can inspect not only the summary but also the result:

import('https://m.bednarz.dev/test.test.js')
  .then(module => module.default)
  .then(result => console.info(result));

You can try that for yourself right now by copy and pasting both examples into the console.

The report and suite web components are an example of integrating on demand reports in a web page using a declarative HTML API. See the unit test reporter of this repository for usage.

Deno

Deno is a runtime for native TypeScript that can also be used for plain ECMAScript. It is easy to run a single test suite manually from the CLI:

deno run --allow-read --allow-net my-unit.test.js

An installable automated test runner that creates a YAML report is available at:
https://m.bednarz.dev/deno/test_runner.ts
See the file header for instructions.

Node.js

Caveats: