The short answer is: use all three selectively, not uniformly. Each solves a different problem, and trying to make one approach do everything usually leads to either slow tests or gaps in coverage.
A strategy I would typically recommend for a new Spring Boot service looks like this:
| Test type | Primary tools | What it validates | Frequency |
|---|
| Unit tests | JUnit 5 + Mockito | Business logic in isolation | Most tests (70–80%) |
| Slice tests | Spring Boot Test (@WebMvcTest, @DataJpaTest, etc.) | Spring configuration for one layer | Moderate |
| Integration tests | Spring Boot + Testcontainers | Real database, messaging, infrastructure | Key workflows |
| End-to-end/API tests | Spring Boot + Testcontainers or deployed environment | Entire application | Small number |
| BDD (optional) | Cucumber | Business-readable acceptance criteria | Only when it adds value |
1. Unit tests (JUnit 5 + Mockito)
This is where most of the testing effort should go.
Good candidates:
- Service classes
- Validation logic
- Calculations
- Mapping
- Domain rules
Example:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository repository;
@InjectMocks
private OrderService service;
@Test
void shouldCalculateDiscount() {
// Arrange
// Act
// Assert
}
}
Advantages:
- Extremely fast
- Easy to debug
- Encourage good design
- Run on every build
2. Spring slice tests
Don't always start the whole application.
Examples:
@WebMvcTest
@DataJpaTest
@JsonTest
@RestClientTest
These verify Spring configuration without paying the cost of a full application context.
Example:
@WebMvcTest(CustomerController.class)
class CustomerControllerTest {
}
These catch things Mockito can't:
- JSON serialization
- Validation annotations
- Controller mappings
- Security configuration
3. Integration tests with Testcontainers
This is the approach I'd strongly recommend for modern Spring Boot applications.
Instead of mocking PostgreSQL, Redis, Kafka, or RabbitMQ, run the real thing.
Example:
@Testcontainers
@SpringBootTest
class CustomerIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16");
}
Benefits:
- Real SQL behavior
- Real transactions
- Real indexes
- Real migrations (Flyway/Liquibase)
- Very few "works on my machine" surprises
I generally prefer:
- PostgreSQL container instead of H2
- Kafka container instead of embedded Kafka
- Redis container instead of mocks
4. End-to-end tests
Only a handful.
Test complete user journeys like:
- Create customer
- Place order
- Cancel order
- Generate invoice
These should verify that the whole application works together.
Keep the number small because they're slower and more brittle than unit tests.
5. Should you use Cucumber?
My default answer is only if your team benefits from executable specifications.
Cucumber is valuable when:
- Product owners help write scenarios
- Business analysts review acceptance criteria
- There is a strong BDD culture
- Feature files are actually maintained
Example:
Scenario: Customer places an order
Given a customer exists
When they place an order
Then the order is confirmed
If developers are the only people reading the tests, Cucumber often adds an extra maintenance layer without much payoff.
For many engineering teams, well-named JUnit tests communicate intent just as effectively:
shouldRejectOrderWhenCreditLimitExceeded()
Recommended test pyramid
E2E
--------
Integration
------------
Slice Tests
--------------
Unit Tests
--------------
Most tests should be at the bottom, where they are fast and inexpensive to run.
What I'd choose for a typical Spring Boot microservice
- ✅ JUnit 5
- ✅ Mockito
- ✅ AssertJ
- ✅ Spring Boot Test
- ✅ Testcontainers
- ✅ Flyway or Liquibase migrations in integration tests
- ✅ MockMvc or WebTestClient for controller tests
- ❌ H2 for PostgreSQL-backed applications (prefer the real database via Testcontainers)
- ❌ Cucumber unless the project genuinely practices BDD with active participation from non-developers
This combination usually provides a good balance of speed, confidence, and maintainability. Unit tests give rapid feedback, slice tests validate Spring wiring, Testcontainers catch infrastructure and persistence issues that mocks can't, and end-to-end or BDD tests are reserved for the highest-value workflows rather than trying to cover every code path.