The premise of doing the simplest thing to make a test go green feels a bit ridiculous to me.
The premise of doing the simplest thing to make a test go green feels a bit ridiculous to me.
« Test-driven development (TDD) is a software development process relying on software requirements being converted to test cases before software is fully developed, and tracking all software development by repeatedly testing the software against all test cases. This is as opposed to software being developed first and test cases created later. » [1]
So both Agile and TDD evolve a solution from "in this case, the software needs to do this". You take the software to the client/users, find out the next layer of requirements when they say it's not quite right, and adapt it.
(Every now and again someone tries to make a sort of requirements document that's also an executable test. Directly mapping the given-when-then of user stories to arrange-act-assert. It doesn't work for the usual 5GL reasons, but it doesn't stop people trying)
In Haskell I do TDD top-down frequently.
As for "tactical programming instead of strategic design" - that's entirely up to the developer and how they approach the problem. if you are prone to neglecting the design you'll do it any way. If anything, (actually) practicing TDD would force you to come up with better design.
You can see this same thing in action when watching functional programming live coding sessions: most live coding is bottom-up, but they still write the function signatures before writing any code.
Good design is a creative activity and cannot really be forced, only given space to grow.
I think the assumption that you can test your way out of bad design is false. Design is about assumptions you make, and you cannot test these assumptions, because your tests have to make the same assumptions.
In other words, the problem with the scientific analogy is that in science, the model and the world are never going to be the same thing. But in TDD, the model (specification) and code are ideally the same thing. You should make sure they are the same thing and try NOT to deviate from that in the first place.
What you can do, however, when refactoring, is to compare old implementation (non-efficient one, for instance) and the new implementation. That is possible, but I rarely see that in TDD approaches (modern testing focus is on one instance of code only).
I think that's right. But what TDD buys you is that, when the design changes (and it should, if it's a bad design), and you have to modify your component/class/module/whatever, you can run the tests and see if you broke anything. You can then, for each failing test, think through whether the change should have broken that (and therefore the test is now wrong), or whether your change actually broke something that had been working.
This can make it far easier to make changes safely, and lets you make them with far more courage. It means you're not stuck at "we really should change that, but it might break something, so it's not worth it."
That's not exclusive to TDD, it is true for any automated testing, and not only that. Any formalizing of additional assumptions into the code (as I write elsewhere, things like type checks, assertions, property tests) will also manifest potential issues when code changes.
My point is, there are other techniques to solve this problem, more straightforward than TDD.
But if you’re doing a lightweight version where you do actually design, or explore a design, and use tests to simply run your code in isolation, document usage variations and failure modes, then it’s a very useful and pragmatic tool.
This doesn’t necessarily mean you should hack your code into small bits and all that. Example tests and a REPL are just a nice combination of tools for interactive programming.
What do you find ridiculous about doing the simplest thing to make a test go green first?
Thinking strategically is understanding the trade-offs of your code, and coming up with good abstractions, that take these chosen trade-offs into account. You want to express the business problem in a language that is close to the domain, and then write a translation (and sometimes the translator) to the language closer to the metal. Coming up with the correct language how to express it (the language can be as simple as recognizing that your problem is a general one on which you can apply a known algorithm) is precisely the strategic thinking.
Applied top-down, it also means exhaustively understanding states of the application and major data structures (for example, database schema) and designing the functions around that. It can also be applied bottom-up, by building reusable tools and building blocks, which are independent on the global state, and then connecting them as simply as possible (this is also sometimes called hexagonal design). Both top-down and bottom-up have pros and cons.
I am much in favor (instead of too much unit testing or TDD) of building better abstractions that make humans less prone to errors (these can even be DSLs), building assertions into your code that warn you something is amiss (and can also be used in property tests), and always testing against something (i.e. the test code doesn't use the same assumptions as the program it is supposed to test, which is a big issue I have with unit testing). The best parts of the code are the ones that you don't have to test - if the compiler (or your abstraction) can guarantee you an assumption about your code, then you don't need to worry about it being wrong in the first place.
And in particular, the problem with "x+y in C++ can launch nuclear missiles" is not syntax, it is a weak type system (ideally you would use something like Haskell that controls the side effects). Again, using checks in a stronger language is a strategic, not a tactical decision.
If I know that a class will need to be accessed from multiple threads, I am not allowed to use locks, concurrent data structures or lock-free patterns until I add the first test that checks for multiple thread access. When that happens, I need to throw away almost all of the previous implementation. The same happens for any special data structure or algorithm that I know in advance will be necessary to solve the high-level problem, but it takes tens of tests to reach a point where the "simplest solution" no longer works. As an exercise, consider implementing a Union-Find data structure while constraining yourself to never writing a line of code that is not the simplest thing to solve the current test.
My experience has been that it's better in those cases to construct a unit testing roadmap so that:
- you incrementally build the correct solution - you throw away as little code as possible - throughout the process, you minimize the amount of code not covered by a test
Who's imposing that constraint on you? Why? Can't you negotiate your way out of that?