This blog post describes in detail the obscure fail()
API in various testing frameworks, which is helpful when dealing with nullable types inside tests. We'll cover multiple popular libraries and even a custom simple solution you could use, in case your testing framework doesn't support this API.
This post assumes you're writing tests using TypeScript with strictNullChecks
enabled. Without this option, you will not encounter the problem described below. However, if you're not using strictNullChecks
, I strongly recommend that you do, as it will greatly increase the sturdiness of the code and prevent a multitude of runtime errors from occurring.
Before jumping into the solution, let's look at the problem we're trying to solve.
The problem
Let's consider we want to test a function called buySubscription()
, which has some side effects. Besides adding a subscription
, it will also create an invoice
that references a generated file
.
To assert the side effects, we have to use some helper functions that return Nullable Types. Common use cases would be functions that return single values from some sort of storage, like a database, local storage, cloud, etc. All these values fall into the category of Nullable types because we don't know at compile time if the value will actually exist at runtime.
declare function getInvoiceById(id: string): Invoice | null;
Here's the test code:
test("buySubscription() should generate invoice file", () => {
// arrange
const user = addUser();
// act
const subscription = buySubscription({ userId: user.id });
// assert
const invoice = getInvoiceById(subscription.invoiceId);
const file = getFileById(invoice.fileId);
// ⛔️ Object is possibly 'null' ☝️
expect(file).toBeDefined();
});
The problem here is that we're trying to access a property on nullable object. At runtime, this error would probably not occur, because we're creating the entity before, unless something breaks within the source code.
However, the type system cannot statically infer that we're creating the required before accessing it. The getFileById()
function is clearly defined as potentially returning null
. Therefore, it could potentially result in a TypeError
at runtime when reading invoice.fileId
.
This problem is most prevalent in non-trivial integration tests, especially when dealing with side effects.
We could use type assertions to make the type system happy. However, that's a pretty poor solution, plus it might introduce other problems because we're telling lies to the type system. Fortunately, there is a much better alternative.
Jest
If you're using Jest, you can use fail()
within a conditional to explicitly specify that the test will fail if it encounters a null
value.
test("...", () => {
// ...
const invoice = getInvoiceById(subscription.invoiceId);
if (!invoice) {
fail();
}
const file = getFileById(invoice.fileId); // ✅
// ...
});
Using fail()
has 2 benefits:
- At runtime, it will make the test fail in case the invoice is
null
, which should happen only if there is an issue with the source code. - At compile time it will narrow the type, inferring the
invoice
as being NonNullable, therefore allowing us to safely access its properties.
Unfortunately, there is no Jest documentation for fail()
, which is probably the reason you might have never heard of it.
Jest is using Jasmine matchers which include a global fail() function. Since Jest version 20, Jasmine was forked by the Jest team, which might be the reason there's no clear separation today regarding the dependency on Jasmine.
This brings the question if the Jest team is aware of the global fail()
API or not.
In order to use fail()
in a TypeScript project you must import the global @types/jest
, which includes the global fail()
function declaration.
However, if you don't want to use implicit globals, and you want to explicitly import the Jest dependencies, please note that @jest/globals
does not expose fail()
.
As a workaround, you could use either Node test runner's built-in method or a simple custom implementation instead.
Vitest
It's not a secret that Vitest's API is almost similar to Jest, therefore it includes assert.fail()
which has the same effect.
import { assert } from "vitest";
// ...
if (!invoice) {
assert.fail();
}
Hats off to the Vitest team. I wonder if they "found" this API in the Jest source code, or if it was inspired by other solutions.
Node test runner
If you prefer built-in solutions, you should know that Node.js already has a built-in assertion library, which includes an assert.fail() method.
import { fail } from "node:assert";
// ...
if (!invoice) {
fail();
}
It's worth mentioning that even though the native test runner was stable only since Node.js 20, the assertion library is available from the very beginning.
Custom implementation
If you're using a different testing framework that doesn't expose a fail()
method, you can easily use your own implementation, which is pretty basic:
function fail(message?: string): never {
throw new ReferenceError(message);
}
The only thing that this function does is to throw an Error, with an optional message.
However, the magic here, when it comes to type checking, is the return type never
. Placing this function inside an if
statement will narrow down the type of invoice, thanks to TypeScript's control flow analysis.
const invoice = getInvoiceById(subscription.invoiceId);
// 1️⃣ invoice: Invoice | null
if (!invoice) {
// 2️⃣ invoice: null
fail();
// 3️⃣ unreachable code
}
// 4️⃣ invoice: Invoice
const file = getFileById(invoice.fileId);
Let's dissect this in more detail:
- We start with the
invoice
being a union type ofInvoice | null
, as returned bygetInvoiceById()
. Because there is the possibility of beingnull
, we cannot safely accessinvoice.fileId
, becausenull.fileId
would throw a runtime TypeError exception. - Using the
if
statement with the negation!invoice
, we take thenull
path inside the conditional. Therefore,invoice
will benull
, both at compile time, but also at runtime as well. - Thanks to the
never
type returned by thefail()
function, the code will never reach the line after thefail()
call. Thethrow
statement in the implementation guarantees runtime behavior, while thenever
type guarantees compile type correlation. - TypeScript compiler performs control flow analysis, being able to understand code branching and narrowing types based on control flow statements. In this case, it knows that if the code takes the conditional path, where
invoice
isnull
, it will never continue beyond that point. Therefore, if the code does not enter the conditional, the only type that it could have isInvoice
.
Using the fail()
API allows us to easily deal with Nullable Types in tests, without employing poor alternatives such as type assertions or //@ts-ignore
comments.
There is an unwritten rule that we shouldn't use if
statements in our tests. However, we're not using them to branch the test logic. They are used only for type safety purposes.