The ability to unit test code is highly correlated with the modularization of said code. The more modular code is, the easier it is to write accurate tests for it. When code is tightly coupled, writing good unit tests becomes nearly impossible. Therefore, unit testing has introduced new ways of thinking about code. This post is about one such important concept: separating code into complexity and collaboration. This not only helps with unit testing but also improves modularization and long-term maintainability.

This post is inspired by the excellent book Unit Testing Principles, Practices, and Patterns, which introduces the concept as well as other important concepts. It’s a highly recommended read that covers more topics than this post can. It provides a powerful foundation for how you think about not only unit testing but coding in general.

Complexity vs. collaboration

The attributes can be visualized using the following graph:

Complexity vs. collaboration graph

The X-axis represents the number of collaborators, while the Y-axis represents complexity. It contains four distinct quadrants:

  • Low complexity and few collaborators = Trivial code
    This code is so trivial that it might not be worth testing at all.
  • Complex and low number of collaborators = Domain model, algorithms
    This code is where the domain logic and implementations of algorithms are. They should be thoroughly unit tested.
  • Low complexity and multiple collaborators = Controllers, Application layer
    This code is responsible for collaboration with other components. For example, application services are responsible for coordinating with the database (which is a collaborator) and sometimes integrations. The controllers are responsible for the communication between the presentation layer and the application. This code should be integration tested.
  • Complex and multiple collaborators = Overcomplicated code
    This code is both complex and contains multiple collaborators. It is almost impossible to appropriately test it. It's also the kind of code no one wants to touch, because it's difficult to understand. This is the kind of code that demands refactoring.

Most code should be in the two blue quadrants. In case of overcomplicated code it should be refactored.

Refactoring overcomplicated code

The following example is a scenario where a new customer is created. The system then has to do a couple of things:

  • The Customer needs to be saved to the Database with a check that it does not already exist.
  • The address of the Customer should be verified by an ExternalAddressRegistry.
  • The creditCard of the Customer needs to be verified to a certain bank through the ExternalBankSystem
@AllArgsConstructor // lombok annotation to automatically add a constructor with all fields as arguments
@Setter // lombok annotation to automatically add a setter to each field
class Customer {
	String name;
	String address;
	String creditCard;
	String bank;
}

class CustomerRegistrator {
	Database database;
	ExternalAddressRegistry externalAddressRegistry;
	ExternalBankSystem externalBankSystem;

	public int createCustomer(String name, String address, String creditCard) {
		Objects.requireNonNull(name, "name must not be null");
		Objects.requireNonNull(address, "address must not be null");
		Objects.requireNonNull(creditCard, "creditCard must not be null");

		var customer = new Customer(name, address, creditCard, null);

		if (database.exists(customer)) {
			throw new ValidationException("User already exists");
		}

		var isValidAddress = externalAddressRegistry.validate(address);
		if (!isValidAddress) {
			throw new ValidationException("Invalid address");
		}

		var bank = externalBankSystem.findBank(creditCard);
		if (bank == null) {
			throw new ValidationException("Bank could not be found");
		}
		customer.setBank(bank);

		var customerId = database.save(customer);

		return customerId;
	}
}

The creation of a Customer is an important business event, and it should have high test coverage. In this example each test is forced to be an integration test since the method starts out by saving to the database. If the management required 100% coverage for this, the following would have to be integration tested (whitebox testing):

  • Success: The Customer can be successfully created if all parameters are included, the address exists and the bank is found
  • Fails: When name is null
  • Fails: When address is null
  • Fails: When creditCard is null
  • Fails: When Customer already exists
  • Fails: When address is invalid
  • Fails: When bank is not found The integration tests are usually quite heavy and it is not uncommon to add 4 seconds of test time for each test. This test suite alone might then add 30 seconds of testing. This can quickly lead to unsustainable test times.

To mitigate this, let's try refactoring the code with the following in mind:

  • Utilize the Customer more in validating its state, so that it can be unit tested
  • Move all logic and complexity out of CustomerRegistrator
    • The null checks can be moved to the Customer.
    • The check for existing can be done by the Customer.
    • The address check can be moved to the Customer.
    • The bank not found check can be moved to the Customer.
    • This can be done with a static factory method and Functions.

The refactored code becomes the following:

@AllArgsConstructor(access = AccessLevel.PRIVATE) // lombok annotation to automatically add a private constructor with all fields as arguments
class Customer {
	String name;
	String address;
	String creditCard;
	String bank;

	public static Customer of(
		String name,
		String address,
		String creditCard,
		Optional<String> bank,
		Predicate<String> verifyAddress,
		Predicate<Customer> customerExists
	) {
		Objects.requireNonNull(name, "name must not be null");
		Objects.requireNonNull(address, "address must not be null");
		Objects.requireNonNull(creditCard, "creditCard must not be null");

		if (bank.isEmpty()) {
			throw new ValidationException("Bank could not be found");
		}

		var customer = new Customer(name, address, creditCard, bank.get());

		if (customerExists.test(customer)) {
			throw new ValidationException("User already exists");
		}

		var isValidAddress = verifyAddress.test(address);
		if (!isValidAddress) {
			throw new ValidationException("Invalid address");
		}

		return customer;
	}
}

class CustomerRegistrator {
	Database database;
	ExternalAddressRegistry externalAddressRegistry;
	ExternalBankSystem externalBankSystem;

	public int createCustomer(String name, String address, String creditCard) {
		var bank = externalBankSystem.findBank(creditCard);

		var customer = Customer.of(
			name,
			address,
			creditCard,
			bank,
			externalAddressRegistry::validate,
			database::exists
		);

		var customerId = database.save(customer);

		return customerId;
	}
}

After refactoring the only test needed in CustomerRegistrator is the successful test. All failure scenarios can now be unit tested within the Customer class. Additionally the implementation of the external systems and database have been abstracted away in Predicates. This is much easier to test as it can be done directly in a unit test. Whereas previously mocks would have to be used, the Customer can now be tested by providing a Predicate like so:

class CustomerUnitTest {
	@Test
	void failsOnInvalidAddress() {
		assertThrows(ValidationException.class, () -> {
			Customer.of(
				"name",
				"address",
				"creditCard",
				Optional.of("bank"),
				address -> false, // Predicate hardcoded to always return false
				customer -> true
			);
		});
	}
}

The same could be done with the existing customer check. The bank check could be done by passing in an empty Optional.

This provides an obvious example of how refactoring helps unit testing, but it is important to also think about long-term maintainability. If we later want to add more checks or change the Customer creation process, it is much easier in second setup, since it provides a clean interface. If logic has to be changed the Customer is altered. If collaborators have to be added, the CustomerRegistrator is altered.

Conclusion

Thinking about code as either complex logic or collaboration/communication can help drastically in creating good testable code. This mindset will help any developer write better code and provide long-term maintainability.