By:Arminas Katilius Posted On: Topic:Engineering

Why you should apply clean code processes to test code, too

Most developers put a lot of effort into creating good code structure. This means naming classes and methods consistently, putting them in the right packages, grouping them correctly, and adopting good processes.

Similar clean code rules should also apply to test code as well, but this category is often overlooked.

As a result, developers often have different opinions on how to write tests. Differing opinions aren’t always a bad thing, but if you want to add some order to your test base, this article can help.

In this article, I’ll review some of the guidelines we follow to keep test code organized when working on Java projects. These guidelines may not always work for your specific project or team, but are still worth considering when starting your next project.

Clean code rules should also be applied to testing

Separating tests

Classification

Before separating tests, we need to identify the different categories. In theory, this should be easy: There are unit tests, integration tests, and end-to-end tests. All you have to do is separate them. However, there are grey areas. Some integration tests can be grouped with unit tests, and some unit tests may use external dependency, yet still be labeled unit tests.

In Google's Testing Blog, there's an article about throwing away standard naming for tests and introducing "small," "medium," and "large" tests. We don’t go to these extremes, but the speed of the test is a good indicator on how it should be classified. You can decide how strict you want to be. We usually separate tests as follows:

  1. Unit tests (or "Unit-like" integration tests). This is the largest part of the test base. These tests should be fast and use no external dependencies (With few exceptions—tests that use Spring Context, for instance). If the test is simple enough and finishes quickly, but there is no way to run it independently (e. g., the test requires a simple Spring context), or it's an integration test for your classes, it might belong to this group.
  2. Integration tests. These tests tend to be slower and usually use some external dependencies. Tests that use a real or mocked database, open local files, and launch all applications locally belong to this group.
  3. System tests. If you launch an application locally and use a real database (dedicated for these tests), you need to prepare seed data for them, or start a Docker image instance, these tests belong to this group. Usually these tests are executed from outside of the system, and you don’t need to have access to its source code.
  4. End-to-end tests. If we are creating a full web application, not REST API only, we need to check if whole system works. End-to-end tests do exactly that. In fact, we usually don't write system tests if end-to-end tests are involved, because they often cover API quite well.

Separation

Unit and integration tests are placed together and separated by naming. Unit tests classes end with *Test.java; integration tests end with *IT.java. This allows sharing test utilities between integration and unit tests, but because integration tests are slower, it's inconvenient to run them repeatedly. JUnit 5 will support tags, and will open new possibilities in the future. For now, if you're using JUnit 4, there are other ways to run the tests.

By default, the Maven Failsafe plugin recognizes **/IT*.java, **/*IT.java, and **/*ITCase.java (or can be configured to). You simply need to enable this plugin by adding it to pom.xml and run command "mvn verify." IntelliJ does not support test separation out of the box, but can be easily configured by creating two run configurations:

Integration tests in JUnit

All settings are default—only the patterns are different. They can be tuned to cover more cases, but for most cases, these simple regex patterns should work:

  • .*IT$
  • .*Test$

System tests usually don't need to access application source code. They are written the way the system will be consumed by other systems. For example, if it's a REST service, then these tests would send HTTP requests to assert responses. They should be placed in a separate source root or—even better—in a separate project. Therefore, no additional work is needed to run them separately.

Keeping code covered

