Логотип Workflow

Article

Spring Testing Basics

Stage 7 — Testing in Spring: Building a Practical Pyramid

Testing is a way to check software behavior before users discover failures in production. For a beginner, the key idea is simple: a test is executable proof that a piece of code does what you expect in a defined situation. In Java and Spring projects, tests are not only about correctness. They also protect refactoring, speed up code review, and reduce fear when changing existing modules.

Testing pyramid

The pyramid image is important because each layer answers a different risk. Lower levels are cheaper and faster, so they should contain most tests. Higher levels are slower and more expensive, so they should focus on critical flows. If you invert the pyramid and place too much on top, CI becomes slow and teams start ignoring failures.

What each testing level in the pyramid validates

Unit tests check one class or one small behavior in isolation. They run very fast, so beginners should use them to learn business rules clearly: discount calculations, validation branches, mapping logic, and decision tables. Good unit tests assert externally visible behavior, not private implementation details.

Slice tests check one Spring layer with framework support, but without starting everything. @WebMvcTest validates request mapping, JSON conversion, validation errors, and HTTP status codes. @DataJpaTest validates repository queries, entity mapping, and transactional behavior on the data layer. Slice tests are slower than unit tests, but much faster than full context tests.

Integration tests check how multiple components work together. In Spring this usually means @SpringBootTest, sometimes with a real database container. These tests are expensive, so keep them for high-value scenarios: authentication flow, order placement, payment orchestration, or critical business workflow.

Test typeMain goalTypical Spring toolsRuntime costTypical beginner mistake
UnitValidate pure logic in one classJUnit, MockitoVery lowTesting framework internals instead of own logic
SliceValidate one application layer contract@WebMvcTest, @DataJpaTestMediumExpecting full application behavior
IntegrationValidate real collaboration across layers@SpringBootTest, TestcontainersHighWriting too many heavy tests for simple cases

What a Mock is and when to use it

A mock is a substitute object used instead of a real dependency in a test. You replace the real collaborator with controlled behavior to isolate the subject under test. For example, in a UserService unit test, a UserRepository can be mocked so you test service logic without touching a database.

Mock replacement scheme

Use a mock when the dependency is outside the behavior you currently verify, or when the dependency is slow, nondeterministic, or infrastructure-heavy. Avoid mocking everything by default. If your test needs to verify SQL behavior, use real repository integration instead of mocked repository responses.

Practical example for a beginner Spring team

Imagine you add user registration with these rules: email must be unique, password length must be at least 8, and API should return a structured error body for invalid input.

Start with unit tests for RegistrationService. Mock the repository and assert business decisions: reject short password, reject duplicate email, allow valid payload. This layer proves your decision logic quickly and gives fast feedback while coding.

Then add @WebMvcTest for RegistrationController. Verify HTTP details: validation errors return 400, JSON error fields are stable, and successful request returns expected status and body structure. At this level you are testing the API contract that frontend or external clients rely on.

After that, add @DataJpaTest for repository methods, for example existsByEmail. This is where you validate that naming conventions or custom queries map correctly to database behavior.

Finally, keep one or a few @SpringBootTest scenarios for end-to-end confidence: send real request, persist data, verify full flow with all layers. If production uses PostgreSQL, add Testcontainers for these scenarios so dialect-specific problems surface before release.

Nuances that usually confuse newcomers

New engineers often think more tests always means better quality. In practice, quality depends on test signal. Ten focused tests that fail for real regressions are better than fifty unstable tests that fail randomly.

Another common confusion is mixing responsibilities: asserting HTTP status in service unit tests, or asserting business branches inside controller slice tests. Keep assertions at the right layer boundary.

Flaky tests are usually caused by shared mutable state, clock/time assumptions, random data without control, or hidden dependency on execution order. Stabilize them by using deterministic test data, isolated fixtures, and clear setup/teardown.

If a test passes with H2 but fails with PostgreSQL, this is not “bad luck.” It means environment mismatch. For database-dependent behavior, include real DB integration coverage at least for critical queries.

Stage takeaway

Testing in Spring is a risk-management strategy, not a single annotation. Build a pyramid: many fast unit tests, enough slice tests for layer contracts, and a small set of high-value integration tests. Use mocks to isolate behavior intentionally, not mechanically.