It took me a long time to become so comfortable with unit tests that I can consider them just a part of my usual development process. Looking back, I can identify four different stages in learning to write and appreciate them. I'm going to call them styles:
- No-Test Development
- Test-Last Development
- Test-First Development
- Test-Driven Development
No-Test Development is what I started with. I wouldn't write unit tests for anything, just relied on my ability to keep the whole code base in my head and understand what affects what. This doesn't exactly scale very well. But with good testers and a clean architecture we could make it work somewhat.
Test-Last Development was the "I should write unit tests but I don't really understand why" stage for me. I would write the code and then I would write one humongous test function that would test every feature of it one after the other. Sure, sometimes a test broke to indicate a problem but it wasn't always very easy to figure out what exactly had gone wrong.
Test-First Development began for me when I started reading more about good unit test practices. I started splitting up my humongous tests into testing individual things. I learned about the Arrange-Act-Assert structure. I began trying the approach of first writing a test and then writing the code to make it pass. That felt more natural to me, incrementally implementing functionality while ensuring everything was tested.
To me, the difference between Test-Driven and Test-First Development is when the API design happens. In Test-First Development, I still design the API of the tested component before writing tests. Test-Driven Development is more exploratory. I don't yet know exactly what's a good API, so I use the tests as a client for the future API to explore the design space and figure out what is natural to use.
These were the stages that I progressed through when learning good ways to write unit tests. But that doesn't mean I abandoned the earlier styles when I got comfortable with a later one. I consider each style to still be applicable in appropriate circumstances. The difference is that, now, choosing the appropriate style is a conscious decision rather than just what happens, and learning the other styles has helped me write cleaner code even when I'm not applying those styles specifically. For instance, even when I'm doing Test-Last and not thinking of tests when writing the code, the code ends up being pretty testable anyway.
It can be perfectly valid not to write tests. Even when it's not something that's not suitable for unit tests, like UI or database access code, I don't typically write tests for simple functions that are unlikely to change. This is also a common way in which I practice the Test-Last style. A function I thought simple starts accumulating complexity to the extent it needs tests. So in this case I write tests after I've written the code.
Most of the time, I work in the Test-First style. Truly new situations don't come across that often, so typically I know quite well how the API is going to look like and can just get to writing tests. I prefer Test-First to Test-Last because writing small tests for each incremental piece of functionality ensures that everything is tested, and it gives me that feeling of making progress that I always crave when programming.
But sometimes I have only an idea of the functionality I need, not how it should be called by the rest of the application. This mostly happens when I'm writing some more general module that is not application-specific and might end up being quite complex. In such cases, the Test-Driven style works really well for me. I implement the module piece by piece so I can focus on the best way to design each small increment. In this kind of work the refactoring step of TDD really is mandatory to keep the code clean, as the module API and its internals tend to change constantly.