Throw an Error with a Simple Test
The most fundamental form of a test in JavaScript is code that will throw an error when the result is not what you expect.
export const sum = (a, b) => a + b
export const subtract = (a, b) => a + b
import {sum, subtract} from './math'
let result, expected
result = sum(4, 8)
expected = 12
if (result !== expected) {
throw new Error(`${result} is not equal to ${expected}`)
}
result = subtract(8, 4)
expected = 4
if (result !== expected) {
throw new Error(`${result} is not equal to ${expected}`)
}
This example throws an error for the subtract function, as it clearly contains a bug.
The job of testing frameworks is to make the error message as useful as possible
Abstract Test Assertions Into an Assertion Library
It would be nice to make the code from the previous example a bit less imperative. To do that, we can write an abstraction of the assertion in a new expect
function.
export function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) {
throw new Error(`${actual} is not equal to ${expected}`)
}
},
}
}
We can then use this function in our tests.
import {expect} from './assertion-library'
import {sum, subtract} from './math'
let result
result = sum(8, 4)
expect(result).toBe(12)
result = subtract(8, 4)
expect(result).toBe(4)
This expect
function acts like an assertion library. It takes an actual value and returns an object that has functions for different assertions that we can make on that actual value.
We can easily expand the functionality of the expect
function by adding more assertions.
export function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) {
throw new Error(`${actual} is not equal to ${expected}`)
}
},
toEqual(expected) {},
toBeGraterThan(expected) {},
toBeLessThan(expected) {},
// And so on
}
}
Encapsulate and Isolate Tests in a Testing Framework
While the above approach makes the code much more readable it still has a few issues:
- As soon as one test fails, program execution stops, meaning any remaining tests will not run
- It's hard to pinpoint in the stack trace which test fails as the error is thrown in the assertion library
To help developers identify what is broken as quickly as possible, a testing framweork should:
- Run all the tests
- Have helpful error messages
export function test(title, callback) {
try {
callback()
console.log(`√ ${title}`)
} catch (error) {
console.error(`x ${title}`)
console.error(error)
}
}
Our tests now look like this.
import { expect } from './assertion-library'
import { subtract, sum } from './math'
import { test } from './testing-framework.js'
test('sum adds numbers', () => {
const result = sum(8, 4)
expect(result).toBe(12)
})
test('subtract subtracts numbers', () => {
const result = subtract(8, 4)
expect(result).toBe(4)
})
With a nice error message.
Suport Async Tests with JavaScript Promises
The current code works great for synchronous tests, but falls apart when we want to test asynchronous functions. In order to fix this, we can turn the test
function into an async
function and then await
the callback
. If the promise returned from callback
is rejected we land in the catch block.
export const sum = (a, b) => a + b
export const subtract = (a, b) => a + b
export const sumAsync = (...args) => Promise.resolve(sum(...args))
export const subtractAsync = (...args) => Promise.resolve(subtract(...args))
We make the callback functions of our tests async
und use the await
keyword for the promise to resolve. Then we can make our assertions on the result
.
import { expect } from './assertion-library.js'
import { subtractAsync, sumAsync } from './math.js'
import { test } from './testing-framework.js'
test('sumAsync adds numbers asynchronously', async () => {
const result = await sumAsync(8, 4)
expect(result).toBe(12)
})
test('subtractAsync subtracts numbers asynchronously', async () => {
const result = await subtractAsync(8, 4)
expect(result).toBe(4)
})
Because the callback
functions in this case are async
functions they will return a promise. When an error is thrown, the promise is rejected. Inside the test
function the callback
is going to return a promise. If we turn test
into an async
function and then await
that callback
, if that promise is rejected we'll land in the catch block.
If no error is thrown we'll continue inside the try block.
This will work for both synchronous and asynchronous tests.
export async function test(title, callback) {
try {
await callback()
console.log(`√ ${title}`)
} catch (error) {
console.error(`x ${title}`)
console.error(error)
}
}