On Testing: The Diligent Engineer’s Path to Peaceful Nights

Writing a test suite for full code coverage may take 2 hours, but without it, debugging production issues may take 2 developers’ lifetimes due to more unforeseen bugs than necessary.

This isn’t an exaggeration — it’s reality. Testing code isn’t a luxury; it’s insurance. A robust test suite guards against regressions, signals when systems fail, and lets you make changes confidently instead of hesitantly. It’s the habit that separates rushed hacks from sustainable engineering.

We’ll break down the benefits of testing, common mistakes (and how to fix them), best practices, and Java-based examples you can apply in real projects.

Photo by Alexander Drummer on Pexels

Why Testing Matters

1. It Catches Bugs Early

Bugs caught during development are orders of magnitude cheaper to fix than bugs found in production. And they’re way less embarrassing.

2. You Can Refactor Without Panic

With a solid test suite, you can change internals, rename methods, or replace components without worrying if you just broke something critical. Good tests make fearless refactoring possible.

3. It Improves Design

Code that’s hard to test is often poorly structured. Writing tests forces you to modularize, simplify, and decouple your logic — improving maintainability.

4. It Documents Behavior

Unlike comments, which can go stale, tests show exactly how a method is intended to behave. They’re executable documentation.

5. It Lowers Stress

You don’t need to keep mental models of how everything works or live in fear of that one module breaking. Tests help you sleep.

How Not to Test (And How to Do It Better)

1. Testing Implementation Instead of Behavior

Bad:

@Test
void testSortInternalLogic() {
MySorter sorter = new MySorter();
List<Integer> list = Arrays.asList(3, 2, 1);
sorter.sort(list);
assertTrue(sorter.usedBubbleSort()); // You're testing how, not what.
}

This test will break if the internal algorithm changes — even if the new one is correct and faster. That’s brittle and unhelpful.

Better:

@Test
void sort_shouldReturnSortedList_givenUnsortedList() {
MySorter sorter = new MySorter();
List<Integer> input = Arrays.asList(3, 2, 1);
List<Integer> expected = Arrays.asList(1, 2, 3);

sorter.sort(input);

assertEquals(expected, input);
}

Now you’re testing the behavior: the list is sorted correctly, regardless of the sorting algorithm used.

2. Overusing Mocks

Bad:

@Test
void testServiceLogicWithAllMocks() {
ExternalService mockService = mock(ExternalService.class);
when(mockService.getValue()).thenReturn("mocked");

BusinessLogic logic = new BusinessLogic(mockService);
String result = logic.compute();

assertEquals("mocked processed", result);
}

Everything here is mocked. You’re testing that mocks return what you told them to return — not that your system works.

Better:

Use real classes for core logic, and only mock slow or external dependencies (e.g., HTTP clients, DB calls).

class DummyExternalService implements ExternalService {
public String getValue() {
return "real";
}
}

@Test
void compute_shouldReturnProcessedResult_givenRealInput() {
BusinessLogic logic = new BusinessLogic(new DummyExternalService());
String result = logic.compute();

assertEquals("real processed", result);
}

Now you’re testing logic in a realistic, stable way.

3. Unclear Test Names and Structure

Bad:

@Test
void test1() {
assertEquals(5, calculator.add(2, 3));
}

What is this testing? No one knows. And if it fails, it’s hard to debug.

Better:

@Test
void add_shouldReturnSum_whenTwoIntegersProvided() {
assertEquals(5, calculator.add(2, 3));
}

Descriptive, readable, and self-explanatory.

4. Ignoring Edge Cases

Bad:

@Test
void testAddition() {
assertEquals(4, calculator.add(2, 2));
}

Sure, but what happens with zero? Negative numbers? Overflow?

Better:

@Test
void add_shouldReturnZero_whenAddingZeroToZero() {
assertEquals(0, calculator.add(0, 0));
}

@Test
void add_shouldHandleNegativeNumbersCorrectly() {
assertEquals(-3, calculator.add(-1, -2));
}

Covering edge cases makes your system robust and your tests trustworthy.

Best Practices for Testing in Java

1. Follow the Testing Pyramid

  • Unit tests: fast, numerous, cheap to run.
  • Integration tests: fewer, slower, verify system boundaries.
  • End-to-end tests: simulate real user flows, catch everything, but are expensive.

Keep the pyramid in balance. Don’t let UI tests dominate your suite.

2. Stick to the AAA Pattern

Organize your test methods using:

  • Arrange: Set up data or state.
  • Act: Call the method.
  • Assert: Verify the result.

For Example:

@Test
void isEven_shouldReturnTrue_whenNumberIsEven() {
// Arrange
NumberUtils utils = new NumberUtils();
// Act
boolean result = utils.isEven(4);
// Assert
assertTrue(result);
}

This structure makes every test predictable and easier to read.

3. Use Parameterized Tests When It Makes Sense

Avoid repeating yourself. If you’re testing the same logic with multiple inputs:

@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 8})
void isEven_shouldReturnTrue_forEvenNumbers(int input) {
assertTrue(NumberUtils.isEven(input));
}

This condenses multiple tests into one and makes your intent clear.

4. Make Tests Fast and Reliable

Slow or flaky tests don’t get run. Use real dependencies when needed, but don’t bring in the entire application stack unless you must.

5. Use Mocks Wisely

Use tools like Mockito, but don’t mock everything. Focus on mocking external systems, not core logic.

Example using Mockito:

@Test
void getUserById_shouldReturnUser_whenExists() {
UserRepository repo = mock(UserRepository.class);
when(repo.findById("123")).thenReturn(Optional.of(new User("123", "Alice")));

UserService service = new UserService(repo);

User result = service.getUserById("123");

assertEquals("Alice", result.getName());
}

Example: Integration Test with Spring Boot

If you’re using Spring Boot, you can write real HTTP-level tests with MockMvc:

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {

@Autowired
private MockMvc mockMvc;

@Test
void getUser_shouldReturnUserDetails() throws Exception {
mockMvc.perform(get("/users/123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Alice"));
}
}

This ensures the whole request pipeline works — from controller to service to response.

Code Coverage: Useful, Not Absolute

Chasing 100% code coverage can lead to meaningless tests. A better goal: cover all critical paths and edge cases with meaningful assertions.

Don’t just run the line — verify the result.

Testing isn’t glamorous, but it’s the highest-leverage habit you can develop as an engineer. It’s the thing that lets you move faster, sleep better, and deploy confidently.

Writing a test suite for full code coverage may take 2 hours, but without it, debugging production issues may take 2 developers’ lifetimes due to more unforeseen bugs than necessary.

So, take the two hours. Write the tests. Your future self — and your whole team — will thank you.

Peaceful nights are earned one assertion at a time.


On Testing: The Diligent Engineer’s Path to Peaceful Nights was originally published in Javarevisited on Medium, where people are continuing the conversation by highlighting and responding to this story.

This post first appeared on Read More