When deciding if, when, what, and how to test, we should always be guided by this mindset.
We write tests to increase confidence in our code and its behavior.
How we get there 🏃♀️
We test behavior, not implementation details 🎭
The only two users of a piece of code that we care about are:
- The end user, using our app
- Consuming code
When testing a piece of code, if we are testing implementation details, we are no longer testing behavior of either of our two users. Instead, we are testing for a third user: the test user. Therefore, we are not gaining any true confidence from the tests.
Examples of implementation details to avoid testing:
- Internal state (of a component or class)
- Details of a third party dependency
We test use cases, not lines of code 🖥
Rather than thinking about and looking at the code we're striving to test, we think about the use cases. When we do this, we keep the focus on the end behavior we're looking to get confidence in, rather than arbitrarily "covering" lines of code.
We prefer no tests over bad tests 🙅♂️
If a test does not give us any meaningful confidence that our code works to our two users as expected, it is not a valuable test.
We do not write or keep these tests in our codebase.
- Bad tests that are prone to false positives decrease our confidence in our tests (“the tests passed, but does that mean anything?”)
- Bad tests that are prone to false negatives clutter up our codebase, add fruitless overhead every time you touch a piece of code, and build up stress and angst towards testing in general
- Bad tests that are technically correct, but don't add much confidence, are extra work, maintenance, and clutter in our codebase
What does this mean concretely?
- We never introduce new tests that don’t give us confidence
- We strive to remove or rewrite existing tests that don’t give us confidence
We write mostly integration tests 🤝
Very rarely can you mock out the behavior of all downstream dependencies of a piece of code, write tests for the isolated code, and still be very confident that when the code is to be used in production, it will behave as expected.
Because of this, mosts of the tests we write will end up testing some integration between different pieces of code. And that's OK — if the
<Button> we're clicking in the
<Accordion> component is broken, our
<Accordion> is, for all intents and purposes, broken too.
What constitutes an integration test?
Obviously, there’s always going to be a range of just how much a test integrates with multiple modules of code. For the backend, sometimes it’s going to make sense to stub out certain things like the database and third-party APIs, and sometimes it won’t. For the frontend, it means not stubbing out components
The exact definition and implementation isn’t the point here — what matters is that we strive to write tests as closely to their use case in production, which will involve testing downstream dependencies in concert with the module we’re testing. Then, it’s up to us to decide when it’s appropriate to stub something out as a tradeoff.
Does that mean we write no unit tests?
No — unit tests still have their place. Some modules/functions are simply just written be written as isolated code, and we really have no choice but to write a unit test there, since there are no dependencies. For example, a
sum function that adds two numbers will need “unit” tests.
If we’re not mocking downstream components, how do we know what to test?
We test, at a high level, the behavior the component is directly responsible for.
Accordion example above, the
Accordion test will only write tests for its behavior (collapsing content, expanding content, edge cases, etc.). It will not test behavior the button is responsible for (disabled state, hover state,
Button-specific edge cases, etc.).
We colocate test files with source files 👯
The closer something is to what it's relevant to, the more likely it'll get updated when it needs to.
Therefore, we keep tests as close to the relevant source files as possible in the directory structure. So when a source file gets updated, it's much harder to forget that a test file needs to be updated as well (and so that it's much easier to find where the test file is).
The more likely we make it that tests get updated when they need to, the more confidence we can have in our code.
We don't duplicate work done by other tools 🌀
For example: If TypeScript is already ensuring that a prop will be present, there's no need to duplicate that work and test the case that it's not present
The tools we use to help us achieve our goal of confidence.
The nitty-gritty details we've agreed upon, to help us get confidence from our tests