Getting Stuck While Doing TDD, Part 3: Triangulation to the Rescue
tddfellow.com
tddfellow.com
However, my mental model for approaching TDD I think is much simpler than the approach presented here. It basically is:
Do I _really_ understand the design implications of the unit I'm about to develop?
- No: Play around and prototype, and learn about my design, don't worry about tests
- Yes: Throw away any prototype and then really do the unit test-first.
I found that I gravitated towards this process after I learned (partly thanks to blogs from Uncle Bob Martin) that unit tests are really formal design specs. They are so formal and specific that they are expressed in code- a program describing what your program does. Before you can write one of those (or as many as your unit needs), you need to have a very precise concept of how you are about to proceed, and what exactly the product code is about to do.
The benefit of this process is that I prefetch a really deep understanding of the system I'm building. This leads to less bugs, and it leads to sensible, well-documented, and meaningful tests. It just takes a bit of discipline to follow at first, when you're not used to working this way.
Some argue that it takes longer to write code this way, especially when I throw away my hard-earned working prototype! It does take a little longer at first, but:
- Once I know the answer/outcome I seek, the tested version comes out way faster than the prototype.
- I spend much less time later on fixing my own bugs, which I learned are mostly due to my own lack of understanding the full dimensions of my system's design.
I let the design of the system emerge on its own, while I'm listening to how hard it is to write tests and steering the wheel using Red-Green-Refactor (2nd cycle of TDD) and while I'm following Specific/Generic rule (applying Triangulation together with Transformation Priority Premise) (this is the 3rd cycle of TDD).
Red-Green-Refactor on its own indeed is very well known for leading quickly to the local maximum of the solution and its design. 3rd Cycle of TDD breaks out of the local maximum of the solution to the problem. 4th cycle of TDD breaks out of the design maximum (4th cycle of TDD - Hourly, we step back and look at the overall design and architecture of the system, taking a careful look at the architectural boundaries and seeing if we are about to cross any of them with our efforts, we proceed with informed decisions steering to the right design we want for the next hour).
Here is the Robert C. Martin's blog post on the cycles of TDD: http://blog.cleancoder.com/uncle-bob/2014/12/17/TheCyclesOfT...
Here's my take-aways on TDD...
- It' about designing software through feedback
- Code should compose together easily, refactor till it does, be mindful of the tiny details
- Test as little as possible (or I think Kent said "as is Responsible"). The tests are there to validate your intent and provide feedback. Tests are liabilities that need to be maintained, so only maintain good ones.
learning TDD? do it on throw away problems, play around with how small the steps you can take are. Play around with refactoring, don't have have preconceived ideas about the solution. TDD learning problems with other people. Pay attention how it changes how you design something. Do some bigger things, start noticing the problems of TDD, especially when dealing with outside frameworks and systems.
Production TDD? It's a tool and may help provide design ideas or help stitch together preconceived design ideas, use everything in your arsenal to design and create good software, there are no rules or doctrine. But understand as many tools and "rules" that the software community offers and the reasoning behind it. Collaborate with others to create and design, and make sure you have quick feedback cycles. Be quick to recognize new ideas, tools, and techniques that either provide feedback or designs.
Every time I wrote code without TDD I regretted it. Every freaking time. Even on small, two-day projects (which became three-month projects which became two-years projects).
Not all code is like that.
My point wasn't about being a good programmer, although that's what the parent said. My point was that OO itself is inherently a nest of snakes, where a lot of damned good coding can amount to jack squat because of one error. So yes, we agree.
In OO, you screw up one piece on a class somewhere? It could hose up systems near and far. In fact, in many cases it's impossible to predict just what you've screwed up. In pure FP, you screw up one piece of code? That piece is screwed up. Everything else continues to work just fine.
In OO it becomes more and more imperative to use TDD the larger and more non-trivial the codebase is.
Talking about how you only use it is great. It's also pretty cool to understand why this should be true -- and be able to explain the edge cases. It's very easy to sound like one of those religious folks we're talking about when you don't mean it that way :)
Even in pure functional environments I do use TDD, because it gives me enough confidence to know, that I know what I am doing. Maybe it is just a mindset, or my brain is wired this way. For any change to the production code I require a proof, that system needs to be changed in a form of failing test.
I've had people object to this design: I simplify the classes too much so as a result, I have too many of them. I disagree with that idea - I don't find English too complicated because "there are too many words". (Or the .NET framework because there are too many classes, to keep it in scope.) As long as abstractions are built correctly, with good names (a hard problem :D), I can handle the result much better than if I have only a few "God classes".
TDD I think works best when, mentally, it is design centric rather than test centric. The tests are used to prod a design around till you are happy with the design and act as validation of the intent of the design. The goal is to keep the tests at the minimal level to test that intent.
If, testing sum of two numbers, from the article
def sum(a, b)
if b == 2
4
else
5
end
endIf this is what you do,I think you've gone wrong. The name of your function is either wrong and should be renamed, or your design of sum is wrong. Attempting to test that functionality into place is the wrong approach. I know its tricky with examples, but every extra sum(2,3) sum(4,5) sum(9,1) etc that doesn't reveal more design intent from the implementation of returning a+b is a wasted test that must be maintained.
You don't want your test to try and prove every possible implementation of sum. It really doesn't help in practical terms. I know when I used to use the Bowling game as an interview coding question, I was amazed how many tests I needed to try and test every possible implementation. People would do crazy things with their design, such as the equivalent implementation for sum...
return a == 392984589? 0 : a+b
it was a conditional to handle an edge case that introduced a bizarre bug for 392984589. It would mean, to test any possible implementation of bowling ( or sum) you need to test every combo. But that's not really the purpose of TDD. TDD is about design and incrementally putting a design in place.
Implementation evolution is as following (backed up by respective test):
Sum(a,b): 4
Sum(a,b): a+2
Sum(a,b): a+b
1 example test for the requirement and 2 iteration of triangulation with transformation priority premise.
I hope that makes sense. Sorry for not being clear enough in the article about the example being a violation of the rule, that you shouldn't do.
(Trying to compartmentalise the mutable state wouldn't hurt, either...)
If instead you wrote what's necessary to pass each test properly as you went, you'd be building the class incrementally, and importantly you'd have a series of commits which each have worthwhile code in them instead of a bunch of useless slop.
The "red, green, refactor" approach is (despite conflicting ideologies) best suited to tests which express a behavior in the domain where you're trying to solve a problem.
If you write a "unit" test where the "unit" provides some expected value or outcome absent meaning in the problem domain, of course the way to make the test "pass" is to hard-code some meaningless return value.
If you write a test addressing a behavior relevant to the domain you're solving a problem for, and then create the conditions for that test to fail, you're almost certainly going to write code that is meaningful to solving that domain problem in order to make the test pass.
The fact that the examples are using Rspec, a BDD framework, but writing traditional input->output test signatures, is a huge warning sign. Those kinds of tests are useful to catch boundary condition regressions (which is to say they're not totally useless; they should be written when there is hairy implementation-specific logic that needs to be preserved). But they're not going to help you write a program to solve a problem for human beings.
And I like the BabySteps feel that it produces, it is so nice to evolve your code bit by bit one test at a time and without harming Semantic Stability (you can read about it here: http://blog.cleancoder.com/uncle-bob/2016/06/10/MutationTest... )
Do people really pay for this kind of work?
Because it is actually faster and less error-prone once you get familiar with Evolutionary Design.
A good read from M. Fowler: http://martinfowler.com/articles/designDead.html - this talks, why a lot of Extreme Programming practices come into the synergy to make Evolutional Design effective (and not only). Drop enough of these practices, indeed, it will stop being effective.
http://ravimohan.blogspot.com/2007/04/learning-from-sudoku-s...
And yet, among the most agreed ideas you can ever make in popular programming circles is that you are fond of TDD...
I'm pretty sure, that the case study of such project will take up the whole book, not just 3 blog posts ;)
Unit test driven development only really works on "algorithmic" style code. Stuff that's mostly linking disparate systems together - if you try writing unit tests for it you'll end up with a mess of mocks that aren't actually testing if your code works.
Because sum method was used to quickly explain Specific/Generic rule on the simplest example. Then it follows through with real-ish (a bit modified) business case, that has gotten me stuck in the past.
I know that readers on the internet have limited attention span, so I shouldn't have put a sum method example at the beginning, rather I should have started with the main case study and made a detour before applying this rule to the sum example.
Thanks for the feedback again, next time I will try to not make this mistake.
My complaint is akin to the modern complaints on the gang of 4 patterns. Individually they make compelling arguments and if you follow the discussion, it is appealing.
And I don't want to throw out the baby, so to speak. I think all of these make fine guidelines. We are just an industry that seems hellbent on finding prescriptive directions where it is becoming apparent none exist.
For such projects at least for my own case I have seen that an Excel driven development (Yes i just made it up) is a really good idea. Instead of writing tests and creating mocks and doing a lot of legwork I just write the function name in an Excel file, with the constraints and output in columns. Hardly takes me 1 minute since I always have the .xls file open. Then I go into a free-flying mode, creating my function and code and never think about it until the project is finally complete. I of course am mindful that I will have to write tests for it later so I do use DI and staw away from Singletons, etc.
Then when everything is ready (think v0.01), then I go back to each function in the XL file, and write a test for it creating assertions using the constraints and output I jotted down earlier. This way I'm not writing or re-writing my tests because something I hoped to use in my project has changed during the course of development.
After that it is TDD only because the v0.01 is out and now I have to careful that any changes I make don't break anything. I don't think this would work for a corporate environment but for solopreneurs and not so serious projects this can really be helpful. It does seem to work for me at least.
TDD does best when you need to be able to respond quickly to new knowledge as you dive into the problem domain or make changes due to migrating business requirements. TDD lets you create your abstractions using concrete evidence, instead of trying to guess.
However, I have found TDD to be a difficult skill to master. You cannot just try it out for 6 months and expect to be quicker using TDD than you are without it. Until you have a bunch of experience built up in TDD, it will slow you down a great deal - which is what you may have experienced. It took me about 18 months of continuously using TDD daily to really get a solid grasp of TDD.
It also took me about 18 months to build up a good level of confidence and speed with TDD. I found it to be a really rewarding experience.
If it is not clean, but well-tested, then I need to clean the code before extending it further. Also the tests will probably be messy, I'll spend time cleaning those up if I have time. But in all likelihood I'll only be able to clean up the code this time around.
If it is clean, but not well-tested, then a test-after approach is better. Take your feature-implementing extension, and write tests for both the extension and what it's extending. Then the next time I'll have clean, well-tested code to TDD off of.
If it is neither clean nor well-tested, then I wouldn't bother writing tests at all, for this iteration. Instead I'll clean up the code, make my extension, and call it a day. The next time I extend that part of the codebase, I'll use the aforementioned test-after approach.
Tests take time to refine and get right. They are an added layer of security, but a much better line of defense is nice clean code that you know what it is doing at a glance. Tests will not save you from bugs, but clean code will make bugs way easier to iron out.
The prevailing wisdom seems to be to don't refactor unless you have test code to fall back on and if you don't have that, to write smoke tests.
I do not like this approach, I think if you have to deal with unclean, untested legacy code, then you need to do the work to understand it, and the best way to come to an understanding of code is to clean it up. As you clean it up, domain concepts become clearer.
Tests are a form of documentation, their intent is to help you understand the codebase. The code itself tells you how it's accomplishing business objectives, tests tell you what those business objectives actually are. Unless you are very unlucky you should have alternative sources for that knowledge. So tests aren't strictly necessary, but they are very nice to have.
I strenuously disagree with this point in the following scenario: your new test could validate an edge case that your current implementation may only pass by happenstance. i.e. it's important for whomever refactors the code to pass your new test and all the old tests.
Production code doing something, that is not defined by tests is generally not a good idea, because this is clearly an untested functionality. And it has to be eradicated if nobody needs it (business and customers) or it should be covered with a test, and I want to see this test fail (by applying Mutational Testing).