Automated tests are fundamentally just code that will throw or cause an error if something is wrong. Most libraries or testing frameworks provide a variety of primitives that make tests easier to write.
As mentioned in the previous section, these primitives almost always include a way to define independent tests (referred to as test cases) and to provide assertions. Assertions are a way to combine checking a result and throwing an error if something is wrong, and can be considered the basic primitive of all testing primitives.
This page covers a general approach to these primitives. Your chosen framework likely has something like this, but this isn't an exact reference.
For example:
import { fibonacci, catalan } from '../src/math.js';
import { assert, test, suite } from 'a-made-up-testing-library';
suite('math tests', () => {
test('fibonacci function', () => {
// check expected fibonacci numbers against our known actual values
// with an explanation if the values don't match
assert.equal(fibonacci(0), 0, 'Invalid 0th fibonacci result');
assert.equal(fibonacci(13), 233, 'Invalid 13th fibonacci result');
});
test('relationship between sequences', () => {
// catalan numbers are greater than fibonacci numbers (but not equal)
assert.isAbove(catalan(4), fibonacci(4));
});
test('bugfix: check bug #4141', () => {
assert.isFinite(fibonacci(0)); // fibonacci(0) was returning NaN
})
});
This example creates a group of tests (sometimes called a suite) called "math tests", and defines three independent test cases that each run some assertions. These test cases can usually be individually addressed or run, for example, by a filter flag in your test runner.
Assertion helpers as primitives
Most testing frameworks, including Vitest, include a collection of assertion
helpers on an assert
object that allow you to quickly check return values or
other states against some expectation. That expectation is often "known good"
values. In the previous example, we know the 13th Fibonacci number should be
233, so we can confirm that directly using assert.equal
.
You might also have expectations that a value takes a certain form, or is greater than another value, or have some other property. This course won't cover the full range of possible assertion helpers, but testing frameworks always provide at least the following basic checks:
A 'truthy' check, often described as an 'ok' check, checks that a condition is true, matching how you might write an
if
that checks if something is successful or correct. This tends to be provided asassert(...)
orassert.ok(...)
, and takes a single value plus an optional comment.An equality check, such as in the math test example, in which you expect the return value or state of an object to equal a known good value. These are for primitive equality (such as for numbers and strings) or referential equality (are these the same object). Under the hood, these are just a 'truthy' check with a
==
or===
comparison.- JavaScript distinguishes between loose (
==
) and strict (===
) equality. Most test libraries provide you with the methodsassert.equal
andassert.strictEqual
, respectively.
- JavaScript distinguishes between loose (
Deep equality checks, which extend equality checks to include checking the contents of objects, arrays, and other more complex data types, as well as the internal logic to traverse objects to compare them. These are important because JavaScript has no built-in way to compare the contents of two objects or arrays. For example,
[1,2,3] == [1,2,3]
is always false. Test frameworks often includedeepEqual
ordeepStrictEqual
helpers.
Assertion helpers that compare two values (rather than a 'truthy' check only) typically take two or three arguments:
- The actual value, as generated from the code under test or describing the state to validate.
- The expected value, typically hard-coded (for example, a literal number or string).
- An optional comment describing what's expected or what might have failed, which will be included if this line fails.
It's also fairly common practice to combine assertions to construct a variety of checks, because it's rare that one can correctly confirm the state of your system by itself. For example:
test('JWT parse', () => {
const json = decodeJwt('eyJieSI6InNhbXRob3Ii…');
assert.ok(json.payload.admin, 'user should be admin');
assert.deepEqual(json.payload.groups, ['role:Admin', 'role:Submitter']);
assert.equal(json.header.alg, 'RS265')
assert.isAbove(json.payload.exp, +new Date(), 'expiry must be in future')
});
Vitest uses the Chai assertion library internally to provide its assert helpers, and it can be useful to look through its reference to see what assertions and helpers might suit your code.
Fluent and BDD assertions
Some developers prefer an assertion style that can be called behavior-driven
development (BDD), or
Fluent-style
assertions. These are also called "expect" helpers, because the entry point to
checking expectations is a method named expect()
.
Expect helpers behave in the same way as assertions written as simple method
calls like assert.ok
or assert.strictDeepEquals
, but some developers find them easier to read.
A BDD assertion may read like the following:
// A failure here would generate "Expect result to be an array that does include 42"
const result = await possibleMeaningsOfLife();
expect(result).to.be.an('array').that.does.include(42);
// or a simpler form
expect(result).toBe('array').toContainEqual(42);
// the same in assert might be
assert.typeOf(result, 'array', 'Expected the result to be an array');
assert.include(result, 42, 'Expected the result to include 42');
These style of assertions work because of a technique called method chaining,
where the object returned by expect
can be continually chained together with
further method calls. Some parts of the call, including to.be
and that.does
in the previous example, have no function and are only included to make the call
easier to read and potentially to generate an automated comment if the test
failed. (Notably, expect
normally doesn't support an optional comment, because
the chaining should describe the failure clearly.)
Many test frameworks support both Fluent/BDD and regular assertions. Vitest, for example, exports both of Chai's approaches and has its own slightly more concise approach to BDD. Jest, on the other hand, only includes an expect method by default.
Group tests across files
When writing tests, we already tend to provide implicit groupings—rather than all tests being in one file, it's common to write tests across multiple files. In fact, test runners only usually know that a file is for test because of a predefined filter or regular expression—vitest, for example, includes all files in your project that end with an extension like ".test.jsx" or ".spec.ts" (".test" and ".spec" plus a number of valid extensions).
Component tests tend to be located in a peer file to the component under test, as in the following directory structure:
Similarly, unit tests tend to be placed adjacent to the code under test. End-to-end tests may each be in their own file, and integration tests may even be placed in their own unique folders. These structures can be helpful when complex test cases grow to require their own non-test support files, such as support libraries needed just for a test.
Group tests within files
As used in prior examples, it's common practice to place tests inside a call to
suite()
that groups tests that you set up with test()
. Suites aren't usually
tests themselves, but they help to provide structure by grouping related tests
or goals by calling the passed method. For test()
, the passed method describes
the actions of the test itself.
As with assertions, there's a fairly standard equivalence in Fluent/BDD to grouping tests. Some typical examples are compared in the following code:
// traditional/TDD
suite('math tests', () => {
test('handle zero values', () => {
assert.equal(fibonacci(0), 0);
});
});
// Fluent/BDD
describe('math tests', () => {
it('should handle zero values', () => {
expect(fibonacci(0)).toBe(0);
});
})
In most frameworks, suite
and describe
behave similarly, as do test
and
it
, as opposed to the greater differences between using expect
and assert
to write assertions.
Other tools have subtly different approaches to arranging suites and tests. For
example, Node.js's built-in test runner supports nesting calls to test()
to
implicitly create a test hierarchy. However, Vitest only allows this kind of
nesting using suite()
and won't run a test()
defined inside another test()
.
Just like with assertions, remember that the exact combination of grouping methods your tech stack provides isn't that important. This course will cover them in the abstract, but you'll need to figure out how they apply to your choice of tools.
Lifecycle methods
One reason to group your tests, even implicitly at the top level within a file, is to provide setup and teardown methods that run for every test, or once for a group of tests. Most frameworks provide four methods:
For every `test()` or `it()` | Once for the suite | |
---|---|---|
Before test runs | `beforeEach()` | `beforeAll()` |
After test runs | `afterEach()` | `afterAll()` |
For example, you might want to prepopulate a virtual user database before each test, and clear it afterwards:
suite('user test', () => {
beforeEach(() => {
insertFakeUser('bob@example.com', 'hunter2');
});
afterEach(() => {
clearAllUsers();
});
test('bob can login', async () => { … });
test('alice can message bob', async () => { … });
});
This can be useful to simplify your tests. You can share common setup and teardown code, rather than duplicating it in every test. Additionally, if the setup and teardown code itself throws an error, that can indicate structural problems that don't involve the tests themselves failing.
General advice
Here are a few tips to remember when thinking about these primitives.
Primitives are a guide
Remember that the tools and primitives here, and in the next few pages, won't exactly match Vitest, or Jest, or Mocha, or Web Test Runner, or any other specific framework. While we've used Vitest as a general guide, be sure to map them to your choice of framework.
Mix and match assertions as needed
Tests are fundamentally code that can throw errors. Every runner will provide a
primitive, likely test()
, to describe distinct test cases.
But if that runner also provides assert()
, expect()
and assertion helpers,
remember that this part is more about convenience and you can skip it if you
need to. You can run any code that might throw an error, including other
assertion libraries, or a good-old-fashioned if
statement.
IDE setup can be a lifesaver
Ensuring that your IDE, like VSCode, has access to autocompletion and
documentation on your chosen test tooling can make you more productive. For
example, there are over 100 methods on assert
in the Chai assertion
library, and having documentation for the
right one appear inline can be convenient.
This can be especially important for some test frameworks that populate the global namespace with their testing methods. This is a subtle difference, but it's often possible to use testing libraries without importing them if they're automatically added to the global namespace:
// some.test.js
test('using test as a global', () => { … });
We recommend importing the helpers even if they're supported automatically,
because that gives your IDE a clear way to look up these methods. (You may have
experienced this problem when building React, as some codebases have a magical
React
global, but some don't, and require it to be imported in all files using
React.)
// some.test.js
import { test } from 'vitest';
test('using test as an import', () => { … });