TDD Strategy in Real Life
TDD Strategy in Real Life
How do we use TDD when writing code in a complex system? How do we make decisions when constrained by existing design? When are we really done?
Join the DZone community and get the full member experience.Join For Free
Discover how you can take agile development to the next level with low-code.
TDD is always described as a design method for units, whether these are classes or modules.In the real world, however, we don't write just classes. We're writing complete flows that consist of classes, APIs, generated code, and data schemas. We still want to get the benefits of TDD, so how can we fit it into our work?
TDD comes from the world of unit testing. It’s optimized for small pieces of code; small increments of functionality. BDD takes the test-first approach, adds functional and user semantics and tries to follow the same formula for the whole software.
TDD helps us build a class or a module. But in real life, we’ll need to build complete scenarios. BDD helps in defining those flows, but doesn’t help us design the software, like TDD does. Not to mention if we’re constrained with existing systems.
There’s an impedance mismatch. On the big picture side, we have stories (or even epics) that take a while to complete (especially if you don’t write them from scratch, and you already have components you need to maintain, upgrade, and interact with).
On the other side of the scale, TDD allows you to design components, but it doesn’t provide the feedback about all the flow. Also, it doesn’t tell us if the emergent design is the “right” one. (PSA: There is not only “one” design, this is not Highlander.)
So what approach should we take? We want to use TDDfor its benefits, but we have existing constraints we need to adhere to. If we’re writing a complete new feature, we don’t have any design at all.
Here’s my approach. It requires some planning and thinking ahead, which never hurt anyone. Much.
Let’s take it step-by-step.
Step 1: Identify the Important Stories
First, we need to identify the main stories (flows) we’re going to write. If we have them in a form of BDD tests, great. If not, we need to at least have a list of the stories/requirements that are important.
Why do this before designing a big solution that answers all the requirements? If we start before identifying the important stuff, we’ll come up with an over-engineered architecture that has a lot of unnecessary code (YAGNI) and what it always bring to the party: bugs.
Like with TDD, our intention is to write the minimal code that works. However, in integration or system tests, we need to develop the minimal design that works.That means we’ll try to build incrementally, completing a story before moving to the next, and NOT completing a component for all requirements.
One more thing: it is very important we state the requirements or stories as a flow in the system. That means operations that the user or the system does, where data is transferred and operations are completed. If that is not the case, we’ll find ourselves in this stage again after visiting step 2.
Step 2: Definite the Acceptance Criteria
This is extremely important, because if we just stick to “this flow should work” we’re leaving it open ended.And with that, we’ll be out of focus, building things we don’t need. Like with TDD, we specify how we expect to execute and check the behavior we’re interested in. And that requires that we know what the acceptance criteria is.
With BDD-style tests—which are really examples—it’s easy. Although even with those, sometimes we leave the test in the Gherkin stage, and only later when we have something to connect do we do that.
With plain stories and requirements, we run the risk of implementing something that is not needed. And, by specifying an acceptance criteria, we get the sense of the effort and complexity involved in the implementation. If we can’t say how we’re going to test the story, that’s a sign we need to do more thinking before jumping to development. Those might lead us to re-prioritize stories in step 4.
Step 3: Identify the Main Components That the Stories Flow Through
They can already be completed, half written, or nonexistent. A story flows through the components, and it may either use them as-is, or it may need interface modification or extension, new coding, or a full rewrite.
While this is a design step — we’re proposing a solution to how we’re going to make the story work — it may not be the final one. We’re doing just enough design up front. This helps to confirm the feasibility of a solution.
When we map the stories to components, we can see options for design. We can see how the different pieces of the puzzle fit, and if we have problems we need to attend to.
Step 4: Re-Prioritize the Stories
“Wait, now the developers prioritize?”
I know, it sounds a bit anti-Scrum, but hear me out.
Even after someone (product owner or similar) had already prioritized the stories in terms of value, there’s still valid input that can come from the dev team.
Sometimes, there are dependencies between the stories.While story #1 is more valuable than #2, we won’t release without the first 10 stories completed. That means we can play with the order if there’s a good explanation for it. In this case, if we can’t easily test or develop story #1without having #2 in place first, we’d probably want to develop #2 first.
Note, we’re not questioning the value of the stories, rather how quickly we can complete them. If we deliver complete stories early, we get feedback early and can make decisions based on this feedback.
If, however, everything we do is suspect to change, and learning is what leads all the stories, then the higher value stories' delivery is more important, and we go with those.
Agile note: If you want to think about it as the team providing information about risk and effort and then the PO re-prioritizes the list — fine. Eventually, in a good agile process, the whole-team approach works best with backlog prioritization.
Step 5: Component Decisions
Not going into the emergent discussion again, there is still room for some upfront design. Before we start coding we can make additional design decisions. For example, if the stories go through an existing component, one that is buggy and doesn’t have any tests, we need to do something about it. If we just patch on code (TDD or not), there is still risk of breaking existing functionality. We can decide to wrap this component in tests, rewrite it, or build around it. This is not a design decision we’ll make as part of the actual TDD cycle, but it has an impact on how our component interacts with the rest of the system.
Other decisions we might make are if we’re not satisfied with the current design (are we ever?).
We can think about breaking down big modules or combining small modules, as part of the work we’ll do.This is architectural refactoring—there are changes we plan to do in the architecture, without modifying existing functionality. We’re doing this because we want to get a sense for where we’re going. We want boundaries to guide us. Constraints are helpful, remember?
Step 6: Interface Decisions
We can also start identifying which connection points to use. While this is not a full interface definition between components (it’s a bit early for that), we can decide on reusing existing interfaces, or adding or modifying them. Of course, if all the code is new, we can define whatever we want.
In TDD, we define the interface of our components through tests. If the interface depends on usage by existing consumers, this has an effect on our design.
We can agree on interface contracts if these can be“managed.” Why the quote marks? When we discover we need changes to the interfaces (and we will), they should be easy to change—meaning, not too much work, or not introducing big risks. We don’t want to define interfaces that take weeks to re-discuss, or retest. If the interfaces are defined within a team, these are easily managed, so go ahead. If not, make sure not to close the definition too early.
Step 7: Write the Code
Finally, we’re getting to the coding bit.
Remember, we’re doing it by the book. We’ll write just enough code for the story. That’s right — not a whole component at once. If you can use TDD at this point, great. Just stop when you get to the end of what the component needs to do for that story alone. If test-first won’t work (for any reason) make sure you write tests when appropriate.
The worst we can do is to write the components “fully” for all the stories. What we’ll end up with is “mostly working complete" components.
Step 8: Integrate Continuously
I don’t mean that in the CI-tool sense. Make sure you are always integrating the whole story to see how it works.You’ll learn what works, and what needs to be changed. Then you adapt.
Since you and your team are focused on only one story, there’s a lot less code to integrate. The integration is far less complex than integration of multiple, full-bodied components.
Obviously, we alternate between steps 7 and 8 until…
Step 9: Complete the Story and Move to the Next
“Complete” means having some proof that it — the entire story — works. The proof should come in the form of an automated end-to-end test and a product review in order to get feedback. This feedback can(and will) change things, in terms of what comes next.
Then again, the waste is not going to be big if we built just enough for the first story.
And that’s the important thing. Every code we have is a liability. It can contain bugs, it will require change, and we’d like to write as little as we can of it, just to make sure we’re on the right track. TDD helps us with the design and with creating a risk mitigation (in the form of a regression test suite) that will help with the upcoming changes.
All the upfront thinking, considerations, identifying constraints and boundaries—they are all helpful in using TDD in the right context. Otherwise we’ll be building the wrong thing right. We don’t want that waste.
More Agile Goodness
If you'd like to see other articles in the guide, be sure to check out:
Opinions expressed by DZone contributors are their own.