Massimiliano Mirra

Notes


















Date:
Tags: testing
Status: finished

Faster, simpler tests with explicit IO channels

Something that frustrates even the most honest effort to enact an efficient testing strategy is misunderstanding this:

  1. prepare input
  2. run computation
  3. assert output

For this:

  1. prepare arguments
  2. run function
  3. assert return value

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:

  • mocking modules, at the price of deep doubts (“…does this mean I’m testing implementation details…”) and fragile acrobatics (should your system’s testability really depend on how good the test framework can hack into the runtime’s import mechanism?)
  • presenting a heavy, as-realistic-as-possible environment to the test (network services, browser DOM, …), at the price of slowness in writing the test and slowness in running it.[1]

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.

Summary

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.


  1. 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. ↩︎

I write in the hope of making other people’s developer journey smoother and more rewarding; the more people I can make a difference for, the more meaningful the effort. If you think this article can be useful to others, please consider sharing it:

Stay in touch

© 2020-2024 Massimiliano Mirra