-1

This is a question concerning the fundamental approach of TDD, so the example below is as simple as possible which might make it seem a little useless; but of course the question applies to more complicated situations as well.

Some colleagues and I are currently discussing and trying out some basic TDD ways of coding. We came across the questions how to deal with cheap solutions for existing but not encompassing TCs. In TDD one writes a TC which fails, then implements whatever it takes (and not more!) to let the TC pass. So the task at hand would be to make the TC green with as little effort as possible. If this means to implement a solution which uses inside knowledge of the TC, so be it. The reasoning was that later TCs would check for more general correctness anyway, so that first solution would need to be improved then (and only then).

Example:

We want to write a comparison function for a data structure with three fields. Our comparison shall return whether the given values are equal in all three fields (or differ in at least one). Our first written TC only checks if a difference in the two first values is detected properly: It passes (a,b,c) and (a,b,c) and checks for a correct detection of equality, then it passes (a,b,c) and (x,b,c) and checks for a correct detection of inequality.

Now the cheap approach would be to also implement only a comparison of the first field because this should be enough to pass this TC. Keep in mind that this can be done because we know that later tests will also check for the equality of the two other fields.

But of course it does not seem very useful to only implement such a (more or less) nonsense solution; every programmer doing this would do it in the knowledge of writing a bug. It obviously seems more natural to write a decent comparison right away in the first iteration.

On the other hand, writing a correct solution without having a TC which checks it might lead to the situation that such a TC which tests the behaviour more thoroughly will never get written. So there is behaviour which was written without having a TC for it (i. e. which is not developed test-driven).

Maybe a proper approach is to not write such rudimentary TCs (like the one only checking the first field) in the first place, but that would mean to demand perfect TCs in first iteration (and of course in complexer situations one will probably not always write perfect TCs).

So how should one deal with rudimentary TCs? Implement a cheap solution or not?

Alfe
  • 251
  • 1
  • 2
  • 8

6 Answers6

4

I think your problem arises only because the requirement is very simple and the solution comparing all 3 values at once maybe just a one-liner. Having slightly more complex requirements, and it will perfectly make sense not to implement anything beyond the scope of the already implemented test cases.

Nevertheless, your "cheap approach" has indeed one advantage which makes it less nonsensical than you might think: chances are much better you don't forget to add all of the important test cases. If you implement the three-value comparison at once, there is a certain probability that you might omit further test cases, since you are already in the mental state of "beeing done". If, however, you know your code is "not ready" yet, and you force yourself not to change it without further test cases, chances are much higher you actually will take the time and add those test cases.

Especially for TDD learning purposes, I recommend "TDD as if you Meant it", an exercise invented by Keith Braithwaite to train developers doing TDD in even smaller steps. Applied to your example: in this exercise, your first step would not even be to implement a function with one equality check, you would implement the equality check in the testing code first, and then refactor it out afterwards to the comparison function.

Doc Brown
  • 218,378
2

Implementing the "cheap" solution first is a good idea, not just because it forces you to write tests that cover all expected behaviour, but also because it sometimes ends up with you writing a simpler solution than you might have in your head.

A good example of this is given in the book Beautiful Code, describing the FIT testing framework. This system works by scanning HTML documents for tables containing data to plug in to test cases and then produces an output document with the table row coloured red or green. A naive approach would be to use an HTML parser, but by just taking the tests one at a time and writing the simplest possible solution at each step, the authors arrived at a much simpler solution that just uses simple string manipulation.

Another thing to consider is Robert C Martin's "Transformation Priority Premise". This is an extension of TDD that gives some extra rules for writing your code as a sequence of simple transformations (similar to refactoring, but with the goal of changing behaviour in controlled ways rather than preserving it), and can give some very interesting results. It's well worth investigating further, I think.

Jules
  • 17,880
  • 2
  • 38
  • 65
2

I would like to say that the refactoring phase in the TDD cycle is the second most important step, and the one that make the switch from simpler and cheap to intended code. The most simple example I can think of is a constructor.

My first test can be that my new class receives a certain parameter and check the value of a parameter. That's pretty easy to implement right? I can make it pass very cheaply. But once that it passes the implementation is left to a refactor.

  • I can implement a getter/setter into that parameter.
  • Or maybe that my class makes a get request to a external API (mocked) and stores the result into the parameter.
  • Or that parameter is the result of mat operation.

But all those cases are tested with the premise that you input a value and it expects another, the back workings are irrelevant, you want the same result. That's to me the great advantage of cheap solutions.

cllamach
  • 313
1

A lot of this boils down to what is "cheap, useless and ... little effort as possible."

Here's another simple example: Write a function that returns the result of two numbers added together. Test:

check func(2, 2) = 4
// Simple:
  func(a, b) { return a + b}

I would like to think this is the first strategy I would take. Seems simple enough to me, but now that I think about it, what about an even simpler approach:

//Simpler
func(a,b) { return 4}

It is simpler and probably useless, but did it really take the least amount of effort? Seems like the first reasonable solution that pops in your head would take the least amount of effort unless the amount of typing is a productivity problem. If something is so obvious that it is a waste of time, don't do it. This is the advantage of having experience and a possible explanation why some people feel good programmers can be 10x more productive because they don't waste time repeating mistakes or writing code they don't need.

JeffO
  • 36,956
0

You're correct that demanding perfect Test Cases is unrealistic for most code (with a possible exception for Mars Rover code).

I'd argue that it's necessary to honor the intent of the Test Case / specification in addition to the 'letter of the law'. In your example the developer has the opportunity to write an obvious bug. The correct action would be to fix the Test Case first, then write the code.

Tests are code too, and are also subject to bugs, incomplete implementations, etc.

Dan Pichelman
  • 13,853
0

The problem that you have is really in thinking about writing your test cases. You are getting stuck in TDD Dogma of needing to have only one test fail at a time.

You define the behavior that you want to be that a function determines if two groupings of three elements have the same values. Then you define the test case checking only the first one. You know from the definition of the problem that you have at least four cases to test (equal, 1st not equal, 2nd not equal, 3rd not equal). So you would create these test cases immediately so that you define the behavior of the code.

In your example, the behavior is that in the two groupings the first element is checked to be equal. That is an incomplete definition of the behavior. And you would end up creating the naive solution that you have and thus needing to do more work as you go on.

Use your tests to define one behavior and write tests that fail and only pass when that behavior has been properly created.

Schleis
  • 3,416
  • 1
  • 21
  • 21