0

Let's say that I've been iterating over my feature A with TDD. After several red-green-refactor cycles, I ended up with a nicely polished implementation with a part of the SUT encapsulated into some abstraction that provides a reusable piece of logic L. There are a bunch of tests that support this piece of reusable logic.

Now, I will work on the next feature B that should reuse the same logic L, but in a slightly different context.

I don't want to test-drive this logic test-by-test again, effectively reinventing the wheel, not reusing. If I go for a test-by-test approach, and just add the first test, I cannot just add a reusable code in the implementation, cause it does more than the test currently asserts about. I'm also not a fan of duplicating tests among these two features. I'm after clean tests and best practices.

How do you approach such a problem with TDD?

10
  • 3
    Please clarify. You've got reusable logic L, and tests for L. You've got client A, and tests for A. What prevents you from adding tests for client B, and B? Do you need to change L to support B? Commented Jul 27 at 15:21
  • 1
    I'm not sure I understand your problem. If you have component A then test it. If you have B then test it. If B depends on A, then don't test A again - the proper way is to pass a mock of A to B. For that you would need a proper design of B: so that it accepts an interface of A (not literally A) as dependency. That's why TDD works very well with dependency injection. Monkey patching is an option (if your language supports it) but it is inferior to dependency injection.
    – freakish
    Commented Jul 27 at 15:51
  • 1
    You design the new component so that the reusable part is constructor-injected into it. In the test suite for the new component, you only write tests for the behavior that strictly relates to the new component itself. You inject a mock to "simulate" misc scenarios involving the reusable component, but you only "simulate" as much as needed for a specific test method. I.e., the mock (or the way it's configured) is specific to each particular test method, and only returns canned values and supports called methods, without actually replicating the internal workings of the real dependency. Commented Jul 28 at 10:57
  • Re your question @KilianFoth: It's reusable logic L that is a part of A. L came out in the process of refactoring A. A is a SUT. Tests are not isolated to L. Nothing prevents me from adding L in B, I'm just wondering what would be the best approach to add it there in TDD way Commented Jul 29 at 12:07
  • 1
    @FilipMilovanović, and to add to my previous message to freakish as well - I'm not a fan of mocking. Mocking logic especially... The bad thing about it is that I leave out the wiring/DI part of real logic and lose some certainty about the expected behavior. My tests are more focused on how details are put together, not the behavior itself. Commented Jul 29 at 12:34

4 Answers 4

4

I cannot just add a reusable code in the implementation, cause it does more than the test currently asserts about.

That's not "forbidden". The red-green-refactor cycle rules say you should not write more code in the coding step than necessary to make a failing test pass. The minimal amount of coding might be to reuse some other code which is already there, even when that makes your code provide more of the intended behaviour than the current tests assert.

The issue with "literal" TDD comes in the next cycle, when you add more tests for behaviour which is already there (coming from the reused component), hence these tests are "green" right from the start. Note this can also happen when you don't have a reused component, only a general implementation for a task which solves more than one requirement at once.

In my experience, this "issue" can be ignored. Just make sure any newly added test which is "green" does not pass because it forgot to call the right assertion. Don't take the TDD rules too literally or dogmatically,think what is necessary to make yourself confident about your own code and use that as a guideline for choosing the tests.

2
  • I see. Well... Dogmatism and limitations imposed by a specific framework or process can be painful in production and under the deadline, but in the ivory tower of an internet forum, we can still look for the perfect or a better solution :) What I don't like about your solution is that we end up with duplication in tests (can be painful when we have many features relying on reusable logic L) and that it will disrupt the TDD for the future (for example - which feature should I use to enhance the behavior of L? How do I make sure that all new tests are added to all features that use it?) Commented Jul 29 at 12:56
  • Duplication in tests can be removed or avoided by refactoring the test code itself regularly.
    – Doc Brown
    Commented Jul 29 at 21:06
3

I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence -- Kent Beck (author of the first TDD book).


If I go for a test-by-test approach, and just add the first test, I cannot just add a reusable code in the implementation, cause it does more than the test currently asserts about

Key idea: we do the TDD ritual when we want the results that the ritual will produce, not just for the sake of doing the ritual. Nobody is running around awarding prizes for how deftly you navigate the red-green-refactor loop.

Write your one test, make it pass, clean it up, move on.

You should write the tests that help get programs working and keep programs working. Nothing more. -- Beck (Extreme Programming Explained).


Now, you are making a trade-off, in so far as the collection of tests that you have for this new component is not as comprehensive as it might have been had you built this part of the program first (before you had your wheel).

Which is to say that, although the design is satisfactory, you haven't captured all of the constraints on behavior in code.

So there's possibly some risk that at some point in the future, the requirements for the well tested thing will diverge from the not-so-well-tested thing, and someone meaning well will put the change in the common code, run the tests, and "everything works" because the testing isn't adequate to detect the regression in not-so-well-tested thing.

It's not really a TDD tradeoff, as such, because the design is "fine" up until the point that you discover that the old and new behaviors should diverge. But if you are in a culture that expects that the tests will be best-effort-complete, then you may need to pause the TDD work and fill in the missing automated tests.

The good news is that because you've laid the groundwork already, you "know" that you are going to be able to write cost effective "unit" tests, because you've already shown that the test subjects can be effectively isolated and fed controlled data.

(Recommended reading: chapter 4 of Accelerate by Nicole Forsgren)

1
  • Accelerate is a fantastic resource, I've read it from cover to cover 👍 Can you tell me exactly which part of chapter 4 you're referring to and how can it help me solve the original question that I asked? Commented Jul 29 at 13:04
1

After several red-green-refactor cycles

[...]

I cannot just add a reusable code in the implementation, cause it does more than the test currently asserts about.

But this is exactly what you should be doing in the "refactor" phase. If you want to, you can do two separate commits for "green, naive implementation" and "refactored, maintainable implementation"; personally I've never found much benefit in that approach but YMMV.

TDD isn't meant to take away the use of your brain; when and what to refactor is a very important skill.

1
  • "when and what to refactor is a very important skill" - 100%, that's why I started the discussion 🙂 Let me make sure that we're on the same page about the rest of your answer, and what you propose. I'm not sure whats your proposal about two separate commits. 1. Add the reusable logic L to satisfy the current test 2. What exactly would you put there? I still find it a bit suboptimal... Have you seen Doc Brown's answer and my comment above? Commented Jul 29 at 13:20
1

If I understand you correctly, you start with some big function. eg Account.AddMoney(int amount) which you write tests for. Then during refactoring you move some of the logic out into its own component eg Money.Add(int amount). But! you aren't writing new tests for Money, you are just refactoring the logic for the already passing tests for Account.

No you are in the situation where the component has no unit tests but want to use it in a new 'big' component. eg PiggyBank.Add(int amount) Has TDD let you down?

I think not. Say we have inherited all three classes with no tests and want to make the project "good". We would want to have 100% code coverage and unit tests for all three classes. By following TDD from the start we have tests for 2 classes and 100% code coverage. Sure, it hasn't forced us to write the Money tests, but Meatloaf would agree we aren't badly off* and we haven't done any extra, unneeded work.

*https://youtu.be/k5hWWe-ts2s?feature=shared&t=104

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.