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.
- 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.
- 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
orfalse
,null
orundefined
.
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:
- foo(): is true
- foo(): is numeric
- bar(): is true
- bar(): is numeric
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:
-
Only relative and
file:
URLs are supported for imports; since both the test and test-io modules have no dependencies, saving them from the network for local usage requires no additional steps, but the problem remains for network dependencies of modules under test.-
Network based modules using
https
can be imported since version 16 using the--experimental-network-imports
flag (this is unchanged up to version 20).
-
Network based modules using
-
ECMAScript modules must have a
.mjs
extension, or thepackage.json
that governs the current working directory must have a"type": "module"
field.