Test-Driven Development (TDD) is funny: Most developers hesitantly agree we should do it more often. Yet, I rarely see a whole team doing it. It seems to me, most of us still don’t understand why it is so powerful. As a result, some of us still reject it completely, and most of us only use it when we are working on the most simple things. While, in fact, we should do the exact opposite.

What Most People Don’t Understand about TDD

There are so many misconceptions about TDD out there that I feel the need to get the most destructive ones out of the way.

TDD is not slower, given you strive for the same quality. The State of DevOps Report also debunked the „quality vs. speed“ misconception. In a nutshell: To go fast, you need to go high quality. You need tests to achieve high quality. If quality drops, you inevitably go slower.

Test-Driven Development can not only be used on simple problems. It can be applied to much more than well-understood problems where architecture and design are clear from the beginning. It doesn’t make your code less flexible, and it certainly doesn’t make changes harder. In fact, it is most effective on complex problems that are not understood. Why?

Because TDD is a design technique.

Here’s a very good video from Dave Farley, explaining that in much more depth than I ever could.

Wait! Test-Driven Development is a Design Technique?

The moment you start to write tests, you have to re-think the design of your code “top-down”. You are forced to think about the logic you want to encapsulate in a given component or class. You are forced to think about the API of the component that will control its behavior. Every time you want to add logic to the component, you have to think about how it will be invoked. To put it in other words: TDD makes you look at your code from another perspective.

On the contrary, if you don’t think about testability, you do not have to approach your design on how your code will be used. Instead, you can let it evolve organically. This may work for you but usually yields much worse results. Here’s why.

How Does Test-Driven Development Aid Your Design?

Recently, I was working on a small UI after more than a year of blissful abstinence from LWC. I didn’t work test-driven, and when I finally wrote my tests, I realized: My component had way too many responsibilities. It was wired to four apex methods, loading data and dynamically rendering contents based on the data received. Mocking those apex methods is not only a lot of boilerplate and makes your tests very hard to read. It literally made me feel uncomfortable.

Those tests uncovered plenty of design flaws in my code. And here’s the thing that most people don’t understand: If I had started with tests, I would have uncovered them much earlier. My code was bad, and my tests brutally showed me. Sure, if I had been just better, I probably would have gotten there without learning it through my own tests. Now I had to spend much more time refactoring toward a better design. I could have saved that if I would have started test-driven.

And that’s the thing: Tests help you to improve encapsulation, reduce coupling, and write overall better code. It makes it easier to design a concise input and output API and encourages you to design for reusability. And as you get better at writing your tests, it also makes experimentation with different designs much easier, enabling you to learn faster.

Is TDD your silver bullet to achieve the best designs? Of course not. But if you use it properly, it makes your life easier.

How To Use TDD Effectively

As explained, TDD makes the process of writing better code much easier. It uncovers flaws in your architecture very quickly and brutally. This gives you actual feedback on how usable your code is.

If you are a very experienced developer, chances are you don’t need this technique to write good code. When I write Apex, I don’t struggle with encapsulation, single responsibility, and good testability. It all comes naturally because I am used to writing tests. But since I am a lot less experienced in LWC, TDD helps me a lot to improve my design there.

To apply TDD effectively, I found it very helpful to listen to your feelings while writing tests. I have experienced it myself, and I have observed a lot of developers rejecting test-driven development because of certain feelings they experience when trying to write tests for the first time.

The funny thing is: They use these feelings as an excuse to not write tests at all. While instead, they could use them as a driver for better code. Let me explain the most common feelings. They all point you toward a particular design flaw, that you need to address.

I Cannot Even Describe This Test

The most common feeling I regularly observe. It basically tells you, that your code does too much, as no concise responsibility and there are too many side effects going on.

