Testing Best Practices
Testing Best Practices
Writing tests that pass is easy. Writing tests that communicate intent, survive refactoring, and catch real bugs is a craft. This lesson distils the most impactful best practices around three themes: naming, isolation, and scope — what to test and what to deliberately leave out.
1. Name Tests Like Specifications
A test name is the first thing a failing build shows you. It should answer three questions without reading the body: what is under test, under what condition, and what is expected.
A widely adopted pattern is Given / When / Then compressed into a method name:
Underscores inside the method name are acceptable (many teams prefer them) because they make the three-part structure scannable in IDE test runners and CI reports. Alternatively, use @DisplayName to attach a sentence-style label without changing the method identifier:
2. One Logical Assertion Per Test
Each test should verify exactly one behaviour. This does not always mean exactly one assertX() call — sometimes verifying a single behaviour requires checking two related values. But a test that verifies ten unrelated things hides the root cause when it fails.
3. Isolate the System Under Test
Every test should create its own fresh state. Shared mutable state across tests causes order-dependent failures — tests that pass alone but fail when run in a different sequence. JUnit 5 creates a new test instance per method by default, which helps, but you must also:
- Initialise fixtures in
@BeforeEach, not as static fields. - Replace real collaborators with mocks or fakes so the test only exercises the class it claims to test.
- Never rely on the filesystem, a running database, or an external HTTP endpoint in a unit test.
@AfterEach, a test that throws unexpectedly before the teardown runs leaves state behind for the next test. Prefer instance fields reset in @BeforeEach.
4. Follow the Arrange–Act–Assert (AAA) Structure
Every test body should have three clearly separated phases: set up data and collaborators (Arrange), invoke the code under test (Act), then verify the outcome (Assert). A blank line between phases makes the structure instantly readable.
5. What to Test
Focus tests on observable behaviour from the perspective of the caller, not on internal implementation details.
- Public API contracts — return values, thrown exceptions, state changes visible through getters.
- All branches of conditional logic — the happy path, every meaningful error path, and edge cases (null, empty, zero, max value).
- Business rules — validations, calculations, invariants your domain model must uphold.
- Integration points via integration tests — that a
JpaRepositoryquery returns the right rows, that an HTTP endpoint responds with the correct status code.
6. What NOT to Test
Untested code is a risk, but over-testing is also a problem: tests that are tightly coupled to implementation details break on every refactor, even when the behaviour is correct, making developers distrust the suite.
- Private methods — test them through the public API that exercises them. If a private method is so complex it demands its own test, extract it into a collaborator class.
- Framework internals — do not test that Spring wires a bean or that JPA generates a SQL statement correctly. Trust the framework; test your logic.
- Trivial getters/setters — a getter that returns a field has no logic. Testing it adds noise without safety.
- Configuration data — a constant, an enum value, or a property file entry does not need a unit test.
7. Keep Tests Fast and Deterministic
A test suite that takes ten minutes to run will not be run before every commit. Guard speed relentlessly:
- Replace slow dependencies (databases, queues, HTTP) with in-memory fakes or Mockito mocks in unit tests.
- Use
@Tag("integration")to separate integration tests so they can run in CI but not on every local build if speed is critical. - Never use
Thread.sleep()in a test to wait for an async result — useAwaitilityorCompletableFuture.get(timeout). - Avoid
new Date()orInstant.now()directly; inject aClockso tests can pass a fixed time.
Summary
The hallmarks of a high-quality test suite: test names that read like specifications, one logical assertion per test, full isolation of each test, the AAA structure, tests aimed at public observable behaviour rather than private implementation details, and a fast deterministic run time. Internalise these practices and your suite becomes a safety net you trust — one that accelerates development rather than slowing it down.