42

Doing TDD in a kata is simple. A red test, small amount of code, green test, and refactor. Repeat. And that's it.

But, I work on a real application. With a REST controller, a service layer for business logic, a database layer and a converter from entity classes to data transfer classes.

How can I start TDD in such cases?

When a user story asks to add new functionality (like a new endpoint to do a complex operation on data) I don't know where and how to start.

Do I need to start from the controller? But it will do nothing and only call the service layer (so in test I will end by setting up a mock, I think). Do I need to start from the database layer?

5
  • 14
    There isn't one right answer - different programmers have different styles. In terms of where to start, you might want to read about "outside-in" vs "inside-out" styles of TDD, maybe try them both or a few variations, and see what you prefer.
    – bdsl
    Commented May 27, 2022 at 13:14
  • 6
    Can you write a test that will fail the new requirement? Commented May 28, 2022 at 10:36
  • 1
    @ThorbjørnRavnAndersen has made an excellent suggestion. Additionally, I wonder whether you understand everything that is in the existing codebase. If not, I recommend making guesses and then writing tests to verify them. Commented May 28, 2022 at 19:16
  • @SimonCrase, I know the existing codebase for working on it for two years. The code coverage is good. But the tests are written after the code and I want to switch to TDD.
    – fluminis
    Commented May 30, 2022 at 6:57
  • 2
    If you know how the current test cases are written then write a test the same way as other test cases for an imaginary (not yet written) feature. After that try to implement enough code to pass that test.
    – slebetman
    Commented May 30, 2022 at 8:06

3 Answers 3

61

Behavior

Start with behavior. Don't focus on structure.

What matters is some complex operation is supposed to transform some old data to some new data. You can create a test by finding examples of the old and new data.

Where that data is stored, file, DB, or memory, is an implementation detail. It doesn't have to leak into the test. Keep that out and you can change how the data is stored without having to touch the test. Fail to keep that out and the tests will actually make refactoring harder.

You may find some way to decompose the complex operation into multiple testable steps. If you need that to diagnose errors go for it. Don't feel like TDD demands it though. This may make diagnosing a problem go faster but it locks down implementation details. Now the 3 step complex operation has to be a 3 step complex operation. If you ever figure out how to make it a 2 step operation you'll need to come back and remove some of these tests.

Removing them not only improves flexibility but speeds up the test suite without costing you coverage. But once these micro managing tests have been created they tend to stick around. Consider giving them somewhere harmless to be where they wont be run unless needed. Just find some way to get them out of the main suite of tests.

Yes code katas are simple. But TDD expects you to take your real application and break it down until the part you're testing is simple. Then you build on that by adding more and more. Yes, that will change the code you can write. That's the point.

Now that said, there is some dry boring structural code that doesn't need to be wrapped up in a test of its own. Test the interesting behavior.

Mocks

Does that mean never mock? No. Tests need the "unit under test" (which is not necessarily just one class) to be unit testable. That is, the unit should be fast (run-on-every-compile fast), and deterministic (always does the same thing), always ready for testing (no configuration magic) and should not care about whatever else is running (parallelizable). The best argument for mocking is that the unit won't be those things without the mock.

Another argument often made for mocking is to confirm that something was called. This is thorny because sometimes it's right and sometimes it's wrong. If we're testing behavior it's none of the tests business how the unit gets it's work done. Period. Full stop. Except... well sometimes that method call is the behavior of the unit. What gives?

Many tests are written in a strictly functional style. Input goes in the arguments. Output gets returned. And side effects are evil! Avoid at all costs!

But TDD is used in codebases that aren't purely functional. And sometimes, just sometimes, a unit doesn't return it's output. Instead, it calls a method on an output port. One way to test such units is to mock that output port.

Those are my two excuses for mocking: to improve the testability of the unit or to capture the output to test. Being mockable is not a good excuse to mock.

Granularity

But some say: every class has an interface. Every interface should be tested. And in isolation or it's really an integration test.

Then I say: I've worked in shops that insist on this. I understand the urge to not trust developers to do testing properly and the desire to have rules that are easy to verify. However, us lazy programmers are often smart enough to realize that in such an environment the lazy thing to do is to just not create many classes. Solve the problem procedurally and you can avoid writing the explosive number of tests this philosophy would demand. No static analysis tool will ever catch you deciding against extracting a class because your shop made it too much of a pain.

In short, there is no substitute for developers who care about doing this right. Rather than demanding we conform, inspire us to care.

This isn't to say you can't test very granular behavior. I'm just saying the way to identify that behavior isn't by ensuring every single class has a single test class. Sometimes a class is the interface for many classes.

Types of tests

As for the unit vs integration test distinction, I've seen them defined many ways. The most useful definitions will give you two separate piles of tests. Fast ones that you can run with every compile and slow ones that you can run with every merge. Keeping those separate is important because nothing ruins a fast test suite like a slow test. I don't really care what you call them.

The point of a test

A passing test should make you feel like you can trust the unit to behave. You should feel like you don’t need to read it. You should feel like you trust it the way you trust your languages print command. That should free you to focus on the suspect code. Write tests that will make you feel that way.

Conclusion

TDD isn't everything. There are many other successful ways to develop. And you can successfully mix them. But if you feel like TDD only works on toy katas you need to play with it more.

