For example, I think of the experience doing TDD in Rails with all its ORM and callback interconectedness to TDD on functional code that is mostly exchanging values between systems. All the stub and mock crap goes away since values become the boundary of the system. Gary Bernhardt has an awesome talk making that exact point.
I'm now of the opinion that if TDD is a struggle, its a sign that your environment has serious underlying flaws.
That's generally why I think functional programming is taking off. It's much easier to reason about input-output of stateless functions. They're much easier to test and develop. They're way easier to understand for the reader, because the reader can understand what's happening in one place, instead of having to look through many different files, creating a complex model of all the inheritance relationships between classes.
I consider the 50-line function to be "less ugly" than the same function butchered for "testability" into a bog of poor abstractions and single-use smaller functions.
Unfortunately this approach tends to be less effective in languages that do not have good support for functional ideas like Java and C#.
I've worked at a TDD-focused company and it always felt like I was coding through molasses. Some of us weren't as strict about TDD and I didn't notice a difference in our code quality.
To me, doing TDD right is about having the minimum amount of testing needed to keep bug rates extremely low. It's also about keeping the test code as well factored as the production code, such that changing or refactoring the production code doesn't create disproportionate work in the test suite to bring it back in line.
An experiment I've tried repeatedly is to start a new project and shoot for zero bugs. I think I'm pretty smart, so that at first seems achievable without tests. But pretty quickly complexity increases that I at least have to manually test. And not long after that, manually testing everything gets tedious, so I have to start automating the things I am manually checking. If I keep pursuing zero bugs and feeling like I'm spending my time optimally, I keep ending back up at TDD. Maybe try that for yourself on a hobby project? You might find that the TDD-focused company was better at talking about TDD than doing it.
TDD can be nice for small modules when you have a good idea of where you want to go with your code. For large modules with a lot of unknowns it can be a pain, and in those cases I prefer testing after a first draft.
Perhaps you haven't built a module that was very large yet? Maybe try writing a large module with lots of unknowns. You might find that TDD gets in your way more than it helps in the early stages.
As to whether or not I have enough experience with TDD to judge it, I'll leave that up to you. But I've been doing TDD ~20 years, and I wrote a lot of code before that. Let me know if that's sufficient.
When you make a change to Guest, you only need to change the GuestObjectMother and any tests that may directly be involved in the change to Guest. Tests that simply require a Guest to be involved in the test probably won't have to change, as they're simply retrieving the appropriate Guest object and then handing it off to something else.
Would you feel that way about the linux kernel for example?
Indirection can also make it harder to rigorously analyse all possible paths through a piece of code and make sure you’ve handled all required cases.
I used to work on a library that solved a certain type of mathematical problem, written mainly in C++. It was built separately for many target platforms in production: different operating systems, processor architectures, compilers. Each supported combination of these might require special provisions in the code due to compiler bugs, hardware limitations, etc. Then you’d have diagnostic code that was only included in the debug builds used during development. There are more than enough preprocessor shenanigans to go around in that kind of environment already, and another layer for test-related flags and checks isn’t going to break any records for most readable code.
I think this is just internalized pain of working with C++ (I suffered a lot with that 20 years ago). For systems-level stuff, I'm getting very excited about Zig, I imagine it will be basically "equally easy" to instrument these sorts of things at compile-time in Zig, as types are first-class values at compile-time. Zig is also super-composable; I have an example prime sieve that can run 60+ settings (combinations of single threaded, multithreaded, bool array, bitmap, hyperthread-awareness) by using a compile-time for loop across a series of instrumented settings; so long as I stay within the standard library the language itself already takes care of the polyfill for platform stuff, and I have access to those flags, if, say I wanted hyper-optimize for things like sse. I don't see any mocking libraries yet, but I doubt that instrumenting a mocking namespace in test will be any more difficult than it would be, in, say, elixir.
I can assure you the linux kernel is not TDD'ed.
For example, we might see ways of expressing indirection for testing that then get removed when building the final product. Or we can look at the great strides made in virtualization since the Linux kernel was started. Something previously hard to test becomes much easier. And I'd expect more so if the testing had co-evolved.
Of course, there still might be hard-to-test areas. But given the massive ingenuity applied to the kernel development process over the years, I expect that they would have advanced what's possible in TDD if they had pursued it.
If indeed they have continued for decades to do it "the wrong way", imposing costs onto the project and society proportional to the number of preventable bugs creeping in, is this not a major scandal for the industry?
I suspect that the core team have reasons that they have not developed tooling to enable TDD of the Linux kernel. And that those reasons are more substantive than "meh".
This is a language/library thing. Some language/libraries let you do TDD with very little indirection, a table of mocked dependencies as compile-time configuration, and all of the mocking events happen in code in your tests.
This is another underexposed feature of TDD: reviews. When I see a PR with solid test coverage, and those are green, it greatly speeds up the process. I just need to read through tests, see if they make sense, match the requirements and test those. And then glance over the added code quickly.
It (unfortunately, I might add) is not always possible to flat out reject a PR without solid test coverage, so it often is less work for me to add some test myself to a PR, than to manually poke around the changed code or to read through it in detail. This is not TDD, but testing-after-the-fact, and those test often are of much lower quality, but at least this is less work, for me.
Testing greatly speeds up the work for a reviewer.
But consider this article on how it is tested: https://embeddedbits.org/how-is-the-linux-kernel-tested/
It mentions a lot of automated testing, which is good. And if you're doing automated testing anyhow, I think TDD is the most effective way to get there. But consider this:
"The kernel development process also proves that there is a big focus on testing since around 20% of the development process (2 weeks) is reserved for code integration and the other 80% (8 to 10 weeks) is focused on testing and bug fixes."
80% is a lot! And that also suggests significant lag between writing a bug and having to fix it, which means a lot more time figuring out what went wrong, and a lot more code written after the bug, meaning really fixing it might be expensive.
So I think it's reasonable to ask how much more effective kernel development would be if they could safely eliminate that 80% of time spent on cleaning up after the 20%.
Thats just the thing with TDD: used properly (which is clearly tricky for many of us), it should help you think through what the inputs and outputs of the program you hope to write are. It does assume you know what you want to build though, even if you're unsure of how to build it.
I think of it as a design tool to be honest. When I'm not quite sure exactly how something should work, but I have a clear idea of what I want it to do, tests help me work through it in a way that produces extensible code.
If you have a problem that is well understood, well documented and that has a straightforward solution, TDD is great because you know where you are going.
But a lot of the time, you have none of that. You don't know where you are going. You need to experiment, you need to iterate, you need to occasionally change directions completely. When you're doing that, TDD holds you back and gives you nothing.
Rarely are we building something with absolutely no idea what the final outcome should be, are we? There’s a general idea of, “this new feature should mostly work like this” description, correct?
If not, you’re doing code very different than the stuff I build.
And that might be the case! But I’m curious what kind of projects you’re working on where you have basically no idea what you’re aiming for.
Even when I don’t understand what’s wrong, I’ll generally have an idea of either: * what is undesired behavior to fix, or * new behavior to add
I can at least play around with the code a little to see how that impacts existing tests, or write some simple test that will demonstrate a really high level desired outcome. As I iterate the functionality, I might see the opportunity for another assertion or three, and then keep iterating on that until I’ve figured out what I truly want, both functionally and test-wise.
Once I know, I can write a test based on just that information. The problems usually come well after that step as my implementation becomes more complex with the size of the app.
I have done a variant of TDD where the output is slightly uncertain where:
* I build a test with everything filled in except output of some kind.
* I write code that generates an output I want to eyeball.
* I run the test in "rewrite mode" where the output is generated by the program and saved with the test. I eyeball it to check if it's ok and then commit.
* In CI it checks the output against the fixed version.
There's plenty of scenarios where it doesnt work or is generally inadvisable but I find it to be a super effective technique where it does.
I think it's an approach that would work better for code that generates text, images, sounds, than common-o-garden TDD.
If you are learning a new language, new paradigms or new frameworks, then, indeed, often testing is an extra burden that adds little at this point. But in this stage, I hope, one should be aware that the code you are writing is bad anyway. In a year, you'll probably be very ashamed of all the things you did there.
But in languages or paradigms that you are already familiar with, TDD helps a lot when learning new things. You can very easily learn about a new library by writing tests that use the lib. Or learn a new pattern by implementing them in tests.
For example, write some tests that wrap a Stripe library, then change the test to change some setting, call a method, or feed it some weird data, and assert some outcome rather than manually putting stuff in a cart, filling your address, CC details and then see that the setting you thought did X actually does Y. And repeat again.