5 Uncommon Habits for Writing Truly Great Unit Tests
Unit Testing Tips You Wish You Knew Earlier
Writing tests isn’t just about coverage it’s about confidence.
Here are 5 lesser-known but powerful habits to make your unit tests rock-solid and readable.
1) Test behavior, not implementation
Write tests that check what the code should do - not how it does it internally.
Internal methods may change, but expected behavior should remain consistent.
Bad Example (testing implementation):
@Test
void shouldCallApplyDiscountMethod() {
PricingService spy = Mockito.spy(new PricingService());
spy.getPrice("premium", 100);
verify(spy).applyDiscount(); // you're testing internal method call
}
This test will fail if you refactor and remove applyDiscount()
, even if the behavior (discounted price) remains correct.
Good Example (testing behavior)
@Test
void shouldReturnDiscountedPriceForPremiumUser() {
double price = pricingService.getPrice("premium", 100);
assertEquals(80, price); // checks outcome, not method internals
}
This test will still pass even if you later use a different way to calculate discounts.
Test what your users care about - not the plumbing behind it.
2) Name tests like documentation, not code
Write test method names like they’re telling a story - clear, specific, and human-readable.
It will:
Makes test reports easy to scan - no need to dig into code.
Helps future devs understand intent instantly.
Acts like living documentation for edge cases and requirements.
Bad Example (vague, codey)
@Test
void testLoginFail() {
// What kind of fail? Why? What user? No clue.
}
Good Example (self-explaining):
@Test
void shouldThrowExceptionWhenPasswordIsInvalid() {
assertThrows(AuthException.class, () -> authService.login("john", "wrongpass"));
}
Without even reading the code, you know the exact scenario being tested.
Well-named tests read like user stories - not cryptic code snippets.
3) Write the test before fixing the bug
When you discover a bug, write a test that reproduces it before fixing the code.
Why?
It proves the bug actually exists (not just in your head).
When you fix the code, the test passes, now you know the bug is fixed properly.
If someone reintroduces that bug later, the test will catch it.
Let’s say you find that leap year birthdays are crashing your system.
// Write the test first — it fails right now.
@Test
void shouldAllowLeapYearBirthday() {
User user = userService.register("John", LocalDate.of(2024, 2, 29));
assertNotNull(user);
}
You run it - it throws an exception or fails.
Now fix the logic in userService.register()
.
Once fixed, the test passes. Done.
A failing test written before the fix proves the bug and guards against its comeback.
4) Use data-driven tests for edge cases
Instead of writing many similar tests, loop through a set of inputs using parameterized tests.
It prevents copy-paste clutter and makes test suite cleaner.
Also edge cases are easier to discover when grouped together.
Bad Example (repetitive and bloated):
@Test
void shouldRejectEmptyUsername() {
assertFalse(userValidator.isValidUsername(""));
}
@Test
void shouldRejectSpaceOnlyUsername() {
assertFalse(userValidator.isValidUsername(" "));
}
@Test
void shouldRejectNewlineUsername() {
assertFalse(userValidator.isValidUsername("\n"));
}
Good Example (clean and scalable):
@ParameterizedTest
@ValueSource(strings = {"", " ", "\n"})
void shouldRejectInvalidUsernames(String input) {
assertFalse(userValidator.isValidUsername(input));
}
One test, multiple edge cases covered and easy to add more later.
Edge cases love company - test them together with parameterized inputs.
5. Assert what matters, not every field
Focus your assertions only on the important business outcomes, not on every field of the object.
Why?
Keeps tests stable even as non-essential details evolve.
Makes your intent crystal clear: you’re testing this, not everything.
Saves time in test maintenance during refactors.
Bad Example (over-asserting, brittle):
@Test
void shouldCreateOrderSuccessfully() {
Order order = orderService.create(user, cart);
assertEquals(123, order.getId());
assertEquals("2024-07-19", order.getCreatedAt().toString());
assertEquals("John", order.getCustomerName());
assertEquals(2500, order.getTotal());
}
This test breaks if ID generation changes, date format updates, or name field is renamed - even if total amount logic is perfect.
Good Example (assert business-critical value):
@Test
void shouldCalculateTotalCorrectlyInNewOrder() {
Order order = orderService.create(user, cart);
assertEquals(2500, order.getTotal()); // This is what actually matters
}
Now the test will only fail if the real logic (total amount) breaks.
Test the contract, not the cosmetics, assert what users or business actually care about.
Most developers write tests. Great developers write tests that are useful.
Adopt even one of these tips, and you (and teammates) will notice the benefit.