3
  • 13
    The very first line of this answer is the most important. If you focus on structure, your tests become brittle and any refactoring has a ripple effect on your unit tests. Commented May 27, 2022 at 15:44
  • 1
    It is acceptable to test parts of your structure and implementation details, though (e.g. if you write a function to get the first Unicode codepoint of a string, you can test that). Units can be made up of, or use, smaller units, if you find that makes sense. (If you remove that code because you no longer need it, don't be afraid to remove the corresponding tests.)
    – wizzwizz4
    Commented May 30, 2022 at 9:47
  • 2
    Also don't be afraid to write tests while developing and remove them when you are done because other tests cover the unit better Commented May 30, 2022 at 14:43
12

Your question is very difficult to provide a general answer for. Also, your choice of tech-stack greatly affects how to approach this. E.g., Ruby on Rails is extremely opinionated, so following these practices in that framework could easily lead to something very unidiomatic. Also, the use of an ORM often has some effect on the interface to the data access layer (which is the primary reason I don't use an ORM).

But you mention separate parts of the application:

  • Controllers
  • Service layer for business logic
  • Database layer (I think you implied that conversion went into this layer, an approach I support)

This distinction is a very good starting point in my experience. This also lends itself well to the "Clean Architecture".

The different layers have different responsibilities, and how you would test it differs from layer to layer. But generally, your test should focus on the responsibility of that layer.

Business logic

If you follow the "Clean Architecture", this would be divided into functions or classes (depending on your programming paradigm), each representing a concrete use case. Each use case would receive parameters for that use case. E.g., if the use case is "transfer money", the parameters could be the "debit account no", "credit account no", "amount", etc.

The return value of a use case would then be some object representing the outcome. This could contain information about if this was successful or not, and relevant information that the caller would want back. E.g., if the use case creates a new entity in the system, the caller might want the ID, or some kind of reference to the new entity.

If you start by writing tests for this layer, you have a good chance that your tests describes actual business needs. I prefer to test this layer as a whole, mocking out the database layer. By doing so, you are letting your business rules define the needs of a database layer (i.e., define the interface).

Note: Most developers I've worked with (which is quite a few) don't like testing this layer as a whole (they argue that it isn't unit testing), and separate testing of the database save/load from testing the domain logic. In my experience that approach however, leads to a lot more tests that doesn't provide much value, and more often needs to be changed when code is refactored. I have also often seen this leads to excessive mocking, and in some cases the developers had accidentally mocked out the very thing that should have been tested.

Database layer

Now that your tests for the business logic defined what the interface for your database layer needs to be, you can start writing this functionality test first. When testing this layer, I would always use a real database.

My tests for this layer would normally be some kind of round trip tests, something that inserts data in the database, loads it again, and see that we get the same thing out as we put in. For queries, I would insert data that should be found by the query, and data that should be ignored by the query, and then verify that the correct results are returned.

Always write these tests in a way that they don't depend on existing data. Some years ago, it was quite popular to have standard fixture data for tests, but I would strongly advise against that. Let the tests control explicitly everything that affects the outcome of the test.

Controller layer

Again, if following the clean architecture, the responsibility of this layer is basically to receive an HTTP request, convert the URL parameters and request body into a call to a use case. The outcome of the use case should then be translated into an HTTP response. This is basically true no matter if it's an API or server generated pages.

So to test this layer, I would mock the use case implementations, and verify that the HTTP request is converted to the correct use case request, and that the use case response is converted to the correct HTTP response.

Notice that I express my tests using HTTP requests and responses. That is how I would test this layer. Most often, I see tests for this layer that calls functions on controllers. But if you test this on controller functions, your tests are coupled to an implementation detail.

The system I am building right now uses Express.js to serve the API, but I could change that to something else without modifying a single test. In fact, my tests would tell me if such a change was implemented correctly.

You ability to express the tests in terms of HTTP requests and responses depends on your tech stack. With Node.js and go, it's quite easy. In .NET and .NET Core, it's very difficult, though Nancy seems to support this style of testing.

In Node.js I use the supertest library and in Go, you have something build in.

Don't forget to refactor

TDD is not just about writing tests first, it the "red, green, refactor" cycle.

In my experience, TDD works because it provides

  • Fast feedback
  • A safety net for refactoring.

And by constantly refactoring, you are encouraging yourself to not over-engineer in the first place.

So a good measure of you test suite is, how often do you need to change your tests when you refactor your code.

So this is my general approach to applying TDD when writing backend code. Many will disagree to this approach. Some say my tests are not unit tests, but integration tests. But I don't find that distinction useful, what matters is that I get fast feedback, and that I can refactor safely.

1

I have very little to say that has not been yet told in previous answers. However, let me share with you some tips.

@candide_orange purposes Behavior as the first thing to focus on. I agreed. But first, contextualize. Whether the application has an API REST or countless layers doesn't matter. Don't get overwhelmed by the forest. Contextualize the changes. You approach tests from different angles depending on the task and the elements involved. Fixing code backed by tests is not the same as fixing code without. Neither is designing a whole new API feature than changing the behaviour of an existing one.

Whether the elements exist or not, identify them first. Figure out to which layer they belong and the relationship between each other.

Once you have the vision, the question is if you approach the solution from a top-down (from outer to inner layers) or bottom-up strategy (from inner to outer layers).

If you are implementing a new API feature, a top-down approach is more natural because of the information you have. The information you have is usually a brief description of the "what" and very little of the "how".

On the other hand, If you are changing existing code, bottom-up is quite useful. Changing elements deep in the hierarchy allows you to comprehend the scope of the "drama". You start working on the most stable abstractions (those that have less or no dependencies at all) first and climb through the design as the changes so demand it.

@candide_orange also suggest not focusing on structures. Absolutely. There's a saying solve the problem first, leave patterns to emerge. That, definitively, belongs to TDD's refactor and repeat phases.

1
  • I find myself agreeing. Even with the parts that aren’t simply agreeing with me. ; ) Commented Jul 1, 2022 at 2:49

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.