Unit tests are widely believed to be a good practice. After all, it has the word “test” and no one would say that tests are bad, right? Well, there is a group of people who believe the practice of unit test coupled with TDD philosophy is harmful and should be avoided (although I do not know if they would also say that unit tests apart from TDD are good).
This is my experience, and it could be yours as well: I have seen many applications, both legacy applications and green-field applications. Some of them were badly architected, or incredibly buggy that you and your colleagues thought it would be worthwhile to re-write the applications from scratch, and that would be cheaper than fixing them (even though you know that re-writing can be a bad choice). These applications are difficult to change, because any simple change will have to go through rigorous testing to validate these changes are correct and it do not introduce new bugs.
Ignore the bad architecture issue for now. Why does code become buggy? One can insert many answers here. Inexperienced developers, careless coding, lack of ownership, etc. But you have to add “lack of unit tests”. Unit tests is definitely an enabler for better software quality.
Why Don’t People Write Unit Tests?
I wish I could go into developers' heads and understand the psychology behind it. It is difficult to point a finger at a single reason why people ignore unit tests. But I can list some reasons I found around, real reasons that I picked up from talking to people:
I don’t know what unit tests are. This reason is common around beginning developers. Don’t blame them: schools don’t require software testing, or unit tests as part of the degree, and personal projects don’t enforce unit tests. People get to know unit tests from experience or reading a book. Take the time to teach your fellow colleagues what unit tests are.
I don’t have time to write unit tests. Unit tests do take time, and sometimes they require more time than writing the actual code. Writing unit tests requires discipline and focus, and such habits cannot be taught. The best way is to mentor your colleague, or to show them the workflow difference, and the quality difference between a project that has no unit tests, and a project that has good unit tests. Many open source projects includes unit tests, so that should be a good start.
Writing Unit tests is difficult. This could be because they have not done unit tests before. But this is could also be because of the design of the software. /Badly designed software would encourage badly written unit tests, difficult-to-design unit tests, or difficult-to-read unit tests/. Hence, before writing anything, you have to have a good design in mind, one that would adhere to SOLID and other design patterns. Well-design classes, code, function, and method are such enablers for unit tests. This is exactly what TDD culture enforces: write that unit tests first because unit tests will encourage you to write well design software. I would encourage your colleague to consider why writing unit tests is difficult and see if redesigning the class of the method would help.
Why Unit Tests?
But why writing unit tests in the first place? There are few pros that I want to highlight:
Unit tests are tests: they ensure the code is correct. Unit tests divide the code into independently testable units. Unit tests ensure these units are correctly written independently from each other.
Unit tests are refactoring tests: when refactoring your code, you can ensure the refactor is correct if it passes the unit tests. Without unit tests, you will need to issue an integration test or end-to-end tests (or manual test) to ensure the changes are valid. This is time consuming in the long run.
Unit tests encourages good design. Unit tests are incredibly difficult to write if the software is not design well. Consider how difficult writing the unit test would be if the method breaks the principle of least knowledge: you will need to test the method, and then look into the dependencies and ensure they are working correctly too. Unit tests encourage SOLID, proper abstraction, and loose coupling. Some languages, such as Go, have implicit interfaces rather than explicit (such as in Java). Such language feature makes design changes minimal for testability.
Why Unit Tests Over Integration and End-to-End Tests?
Unit tests is not the only category of tests you can create for a project. Integration tests (which tests the coupling of two or more components of the system), such as testing a query all the way to the database, and end-to-end testing, which test the whole application (user clicking on a button and expecting some data) are two different tests you can apply. How should you balance between the three?
My argument would be this: if you can avoid integration tests and end-to-end tests, and produce the same result with unit tests and fakes, then do so.
Integration tests and end-to-end tests are flaky: the test could fail for reasons other than your code: the network went down, the database is unavailable, the external services in under maintenance, etc. When you read such error, you will need to spend the time to verify that it is not your code. This is usually done by verifying that all dependencies are running well, and that the database is the correct state, or by running the tests again. Either way, you are wasting your time.
Integration tests and end-to-end tests do not help with finding bugs as much as unit tests do. When a test fails, you still need to debug and find where the failure is. These tests only tell you that there is a bug and you need to fix it. Just imagine if your integration test expects three records, but you get two: this is an issue, where where is the issue? It could be that the database is seeded incorrectly, it could be that the query issued to the database is incorrect, or maybe the logic that process the result back from the database is incorrect. Unit tests, on the other hand, because they test single functionality, tell you exactly where the bug is. Unit tests run in a predictable, repeatable environment.
Integration tests and end-to-end tests are slow. If you have enough of them, your tests might take 10 minutes to run. This is just a reason for the developer not to run them often, and having to rely on the build server to run these tests. What happens in this case is that the developer would waist time pushing the code, and having it rejected because of integration failure. With unit tests, things are quick. In fact, most IDE knows what unit tests to run given any change in the code. This saves a lot of time.
However, this is not to say that integration tests and end-to-end tests are bad. If you cannot write fakes, and the author of the external service does not provide fakes, then integration tests are the only way to ensure your expectations of the external service are correct. You want to write integration tests to ensure that the database configuration is correct, etc. Or if your application creates or destroys resources in the cloud, you might want to write integration tests for such thing.
But, you do not need to write an integration test for every request to the external service: Once you verify that the external service respond predictably given a request, then write your unit tests against that response. No need for another integration test. The same goes with end-to-end tests. Google has written an excellent article on unit tests vs end-to-end tests that is worth reading.
At the end, if you are working on a new project in 2019, you have no excuse for not writing unit tests. If you are not writing unit tests, you are doing it wrong.