devnull.land

Miscellaneous bits and bobs from the tech world

Best Practices for Writing Tests

8/5/2022, 10:40:46 AM


This is a living document, it will be updated from time to time as my test writing evolves.

It's easy to fall into the trap of writing your tests with less care than you would your regular code. After all, if tests pass, then ship it is de rigeur, so there's an incentive — especially if the clock is ticking closer to the end of workday — to get those tests passing ASAP.

Avoid globals/leaky states

One corner that is often cut is to introduce or reuse variables between tests. For example:

  1. I am writing tests for updating a user's profile. I will create one user and run all tests against that single user.
  2. I am writing additional tests, I will reuse the user created in earlier tests

The problem with falling into this trap is that you're relying on the state/value of a variable that is not tightly scoped.

Failure cases

  1. The user's uid is 7, so I will assert.strictEqual(7, uid);. However if additional tests are created above mine, and another user is created, then uid could become 8 and this unrelated test would fail.
  2. The tests pass, but when a regression is introduced in unrelated code, this test can still fail — often with unhelpful/unrelated errors! Nothing is worse than encountering a failing test with an unrelated error, with no pointer as to where the real problem lies.
  3. I am running a subset of tests (either via commenting out other tests, or by using describe.only()), but the tests fail because the state of earlier variables is different.

Best Practice

If testing temporary entities (a user account, some data object, etc.) — create a new one for testing, and use it only in the context of that test.

define('user tests', () => {
  let uid;
  before(async () => {
    uid = await createUser();
  });
  
  ... tests go here...
});

In the example above, uid is scoped to the top level define(), so additional tests will not have access to that uid. Scope tighter as necessary, follow the Principle of Least Privilege.

Every test (or at least every block of related tests) should be able to be run independently. If they fail when run exclusively, then there is an issue with leaking state.

Clean up after yourself

If mutating global state (e.g. application config), it is easy to fall into the trap of not cleaning up after yourself. For example, if you are testing the logic of the foo feature, you can turn it on, run your tests, and leave it on.

The same failure cases apply — side effects, and test failures due to unrelated regressions.

Best practice

If possible, clean the slate completely (database wipe), or as close to as possible. Failing that, clean up after yourself by unsetting configurations you've set, and changing back configs you've changed.

define('foo feature', () => {
  before(() => {
    global.foo = true;
  });
  
  after(() => {
    global.foo = false;
  });
});