Unit tests — Playing chess against yourself
Recently I read an article about how many incredible developers don’t use debuggers at all. It is a really good read. I did write a lot of code in PHP so I was familiar with the concept. When I was writing PHP it did not have a debugger in the full sense of the word, so most of the debugging was print_r/die combination. I can confirm that not having capability to just put breakpoints on some line and simply go through the code do make the difference.
It forces you to think about what code really does and why something broke, and not only how to find it and fix it. That eventually leads to better understanding of the product you are working on, and how to avoid the same mistakes later.
Not having a debugger did make me a better programmer, but writing unit tests has more effect on my progress as a software developer. It helped me to better analyze the problem I was working on. And as a result I would locate problems faster, if not preventing them.
Playing both sides
Chess is a strategy game where both sides have perfect information. This means, unlike when playing poker, each player is perfectly informed of all the events that have previously occurred. Your goal is to capture your opponent’s king. So you plan ahead, and try to set a trap to do so. It might sound simple, but traps need to be sophisticated because both players see everything that is going on the board.
Did you ever tried to play chess against yourself? You make moves for both sides, White and Black. This is fascinating experience. Regularly, when you are playing against other person, you analyze the position as best as you can in your head, and try to determine what is best course of action for both sides. This helps you to predict opponent’s next moves and how to react to them. In the way you are already playing some portion of the game against yourself.
But when you are truly the only one moving pieces on the board you already know the other person’s (yours) intentions. This gives you an opportunity to study your gameplay, find weaknesses in it, and determine what could have been done better. Not only that it helps you avoid repeating mistakes next time, but it trains you to do an analytic thought process that is necessary to play chess.
Writing unit tests
Unit tests are a type of test where you are testing a small portion of code called unit. Unit can be a method or an object. I never really saw the purpose of it before I started writing them. I thought that you were trying to find a bug in your code. But that is not true. Purpose of unit tests is to validate behavior.
Unit tests belong in a group of so-called white box tests. This means that the person who writes them is aware of how things are implemented. Just like in chess, you know “opponent’s” intentions. And now you can study their gameplay and try to find weaknesses in it.
You want to know exactly how code will behave under different circumstances. So you write every possible scenario and see how things will play out. Unlike chess, the goal is not to win, but to be certain that you know what will happen. Code may throw an exception on invalid input, and that is ok, but you need to know that that is going to happen.
This was an eye-opening for me. First time when I was writing unit tests I thought that I knew how my code behaved. I was wrong. There were a couple of edge cases that I did not take into consideration. My code would throw unwanted exceptions. But that was a minor problem. Big problem was the use case where code would return the wrong value. Everything after that would work fine. Invalid data would be saved in the database and it would cause data corruption. Since there was no immediate effect this case would be harder to detect.
Unit tests remove assumptions about code. They are documenting behavior. Let’s say you are using some internal library. You are not sure how something should behave. There is no documentation or it is obsolete. Unit tests got you covered. Find (or write) unit tests for your case and you are golden. Run it and you are 100% sure what to expect. Even better, integrate tests in your deployment process. This will ensure that deployment fails if code does not behave as expected in unit tests, preventing unwanted behavior in production.
Good naming convention
I have tendency to name tests by combining 3 things:
- name of unit that need to be tested
- conditions under which you are testing that unit
- expected result
For example:
CalculateDiscount_ByPercentageForValue1000_Return100
From name there is pretty clear what is going on. You are testing method CalculateDiscount. That method receive input 1000 and type of discount (ByPercentage). Result need to be 100. If this test fails you know just by looking the name of unit test that something is wrong with CalculateDiscount method if type of discount is ByPercentage. This is a great start for finding bugs.
These are my personal preferences. In .Net ecosystem there is convention to name tests like:
CalculationDiscount_should_return_100_for_ByPercentage_when_price_is_1000
Use whatever you like, just keep it consistent. In both cases the name is pretty descriptive and that is all that matters.
What not to cover with unit tests
Writing unit tests is time consuming. You need to write a test for every possible use case. So be sure that you know what you are testing. Never test dependencies, only business logic of the unit you intend to test. Just presume that dependencies work perfectly. These dependencies should have their own unit tests to validate behavior.
Do not test external libraries. For example, if you are using AutoMapper then do not write tests for it. Do not write tests for cases when AutoMapper returns null as result of mapping because that will never happen. If there is a mapping profile between two objects then AutoMapper will always return a valid object. If not, it will throw an exception. But that case should be covered with configuration validation. So presume that AutoMapper is working perfectly, and just test your own business logic.
This is also true for internal dependencies. Let’s say you have some utility class. You ensure behavior of that class with unit tests, and when you use it somewhere in your code you only need to cover expected behavior.
On one project I had a service that was used as an additional layer of authorization. Based on role, current status and new status it would return true or false as indicator if user is able to change issue from current to new status. Since there were 4 roles and 16 statuses, there were around 800 unit tests for this method. Since it returns only 2 possible outcomes any unit that used this service had only two use cases for this dependency, one when service would return true, and one when it would return false.
If dependency is internal, and there are no IO actions (Database, reading files from disc…), you do not even need to mock it. If you use some utility class that is POCO you can simply use it as dependency. You are breaking “rules” of unit tests because you are not fully controlling the state of the test, but let’s be honest, you are writing tests to validate behavior, not to demonstrate that you are implementing a certain paradigm.
Failed unit tests are friends, not the enemy
True power of unit tests comes to light once you start changing things. Let’s say the client wants you to extend some functionality. You need to add new things, but old use cases need to remain the same. You implement new business logic, but now 5 unit tests fail. You broke something, and because of unit tests you know it right away.
Conclusion
It is fun to look at code from different perspectives. On one hand you are just trying to make a feature, and on the other you are trying to break it. Experiencing your code through unit tests will make you a better developer. Just like in chess, you will see weaknesses in your code and strengthen up your gameplay for the next match. With time you will cover edge cases by default because you will know where an error may occur. And one day, when new requirements come, you will recognize it just like chess masters recognize chess positions, as a face of someone you already know, and unit tests will prepare you to know what to expect and what to look out for.