Testing with JUnit 5 & Mockito

Why Test? Testing Fundamentals

15 min Lesson 1 of 13

Why Test? Testing Fundamentals

You have built complex systems in Java — multi-layered applications with generics, streams, concurrency, and database access. At that scale, a single misplaced null, a wrong assumption about thread safety, or a misread SQL result set can cause hours of debugging in production. Automated tests are not optional overhead; they are the engineering discipline that makes large, changing codebases manageable.

The Real Cost of Not Testing

Consider the economics. A bug caught during development costs almost nothing to fix — the developer who introduced it still has the full context in mind. The same bug caught by a tester before release costs roughly ten times more: it must be reported, triaged, reproduced, and fixed days later. A bug caught in production costs one hundred times more — customers are affected, on-call engineers are paged at night, and fixing it requires a hotfix deploy. This is the defect cost escalation curve, well documented in industry studies since the 1970s.

Automated tests shift that detection point left, to development time, where fixing is cheap.

Tests also document intent. A well-named test like shouldThrowWhenAmountExceedsBalance() communicates a business rule more precisely than any comment. Future maintainers read tests to understand what the code is supposed to do.

The Testing Pyramid

Not all tests are equal. The industry has settled on a three-tier model — the testing pyramid — that balances coverage, speed, and confidence.

  • Unit tests (base, widest layer) — Test one class or one pure function in complete isolation, replacing all dependencies with fakes. They run in milliseconds, give precise failure messages ("this method returned the wrong value"), and form the bulk of a healthy test suite.
  • Integration tests (middle layer) — Wire two or more real components together — a service with a real database, a controller with a real HTTP layer. They run slower (seconds) but verify that the pieces actually fit together.
  • End-to-end tests (top, narrowest layer) — Drive the fully deployed application from a user's perspective: click a UI button, confirm the database row was created. They catch wiring problems across the entire stack but are slow (minutes), fragile, and expensive to maintain. Keep them few and focused on critical paths.
Invert the pyramid at your peril. Many teams start with only E2E tests because they "test everything". Those suites become slow, flaky, and unmaintainable. Invest heavily at the unit level, moderately at integration, and sparingly at E2E.

Unit Tests in Java: the Shape of a Test

A unit test for a BankAccount class looks like this:

// BankAccount.java public class BankAccount { private double balance; public BankAccount(double initialBalance) { if (initialBalance < 0) { throw new IllegalArgumentException("Initial balance cannot be negative"); } this.balance = initialBalance; } public void deposit(double amount) { if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive"); balance += amount; } public void withdraw(double amount) { if (amount <= 0) throw new IllegalArgumentException("Amount must be positive"); if (amount > balance) throw new IllegalStateException("Insufficient funds"); balance -= amount; } public double getBalance() { return balance; } }
// BankAccountTest.java (JUnit 5) import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class BankAccountTest { @Test void depositIncreasesBalance() { BankAccount account = new BankAccount(100.0); account.deposit(50.0); assertEquals(150.0, account.getBalance()); } @Test void withdrawReducesBalance() { BankAccount account = new BankAccount(200.0); account.withdraw(80.0); assertEquals(120.0, account.getBalance()); } @Test void withdrawThrowsWhenAmountExceedsBalance() { BankAccount account = new BankAccount(50.0); assertThrows(IllegalStateException.class, () -> account.withdraw(100.0)); } }

Each test follows the Arrange–Act–Assert pattern (also called Given–When–Then in Behaviour-Driven Development). Arrange: set up the system under test and its inputs. Act: call the method being tested. Assert: verify the outcome.

Integration Test: a Service with a Real Repository

At the integration layer you stop faking collaborators and let real ones participate:

// AccountServiceIntegrationTest.java // Uses an in-memory H2 database wired through Spring Data JPA @SpringBootTest @Transactional class AccountServiceIntegrationTest { @Autowired private AccountService accountService; @Autowired private AccountRepository repository; @Test void transferPersistsBothDebitAndCredit() { Account sender = repository.save(new Account("Alice", 500.0)); Account receiver = repository.save(new Account("Bob", 100.0)); accountService.transfer(sender.getId(), receiver.getId(), 200.0); Account updatedSender = repository.findById(sender.getId()).orElseThrow(); Account updatedReceiver = repository.findById(receiver.getId()).orElseThrow(); assertEquals(300.0, updatedSender.getBalance()); assertEquals(300.0, updatedReceiver.getBalance()); } }

This test catches bugs that a unit test cannot — for example, a transaction boundary misconfiguration that rolls back only half the transfer.

Key Properties of Good Tests

The F.I.R.S.T. principles define what a test suite should feel like:

  • Fast — Unit tests should run in milliseconds; the full suite in seconds. Slow tests are skipped.
  • Isolated — Each test is independent. No shared mutable state between tests. Tests can run in any order.
  • Repeatable — The same test always produces the same result regardless of environment, time, or network state.
  • Self-validating — The test itself reports pass or fail. No human reads a log file to decide.
  • Timely — Tests are written at the same time as (or before) the production code, not six months later.
A test that always passes is worse than no test. If you write assertions that can never fail — for example assertTrue(true), or an assertion on a value you never actually change — you get false confidence. Every assertion must be able to catch a real bug.

Test Coverage: a Tool, Not a Goal

Code coverage measures what percentage of your production code is executed by at least one test. 80% line coverage is a reasonable target for many projects, but the number is not the objective. A suite with 95% coverage that has no meaningful assertions is useless. A suite with 70% coverage that asserts every important business rule and edge case is excellent.

Use coverage to find gaps — untested branches, error paths, and edge cases — not to hit a number and declare victory.

Summary

Automated tests are an economic decision as much as a technical one. They shift defect detection left, document intent, and enable safe refactoring. The testing pyramid guides how to allocate effort: many fast unit tests, fewer integration tests, very few E2E tests. Every test follows Arrange–Act–Assert, and every assertion must be capable of failing. With these foundations in place, you are ready to learn the specific tooling — JUnit 5 and Mockito — in the lessons that follow.