3 min read

Simplifying Unit Tests with Loops

Loops are the forgotten tool in your toolbox when writing unit tests.
Simplifying Unit Tests with Loops

Duplicated Statements

In this article, JUnit will be the testing framework that is used for examples.

Code duplication is generally more of a problem in unit tests than it is in production code, although you see it in both places. Code duplication in unit tests is very common by duplicating statements instead of using a loop. Code can be simplified by using loops and adding clarity to what the developer is trying to test. It can also help minimize bugs and simplify refactoring.

Tests Without Using Loops

The following code tests a DAO (Data Access Object) method on a UserDao. This shows setting up the data, exercising the method being tested, and then asserting the result is what is expected.

// Java code
final var toFind1 = new User();
final var toFind2 = new User();
final var toNotFind1 = new User();
final var toNotFind2 = new User();

final var totalResults = 2;

toFind1.setAdmin(true);
toFind2.setAdmin(true);

userDao.insert(toFind1);
userDao.insert(toFind2);
userDao.insert(toNotFind1);
userDao.insert(toNotFind2);

final var result = userDao.findAdmins()
    .stream()
    .map(User::id)
    .toList();

assertThat(result.size(), is(totalResults));

assertThat(result.contains(toFind1.id()), is(true);
assertThat(result.contains(toFind2.id()), is(true);

There are multiple times a concept is duplicated in this test. The way this code is written, if you wanted to add more data or change the test, it is going to be a statement-by-statement basis. Steps can easily be forgotten.

The previous test can be broken down by the following:

// Java code

// Duplicating the concept of creating a new User
final var toFind1 = new User();
final var toFind2 = new User();
final var toNotFind1 = new User();
final var toNotFind2 = new User();

// Have to hard code an expected size.
// This doesn't allow the code to change
// based off of dataset.
final var totalResults = 2;

// setAdmin(true) is duplicating a concept.
// The thing changing is the dataset.
toFind1.setAdmin(true);
toFind2.setAdmin(true);

// This is the same duplication of a concept as above.
userDao.insert(toFind1);
userDao.insert(toFind2);
userDao.insert(toNotFind1);
userDao.insert(toNotFind2);

// Nothing wrong here.
final var result = userDao.findAdmins()
    .stream()
    .map(User::id)
    .toList();

// Assertion to make sure the total is correct.
assertThat(result.size(), is(totalResults));

// Same duplication of a cocept as above.
// The only thing changing is the dataset once again.
assertThat(result.contains(toFind1.id()), is(true);
assertThat(result.contains(toFind2.id()), is(true);

Tests Using Loops

The above example can be refactored using loops to the following:

// Java code
final var dataset = IntStream.rangeClosed(1, 4)
    .mapToObj(i -> new User())
    .toList();
final var toFind = dataset.subList(0, 2);

toFind.forEach(
    user -> user.setAdmin(true)
);

dataset.forEach(userDao::insert));

final var result = userDao.findAdmins()
    .stream()
    .map(User::id)
    .toList();

assertThat(result.size(), is(toFind.size()));

toFind.forEach(
    user -> assertThat(result.contains(user.id()), is(true))
);

This code may seem more complex at first, but this code is broken up by concepts. Each concept has operations to perform or assertions against it. This helps prevent developers from just copying and pasting code as well.

This can be broken down as the following:

// Java code

// Define the dataset.  No duplication on creating a User
final var dataset = IntStream.rangeClosed(1, 4)
    .mapToObj(i -> new User())
    .toList();

// Define the dataset of admins
final var toFind = dataset.subList(0, 2);

// Configure what an admin is
toFind.forEach(
    user -> user.setAdmin(true)
);

// Save the dataset
dataset.forEach(userDao::insert));

// Get the results of the test
final var result = userDao.findAdmins()
    .stream()
    .map(User::id)
    .toList();

// Assertion to make sure the total is correct.
assertThat(result.size(), is(toFind.size()));

// Assert we only have admins
toFind.forEach(
    user -> assertThat(result.contains(user.id()), is(true))
);

This test reads more like natural language. I have four Users in my dataset. Two of them are admins. When I find admin users, do I get admin users?

Conclusion

Don't forget about loops in tests. They really shine when you need to setup data and perform the same operations on a dataset, or assertions.