So, you decided to add some tests to your ugly looking code. You have prepared the test scenarios you would like to test. You are starting to write end-to-end tests, that would verify specific functionality.
There is a really good chance, you will find something like this:
- you want to replace some dependency, but it is not injected, but rather created in code using constructor, e.g.
SomeService someService = new SomeService();.
- you want to change the outcome of a method call that is calling external service over the network, but it is a static method, e.g.
- anything else, that violates every design principle you've been trying to adhere
Do a simple refactoring. Replace manual object instantiation or static method calls with dependency injection. Sounds easy enough, right? Well, usually it is, but I would like to illustrate a problem that may occur.
I have been working with some enigmatic code, where we've decided to move some simple POJO classes to another package. The hierarchy didn't seem right, and this looked like really simple change. However, when we did this, some operations start failing in the runtime. After some serious investigation we have discovered, that some external (but in-house) dependency was checking for specific package names, that were then used in "a special way". It is not important what it did, the main point is that a simple change like this caused several hours of investigation. Things like these are hard to predict, especially when reflection is misused.
Thus, always proceed with care, don't start changing the code right away without studying possible consequences.
...but is still not ideal. You can use bytecode manipulation or reflection for those special cases. Prepare test, verify the functionality and then do the refactoring. Instead of doing things manually, you should use some library, for instance we have been using PowerMock extension for Mockito.
Why I think it is not ideal? Well, first of all, PowerMock is basically a hacking tool. If you already have some bytecode manipulation present in your code, you will most likely need to adjust your setup. There's an ongoing problem with JaCoCo code coverage tool. You should probably do a research first, before you start using it heads on.
PowerMock and similar tools also force you to rely on the implementation details, which will result in your tests being tightly coupled with the production code. That means, you will have to refactor the tests each time you refactor the production code.
Therefore, you should use it only when there's no other way.
As I've mentioned in the first part, you should test the least obscure blackbox. This means, you will leave the implementation as it is for now, and go with some component/integration test that would use input parameters to create alternative scenarios.
There is a very high probability that you will need to mock external dependencies. These are the ones, that reside on some servers within your test infrastructure, are shared and really difficult to change. Luckily for us, developers, there's an alternative for almost everything. Here are just some of the examples:
- for HTTP communication (REST APIs, etc.) you can use WireMock. It is highly flexible tool that can be used within the project or as external server. You can mock the response of an external server and also setup expectations that can be verified. This is especially helpful, if your project does not have any application framework that would provide similar functionality.
- for more complex scenarios, you can use Maven Cargo plugin. Sometimes it is easier just to create a separate application for mocking your calls. If WireMock is not enough, you can use a framework for rapid prototyping such as Spring Boot. We've been successfully using this on one of our refactoring project where we wanted to replace database access layer with REST API that was being developed simultaneously. Our mock server was a simple implementation of a proposed interface, that was just accessing the database.
- if database access is a problem, you can always use an in-memory instance of some test DB library, such as H2. The problem arises when the production code is using specific dialect (yes, I am looking at you, Oracle).
- for most of the No-SQL databases there exists an embedded instance or test instance that can be used as a replacement. You can also find helpful test libraries that will perform test data preparation and verification.
Of course, there are many approaches and almost all of them are based on careful investigation of all possibilities. It always depends on your project setup. I would be really interested in other solutions, therefore I would highly appreciate if you could share your own experience.