Ask HN: Seriously, how do you TDD?
I have been trying to create a command line tool to categorise my expenses and show a total by category. I’ve been using this exercise to practice TDD.
In my latest frustrated attempt, my first test was along the lines of “it can categorise a single transaction correctly”. The problem with this is that this is testing the “tip of the iceberg” that will be almost the whole, final app. I think I was attempting a more “top down” design strategy here.
Before that one, I tried the opposite: a more “bottom up” approach. I know I need to, at least, read a CSV, parse a CSV row into some data structure and then categorise it. So I TDDed my way through to complete these “sub problems”. However, when I was about to apply the categorisation rules I realised that the data structure I created didn’t help, was just bad and didn’t work at all :).
So, how do you do it when you have a “larger project” (here, “larger” means something that’s not your typical “2/3 points user story” at work)? I always end up feeling stuck whether I try bottom-up or top-down.
85 comments
[ 2.6 ms ] story [ 158 ms ] threadA practical example is that my team is building a product that has a workflow which scrapes websites and then converts the output to internal models (before more processing occurs). The way we make this TDD is by simply loading static "fixtures" during testing instead of actually reaching out to the website to grab the HTML.
You might make the case "what if the HTML DOM changes at the origin"?
That's not the point of the unit test; the unit test is only testing "given this structure, this is the output I would extract from the HTML". If the HTML structure at the source changes, that's an integration or E2E test.
I couldn't disagree more. Indeed, it's precisely because framework and library black box behavior and upgrades tend to introduce random unforeseeable bugs that TDD is essential.
It's super important to take some known inputs, and after a series of framework/library operations, to check that the outputs are what they're supposed to be, for normal operation and for all the wacko edge cases (zero-length strings, etc.).
If it doesn't fit your mental model of programming, just don't use it.
However, a big inflection point for me was getting hold of a good/capable IDE (intellij). It removes a lot of the drudgery.
You write test with yet-to-be defined functions. IDE let’s you run the single test (amongst many) with one shortcut. Fails ofc!. Then you use a shortcut to let IDE implement the function (takes you where it needs to be implemented, easy with python modules eg. YMMV). Rinse and repeat.
TDD is kind of a forcing function for modular design (whether OOP or functional programming).
In your scenario, you'd want:
1) a module to read the CSV -> test that it can load a file correctly given a path string, correctly handles invalid paths (format, file does not exist, etc)
2) a module to parse a row -> given a string, test that it can parse a 0 row file, a 1 row file, a 2 row file, and correctly handles an unparseable file (e.g. binary file); test that it correctly handles incorrectly formatted rows
3) a module to categorize -> given a set of rows, test that it can categorize a sample set correctly with expected output
If you try to make 2 depend on 1 or 3 depend on 2 and 1, it's no longer a unit test and becomes more of an integration test.
Take that first module, the CSV loader… if you build it in a truly modular way, it can be hard to stop with the features that just support your end user application.
I mean, it's not hard, you just choose to stop.
All it really takes is an awareness that you're programming to solve a specific limited defined task, not programming just for fun.
Of course, if you're doing it for fun, then fine, don't stop.
But nothing about stopping is hard. It's just a choice you have.
Sounds right to me! I usually start projects/features with a test that covers the end-to-end behavior I want to see. When you have no other tests, a smoke test is the highest value test you can add because it measures the thing you care about.
TDD encourages a form of programming where you build from the outside-in, building layer after layer of abstraction, putting off solving the actual problem until you get to the messy gooey centre where it ends up being a kludge. It's also very easy to make flawed assumptions in your original TDD spec which you don't realize until you get to that gooey centre, having wasted N days in the process.
I honestly don't tend to start anything until I have a basic idea of how it's going to work (not a fashionable idea these days), and find that projects end up with a much saner structure when they're built from the inside out.
So my preference is to start TDD once I've got a very basic version of something working, at which point you can start diving in to all the corner cases.
In TDD, all production code must be written against some pre-existing test. Any code which does not meet this criterion is broken.
I've also found having apparent time-luxury to be a rather paradoxical thing, the more of it you have, the more people start spending it by creating bureaucracy and busy-work. It never gets spent how you expect it to be spent.
Sometimes that's building a full app and getting it to market. Sometimes that's isolating a bug in a legacy system to a unit test. Sometimes its the compiler failing to build your code. Sometimes it's a data science problem, and it means training a model in a notebook, to measure its accuracy.
Don't get hung up on using one particular feedback loop.
Getting stuck in the wrong feedback loop is a big anti pattern of software development. When it takes forever to submit a job to some system, then takes 20 minutes to give you a type error in a scripting language, that could be caught by a linter or type checker sooner. Those 20 minutes easily turn into 60 minutes of distraction. Imagine instead you could catch this in 5 seconds with a test / linter / whatever. Then you'd resolve the issue in minutes. OTOH spending hours decomposing simple code into unit tests can be a waste of time, when the most value is submitting that simple code to a batch processing system, to see if it produces output...
In my experience I get those benefits when testing at the system/use case level but not when doing unit tests. At that higher level automated tests works wonderfully. I haven’t had a bug in production for 5+ years because of test automation. Which means that I spend 95% or more of my time adding new features or optimisations instead of bug hunting and/or getting shouted at because of customer problems. Love it!
This is also what I've run into in practice, across numerous systems.
Actually the only cases where I've been able to do TDD effectively (and get >95% test coverage) was when I was in control over the entire codebase and its design from day one, thus being able to enforce whatever modularization was necessary by the tests, otherwise the systems quickly tended to get untestable (at least in regards to being able to start with a test). In addition, I was generally more successful in cases where I was dealing with writing libraries (that are called by other code), as opposed to web applications which need to deal with shuffling around bunches of data.
Let me give you a few examples of what didn't work. Suppose that I'm asked to help out with this pre-existing codebase that's been around for 5 years or so. I look how some request to purchase a product is processed (just a made up example, though along the lines of what I've seen): first you have some server side rendering with the full complexity of PrimeFaces and around 10-20 variables that affect whether certain buttons are visible, possibly there being a modal dialog or redirection in the middle of the process.
Then, you have the view code, which once again contains different methods that interact with services, passing data around and choosing whether to display additional messages or something else. Then, in the service layer, you might have 5-10 services that are involved in retrieving the data necessary for everything, from auditing and e-mail notifications, getting various pieces of information about users/billing/discounts and then checking all of that against what the user actually wants to purchase, perhaps some validation code thrown in along the way.
Validation code which also might need to interact with the database due to incrementally grown business requirements over the years. And from there on out, additional code for returning early in the case of any errors being present, which might need to be further processed for displaying them to the user. Oh, and if the process is successful without any errors, then you might also have some scheduled processes that would be created and would need to be handled. Almost any of these steps could have transaction rollbacks along the way and there might also be a large amount of PrimeFaces/Spring related code and workarounds in there.
And I've seen similar patterns across many projects, almost regardless of technologies used (Java is just a good example): if you give developers the chance to create tightly coupled code, they will jump at it horrifyingly often, especially if they're under time pressure or have a "good enough" mentality. Over time, any sorts of boundaries will dissolve, unless you go for a microservices based approach, but even then you'll still be dealing with "magic" code which will be hard to test (especially if you need some kind of application context to be running during the tests), be it IoC/DI, annotations/decorators, proxies for your code, or even something like ORM interactions.
Speaking of databases, good luck if your code has like 10-20 database calls along the hot path, which you'll need to mock or actually let it hit some database instance, though hopefully one with state that's either reproducible or can be rolled back (both of which will make your CI slow and more error prone). You might go for an in-memory database, but good luck trying to transpose or make Oracle-specific complex queries portable to something like that, given that for many learning JPQL is too much.
In short: there are situations where TDD is simply unsuitable because of how certain systems/domains are architected (I'm yet to see the PrimeFaces layer as something easily testable) and you're better off simply using unit tests where you can, as well as a healthy helpin...
To go from a high level requirement like "command line application to track transactions and show expenses by category", you need to go through several levels of detailed design before you have anything that is implementable by a test-driven approach, which will necessarily be bottom-up.
Remember that tests can only confirm the presence of a defect, not its absence, and that not everything can be tested due to the intractability of exhaustive testing.
By blindly following test-driven design, you could end up with a useless, inflexible program that correctly categorizes expenses over only a small, fixed vocabulary of categories because it never occurred to anyone that a category must be something user-defined.
A user-defined category feature cannot be implemented with strict TDD because the space of user-defined categories is infinite. Say you have a test which confirms operation for every valid category string up to three characters long. That doesn't prove the system will not fail for a four-character-long category.
The only people who regard TDD as some kind of panacea are those with no formal background of any kind, who don't understand the role of testing in the overall software engineering context, and what its limitations are.
As a programmer with a problem, you first instinct is to start at the solution.
TDD pulls you back, and you first have to write the api and decide how you verify it.
If you're doing it well, you make both of those things as simple as possible, first. Reduce dependencies and inputs, etc.
That's actually the most important part, an easily consumable api that's easy to verify, not the implementation, and TDD forces you to do it first.
Also, a pet peeve of mine is seeing a line or more of code that doesn't do anything. With TDD, such detritus is impossible as you can't write production code unless it's making a red test turn green.
If one then goes and tries to write tests for these kind of methods, it feels like superfluous busywork.
TDD has refactoring steps which only have to preserve passing tests; refactoring can easly be the vector that introduces dead code as well as changes behavior for untested input combinations.
I suspect that a lot of code developed TDD is actually deployed on input combinations that are not covered in the TDD test suite.
A string length function developed by TDD will still work on len("supercalifragilisticexpealidocious") even though that exact string never appeared as a test case, and the consumers of that function will use it on all sorts of untested inputs all the time.
But this has nothing do with what GP said. That isn't what gp is using TDD for. GP is using TDD for,
1. starting with api of a function vs code.
2. avoiding unused lines of code
3. reducing dependencies, its hard to tdd something that has a lots of deps so it drives your refactor it something more testable and thus more readable/maintainable also.
4. simplest set of input/output.
TDD makes you define what your input is, and what your output is. What's in the middle your free to choose.
Then you should find some sort of coverage tool so you can know if you covered most lines or not. I tend to ignore error handling lines but if its easy to get into that case I'll writ a test for it. Then I'll write test that take <1ms to execute. If something needs a network I'll try to separate the logic so it can work on a file OR I'll it connect to something on my local machine.
Generally I try to get 95%+ coverage (including error cases). I have many test and the entire suite run in <100ms. Maybe my project is easier to do TDD on but that's the way I do it
Your program is going to have a flow like this:
Internally it'll be like this: So your second example is the top-down one, it requires all this logic to exist. That makes TDD harder because going from a one-line CSV file and hardcoding the amount and category (first code is dumb code), to a second one-line CSV and actually categorizing is a massive jump.Categorizing is the core, so categorizing is the thing that really needs testing. The rest doesn't even need (substantial) testing anyways, it's a bog standard CSV-data driven tool. Which leads to a point I want to make:
You don't need to do TDD on everything to do TDD. TDD is the "show your steps" of programming (analogous to the high school algebra requirement that you show and name each step in a solution). Many people despise that in their math homework, but it's important for the teacher for the same reason TDD can be important to you, the programmer.
Math teachers (ok, some may, they're poor teachers) don't ask students to show their steps just for grins. They ask it so they can observe the thought process of the student. A student tasked with solving for x with `2x = 4` may come up with `x=2`, but how? Did they divide by 2 or subtract 2 (I've done my share of math tutoring, I have seen students subtract what should've been divided). They got the answer by coincidence. So when they are given another problem where that coincidence doesn't work, they get the wrong answer and in more complex problems how they went wrong is non-obvious ( in 3x=9 => x = 6 it would be glaringly obvious, but with more terms and unobserved steps it's not; it's early the coffee hasn't kicked in yet so I can't think of good example problems to illustrate this).
TDD does the same thing, but it's there for you, not an instructor, to observe what you are thinking along the way. Each test is supposed to be small so that you can easily see that the logic is correct. However, if you can see a larger step to take (like that structure I threw up at the top or parts of it) then go for it. I've written I don't know how many CLI apps and I would never use TDD for anything but that inner categorization logic (or its equivalent for them). It would be silly for me to write a test to see that I was reading the file correctly. I can think of only one reason to do it: You don't know the language and API yet so you want to test your own knowledge, more than the program itself, to ensure you're calling it correctly. Even then, unless it has a weird file reading API I wouldn't bother, 99% of them will be the same or similar enough.
Don't test private functions. Only test the public interface.
Use dependency injection everywhere.
My personal experience is that i start TDD once my project is "mature" enough that the next upgrade/addition can be put at two different place easily enough and i don't know where is the best place to do it. Basically, on my latest project, i asked myself something like: "is this part of this class method, or should the main loop take care of it?". That's where i started completing tests to 90% coverage (main loop + pagination mocks were skipped, ideally i could've reach 95% but laziness caught up) and started TDD. In the end, i still made the wrong choice and added my change to the class method, only to change it back two weeks ago (or roughly six month later). Here, TDD didn't help me to make the best software architecture choice, but having to write test first for this part of code made me decouple it from the method i originally put it in, and allowed me to made a architecture change in less than an hour.
Particularly with exploratory projects, I'd recommend focusing on rapidly iterating to reach the simplest solution (businesswise and implementation-wise).
If in the end non-trivial logic must be specified, write some tests. Your definition of 'non-trivial' will change with experience.
But tests aren't inherently good. Good tests will speed you up, bad ones will slow you down. And it is incredibly hard to write good tests. Might be even harder than writing software that tests supposed to test:
If software works in 80% of cases, it creates 80% of value.
If tests work in 80% of cases, they cause harm in the remaining 20%.
Want to extract some logic into a new interface or introduce a new parameter to a method?
Well, now instead of just doing that, you will also have to update all tests dealing with said logic.
At my previous job I spent much more time updating tests testing trivial shit than actually writing useful code.
Sure, those were "bad" tests. Accidental complexity is not supposed to be tested. And yet I had this experience in every single company I worked at. Maybe I'm just incredibly unlucky. Or maybe there's something wrong with "tests are good" mantra.
"Write tests. Not too many. Mostly integration"
Sometimes, all 50 of those stupidly over-complex tests that people have copy-pasted around are garbage that get in the way of your refactor, but sometimes one of them is protecting some important functional requirement that you didn't think about like "Test that this code called from a tight loop doesn't query the db hundreds of time which would lead to the startup time taking minutes rather than seconds".
No, tests can make refactoring slower, because they often test implementation detail instead of interface. Being able to distinguish the two is not as easy as it sounds, typically it requires deep understanding of the domain, which people writing tests might not have yet.
> in a complex enough code base, refactoring is impossible without sufficient test coverage
Sure, and in my experience complex code bases are typically complex due to the amount of accidental complexity, not because they actually solve complex problems. Like layers of overengineered classes intertwined through dependency injection for the sake of being more... testable. See the irony?
And indeed, refactoring becomes quite hard. You have two options to make it easier:
1. Identify and remove accidental complexity.
2. Cover it with tests and set the complexity in stone.
It's like fighting cancer with painkillers. It might look like it's working, but you going to die pretty soon.
> Test that this code called from a tight loop doesn't query the db hundreds of time which would lead to the startup time taking minutes rather than seconds
Oh, this is a great example. I saw the exact same thing implemented couple years ago. The whole team hated it because it caused tests to break constantly. And yet nobody on the team had the balls to remove it, and they were even following the pattern for new tests, because past tests had it.
If startup time matters for your app, there will be plenty of signals apart from tests that it's broken. If startup time doesn't matter, why are you testing it?
By the way, you haven't even noticed it, but you conflated startup time with number of queries. You're testing the wrong thing. What if hundreds of queries are still blazingly fast (due to being simple and/or cacheable) and have negligible effect on startup time? What if existing startup time is already extremely high (e.g. you're loading ML models in memory)?
If startup time truly matters for you, you shouldn't be testing it, you should be monitoring it. And I'm not even talking about some complex monitoring systems. When someone on your team says their developer experience got worse, that's the type of alert you need to listen to.
If you can’t refactor your code without breaking tests then your tests are testing implementation details and not higher level specification details. Your tests should test your use cases not your implementation details.
In other words, if you can’t refactor your code without breaking your tests then you are doing automatic testing wrong. It’s that simple.
Most tests written by people working on the product will of some baseline level of quality and even if they weren't, even if they were "bad tests". It is much easier to fix bad test than it is to deal with an application on a day to day basis which has no tests.
This is exactly the mindset I'm talking about. Lack of which tests? Manual? Unit? Functional? Integration? End-to-end? Doesn't matter, right? All tests have zero cost, the more the merrier.
And when exactly does the slowdown happen, on day one? Or maybe some time in the future? Do we have a way to tell when exactly should we write tests, how many, and which ones? Most of the time the mindset is "we don't have tests, therefore we need tests". Cargo cult at it's finest.
> Most tests written by people working on the product will of some baseline level of quality
Not in my experience. Tests are often introduced before you have a good understanding of the domain (hello TDD). Epitome of that is hiring junior engineers, realizing you don't want to assign any real work to them, and forcing them to write tests.
> It is much easier to fix bad test
Typically bad tests should not be fixed, they should be thrown away. But pretty much every company sucks at deleting bad tests, because there's the illusion of value they provide.
In a DAW(Digital Audio Workstation), this is like adjusting the knobs, adding effects when you haven't even added any samples, it just doesn't make any sense, stop forcing yourself to do things that doesn't align with your flow, you'll likely get annoyed for no reason.
The way I work is I do the architecture design, I write the code, then I write my test by following the design graph.
If I don't know what the design would look like, I do what I call "code freestyling" to get a basic idea of what the overall design would look like then I repeat the above step.
C4 modeling would give you the abstract design or system architecture graph, you don't have to do all 4 levels diagram, just do the one that makes sense to you.
If you are doing something simple, you might not need to do any of that but as soon as you are building something of scale with optimal security not doing system designing is like building a skyscraper with no design, well, you know what might go wrong :)
Write examples of how you would like to be able to solve common cases and handle edge cases if you had the ideal interface based on what you know at the time, then use unit tests with stubbed out interfaces as a tool while trying to develop the implementation.
Refine and even rewrite your examples as you learn about what works and what doesn't work.
Recursively break down complex pieces into smaller modules as you learn what can be handled independently and composed with the other pieces. Break off independent examples depicting how you expect those modules to work and be used as new unit test suites for those new modules.
Don't sweat testing the implementation details, especially when prototyping. What you want is to use tests as a way to speed up your development process and improve the quality of your design and architecture.
Try to write tests that show off how nice your code is to use and how few edge cases you need to handle when calling it. It'll raise the bar for how your code interacts with the rest of the application or system and give you smaller pieces to work and focus on.
Finally, for really green field stuff with a lot of unknown unknowns, you often have to write a lot of standalone, bottom-up throw-away prototypes for things. That doesn't invalidate the usefulness of tests, so don't be afraid to do that pretty regularly.
It defines the information you need to send in and/or receive.
Non functional. Then capture some basic behavior of one of the methods/properties in the tests, then implement the solution. Then start capturing edge-cases the same way.
If you’re asking yourself “how” or “should” I test this, then you’re doing great.
Apply SOLID principles to separate meaningful concerns into separate classes; and iterate there.
Take my viewpoint with a large grain of salt.
A funny pattern I noticed is most TDD debates is that whoever challenges TDD always comes up with concrete examples, and TDD advocates always stay incredibly vague.
There's a good discussion between DHH and Martin Fowler/Kent Beck on TDD: https://martinfowler.com/articles/is-tdd-dead/
You can also look up Ron Jeffries and Peter Norvig attempts at sudoku solvers. Guess who actually solved the problem, and who wrote 5 blogs posts without getting anywhere?
TDD is snake oil.
http://www.dubberly.com/articles/managing-complex-design-pro...
Another piece of general advise on TDD: Adjust what tests and how many you write based on what your language already gives you. E.g. when I write Ruby or JS, I'll write a ton of unit tests for almost Avery conceivable scenario. When writing Rust or Swift I write mostly unit tests for interesting business logs, but not much else.