When writing new code, it can often be hard to know where to start. Without having a clear strategy for testing and implementing a new feature, it is too easy to flounder, wasting time on indecision or writing dead-end tests. In the world of test-driven development (TDD), the first test is especially important because it implies what the next test will be, guiding your implementation.
We’re going to look at two popular strategies for writing tests and talk about their trade-offs and when to use each.
To get a general idea of their differences, let’s look at an example user story, borrowed from Steven Solomon's post about one easy technique for increasing efficiency on your software team:
Given I am logged in
When I click on the "previous orders" link
Then I see a list of my orders
So, what should be the first test?
Let’s say that we already are familiar with the database where we fetch information about the orders. So we start there, by writing a request from our back end to this database. Then we pass this list of orders up through our application layers until we display this data on the front end. This is called inside-out testing, since we start at the farthest layer from the user.
Alternatively, we could write a feature test (a test that imitates the behavior of the user) that would fail until we had implemented the entire user story. This feature test would first fail because there isn’t a “previous orders” link.
Once we have built the UI, we will need to make a request to the back end, which will in turn fetch the data from the database. In this way, the tests guide us deeper into the app until we’ve reached the point where we have finished the feature. As you’ve probably already guessed, this is outside-in.
Both outside-in and inside-out testing have their place. Let’s examine some of the trade-offs and some situations where one might be better than the other, starting with inside-out.
Going layer by layer, working toward the user, has advantages:
- Focus: One only has to worry about one layer of the application at a time.
- Parallelization: Work can be done across layers even if one layer is blocked.
- Fast feedback: Since the scope of testing is layer specific, the feedback loop of ”red, green, refactor” is very quick, and a whole layer can be written and shipped before moving to the next.
However, the granularity of inside-out comes at a cost:
- Rigidity: If the data model changes, all the layers will need to be changed to reflect the new understanding.
- Slow delivery: While each layer could be deployed independently (bottom up), only once all the work is done can one get user feedback.
- Temptation to be heavy: Since we start at the bottom and don’t exactly know what the data will be used for down the line, it’s very tempting to return the whole data model and let the front end display what it needs.
- Investment: If the feature gets changed or canceled, there might be a lot of cruft to remove, since unfinished work has already been shipped.
When is inside-out TDD the way to go?
Inside-out TDD is especially useful when you aren’t sure of the final design—either the system or the visual components. It can also be useful when you don’t know how it is all hooked together, since you can start with the level that is most familiar and build up from there.
Inside-out testing is also really good when you know what the interface is (or have had one assigned for you to use). Since the scope is limited to a single layer at a time, it is easier to implement and get the tests passing. For example, if you are working on an endpoint that two different teams need to leverage, it might be best to build and ship the feature layer by layer to make sure other teams aren’t blocked.
Another use case for inside-out is when the project is specifically domain driven—for example, the story is“show me the whole of this data object on a page.” Something like that without any kind of data manipulation is a great candidate for inside-out, since the data model itself will be determining what is displayed on the page.
Start from the end—the “end-user,” that is.
- Domain boundaries: Naming and data structures are built to best suit the front end, so any destructuring or mapping is pushed into the back end (where it belongs!).
- Lightweight: We test only against data or code we know is needed for the user, so it’s easy to avoid YAGNI (you ain’t gonna need it) code.
- Iterability: Features are easily extendable, since they’re built top to bottom.
- Independence: Each application layer is built out separately (using mocks and stubs to test), so they can be loosely coupled.
This focus on the feature as a whole has some trade-offs, especially relating to time:
- Red for a long time: Feature tests stay red for longer, since they won’t pass until every layer service test is green
- Slow: Each layer needs the layer under it to be stubbed, which means writing a lot of supporting code just for tests.
- Prior knowledge: Building a new feature requires knowledge of the system’s layers and how they interact.
When to use outside-in TDD
The outside-in TDD strategy is best used when one is working on vertical user stories, especially when specific information is required on the front end. By focusing on the user experience first, we protect ourselves from writing extraneous code.
Outside-in is also helpful when you aren’t sure where you’re going to get your data. It ends up being the last part of the process! This also means that we decouple the layers of the application, so you can swap out data sources easily and have confidence that the rest of the code will work as expected.
I almost always advocate for outside-in testing when working on a story that has acceptance criteria. It focuses on how the data will be used, not on how it happened to be stored. I find that it guides my work more efficiently, since I know how I want the end result to be at the beginning. From the very first test to the last database call, every line of code I write is in service of that.
We’ve gone over both inside-out and outside-in testing strategies and the trade-offs that each of them has. Inside-out focuses on one piece at a time, whereas outside-in focuses on the whole system first. There is no “right” answer, and both can be effective depending on familiarity with the code, the collaboration style of the team, and the nature of the story itself!
Just as every journey begins with a single step, every feature begins with a single test. I hope knowing the strategies of inside-out and outside-in testing and their strengths and weaknesses provides at least two pathways to start that journey toward shipping code.