Even when your team decides to have good test coverage, it’s not always easy to stick to that goal. As projects progress, or as the team grows, important logic can end up uncovered by tests (you need to make a quick fix, it's end of the sprint and features are not still finished, etc.).

To keep code well-tested, the most ­important thing is to have common understanding across the team. If team members don’t want to write tests or don’t see the benefit in using them, no amount of advice or strategy will help. 

Keeping tests clean

It may sound obvious, but developers often try to refactor production code and forget that the same clean code rules apply to test code, as well. If test code is a mess, then changes for class or function won’t be tested—no one will want to touch the code. For such tests, developers usually change the production code, and then try to fix failing tests by changing assertions to expect new behavior.

One time I had to update some web service client logic, I found strange logic and thought that by making it look the “right way,” I was improving the code. It turned out that the web service was buggy. It only worked with those strange values, but because it was not clear in the test, I thought it was a mistake—both in the test and production code.

Always running tests

There are times when you may need to temporarily ignore some tests. It's rare, but it happens. If you ignore the test for too long, business logic can change so much that it's easier to just delete the test and forget it.

Enforcing code coverage

This might look like the simplest way to achieve good coverage. You decide on a percentage (70 to 90 percent is recommended) and fail builds if coverage drops, or watch these requirements in SonarQube. This method is often used by clients when they want to take over support to their IT departments after the project is finished. The problem with this method is that it forces developers to reach a certain goal but doesn't specify how. This can lead to sloppy tests, or tests written just for coverage.

Using pull requests

This method is similar to enforced coverage, but instead of using some code-scanning tool, it uses the team itself. It's a good idea to have some common rules and agree that every team member should check them in pull requests. For example, "If the bug is fixed, then it should have a test for the case that was causing the bug." This not only this encourages better code coverage, it ensures that tests can be reviewed in the same pull request.

Creating objects

Having small and simple classes is not always easy (or even possible), especially when you need to work with complex web services. Often, just to execute the method without “null pointer” or other exceptions, you need to build a complex object and fill it with the required values. Creating such objects in tests takes up many lines of code and can hide the true intention of the test. There are different ways to fix this, depending on the size of the class, whether you can change source code of the object class, and how often this code will be used.

Using factory methods

One of the easiest ways to simplify object creation is by using factory methods. It's a technique described in the book, Effective Java. The intent is to make classes easier to initialize and, at the same time, simplify test data creation. The idea is that, instead of calling constructor and related setters, you create a static method that groups calls together. If you are using immutable data structures, replace multiple constructors with these methods that have a meaningful name.

This:

Message message = Message();
message.setType(ERROR);
message.setException(exception);


Becomes this:

Message message = Message.createErrorMessage(exception);

Creating objects in tests

This approach is similar to factory methods. It can be applied when you don't want to update production code with factory methods, or when initialized data is only meant for a test environment. Object initialization is simplified by creating constructor methods directly in method or in a shared class. This makes the "given" phase of the test easier to understand, since only important data is visible. For example:

This:

Item item= new Item("Keyboard", 1, 1, "EUR");
ShoppingCart cart = new ShoppingCart("1");
cart.addItem(item, 1);


Becomes this:

Item item = createShoppingItem("Keyboard")
ShoppingCart cart = createSingleItemShoppingCart(item);


Be careful to not write initialization methods that are too generalized. In the following case, it isn't clear what kind of data was initialized:

addItems_DuplicateItems() {  
    ShoppingCart cart = initCart(); 
    Item item = initItem();
    cart.addItem(item); 
    assertThat(cart.getItemCount()).isEqual(11);

Creating test data builders (Object Mother pattern)

If you have experience writing code that consumes SOAP services, you've probably encountered complex layered class structures, where in order to find an account by name, you must create an instance of ServiceRequest, then AccountListRequest, then AccountListRequestQuery, and so on. Or, maybe your system itself operates with classes that are difficult to fully initialize.

It is the same problem again: Lots of lines of code just to create an instance of an object—this time, a more extreme one. In this case, an “Object Mother” pattern can help you. These classes initialize objects with some default values, then if it's a mutable structure, you can add specific data for the ­­test case.

Because object factories might be used by multiple unit tests, you should be careful not to rely on data initialized in them. For example, if “account factory” initializes an account with the name “James,” you should not assert this value in the test, as it would be unclear why you are expecting this name. It's a better idea to simply modify the name for the individual test, or make a smarter object factory, where you can modify default data. For example:

AccountTestFactory.createVIPAccount().withName(“Peter”);

General things

Naming test methods

Test names in Java must follow method naming rules and cannot have spaces. It's not easy to describe the intent of the test while obeying these rules. There are different approaches: separating each word with an underscore, (“methodName_It_Should_Work”); separating only the description with an underscore, (“methodName_ItShouldWork”); or even writing a full “given when then” pattern, (“Given_UserLoggedIn_When_ClicksLogout_Then_LogoutUser”).

You may choose whatever your team is comfortable with. In our projects, we tend to avoid long method names, because with snake or camel case, they're still difficult to read and write. Also the test body itself usually describes “given when then” phases. Loosely, our testing pattern can be described in this format:

[methodName_CaseUnderTest]

The first part is simply a method name without the "test" prefix. The second part describes what condition is tested. It could be a simple happy path case, or a more concrete description. For example: "login_InvalidToken", "login_WrongPassword", "login_Success".

Writing assertions

In most of our projects, we use the fluent assertion library, AssertJ. It’s easy to understand and can be learned quickly. Compared to JUnit assertions, it has great Java 8 support, especially asserting thrown exceptions or a result list:

assertThatThrownBy(() -> methodThatThrows(null)).isInstanceOf(IllegalArgumentException.class);
assertThat(persons).extracting("name", "age")
    .contains(Tuple.tuple("John", 29)
    .contains(Tuple.tuple("James", 28));

More examples can be found on the AssertJ official site.

Static imports

“Static imports” is a feature that may look convenient, but it can quickly get out of hand. Even official Java documentation suggests using it sparingly. However, in the context of tests, we tend to forgive the usage of static imports. For assert methods, it's perfectly normal and it would be strange if you had to repeat “Assertions.assertThat()” instead of just “assertThat()”. You may want to consider the usage of other static imports (Hamcrest matchers, Mockito methods, Spring integration methods, and so on). Usually, when you have a more complex integration test, it becomes difficult to follow from where the method was statically imported. Sometimes you might even get compile errors, because two statically imported methods don’t work together. Therefore, it’s a good practice to avoid most of the static imports, except assertions.

Final thoughts

Good code structure is an important practice for developers, but it's also important to remember to apply those practices to test code. Hopefully you've found some of our guidelines helpful. What are some of your structure practices? Feel free to share in the comments below.

Arminas Katilius

Want more industry news?

comments powered by Disqus
Let's Talk