Great Tests
Joist focuses not just on great production code & business logic, but also on enabling great test coverage of your business logic, by facilitating tests that are:
- Isolated,
- Succinct, and
- Fast
Isolated Tests
Isolation is an important tenant of great tests, because any sort of "shared fixtures" or "shared environments" that couple automated tests to an ever-growing, ever-changing shared test data set eventually becomes very confusing to debug and very brittle to change.
With Joist, each unit test starts out with a clean database, and so is concerned only with the minimum amount of data it needs for its boundary case.
I.e. when you run:
describe("Author", () => {
it("can have rule one", async () => {
const em = newEntityManager();
const a1 = em.create(Author, { firstName: "a1" });
await em.flush();
});
it("can have rule two", async () => {
const em = newEntityManager();
const a1 = em.create(Author, { firstName: "a1" });
await em.flush();
});
});
Each it
block will see a clean/fresh database.
This is achieved by running a flush_database
stored procedure in beforeEach
:
beforeEach(async () => {
await knex.select(knex.raw("flush_database()"));
});
Where the flush_database
stored procured:
- Is a single database invocation, so cheap to invoke
- Knows the difference between entity tables and enum tables, and only
TRUNCATE
s entity tables - Resets sequences to restart from 1
- Is only created in local testing environments, not production
The flush_database
stored procedure is created while running npm run joist-codegen
, both because its body is generated based on your current schema (similar to the other joist-codegen
output), and also because joist-codegen
is generally only ran against a local development environment, which avoids having this stored procedure ever exist in production.
Succinct Tests
Given each test starts with a clean database, Joist provides factories to easily create test data, so that the benefit of "a clean database" is not negated by lots of boilerplate code to re-create test data.
Factories can:
- Accept values that are important to the test case being tested,
- Fill in defaults for any other required fields/columns,
- Also accept specific hints/flags to create re-usable "chunks" of data
For example, if you want to test an author with a book of the same name/title:
const a1 = newAuthor({
firstName: "a1",
books: [{ title: "a1" }],
});
If either the Author
or Book
had other required fields, the newAuthor
and newBook
factories will apply them as needed.
See the factories for more information on custom flags.
Fast Tests
Slow tests can kill productivity and dis-incentivize testing in general, so Joist tries to make tests as fast as possible.
Joist does not have a specific approach/feature that enables fast tests, other than:
- The
flush_database
stored procedure makes db resets a single database call instead ofN
calls (i.e. 1DELETE
per table in your schema) - Joist's use of build-time code generation means it does not need to scan the schema at runtime/boot time.
In small projects, you can generally expect:
- A single file takes ~1 second to run (Jest will report ~100ms, but real time is higher)
- Individual
it
test cases take ~10ms to run
In larger projects (i.e. 100-150 tables), you can expect:
- A single file takes ~5 seconds to run (Jest will report ~1.5 seconds, but real time is higher)
- Individual
it
test cases take ~50ms to run
Note that the "5 seconds of wall clock time" for large projects, and in general the discrepancy between Jest time vs. actual wall clock time, can be mitigated by projects like @swc/jest
and @swc-node/register
, as in larger projects the bottleneck becomes Node require
/import
-ing source code and transpiling the TypeScript to JavaScript, instead of Joist / the database operations themselves.
When running Postgres locally for testing, you can run postgres -c fsync=off
(i.e. passed as the command
in your docker-compose.yml
file) to put Postgres into a "sort of" in-memory mode, that is faster because transactions will not commit to disk before completing.
What is "Fast Enough?"
Granted, compared to true in-memory unit tests, these tests times are still ~5-10x slower, but the goal is that they are still "fast enough" given the benefit of still using the real database.
Sometimes applications will choose to mock out all database calls, with the goal of having strictly zero I/O calls during unit tests; granted, sometimes this approach can make sense, i.e. a frontend codebase mocking all GraphQL calls makes sense. But, for testing domain entities that are fundamentally tied to the database schema & persistence layer, it's generally more pragmatic with Joist to just keep testing against the real database.
Joist has explored an InMemoryDriver, that could potentially achieve "no I/O calls during unit tests", with the idea that building this complexity into Joist itself might justify/amortize its expense, instead of complicating each application's architecture.
However, so far the InMemoryDriver
is not actually 10x faster than real Postgres tests (it's maybe ~2-3x), and also does not support custom SQL queries, so for now its development is on pause. Rebooting it on top of pg-mem might be fun, to get custom SQL query support.