As is well known, writing unit tests has many benefits, but some supervisors may ask why writing tests requires engineers to spend so much extra time? In addition to being unfamiliar with unit testing techniques, the root cause is the low testability of product code, which makes it difficult for engineers to focus their energy on the right areas when writing tests, and even leads to giving up writing tests. Writing excellent unit tests has a certain level of difficulty and threshold, and the key lies in how to improve the testability of the code. This article introduces the experience of improving the testability of Java unit testing.
There are various definitions of code testability, including the SOCK Model proposed by the renowned Microsoft testing architect Dave Catlett. Although different definitions exist, almost all concepts revolve around the same thing, which is the ease of testing a software in a certain testing environment, or the amount of testing costs invested.
It is not difficult to notice that the testability of a program is highly correlated with design quality. For example, programs with low cohesion, high coupling, and code smells are often difficult to test. Therefore, if we can improve testability and enable engineers to write more useful and valuable test cases, it will be easy to quickly discover and eliminate bugs during the development process.
Programs with high testability typically have several key characteristics:
If the program under test only focuses on doing one thing, the things and situations that need to be tested are relatively simple, and its readability and expressiveness will also be better. Therefore, it is better to design the program as simple, straightforward, and without unnecessary complexity (KISS principle). On the contrary, the more complex and not simple the program, the more difficult it is to test. For complex programs such as overly lengthy methods, my suggestion is that the method should not be too long and the number of lines should be limited to 30 to be considered qualified.
In unit testing, it is easy to initialize the test program, in other words, it can be easily generated during testing. In this case, the program under test usually has few dependencies or can easily isolate external dependencies. If it is difficult to initialize, it indicates that it may be a poorly designed structure. Easy initialization is the first step in writing unit tests. If even this is difficult to achieve, then you won't reap the benefits of writing unit tests.
This means that we can easily simulate different testing scenarios in unit testing. For example, a certain bank system will issue a warning notice to the bank when a customer withdraws more than 100 million yuan. At this point, we can control the customer's withdrawal amount in unit testing to simulate the scenario of a large withdrawal by the customer, without actually preparing a large sum of money in the bank account beforehand. In Java testing, mock technology is used to simulate various scenarios in practice to improve the controllability of the program. If the input parameters of the program are controllable, it can be repeatedly tested and then incorporated into the Continuous Integration (CI) process to achieve the level of automated testing.
The program must be able to produce the expected output for any operation, and the output is usually represented in the form of predictable return values, internal states, and external behaviors. Regardless of how it is represented, it must be traceable in order to be easily verified. If it is easy to verify whether the program results meet expectations, it can reduce the difficulty of testing, for example, using simple verification methods such as assertEqual() and verifiy() to obtain test results. If the output result of the program is not easy to verify, it means it is not easy to test.
High software testability usually means that the quality of the code is also high, and the resulting product will be easy to use.
If the software has high testability, it is easy to test left shift. The essence of testing left shift is to detect and prevent problems as early as possible. If we can detect problems earlier, the cost of solving them will be cheaper, and we can deliver products faster and receive customer feedback more quickly, which is more in line with the spirit of agile development.
If the software has low testability, engineers will have to spend a lot of time writing tests, and usually the unit tests written in this situation will be messy. Not only will the test results be poor, but it will also be difficult to test the key points and prone to test failures, resulting in low test quality. For example, if there are too many dependencies on mock in testing, it will make the testing very complex, and the testing program also needs to pay attention to quality. If writing tests is like a loss making business, it will make engineers unwilling to write tests or delay writing tests until very late, which goes against the principle of left shift testing and the original intention of unit testing.
Single responsibility means that each class should have one and only one responsibility (or reason for change), which also means high cohesion, making unit testing easier.
If a class/method operates multiple functions simultaneously, it will greatly increase the difficulty of testing because with more functions, there will be more dependencies and more results that need to be verified, making testing more complex. But practicing SRP is not as simple as imagined. The key is how to control the granularity. If the cutting is too fine, it will create redundant classes; Cutting too big makes it less simple. So how to allocate responsibilities appropriately still depends on the actual situation.
Taking the example of a certain bank system just now, the withdrawal function should belong to the responsibility of WithdrawService, and the internal warning function should belong to the responsibility of NotifyService. Both have their own responsibilities, clear rights and responsibilities, and are independent of each other.
This is an important means to significantly improve testability, reduce coupling between programs, and avoid mixing the new keyword with important business logic. By injecting dependency programs externally, the controllability of the test program is improved, and we can easily create test avatars and inject them into the test program.
Generally speaking, there are three types of dependency injection patterns, and I recommend using the pattern of injecting dependencies through constructors.
One solution is for the team to frequently conduct code reviews, and team members need to be familiar with refactoring techniques. If not refactored, besides being difficult to test, it will eventually become a technical debt.
Engineers should only perform corresponding functions when faced with specific requirements. For example, a team member added an if branch in the method that is currently not needed but may be used in the future. Should we test it now?
Constructor should only focus on initialization without any logic. If the constructor not only initializes if-else, Also calling APIs and querying DBs increases initialization costs, makes it difficult to isolate external dependencies, and reduces testability.
In addition, it is not easy to rewrite or mock constructors in unit testing, although some testing frameworks can now replace the behavior of constructors, it is usually not recommended to use them.
A good constructor should have no logic and only perform dependency injection and state initialization:
![]()
It is difficult to replace a static method in testing. In addition, abusing the Singleton pattern can easily lead to a difficult to maintain global state. for example:
![]()
Although Singleton/static is convenient, it also brings about the problem of increasing coupling invisibly, both of which are reasons for being difficult to measure. It may make it difficult for us to immediately detect problems, as the essence of unit testing is to explore how to isolate external dependencies. Sometimes it can be difficult to completely avoid using static methods, but if possible, try to minimize their frequency of use.
However, if it is a simple static method like Strings. isBlank() or Math. abs(), which has no shared state internally and is not dependent on the external environment, I don't think it will cause any difficult to test issues.
But if we really have to, Mockito 3.4 also provides Mockito. mockStatic, which allows us to replace static behavior in unit testing. The cost is that the testing program will become more complex and the execution time will increase.
TDD is a development approach that starts by writing tests from the user's perspective and then goes back to writing product code. Because TDD allows developers to think from the user's perspective, changing positions means changing their minds, making it easier to understand how to design classes/APIs that are more user-friendly. In order to write tests first, developers must first think about how to conduct testing. They not only understand the requirements, but also gradually break them down into simple, small test cases. If proficient in TDD technology, software testability can be greatly improved.








