| >I've often figured out late in the game how to make something a compile time failure rather than a runtime one This is actually a good (albeit somewhat niche) reason to not write a test scenario at all, but it's still not a great reason to write a test after instead of before. >Fundamentally the goal of testing is to describe what behaviors of the software are intentional rather than incidental Yup. A test scenario which is of no interest to at least some stakeholders probably shouldnt be written at all. This is again about whether to write a test at all, though, not whether to write it first. >TDD mixes both concerns I dont think writing a test after helps unmix those concerns any better. In fact it's probably a bit easier to link intentional behavior to a test while you have the spec in front of you and before the code is written. I find people who write test after tend to (not always, but strong tendency) fit the test to the code rather than the requirement. This is really bad. >Maybe other people work on different types of things and TDD is great for them, but I write primarily infrastructure code where correctness is critical and I have the luxury of time Assuming Im understanding you correctly (you're building something like terraform?), integration tests which run scenarios matching real features against fake infra would seem to be pretty useful to me. So...why wont you write tests with that harness before the code? Im still unsure. The only thing "special" about that type of code that i can see (which isnt even all that special) is that unit tests would often be useless. But so what? |
But the before-test is strictly negative - it's a waste of time (deleted code, never submitted) and it possibly slowed down development (had to update the test as I messed with APIs).
>Yup. A test scenario which is of no interest to at least some stakeholders probably shouldnt be written at all.
And yet I see TDD practitioners as the primary source of such tests - if you are dogmatically writing a test for every intermediate change, you will end up with lots of extra tests that assert things in order to satisfy the TDD dogma rather than the specific needs of the problem. Obviously this can be avoided with judgement - but if you have sound independent judgement you don't need to adhere to specific philosophies about the order you make changes in.
>In fact it's probably a bit easier to link intentional behavior to a test while you have the spec in front of you and before the code is written.
When implementing to a spec you are absolutely right, but a very small amount of software is completely or even mostly specified in advance.
>I find people who write test after tend to (not always, but strong tendency) fit the test to the code rather than the requirement. This is really bad.
I agree this can lead to brittle tests and lack of spec adherence, but if you are iterating on intermediate state and writing tests as you go, the structure of the code you wrote 30 seconds ago is very much influencing the test you're writing now.
Another issue is that fault injection tests basically require coupling to the implementation - "make the Nth allocation fail" etc. The way I prefer to write these is to write the implementation first, then write the fuzz test - add a few bugs in the implementation, and fix/enhance the fuzz test until it catches them. Fuzz testing is one of the best bang-for-buck testing methodologies there is, and in my experience it's very hard to write a really good fuzz test unless you already have most of your implementation, so you can ensure your fuzz tester is actually exercising the stuff you want it to.
>Assuming Im understanding you correctly (you're building something like terraform?),
I write library code for mobile phones, mostly in Java/Kotlin. I recently did some open source work (warning: I am not actually very proficient with C, any good results are from enormous time spent and my code reviewers, constructive criticism very much welcome). Here's a few somewhat small, contained changes of mine, so we can talk about something concrete:
https://github.com/protocolbuffers/protobuf/pull/19893/files
This change alters a lock-free data structure to add a monotonicity invariant, when the space allocated is queried on an already-fused arena while racing with another fuse. I didn't add tests for this - I spent a fair bit of time thinking about how to do it, and decided that the type of test I would have to write to reliably reproduce this was not going to be net better at preventing a future bug, given its cost, than a comment in the implementation code and markdown documentation of the data structure. I don't know how I would really have made this change with a TDD methodology.
https://github.com/protocolbuffers/protobuf/pull/19933/files
This change moves a memory layout - again, I don't know how I would have written a test for this, besides something wild like querying smaps (not portable) to see if the final page of the arena allocation had faulted in.
https://github.com/protocolbuffers/protobuf/pull/19885/files
This change was written more in the way you recommend - but the whole change is basically a test. I debugged this by reading the code and thinking about it, then wrote up a pretty complicated fuzz test to help find any future races. I'm guessing that you would not consider adding debug asserts to be a violation of "write the test first"? So in this case, I followed TDD's order - not because I was following TDD, but because the code change was trivial and all the hard work was thinking about the data structures and memory model.
https://github.com/protocolbuffers/protobuf/pull/19688/files
All the tests were submitted before the implementation change here, but not because of TDD - in this case, I was trying to optimize performance, and wrote the whole implementation before any new tests - because changing the implementation required changing the API to no longer expose contiguous memory. But I did not want to churn all the users of the public API unless I knew my implementation was actually going to deliver a performance improvement - so I didn't write any tests for the API until I had the implementation pretty well in hand. Good thing too, because I actually had to alter the new API's behavior a few times to enable the performance I wanted, and if I had written all the tests as I went along, I'd have to go and rewrite them over and over. So in this case I wrote the implementation, got it how I wanted it, wrote and submitted the new API (implemented at first on the old implementation) and added tests, updated all callers to the new API, and then submitted the new implementation.
I don't think TDD would have led to better results in these cases, but you sound like a TDD believer and I'm always interested to hear anything that would make my engineering better.