Something that frustrates even the most honest effort to enact an efficient testing strategy is misunderstanding this:
For this:
But arguments aren’t the only way data gets into a function. This is input:
const activateUser = async (userId: string) => {
// ...
const blockedUserIds: string[] =
process.env.BLOCKED_USERS?.split(",") ?? [];
// ...
};
// ...
This, too, is input:
const activateUser = async (userId: string) => {
// ...
const currentTimeMs = Date.now() / 1000;
// ...
};
Return values aren’t the only way data gets out of a function, either. This is output:
const db = new DB(process.env.DB_URI);
const activateUser = async (userId: string) => {
// ...
await db.updateUser({ userId, active: true });
// ...
};
Faced with testing something like activateUser()
, where the task can’t be fully determined through arguments and its outcome cannot be fully verified via the return value, people usually resort to one of:
The real solution is simpler, more robust, faster, battle-tested, and almost smells like pure functions; its only sin is hiding behind a name that gives no clue about its benefits: “dependency injection”.
Which is why I like to say “make input/channels channels explicit” instead:
const io = { env, db, clock };
const userId = "user-123";
const result = await activateUser(userId, io);
The corresponding test is crystal-clear and runs in milliseconds:
test("activating a user sets the active flag in the corresponding database record", async () => {
// prepare input
const io = {
env: {
get: jest.fn(() => "user-456,user-789"),
},
db: {
updateUser: jest.fn(() =>
Promise.resolve({
id: "user-123",
email: "[email protected]",
}),
),
},
clock: {
getTime: jest.fn(() => 1704067200000),
},
};
const userId = "user-123";
// run task
const result = await activateUser(userId, io);
// assert output
expect(result.ok).toBe(true);
expect(io.clock.getTime).toHaveBeenCalled();
expect(io.env.get).toHaveBeenCalledWith("BLOCKED_USERS");
expect(db.updateUser).toHaveBeenCalledWith({
id: "user-123",
active: true,
});
});
But how do I know that the value will actually be written to the database?
You write a test for db.updateUser
.
Remember, when you test activateUser
, you test that it fulfills its contract. Its contract involves sending out a call to db.updateUser
with the correct arguments. What happens afterwards it’s db
’s business.
Make all input and output channels explicit (or, if you prefer the phrasing, inject dependencies ruthlessly) via a dedicated function argument, and enjoy tests that are simpler to understand and faster to run.
It’s tempting to write both kinds of slowness off as “it’s a human problem, just work harder”, but human resources (in the sense of attention, energy, etc.) are as finite as computing resources, and misusing them leads nowhere good. A system where tests take longer to write is necessarily less robust or less featureful, because the effort that could have gone into writing features has to go into overcoming the testing friction. A system where tests take longer to run is necessarily less robust or less featureful, because the attention that could have improved the system is eroded by frequent, forced ten-second (optimistically) breaks waiting for tests to pass. ↩︎