TDD the RITE Way
Test Driven Development (TDD) is a process for writing software that provably satisfies the software requirements. The process works like this:
- Start with a falsifiable stated requirement, e.g.,
double()should take a number
xand return the product of
- Write a test to prove that the stated requirement is satisfied.
- Watch the test fail. This proves that the test won’t produce a false positive, and that the added code is what makes the test pass.
- Add the implementation code.
- Watch the test pass.
- Look over the code and improve it, if necessary, relying on the test to prove that the improved code continues to work as expected.
This workflow is commonly known as Red, Green Refactor.
When you dig into TDD you’re going to find a bunch of options for test frameworks. Let me save you some time: Which one you pick matters less than how simple your test suite is. Some of the fancier ones (Mocha, Jasmine) tend to encourage users to produce overly-complicated tests, but if you follow the advice in this article, almost any framework will suffice.
If you want to encourage the developers on your team to keep your tests simple, check out “Why I Use Tape Instead of Mocha and So Should You”.
In fact, if you’re not testing a large app, a simple vanilla-js test suite is probably fine:
For this article, we’ll use Tape in the examples, because it’s very simple. I based the example above on it, so the usage should look familiar.
TDD the RITE Way
RITE is an acronym to help you remember some important points to keep in mind. Tests should be:
- Isolated OR Integrated
A good unit test is readable. The following tips can help with that goal:
Tip: Answer the 5 Questions Every Unit Test Must Answer
A failing test should read like a good bug report. That means that answers to the following questions should be obvious at a glance:
- What component is being tested?
- What behavior of the component is being tested (test setup / givens)?
- What are the actual results?
- What are the expected results?
- How can actual results be reproduced?
I call these “5 Questions Every Unit Test Must Answer”.
Tip: Keep the code in your test to a minimum.
You should be able to handle setup and teardown using factory functions. For example, I frequently test the state transitions in my app using reducers and factory functions.
I use a factory function to create the initial state for the test. That way the setup logic doesn’t add much clutter to the test itself. You only need to see the salient bits — the stuff that matters for the particular test at hand.
Imagine you’re building the rejection game app, where the user gets points for having requests rejected by other people. There are two kinds of state that matter in the game: a record of the user’s requests, and the user’s profile data.
If you’re testing some code that just updates the user profile, you can safely ignore the bits that deal with the user’s requests. By using a factory with defaults to create the initial state, you can keep unwanted details out of the test.
So imagine this is our default state:
We could create the following factory to create the initial state for each test:
Now we can call the factory to generate whatever arbitrary state we need for the test:
And override just the parts of the state we need to for each individual test:
Tip: Favor equality assertions over fancy assertion types.
Equality assertions by nature answer 3 of the 5 questions every unit test must answer:
- What was the actual result?
- What was the expected result?
- How do you reproduce the findings?
Since this information encapsulates so much of the critical data that a unit test provides, I tend to favor equality comparisons over all other assertion types. Once in a while, I test to see whether a function was called at all using pass/fail assertions, but better than 90% of my test cases are equality assertions.
Tip: Explicitly name your
This information is so critical to understanding what a test does and how it works that I don’t like to hide it in the assertion calls. Instead, I assign the values to explicit variables so that it’s obvious at a glance which values are which, and how the values are computed.
Isolated / Integrated
There are 3 major kinds of tests, all equally important. Functional/E2E tests, integration tests, and unit tests.
- Unit tests must test isolated components.
- Functional/E2E & integration test components must be integrated.
- All tests must be isolated from other tests. Tests should have no shared mutable state.
Isolated components means that we’re testing a unit of code (think module) in isolation from other parts of the system. You test them at the black box interface level. Each component is treated like a self-contained mini-program.
Instead of testing the whole program as we do with functional/E2E tests, we test units in isolation from the rest of the program, and in isolation from other loosely coupled modules.
To satisfy RITE way requirements, unit tests must:
- Run fast in order to provide the developer realtime feedback as they code.
- Be deterministic: Given the same component with the same input, the test should always produce the same result.
Because unit tests need to run fast, and need to be deterministic, they should not depend on the network, access to storage, etc…
The black box has only one-way communication: Something goes in (generally arguments), and something comes out (generally a return value). Your tests should not care what happens in between.
Unit tests should focus on behaviors that are mostly pure:
- Given the same inputs, always return the same output
- Have no side-effects
Obviously, some parts of your code will have side-effects. Some parts of your program exist for the purpose of communicating with some API over the network, writing to disk, drawing to the screen, or logging to the console.
For components with side-effects, it’s usually better to forget about unit tests, and instead rely on functional or integration tests.
Why? Because if you try to test code which is tightly coupled to non-deterministic process, two things happen:
- Tests are no longer deterministic, meaning that tests can break even when the code works properly. Unit tests should never break for those reasons.
- Unit testing code with side-effects requires mocking. Attempting to isolate tightly coupled code from non-deterministic processes requires lots of mocking. Mocking is a code smell.
Mocking one or two small things is fine. Sometimes I pass a fake function into a function just to see if it’s called with the right value. However, creating complex mocks for complex systems is bad. Don’t do that.
Mocking is a code smell.
If you find that you have to mock a lot of things to test a little thing, that could be an indication that your application is too tightly coupled. You should be able to separate things like network/database/API communication from the logic that processes the data returned from the network/database/API.
The part of the code that processes that data can and likely should be implemented using deterministic processes & pure functions.
Isolate side effects from business rules and domain logic.
Isolate side effects from business rules and domain logic, and you’ll find not only that your software becomes easier to test, but also easier to debug, easier to extend, and easier to maintain.
Why? Because you’ve minimized the impact of randomness and non-deterministic processes on the reliability of your code. Your code will be less vulnerable to bugs, and easier to test, step through, and debug when something does go wrong.
However, if you try to test absolutely everything using unit tests, you might be tempted to contort your application architecture so that it’s easier to mock out big chunks of stuff. I’ve seen teams make their application architecture much more complicated than it needs to be, sacrificing developer experience and maintainability on the altar of TDD.
The heavy-handed dependency injection and mocking you’ll see in many Angular apps is a symptom of this problem.
Instead of Mocking:
If you’re tempted to do lots of mocking, instead of mocking, ask yourself:
- Am I mocking out stuff with side-effects that could be better tested using functional / integration tests, instead?
- Does this component do more than perform side-effects? Are there business rules or data processing that could be pure, and could be easy to unit test if extracted and isolated from the side-effects?
Here’s a simple rule to keep in mind:
As you make your app more testable, it should also make the app simpler and more maintainable. TDD should improve architecture: never harm it.
What About Angular?
Most of the Angular apps I’ve seen have far too much mocking, because Angular encourages mocking all the things with its integrated dependency injection system. Instead of relying heavily on dependency injection, Angular users should create dumb components for display, which can be trivially tested with no mocks. Data processing & business logic can be isolated from components using something like ngrx/store. Side effects can be isolated from everything else using store middleware or services.
Functional Tests Must be Integrated
The idea of functional / acceptance / E2E tests is that they make sure that the whole app works when all the components are working together. That means that you need all the parts of the app running, from database to UI, with all of the required services hooked up and live.
Your app should be a complete working deployment, including a real database. If you need to test a fake user, inject a test user record into the real database.
Because the integrated app will incur penalties from network latencies, and real delays, functional tests tend to be too-slow to provide realtime test feedback to developers as they code. For that reason, I keep functional tests and unit tests separate, and run them independent of each other.
Unit tests provide a realtime test feedback console for developers as they code. Functional tests provide end-to-end acceptance and integration tests to validate that the user stories have been satisfied. Functional tests typically require a deployment of the app to testing servers during automated continuous integration tests.
I strongly believe that critical happy paths (user signups, purchase flows, etc…) should be smoke tested immediately after each production deploy, and it’s fine for those tests to concentrate primarily on the typical expected behaviors, but generally speaking, your app has a lot of unhappy paths:
- Network failures
- Wrong / incorrect user inputs
- Out of range values
- Disk errors
A thorough test suite will take such things into account. A function that takes a number should be tested with
0, negative number inputs, positive number inputs in the expected range, and very large / out-of-range numbers. You need to know how your app will behave when the unexpected (or malicious) happens.
Your unit test should contain everything you need to know to reproduce the results. Avoid magic. Avoid references to shared state — especially shared mutable state between unit tests. Instead, use factory functions (as mentioned above).
For this reason, I tend to shun
afterEach(), preferring instead for each test to handle its own setup/teardown. I’ve seen too many suites using
afterEach() accidentally mutate shared state between tests, only to cause problems when the order of test execution gets rearranged, or when tests are run in parallel in order to save time.
Tests which contain everything you need to know about their state, setup, and teardown are also much easier to understand, debug, and maintain over time.
Now you know, tests should be:
- Isolated (unit tests) OR Integrated (functional / integration tests)
You should also remember that your app architecture should enable RITE way testing. Program logic should be independent of side effects & I/O. You should be able to write isolated unit tests without mocking much. If you can’t write tests like that, it’s a code smell. Perhaps you should rethink your approach and work harder to decouple the parts of your app that can be deterministic.
Want to learn a lot more about TDD and see it in action on real projects?
He spends most of his time in the San Francisco Bay Area with the most beautiful woman in the world.