Test-driven development is a popular yet complex approach that’s easy to ruin with poor implementation (as we explained in our previous article).
But if that didn’t stop you from wanting to implement TDD on your project, we admire your determination. In fact, we’re going to help you implement it. Learning TDD from the general top-rating articles in Google won’t do the trick, so we asked Sombra’s developers and team leads about their TDD experience and complemented their insights with our CTO’s perspective. Dig in!
Things to consider when starting with TDD
As you may have noticed, TDD isn’t something you can start carrying out on the go. Before deciding to use it, you and your team need to understand a lot of details. The most important of them are:
- Mock/spy elements
- Unit tests
- Integration tests
- Available test frameworks (e.g., Mockito)
- and a lot of others
We’d like to share some important but not popular (in most blog posts) TDD techniques.
Disclaimer: Please note that this article is not a complete guide to TDD. If you’re new to TDD, you’ll have a lot of questions about certain screenshots or techniques that are unanswered in this article. Here, we merely demonstrate the complexity you have to deal with as an engineer when implementing a very simple order checkout API endpoint using TDD correctly.
1. Don’t fall for seeming simplicity
All Google articles describe the TDD approach with the simple “fail-pass-refactor” diagram (include this one). But do you really expect everything to be that straightforward?
The entire TDD process looks more like this:
It’s an extended diagram of “fail-pass-refactor” that explains some very important “behind-the-scenes” details.
Following this diagram will make your development more streamlined and your codebase cleaner. Naturally, it requires some time and practice to implement. But having an experienced mentor guide you through this process will make things much easier.
2. Start from the top
The first actual TDD step is writing a single failing test. We already hear you asking: “But where do I start, and which code should I write this test for?”
Let’s imagine you’re implementing a new feature on the backend, a feature your end users are supposed to use later on:
This is an online shop’s simple order checkout feature, so you have a cart with products, their quantity, and product price. You’d like to save this order on the backend and return the saved order ID to the frontend.
Our recommendations are:
- Before coding, clearly define a user story and evaluate how the user’s request (an HTTP request, in most cases) from the frontend moves through your system.
- Make a controller method receiving a request as a starting point for your test. The best thing about this approach is that you don’t have to think much before implementing it – you only concentrate on the API endpoint for now.
- As soon as you design your first API endpoint structure, you should write a failing test case. See this image for more clarity.
An example of a test case written for an API endpoint method. Note that while writing this test case (and not the method itself), you should come to the conclusion that all the controller should do is delegate all the logic to the service layer.
4. Launching this test will fail, resembling steps 1 and 2 from the diagram above.
5. Now, you should proceed to step 3 with just enough code allowed to pass the test, ignoring the acceptance criteria.
Added the createOrder method code (just enough for the test to pass). And yes, we know there’s a bug in returning the null method. The correct way would be to write another test case that verifies if the result of the orderService.createOrder method is returned as the result of the orderController.createOrder method.
This is where many engineers fall for the rookie mistake and start coding fast – implementing service methods, writing database access code, and other business logic to finish the feature (especially if it’s a simple feature like this one). Remember: at this stage, you don’t have to proceed to writing code inside the service method. You should again write a failing test case for it, and so on.
3. Use tests properly
Hold on, let’s distinguish between unit tests and integration tests first.
Unit tests are the core of the TDD approach, bringing the most value. The ones that are entirely independent of each other, any context (environment variables, references to other tests or variables), and execution order. Even if you write five unit tests testing the same method, they should be completely independent and executed correctly in any order. If your unit tests aren’t like that, refactor and “clean” them now before they aggregate.
As for the unit test, code has to be primitive and minimally acceptable to test one specific thing. And here’s the main difference between production code and unit testing code:
- Production code
- Should be as clean as possible
- Should contain fewer lines
- Shouldn’t be copy-pasted
- Unit testing code
- Should consist mostly of “throwaway code;” there’s no point in heavily refactoring it or reducing the number of lines or removing duplication
- Copy-pasting unit tests is a good practice because they won’t depend on one another (e.g., by sharing some common refactored method) and will be easily readable
As you can see, the setup parts of unit tests in these two unit test cases are completely identical. This isn’t good practice in production code, but it’s perfectly fine and practical in the unit testing code.
It works like this: you write the second test by copy-pasting the first one, change its name according to what it should do, add the missing details and implement the code for the test to pass.
Then, you run all tests, including the previous ones, and if a test fails, you go back to the production code to fix it so that the test suite passes. When it does, you can continue writing the next failing unit test.
With integration tests, everything is different. We need them to check how parts of our system work together. Unlike units, integration tests depend on the environment and should be in the code repository as a separate module.
It’s best not to use 3rd party services in integration tests because they can have different parameters or URLs. And again, with integration and smoke tests, you should work more as with production code — maintain a decent level of abstraction to be able to refactor or write additional tests easily.
4. Be aware of the “learning code” concept
Tell us if this sounds familiar: you’re working with a third-party library and have no clue how to approach it. Or you need to write code sending requests to a third-party API, and you aren’t sure what code you should write, let alone writing failing unit tests for it first. This is especially common if the documentation for the library or API is not good and you need to experiment first.
In such cases, it’s natural to write code that uses a third-party library or API to understand how it works. But often, engineers leave the code in production and write unit tests afterward, which violates TDD and may lead to side effects. We recommend creating a separate main method isolated from the rest of your application and experimenting there.
You may argue that it’s still against TDD, but the main difference here is that we create “learning code,” which won’t be committed, pushed to a repository, or released to production. Using learning code can help discover how everything works. You can later delete it or comment for future reference.
After understanding the logic, proceed with TDD in your production code using a third-party library or API for way cleaner code. Our experience tells us that mixing experimentation code with production code may (and most certainly will) lead to many trivial bugs.
5. Working with code not written according to TDD
Rewriting the code not written according to TDD or not covered with tests is a common problem. Seriously, how do you rewrite it with a test-first approach if the code already exists?
Usually, engineers cover this code with unit tests to make it look like it was written using TDD. But if there was no proper refactoring, it becomes just another chunk of poorly written code tied together by poorly written unit tests.
Let’s imagine that the createOrder method isn’t covered with unit tests.
There are two ways to make it right:
- Delete and rewrite all the functionality from scratch using TDD.
- Write this method again following TDD with the same signature but a different name, e.g., createOrder1. When everything’s written in compliance with TDD and all unit tests are passed, delete the old method createOrder and rename createOrder1 method to createOrder. This is how we avoid broken code here at Sombra.
The createOrder1 method is now written according to TDD, having a suite of unit tests and much better quality.
The approach is especially helpful when refactor something bigger than a three-line method. The only drawback is that, at some point, you’ll have quite a mess with every method duplicated. But then you roll up your sleeves, clean everything up, and that’s it.
6. Be wary about testing everything with white-box approach
- The black box approach
In the black-box approach, you should treat the method literally as a black box and not look at what’s inside. When writing a test, send input parameters to the method and check the result. To correctly test a method using a black box, you need to either read its documentation or determine it’s behaviour yourself. This way, you’ll have multiple unit tests with different invocations of the method and numerous input parameter combinations. The important thing here is testing not the internals of the method but only input/output combinations.
You can see that no mocking takes place here and we’re just testing the Order class functionality as a black box.
- The white box approach
In the white box approach, you check the inner details of the implementation, i.e., the invocation of every line of the method and if all parameters were passed correctly inside. A typical example of the white box is examining whether the method you’re testing calls another method with the correct parameters or not. You’ll usually notice a lot of mocks in such unit tests.
A nice example of white box testing — mocking a repository and checking that inside the createOrder method a constructor of the Order class is called with correct arguments.
When you start with TDD, there’s a temptation to cover everything with the white box approach to testing because it seems easy and straightforward. But be careful: you should use black-box testing as your default approach and only use white-box testing in very specific cases where black-box will not give you the desired coverage and testing.
With a black box, it’s much easier to refactor or change method code because you don’t have to change unit tests. In other words, with the black box approach, all tests are passed despite the changes to the method body. This is convenient since they validate how the method behaves, not what it consists of.
On the other hand, testing the exact implementation of a method or class requires the white-box approach, allowing you to test each line of method code and check if the parameters are passed correctly. It should be used to test the critical parts of the software. So, if the order of invocations inside the method is important or a specific parameter passing logic is critical, a white box is your way to go. This comes at the cost of harder refactoring afterward.
In a nutshell, the white box approach ties your test to the implementation details, while the black box one allows you to refactor code painlessly without breaking tests. Overusing the white box approach leads to refactoring problems, so we recommend going for the black box as much as possible.
7. Deal with lambda functions
We’ve come up with two ways to handle lambda testing.
- Taking the lambda out into a separate class field and appointing a name — the static class field — that can be physically mocked. When we call the method, we can set the mock of another function via Mockito, PowerMockito, etc., instead of this lambda. While the option works, you get some ugly code in the end.
- Going with the transitive approach: black-box test all units using the lambda. By testing everything around the lambda function, you indirectly test the lambda.
An Example of the Order class and unit tests that test the complex lambda logic using the black box approach by testing the Order constructor.
In fact, 90% of cases can be solved using black-box testing — take it from our experience.
And this is a good example of why TDD is hard to learn from theoretical articles or on your own — when we stumble upon some uncommon logic with call-backs or lambdas, we usually won’t stop and spend another hour carefully considering how to cover it. Especially when you have pressing deadlines. Most engineers will cover such cases on the go, adding more complexity to the code and tests without having proper coverage.
In this piece, we did our best to cover some non-trivial practical cases scarcely described on the web. And the takeaways are simple: TDD requires working in pairs, and you must have someone experienced in TDD at all times. Some problems, like lambda testing or choosing between black-box vs. white-box unit testing, could be hard to crack. And if you lack experience in TDD, you need to have another motivated engineer working and learning on the same codebase by your side.
Getting TDD right from the start (even after reading both our articles) is not easy. So, instead of experimenting with your teammates, you can find a team leader experienced in TDD and build a team around them. And you’re always welcome to consult our CTO, Yuriy Nakonechnyy, who will help you navigate the right direction.