The problem

A team has either almost no tests, so every release is a gamble, or thousands of slow tests that fail randomly and that nobody trusts. Both situations come from the same root: tests were written without a strategy. A test suite has a job: tell you quickly and reliably whether the behaviour you care about still works, and point at the cause when it does not.

The pyramid as a guide, not a law

The test pyramid says: many fast, small tests at the bottom; fewer, broader and slower tests above; very few end-to-end tests at the top. The reasoning is economic. Small tests are cheap to run and precise in their failure; broad tests give more confidence per test but are slower and harder to diagnose.

Do not apply it as a ratio to hit. A service that is mostly SQL and mapping has little pure logic, and its most valuable tests may be integration tests against a real database. A service full of pricing rules benefits from many unit tests. Look at where your risk is.

Unit tests

A unit test exercises a class or a small group of classes without Spring, a database or a network. It starts in milliseconds.

class PriceCalculatorTest {    private final PriceCalculator calculator = new PriceCalculator();    @Test    void appliesTenPercentDiscountToOrdersOverThreshold() {        var order = anOrder().withSubtotal("200.00").build();        var total = calculator.total(order);        assertThat(total).isEqualByComparingTo("180.00");    }}

Keep business rules in plain classes with constructor-injected collaborators so that they are easy to test this way. If you need @SpringBootTest to test a rule, the rule is probably tangled with infrastructure.

Slice tests

Spring Boot offers slices that load only part of the context.

@WebMvcTest loads controllers, JSON conversion, validation and exception handlers, and nothing else. Collaborators are replaced with mocks (@MockitoBean requires Spring Framework 6.2 / Spring Boot 3.4 or newer; on older Boot 3.x versions use @MockBean instead):

@WebMvcTest(OrderController.class)class OrderControllerTest {    @Autowired MockMvc mvc;    @MockitoBean OrderService service;    @Test    void rejectsOrderWithoutItems() throws Exception {        mvc.perform(post("/orders")                .contentType(MediaType.APPLICATION_JSON)                .content("{\"items\":[]}"))           .andExpect(status().isBadRequest());    }}

This verifies the HTTP contract: status codes, validation, serialization. @DataJpaTest loads the JPA layer and repositories. It is the right place to test custom queries, mappings and constraints, preferably against the same database engine as production, because H2 differs from PostgreSQL in SQL dialect and behaviour. Slice tests are faster than a full application start, and they fail close to the cause.

Integration tests

@SpringBootTest starts the full context and verifies that the parts work together: controller, service, repository, transaction handling, security configuration. Use them for a limited number of important flows, such as creating an order end to end. Run them against real infrastructure like a PostgreSQL container, which keeps the environment honest. Reserve full end-to-end tests through a browser or the deployed system for a handful of critical journeys.

What to mock

Mock things you do not own or that are slow, non-deterministic or have side effects: an external HTTP API, a payment provider, an email sender, the clock. Do not mock the class under test, value objects, or simple in-memory collaborators. Excessive mocking produces tests that restate the implementation: change the code structure and the tests break although behaviour is the same. For a database, prefer a real one to a mocked repository when the query or mapping is the thing that can break. For time, inject a Clock instead of calling Instant.now() in business code.

Naming and structure

A test name should describe behaviour and condition, so that a failure report reads like a sentence: rejectsOrderWithoutItems, returnsNotFoundForUnknownCustomer, retriesOnceWhenProviderTimesOut. Structure each test as arrange, act, assert, with a blank line between the phases. One test, one reason to fail; several assertions about the same outcome are fine.

Test data builders

Large constructors and copy-pasted setup make tests noisy. A builder gives sensible defaults and lets each test state only what matters:

public class OrderBuilder {    private String customerId = "c-1";    private BigDecimal subtotal = new BigDecimal("50.00");    public static OrderBuilder anOrder() { return new OrderBuilder(); }    public OrderBuilder withSubtotal(String v) {        this.subtotal = new BigDecimal(v); return this;    }    public Order build() { return new Order(customerId, subtotal); }}

When a new mandatory field appears, you change the builder once instead of a hundred tests.

Common mistakes

  • Testing getters and framework behaviour instead of your rules.
  • Sharing mutable state between tests, which makes results depend on order. Each test should arrange its own data and clean up.
  • Using Thread.sleep to wait for async work; use polling with a timeout instead.
  • Asserting on log output or private methods.
  • Letting flaky tests stay red or ignored. A test that sometimes fails teaches the team to ignore failures.
  • Chasing a coverage percentage. Coverage shows what code ran, not whether anything was verified.

Trade-offs

More integration tests mean more realism and slower feedback. More mocks mean faster tests and more risk of false confidence. Choose per risk: pure logic gets unit tests, HTTP and persistence contracts get slices, a few critical flows get full integration tests. Revisit the balance when the suite becomes slow or when bugs escape through a gap.

Keeping the suite healthy in CI

A suite is useful only while it is fast and stable. Run unit and slice tests on every commit and integration tests in a separate stage so that feedback is not delayed. Watch for slow tests: a list of the ten slowest tests often shows that several of them merely restart the context. Different @MockitoBean combinations break Spring's context cache, and every combination means a fresh context start; share a common base configuration where you can.

A failing test must give a clear message. With an assertion description (assertThat(...).as("...")) and a meaningful test name, whoever reads the failure can understand the reason without opening the code.

Where to start

In an existing project without tests, do not try to cover everything at once. Pick the flow that brings the most money, risk or complaints and write one integration test for it. After that, whenever a bug is fixed, first write a test that reproduces it; this way the suite grows out of real failures. Make unit tests an ordinary habit for new code, and update the controller slice when a contract changes. This gradual approach lasts longer than one big "testing project".

Finally, treat tests like code: refactor them too, remove duplication, and read them carefully in review. A badly written test turns into technical debt, and the team starts deleting or ignoring it.

Checklist

  • Business rules are testable without Spring.
  • Controllers have @WebMvcTest tests for status codes and validation.
  • Repositories with custom queries are tested against a real database engine.
  • A small set of integration tests covers key flows.
  • Time, randomness and external calls are controlled.
  • Test names describe behaviour; data comes from builders.

Where to go next

Clear layers make testing easier, see Spring Boot layering. Validation and error responses are worth testing at the controller slice, see validation and error handling. A good persistence test can also assert the number of queries, as described in the N+1 problem.