Testing Best Practices
Tests should be obvious, expressive, declarative, concise, read like a story (readable) and in general simple. Should be treated as first-class-citizens, are the primary client and first consumers of your APIs, and should be easy to change. They represent the most up-to-date documentation for your code. If you look at the test and it doesn't read like documentation for your code, it's probably complex.
When I get into a new codebase, and I try to understand what it does, one set of tools I turn to are the existing tests. Sadly, I find the tests to be complex many times 😪. To learn about how the code works, I either refactor the tests to make them simpler, or I write new Tests.
Some thoughts on best practices with testsing
1. Concise
Tests are also part of your code. This means you'll spend some time to write the initial test, but more time reading and updating the test. If the test case case has many lines, it'll always be difficult to make a change each time.
2. Tests should be obvious
Information relevant to a test case should not be hidden away in another class.
class AccountTest extends BaseIntegrationTest {
final String ACCOUNT_ID = "7238232"
void shouldReturnTrue_givenThatAccountIsEligibleForProcessing() {
setupAccount(ACCOUNT_ID); // calls some method in BaseIntegrationTest to simulate HTTP calls to upstream service
}
}
The above test case is anything but obvious. setupAccount()
doesn't tell me what is going on. I'll need to pause, hold the code context in my mind, navigate to the definition of the method, see what it does and keep that also in context and navigate back. At which point, If I do this 2 more times for a single test case, it'll then become too difficult to follow what's going on. I like to think that a test should read like a story
Compare the above to the following code snippet for which I provide the customer Id, and I also can clearly see that it expires and when. This information is not hidden away.
void shouldReturnTrue_givenThatAccountIsEligibleForProcessing() {
var account = AccountHelper.regularAccountWithCustomerIdAndExpiry("723823", OffsetDateTime.parse("2025-08-15T00:00Z").build();
}
3. Favour hiding irrelevant information
Information not relevant to a test should be hidden to some helper method / class, so that the developer is not bothered with that at the time of reading the code. In the example we just saw above, AccountHelper
is responsible for hiding away information irrelevant to the test. This is one of the ways of managing complexity.
In the example we just considered, the helper method hides away a detail, unimportant to this test case - it creates a regular customer account. I can read this line and expect that account
would have a regular account type. But I got this without having to navigate.
Tests are first class citizens. 🤓
4. Code with a Test-first Mindset
For this point, I see a common practice of using field injection to allow Spring or whatever Dependency Injection framework you use to automatically inject your dependency. Problem is: this makes it difficult to test. What you want is to specify to your test: "Hey, here is how you should test this piece of functionality. I don't care about testing so and so parts, as I have thoroughly tested them elsewhere". Instead, you should use constructor Injection. Here's what they both look like:
// field injection example
class AccountTest {
@Inject
private AccountRepository accountRepository;
@Inject
private Clock clock;
}
Refactor code to allow for ease of testing. You should be able to replace swap dependencies easily during testing. If a test depends on time, pass a Clock. Favour constructor injection
//Constructor Injection example
class AccountService {
private final AccountRepository accountRepository;
private final Clock clock;
public Account(AccountRepository accountRepository, Clock clock) {
this.accountRepository = accountRepository;
this.clock = clock;
}
}
5. Fewer asserts
If you have too many assertions in one test case, it's probably complex and doing too much. Because why would you want 10 assertions for a single test case?😡 You're probably asserting the wrong thing
6. Known input to known output
Each test case should be about asserting that you'll get known output with certain known input. Avoid using Random generators for parts of code you're intersted in asserting. Instead, provide a known string in your test. I should also say that it's best to use String values instead of enums.
7. Don't clear Database tables after each test
First, this is overhead and will make the tests run slower. More noteworthy, is that in the real world, your code would run with data all around it. It would be better to assert that your code works (you get known output, given known input), in the midst of existing database records.
8. Avoid System clock
Have you seen tests that fail twice a year, due to daylight savings? 😤
Yes, this actually indicates that the code would fail too.
//example code
class AccountService {
boolean isBanned(Account account) {
return account.bannedUntil().isAfter(Instant.now());
}
}
First problem with this code is that it uses Instant.now()
, which uses System clock. You cannot easily supply a known fixed clock to be used during when the test runs.
Whether isBanned()
returns true or false would depend on the local time zone of the machine in which it is running. If you have 2 nodes in different timezones: say one running at UTC+2, and another running at UTC +1, the code would produce different results if executed by both machines.
If you think about this probem differently, and wrote the plain interface for the service - without implementation, and then wrote a failing test, the problem might become more obvious. Some would say you can use Mockito to mock the static methods of the System
class. That's just bad.
//example showing difficulty testing without Clock dependency
class AccountService {
boolean isBanned(Account account) {
return false;
}
}
class AccountServiceTest {
@Test
void shouldReturnTrue_givenThatBanIsExpired() {
//given
var accountService = new AccountService();
var account = AccountHelper.regularAccount.builder().withExpiry(OffsetDateTime.parse("2025-08-15T00:00Z")).build();
//when
var result = accountService.isBanned(account);
//then
asssertTrue(result);
}
@Test
void shouldReturnFalse_givenThatBanIsExpired() {
//now how do I even setup my system so that the current time is before expiry 🤔.
}
}
//example showing testing with Clock. Notice how easy it makes testing the code.
class AccountService {
private final Clock clock;
AccountService(Clock clock) {
this.clock = clock;
}
boolean isBanned(Account account) {
return false;
}
}
class AccountServiceTest {
Clock clock;
setup {
this.clock = Clock.fixed(Instant.parse("2024-08-02T10:15:00.00Z"), ZoneId.of("UTC"));
}
@Test
void shouldReturnTrue_givenThatBanIsExpired() {
//given
var accountService = new AccountService(clock);
var account = AccountHelper.regularAccount.builder().withExpiry(OffsetDateTime.parse("2025-08-15T00:00Z")).build();
//when
var result = accountService.isBanned(account);
//then
asssertTrue(result);
}
@Test
void shouldReturnFalse_givenThatBanIsExpired() {
//given
var accountService = new AccountService(clock);
var account = AccountHelper.regularAccount.builder().withExpiry(OffsetDateTime.parse("2025-08-01T00:00Z")).build();
//when
var result = accountService.isBanned(account);
//then
asssertTrue(result);
}
}
Follow up Topic
Someone asked me about Mockito vs Wiremock recently. The question was about whether I would use mocks in Integration tests. At this time, my answer is the following: While Wiremock allows you simulate HTTP calls and test aspects of your code such as serialization and deserialization, Mocking HTTP calls with Mockito for example, doesn't, but this is also fine, if you know what you want to assert.
Given code that interacts with HTTP network, It makes sense to use Wiremock to test that layer, and then in my integration tests (depending on what I want to assert), I no longer have to care about asserting that aspect in every case. I can simply provide mock the response I expect.
I get the added advantage of only having to change the related test cases when the code changes.
I also find that overuse of Wiremock contributes to poor readability in tests, at least for some codebases I have seen.
I should also mention that you absolutely have to know what aspects of your code you want to assert. You don't want to be mocking services you should be actually testing.
I'll dig deeper into this topic, and probably update this page. We'll see. Until then,
Cheers, and hope you make peace with testing 😁
Member discussion