Writing tests for any medium-to-large codebases is crucial, but every test also comes with a maintenance cost. If you have tests that are hard to write and maintain, then developers will slowly stop writing tests or only write the minimum number of tests to get their PR merged. You can try adding strict code reviews, code coverage tools, etc., but none of these solve the actual problem. In fact, you may even end up in a worse state than having no tests.
The best way to encourage everyone to write tests is to make sure that writing and maintaining tests is easier than manual testing, even in the short term (current development cycle).
In this post, we will explore one way in which we made writing and maintaining our tests easier. We use Java as a language, Lombok for generating builders, and Guice for dependency injection.
Every test has the following three steps:
One big challenge is the test setup. You want your setup to be easy to write, readable, and maintainable.
Furthermore, a property of a good codebase is being able to constantly rearrange and refactor things. But, if you don't have a good way to set up your test, then every refactoring or improvement in one service/POJO will break the setup for many tests. This makes the refactoring a bigger effort and less likely to occur.
When you’re trying to test a service method that depends on other services and the existence of other Java objects (DTOs, beans, ORM entities, etc.), it becomes challenging to write a setup for your test because you must create all of these valid services and Beans.
On the other hand, if you share your Java objects and create helper classes to create objects, then it becomes challenging to override properties based on your tests.
The following four points will help you with this:
1. Ideally, your objects should be immutable by default and they should change object state using setters just for tests in an antipattern.
2. Adding incremental validation and the ability to evolve objects should be possible without breaking numerous existing tests.
3. If you are using setters, then it becomes challenging to evolve and add more checks and strict validation.
4. Adding new fields will break your tests if some required properties aren’t present.
One way to solve the above-mentioned problems is to use a factory of builders (for creating builders of the objects with default objects) that can still let you override the properties required for your tests.
This gives you the best of both worlds by sharing the default object and having specific logic to override properties in your tests.
Let’s look at an example. We have a class VerifyStepTask with the following properties. Note that we have some common properties, such as accountId, orgIdentifier, and projectIdentifier, which will be shared across multiple objects.
@Builder
@Value
public static class VerifyStepTask {
String accountId;
String orgIdentifier;
String projectIdentifier;
String name;
String callbackId;
String serviceIdentifier;
String environmentIdentifier;
boolean skip;
Status status;
public enum Status { IN_PROGRESS, DONE }
}
Now, let’s define our basic BuilderFactory class.
@Value
@Builder
public static class BuilderFactory {
@Getter @Setter(AccessLevel.PRIVATE) Clock clock;
@Getter @Setter(AccessLevel.PRIVATE) Context context;
public static BuilderFactory getDefault() {
return BuilderFactory.builder()
.clock(Clock.fixed(Instant.parse("2020-04-22T10:00:00Z"), ZoneOffset.UTC))
.context(Context.defaultContext())
.build();
}
@Value
@Builder
public static class Context {
String accountId;
String orgIdentifier;
String projectIdentifier;
String serviceIdentifier;
String envIdentifier;
public static Context defaultContext() {
return Context.builder()
.accountId(randomAlphabetic(20))
.orgIdentifier(randomAlphabetic(20))
.projectIdentifier(randomAlphabetic(20))
.envIdentifier(randomAlphabetic(20))
.serviceIdentifier(randomAlphabetic(20))
.build();
}
}
public VerifyStepTask.VerifyStepTaskBuilder verifyStepTaskBuilder() {
return VerifyStepTask.builder()
.accountId(context.getAccountId())
.orgIdentifier(context.getOrgIdentifier())
.projectIdentifier(context.getProjectIdentifier())
.serviceIdentifier(context.getServiceIdentifier())
.environmentIdentifier(context.getEnvIdentifier())
.callbackId(generateUuid())
.status(VerifyStepTask.Status.IN_PROGRESS)
.skip(false);
}
}
This has all of the basic parameters that can be shared across multiple calls:
Clock – A fixed clock for your tests.
Context – Depending on your use case, define the context that can be shared between different builders. In this case, since Context has accountId, orgIdentifier, and projectIdentifier, then all of the objects will have the same accountId, orgIdentifier, and projectIdentifier for the single test context. The idea is to put common shared properties in the context that can be used in the builder between different objects.
Now, let’s look at how to use BuilderFactory in the actual test.
public class VerifyStepTaskServiceTest {
@Inject private VerifyStepTaskService verifyStepTaskService;
BuilderFactory builderFactory;
@Before
public void setup() throws IllegalAccessException {
builderFactory = BuilderFactory.getDefault();
}
@Test
@Category(UnitTests.class)
public void testCreate() {
String activityId = generateUuid();
verifyStepTaskService.create(
builderFactory.cvngStepTaskBuilder().activityId(activityId). callbackId(activityId).build());
assertThat(verifyStepTaskService.get(activityId)).isNotNull();
}
@Test
@Category(UnitTests.class)
public void testCreate_withSkip() {
String callbackId = generateUuid();
verifyStepTaskService.create(builderFactory.cvngStepTaskBuilder().callbackId(callbackId).skip(true).build());
assertThat(verifyStepTaskService.get(callbackId)).isNotNull();
}
}
This is a simple test for the sake of our example, but you can see that the tests are concise and only concern the fields related to the current testing logic. However, they still have the flexibility to add new properties in the future or update the default builder based on extra validation logic. This also works in more complex scenarios.
Let's say that you’re adding a new required property into VerifyStepTask. In that case, you only have to change the verifyStepTaskBuilder method and all of the tests using VerifyStepTask, which will get the default value of the new property.
Here are some of the advantages of using this pattern:
1. Shared default objects so that the setup can be focused on only setting up specific properties for the test. The test doesn’t have to know about specific details of the objects that aren’t being tested in the current test.
2. One big reason for unmaintainable tests is the breaking of multiple tests when extra validation logic is added to your object. Now you only have to fix your default builders instead of all of the tests.
3. You can truly have setter free immutable classes, as all of the validation logic can be in the build method (or constructor) of builder
4. Easier refactoring, as most of the time you only have to change your default builder object.
5. Shared common context between your default objects, which helps remove unnecessary boilerplates of the initializing of common properties, such as accountId, projectIdentifier, etc.
Things to Keep in Mind
1. Only use builder-factory for returning builders, not actual objects.
2. Don't have parameterized builder methods. This should return default valid builder objects that don’t require any input from the user – that means zero parameters.
3. Dependent objects should be created using the same builder factory default objects. This will let you write more concise tests, as the defaults are consistent and predictable.
4. All of the common object identifiers should be part of the Context.
Taking the steps to make it easier to write your unit test has a dramatic effect on your design. This is also true as we start to write code that is easier to test. If you aren’t already using something like this, then you can try the builder factory pattern for tests in your codebase.
For further reading on related topics, please check out these articles on Test Intelligence and Continuous Integration Testing.