A good test should be named after the functionality under test, the relevant input, and the expected output. If you can’t come up with a concise name, there’s something wrong with your code.

  • If you have problems describing the functionality, chances are there are too many things going on in your component or class. Reduce the responsibilities, extract subclasses or components that are tested separately.
  • When it’s hard to describe the relevant input, your component depends on too many things. This can either be reliance on static variables or dependency on badly encapsulated functionality.
  • If you can’t precisely name the expected output, the component or class does too many things at once. Remember: It’s not about having a „single assert statement“. It’s about concisely describing, what is supposed to happen.

Use this awkwardness. Think about how you can reduce the complexity of your code. Extract logic and responsibilities, until identifying test cases becomes easy again.

How Should I Even Test This?

A good component or class has no side effects and shouldn’t depend on the internal state of an imported dependency. Something goes in. Something comes out. And if you’re lucky, everything is deterministic. Yet I’ve seen components that @wire five apex methods and dynamically load more forms based on data of one of the loaded records.

Every time you have to awkwardly mock the results of multiple apex methods to test if a certain area is rendered, this is where you should get nervous. Or, if you are working in Apex: If you can’t directly prepare your test data with fixtures but instead have to awkwardly mock the results of static service classes you depend on. Or if you feel the need to use other services in your test setup … All these situations tell you one thing: Your design sucks and your code is bad. And you should feel bad.

Use this bad feeling to decouple your service or component from some of its dependencies. For example, if you have a wired apex method in your component and use that data to dynamically render certain layouts: Extract the layout to its own component and make all logic controllable via an @api property. Now you can easily test and describe all edge cases in isolation. Your main component only references the already tested component. As a result, it is much easier to test.

You will be surprised how much easier it will be to test your original parent component or class.

There Are Too Many Tests

If you are like most developers, your code grows organically. Or to use a more fitting metaphor: It spreads like cancer. You didn’t pay attention for 5 seconds and suddenly you have another God object festering in your code base.

There are various ways how you can notice this, but I found the most obvious one to be: You stop paying attention to existing tests. As soon as you start to blindly copy&paste new tests at the bottom of your file, there’s something seriously wrong with you and your code. Acknowledge that, then use it.

Why are you not interested in the existing tests of the class or component you are changing? Most likely it is because you are weakening its responsibilities and separation of concerns. You are adding new things, where they don’t belong.

Why are there so many tests? Why do they look all the same to you? If the component has a well-defined responsibility, the tests could be redundant. If they aren’t redundant, they are most likely testing internal behavior that could be better tested by extracting certain functionality into subcomponents or subclasses.

A good rule of thumb is 2-3 variations of a desired behavior and class files that are no longer than 200-300 lines. When a class reached 400 lines of code, you should feel uncomfortable. When I find myself writing more than 3 versions of tests for the same functionality, I know it’s time to extract something.

My Tests Make My Code Unflexible

Ever had the feeling that a change was very small, but required you to change various tests in various places? You probably felt that fixing all those tests was unnecessary busy work.

To address that, we have to separate two categories of „failing tests“.

The first one is exactly why we test in the first place: So we can see if we broke something. A failing test tells you, that your „minor change“ broke something. Because you changed the contract (and the test directly verifying it failed) or because some code that relied on your functionality failed. Again, this is a good thing. It forces you to reconsider the changes you introduced before you could break them on Production, potentially affecting thousands of users.

The second one is more subtle and tells you —again— that your code sucks. Remember: A test does nothing but using your code. If your change is small but requires a major rework of all invocations, there’s something wrong with your change. Use that knowledge to refactor your code base, so you can more easily introduce change. This will make your code more flexible, and that’s what we are after anyway, aren’t we?

Summary

We all know that test-driven development is useful, yet most developers still fail to understand the practical benefits. They completely misinterpret some of the sensations. They could use them as a constructive force to guide themselves to finally gitting gud. Instead, they use them as an excuse to stay mediocre and produce unflexible, unstable, hard-to-maintain code.

If you know how to listen to your own feelings, you can use them to achieve much better designs much easier and quicker. TDD is by no means a silver bullet. As always, learning something new may be daunting and time-consuming. But it’s worth the effort because it makes high-quality outcomes more reliable.