I have unit-tested my code for many years.
While building a GIS-system, we really cared about our product quality. Our users’ needs demanded the app to work properly. I had all critical and/or complex parts of code 100% test-covered, with multiple paths and corner cases. It was such a pleasure to find a bug, fix it, write a couple of tests for this surprise scenario, and be sure it won’t break again. Ah, good times.
Here is the collection of best practices that I’ve built over the years. Please note: I’m going to assume you’re familiar with the concept of unit-testing already.
What to test
Of course, you have limited resources. Of course, to test absolutely everything will require an enormous amount of time. And, of course, you need to explain unit-testing and its benefits to your project manager and negotiate with them. That’s why you need to prioritize.
The best way to prioritize is to look at the business needs.
It may sound surprising for some developers. You might think: tests are code, they don’t have anything to do with business.
In fact, all code has to do with business. Ultimately, you write code to solve a problem your users have, and that’s a business need.
But I digress. Say, you don’t have any tests but you really want to start. Brilliant!
Pick the most critical part of the system. Is it your customer’s purchase experience? Is it tools with which your users work? Is it the data flow? Is it a complex math calculation?
Identify this place and start from there. Since it’s the most important part of the system, which absolutely has to be working properly, you can justify spending time on unit-testing.
Why is unit-testing needed
Unit testing helps to make sure the code works correctly. Unit-tests are a great way to do regression testing: simply run them every time you change something and make sure nothing breaks. Spotted a bug? Fix it and write a unit-test to make sure it doesn’t happen again.
For your identified important parts of code, you need to write unit-tests to make sure the code works:
In base cases
When the users and the other code do what’s expected from them.
These tests are the easiest to write – after all, we all are aimed at success :).
- Pass proper arguments to your function and make the test expect the proper return value.
In corner cases
You want to make sure the code works in corner and edge cases – when a rare scenario happens.
- Pass 0 to your math function. Pass -1. Pass INT_MAX. Pass an empty string.
In case of failure
You also need to verify that the code breaks properly. For example, if a financial operation failed, we have to be sure that the money didn’t disappear altogether.
- Pass a NULL object reference. Do something that generates exceptions.
- You think this is not possible because the code calling your function doesn’t do that? Maybe right now it’s the case; but the code changes. If the frontend form validates all input, but tomorrow someone refactors it, you don’t want your backend to start failing unexpectedly.
After covering the most important code with the comprehensive unit-tests, you can work your way towards less critical code, with less unit-tests. Prioritization!
Modularization and dependencies
Sometimes, the first step would be to prepare your piece of code for unit-testing. This is when modularization comes handy! The more modular the code is, the easier it is to test.
After you identified dependencies and split them properly, the rest is easy. For a unit-test, you have to mock the dependencies, to make sure you’re only testing your current module’s behavior. Otherwise you will be testing also the behavior of other things it depends on. But these things deserve their own tests.
You also can decide to test the module with dependencies. While some might say it is not unit-testing anymore (but rather integration testing), it is still testing, and it it still useful.
There are several approaches to naming the test methods. In my opinion the most important thing is – that the name should be descriptive. As descriptive as possible. Let it be 200 characters long – the more clear, the better.
Since all tests have very descriptive names, you can add as many small tests as possible – for all your nooks and crannies of corner cases. Make sure your test only tests one scenario.
For me, it turned out to be the most convenient when the test project structure repeats the main project structure. This way it is easy to find the tests for the module.
Make sure your tests don’t depend on each other. Perform a cleaning step in the beginning and in the end of each test, if you need it – there are usually special methods in the unit-testing framework to do that.
Change the code – run tests
Sometimes I hear someone asking: how do I understand which tests to run when I edit the code?
Run them all!
Since the folder structure for tests repeats the one for main code, it is easy to find a test package related to your change.
When you performed the modularization step, and also when you made them small, you already made sure they run fast. So you can afford to run the package of tests quite often, for example, while you develop and before pushing.
Add the code – write tests
Changing the important piece of code, which, according to your business needs, must work? Write a test.
Fix a bug? Write a test. Make it a habit! Tests are an important part of code, so make an effort to keep them up-to-date, and they will save you many times in return.
That’s okay if tests outnumber the code
In fact, that’s expected!
It’s great if you have many tests for one piece of code – that means you check a lot of cases.
It’s okay if you spend time on writing tests – sometimes, even more than on writing code. Tests are a safety net for your critical business parts, remember?
When unit-testing is not needed
In my project, we didn’t cover all our code with unit-tests. For some, we made conscious decision not to do it. So, when is it not needed?
When your code is not critical and your business can afford some mistakes in that part of the system.
When the effort to make the code testable and write tests is too large, and testing manually requires less efforts. You need to estimate the efforts cumulatively, for the period of time you plan to maintain the project.
When there is a dependency which you cannot abstract away: you moved it out from all other modules to just one, but you just cannot remove it from there. In this case you can unit-test all other modules, but this one will have to live as is. A popular example is a module using current timestamp.
When it’s a prototype, a proof-of-concept, or experimental code, and you need to develop it as fast as possible, and you’re probably going to throw it away anyway.
And last but not least – when you are sure your code is the best 🙂