UNIT TESTING – IS IT REALLY HELPFUL?
By ZoomInfo Engineering, Krishna Teja Dinavahi, October 24, 2022Note: This article focuses on unit testing in Java, but the idea and concepts apply to all the languages.
Background
A few months ago, when we as a company decided to pay more attention towards unit testing all our services, I wasn’t really convinced that it would be helpful for all the teams. My thinking was that it made more sense to unit test the services that have fewer external dependencies and integration test the services that have more external dependencies because, if we write unit tests for the services with more external dependencies, we would just end up mocking everything.
Also, our integration test suite is pretty strong, so I was more inclined to use that rather than reinvent the wheel by writing unit tests from scratch.
Unit tests
To get some motivation, I googled the advantages of writing unit tests, but I found textbook definitions like “Unit tests help you test individual parts of your application in isolation”. Though those definitions are theoretically correct, why would I spend my time to test individual units when I can test my entire application with the integration tests that I already have?
So, we as a team decided to start with a few unit tests first, see how it goes, and then build on those later if we think they would be useful. If not, we thought that we could maybe convince everyone on why unit tests are not ideal for our scenario.
I didn’t do much unit testing before so I googled around and found out that JUnit and Mockito are the standard frameworks for unit testing in Java, and I started with those. When I tried to write the first unit test for one of my classes, I wasn’t able to. Not because I didn’t understand the JUnit or Mockito frameworks but because of the fact that whatever JUnit and Mockito were providing were not enough to unit test my class. I thought Mockito was not powerful enough, so I looked into PowerMockito. But PowerMockito was also not powerful enough for my code!
Then, I decided to read about unit testing first and worry about the frameworks later. As I started reading more about unit tests, I realized that the issue is not with the frameworks but in fact the issue was with my code. My code was not unit testable – it was not written properly.
This, to me, was the biggest motivation behind writing unit tests. You cannot unit test badly written code because the frameworks don’t have the ability to do so. The unit test frameworks can only unit test properly written, good quality code. So, I felt unit tests were doing an implicit code review for us. That’s when I started appreciating writing unit tests and decided to break down my task of writing unit tests into 2 sub tasks:
- Refactor my code so that it’s unit testable.
- Write unit tests on the refactored code.
How does writing unit tests lead to good quality code?
Consider the following code snippet:
It’s a pretty simple piece of code. There’s a class called MyClass that has a getTransformedSeconds method which calculates the seconds value at the time the code is run, subtracts 1 from it, and returns the updated seconds value.
Now, you decide to write a unit test for the getTransformedSeconds method that checks whether your method is returning an even number or not. So, you write a unit test that looks like this:
As you may have guessed, your test would sometimes succeed and sometimes fail. The reason is that the returned value can either be even or odd depending on when your test was run (since it returns the seconds value). You have absolutely no control over which number is returned. You realize that this is an external dependency and so it must be mocked for your test results to be consistent.
But, if you look at your code, there is nothing you can mock in your code. LocalTime.now() is a static method, and with the older versions of Mockito, it cannot be mocked. Though you can use alternatives like PowerMockito or the Mockito.mockStatic method in the latest version of Mockito, there’s a much cleaner and better way to fix this issue.
What is your method missing?
Your method is missing a few basic concepts like pure functions, single responsibility principle, separation of concerns, etc. A pure function is a function that operates only based on the parameters that it has received and nothing else. You can read more about the benefits of pure functions online but one of its main benefits is that the behavior of a pure function is predictable. Using pure functions, we can achieve single responsibility principle and separation of concerns.
Keeping this in mind, let’s rewrite your code by introducing some pure functions like below:
And let’s add a test like this:
Now, if you run the test, you can see that it passes. Also, not only were you able to test that your method returns an even number, but you also tested that your method returns 40 when getCurrentSecond() returns 41.
When you check the code coverage report, you can see that your method is completely covered.
You can also see that the method getCurrentSecond which we mocked is not covered. But, it’s very easy to write unit tests for it and improve our coverage. Let’s change our code to look like below:
As you can see, we have separated out the external dependency which is not in our control to a dedicated method. We don’t have to worry about unit testing the externalDependency method because, even if that method fails, it is not because of something that we did. Integration tests are more suitable for testing methods like externalDependency, but we won’t talk about that here.
Now, all we have to make sure of is that, assuming that the externalDependency returns a value, our unit test returns it successfully. If there’s an exception when calling the external dependency, our getCurrentSecond method should fail gracefully by throwing a proper exception. So, let’s update our code to look like below:
Now, let’s write unit tests to test the getCurrentSecond method like this:
In addition to the getTransformedSecondsShouldReturnAnEvenInteger test which tests the getTransformedSeconds method, we added 2 more tests which will test the getCurrentSecond method.
The test getCurrentSecondReturnsTheSameNumberThatExternalDependencyReturned tests the happy path. We test that when the external dependency returns an integer successfully, the getCurrentSecond method returns that integer as it is.
The test getCurrentSecondThrowsBadGatewayWhenExternalDependencyThrowsException tests the scenario where the externalDependency call throws an exception. When the external dependency throws a RuntimeException, our getCurrentSecond method should return a meaningful error with status code 502 and a clear message that says “An exception occurred in calling the LocalTime API”.
Side note: If your test method names are getting too long, JUnit5 supports a DisplayName annotation to describe what your test does. So, if you want, you can rename the test methods to something shorter and add a DisplayName like below.
Anyway, let’s look at the code coverage now.
As you can see, all the tests have passed and everything that’s under our control has been successfully covered.
What has changed?
If we look at it from the functionality point of view, literally nothing has changed from your original code to your new code. But, your new code is a lot cleaner compared to your old code. Now if something changes with the LocalTime API, you know exactly where to look and what to change in MyClass without even worrying about other logic. And the best thing is that no one did a code review on your code and told you that it could be written better. In fact you didn’t even need to know that a concept called “pure functions” exists, but you still ended up writing them without that knowledge.
Also, now you can start reusing the existing code. For example, in the above code, if tomorrow you get a new class called MyNewClass that also needs the same external dependency, you can just move the externalDependency method to a common class and inject the common class into MyClass and MyNewClass. This way, you don’t have to duplicate the same logic twice.
This is the aspect of unit testing that I like the most: the unit testing framework just makes us write better code.
Why should we write unit tests?
The example that we discussed here is obviously a very trivial one. Even with such a trivial code, with the help of unit tests, we were able to do refactoring and rewrite the code in a better manner. As our application grows, we would have a lot of places that could be refactored which wouldn’t be immediately visible when we look at it or even during dedicated code reviews. So, unit testing frameworks can also be looked at as automated tools which indirectly do code reviews.
The reason why good quality code is important is, no matter how fancy the technologies you use are, no matter how performant your application is, if your code is not well-written, over time developers lose interest in working with that code, because it becomes increasingly complex to make changes as the code base grows. You’ll have methods with 8, 9, even 10 parameters, methods that are sometimes 100’s of lines long, duplicated code in multiple classes, if-conditions everywhere, etc. All of these things transform your fancy application into a legacy application that developers are reluctant to touch. Then you’ll end up decommissioning your application and replacing it with a better one. But, if you don’t write good quality code in the new application, the same cycle will repeat!
Few things about unit tests
- Unit tests are not an alternate solution to integration tests by any means. As the name itself suggests, unit tests and integration tests do two different things. Just because you have integration tests doesn’t mean that you don’t need unit tests. Each of them have their own advantages and hence you need both.
- If you are not able to unit test your code, it likely means that you have to refactor your code.
- The idea that unit tests are suitable only for the services that have fewer external dependencies is a wrong assumption. External dependencies are external to you – they shouldn’t impact the way you test your code.
- Having too many mocks is not an issue. What you mock would be something that doesn’t directly affect your test. Also, when you have unnecessary mocking in your application, Mockito is smart enough to complain and fail your test. So there’s no need to worry about having too many mocks. Mockito won’t allow you to have even 1 unnecessary mock in your code.
Common issues and terminology
- JUnit version mismatch
When we write our first unit test, almost 95% of the time we encounter a NullPointerException on some object even though we have mocked it successfully.
The main reason for this could be JUnit version mismatch. When we google for JUnit unit tests (even when we google for something like “mocked object is null”), we usually get JUnit-4 examples. In JUnit 4, we need to annotate a test class with @RunWith(MockitoJUnitRunner.class).
But if your application is relatively new, there’s a good chance that it’s using JUnit5 (you can use the dependency tree to find out). With JUnit 5, @RunWith(MockitoJUnitRunner.class) doesn’t work anymore. Your class must be annotated with @ExtendWith(MockitoExtension.class)
- @Mock
If MyClass is the class you’re testing and if that has FirstClass firstClass as an instance variable, you should do @Mock on FirstClass in your test so that your MyClass code can use the FirstClass mock (instead of the actual FirstClass object) when you run your unit tests.
- @InjectMocks
You should have @InjectMocks annotation on the MyClass object in MyClassTest so that all the mocks (like the FirstClass mock from the above point) are injected when MyClassTest is run. Without this, mocks won’t be injected and you’ll run into a NullPointerException when you try to use any mocked object.
- @Spy
If MyClass has a method getCurrentSecond, you cannot mock the calls to the getCurrentSecond method directly. For this, you should have a Spy annotation on the MyClass object inside MyClassTest. Also, the mockito.when syntax is a bit different when you’re using a Spy
Mockito.when with a mocked object
when(firstClass.getValue()).thenReturn(firstClassValue)
Mockito.when with a spy object
Mockito.doReturn(41)
.when(myClass)
.getCurrentSecond();
- Local variables
We cannot mock local variables. So, if you have a variable.something() (like a.execute()), move this a.execute to a method (like executeA() { a.execute() }) and mock the executeA method.
- Exclude methods and classes from Jacoco code coverage report
Though this sounds sneaky, there are cases where this would be helpful. For instance, if you have a bunch of deprecated code that you are going to remove in the next few months, there is no point in spending your time writing unit tests for that code. At the same time, you don’t want your code coverage report to look bad.
In scenarios like this, you can annotate your method or class with a custom annotation that has the word “Generated” in its name, like ExcludeFromJacocoGeneratedReport. Jacoco ignores these classes or methods when generating the code coverage report. You can find an example here. There are other ways of doing this as well which are discussed here but the annotation based approach is the cleanest one.
Make sure that you don’t abuse this annotation because that defeats the whole purpose of writing unit tests.
Conclusion
If you’re not accustomed to writing unit tests, you might feel that writing unit tests is not a very exciting task. But, that’s totally wrong. Unit tests require us to write good code which is more fun, challenging, and satisfying than writing bad code. Writing good quality code gives us an opportunity to learn and explore a lot of interesting areas like Generics, Interfaces, etc. which are not only fun to learn but also help improve our code quality. Good code is difficult to write initially but it’s exponentially easier to maintain, change, and debug compared to bad code.
Since unit tests not only make sure that our functionality is correct, but also ensure that our application is easy to maintain, they are a great resource that developers should not miss on making use of.