Testing

Tests give us confidence that changes don't unexpectedly break code in other parts of the codebase, and help us write code that is easier to reuse and reason about.

Unit Tests

Ideally, almost every piece of logic that isn't either totally trivial on the one hand or hard to test on the other, should have a unit test (though there's a long way to go towards this goal)

Extract and test pure functions

The goal then is to write tests that provide maximum value while costing minimum development time to write and maintain. To that end, a good approach to testing a stateful system is to extract the core briskness logic into a pure function and test that in isolation. That way you don't have to worry about setting up state and mocking side effects, but still cover most of the logic with a simple test of "given this input, I expect this output"

Mock side-effect-full dependencies

The theoretically ideal unit test is one that tests only the specific logic under test. So under this logic, if a function calls three other functions, the unit test would mock all three functions and simply ensure they are called with the expected arguments, like so:

const transformAndValidate(data) {
  const transformedData = transform(data);
  return validate(transformedData);
}

// test
import * as utils from './utils';
it('transforms and validates', () => {
  jest.spyOn(utils, 'transform').mockReturnValue('transformed')
  jest.spyOn(utils, 'validate').mockReturnValue('validated')
  // Check that the correct value comes out
  expect(transformAndValidate('data')).toBe('validated');
  // Check that the spied funtions were called with the expected values.
  expect(utils.transform).toHaveBeenCalledWith('data');
  expect(utils.validate).toHaveBeenCalledWith('transformed');
})

That totally works, and fully tests that the function calls the right functions and returns the right value, and hopefully transform and validate have their own isolated tests as well. The only risk is that transformAndValidate is acutally passing invalid data to transform, this test wouldn't catch that error!

So if transform and validate are stateful or otherwise require a lot of mocking to set up, then this is probably the correct approach. But if they are simple pure functions, then it may be both easier and more complete to just let them run in the test:

const transformAndValidate(data) {
  const transformedData = transform(data);
  return validate(transformedData);
}

// test
import * as utils from './utils';
it('transforms and validates', () => {
  // The real functions can run, and so if they don't work with the test data,
  // you'll get a useful error message and a failed test.
  expect(transformAndValidate('data')).toBe('validated');
})

React

React tests should be written with Jest and react-testing-library

Components that are purely view logic, or that mostly interact with third-party libraries (like Mapbox) are less valuable to test.

A perfect use case for a component worth testing is one that has some interactive logic (ex: you see some state, you click a button, now the state has changed), or complex conditional rendering (based on the props, the component should render different content)

Integration/E2E tests

Integration tests in general run one or more full systems, and should as much as possible look like a real user using the system. They can give you high confidence that the system is actually working as intended - but there's a tradeoff. They are generally much slower to start and run than unit tests, and also can be flakier due to race conditions.

Ideally, every major flow through the system should have a single integration test, essentially a "smoke test" to make sure nothing major is going to break when deployed. Then specific cases (errors, empty states, etc.) should be covered by unit tests, which can quickly run many scenarios.

CI

To run e2e tests in CI, you'll need to start one or more servers, then run the tests. wait-on is a useful tool for waiting for the servers to start running before running the tests (though the Cypress action will do this for you)

Blockchain Devnet

For services that interact with a blockchain, it may be useful to have an end-to-end test that tests an API or frontend against a real running blockchain (such as the GSR tests). For these, the pattern is

  • Run a local hardhat node

  • Deploy the appropriate contracts

  • Start the server to be tested

  • Run Jest tests that interact with both the blockchain and the server

Blockchain Testnet

For some blockchains, it may not be convenient to run a local node. In these cases, make sure to put any tests that run against a real blockchain in a directory that is not run in CI, since those tests will both take a long time, and start failing if we run out of testnet gas.

Instead, these tests should only be run locally, and the goal should be to eventually set up a devnet to run them in CI

Last updated