📚 Series Navigation:
← Previous: Part 10 - Can You See Me Now?
👉 You are here: Part 11 - Trust, But Verify
Next: Part 12 - Ship It →
📋 Introduction
"It works on my machine." Four words that have launched a thousand arguments and a few career-limiting conversations. But those four words are usually true. The code does work on your machine. The problem is that "your machine" isn't production. And the gap between "works on my machine" and "works in production" is filled with one thing: tests.
But not just any tests. I've seen codebases with 95% code coverage where the application still breaks in production. I've seen codebases with zero tests that somehow run fine for years. Coverage isn't confidence. What matters isn't how much you test — it's what you test and how you test it.
Here's my confession: early in my career, I wrote tests that tested nothing. Methods that verified a string was a string, that a number was a number, that the thing I just set was the thing I just got. These tests padded the coverage report while catching exactly zero bugs. They were the testing equivalent of checking that the sky is still up.
The Weather Microservice takes a different approach. We build a test pyramid — fast unit tests at the base, focused integration tests in the middle, and architecture enforcement tests at the top. We use a centralized test data factory to eliminate duplication, a dedicated security configuration to isolate auth concerns, and a JaCoCo coverage gate that actually means something because we exclude the right things.
In this article, we'll build a testing strategy that catches real bugs, runs fast, and gives you genuine confidence that your code works. Not on your machine. Everywhere.
Grab your lab coat. Time to put our code under the microscope. ☕
🧪 The PYRAMID Framework
Our testing strategy follows the PYRAMID framework:
| Letter | Principle | Description |
|---|---|---|
| P | Pure Unit Tests | Fast, isolated tests with mocked dependencies — the foundation of your pyramid |
| Y | Your Integration Tests | Verify components work together with @WebMvcTest and Spring context |
| R | Rules for Architecture | ArchUnit tests that enforce structural constraints automatically |
| A | Automatic Coverage | JaCoCo gates that fail the build below 80%, with smart exclusions |
| M | Maintainable Data | Centralized TestDataFactory — one source of truth for test objects |
| I | Isolated Security | Separate TestSecurityConfig to decouple auth from business tests |
| D | Deterministic Profiles | @ActiveProfiles("test") with quiet logging and consistent behavior |
🔥 Critical Insight: A test pyramid has a wide base (many fast unit tests), a narrower middle (fewer integration tests), and a tiny top (a handful of architecture tests). Invert the pyramid — lots of slow integration tests, few unit tests — and your CI pipeline becomes a 30-minute coffee break.
🔬 The Base: Unit Tests with Mockito
Unit tests are the foundation of your test pyramid. They're fast (milliseconds per test), isolated (no Spring context, no database, no network), and focused on a single behavior.
Anatomy of a Well-Written Unit Test
Here's the WeatherServiceTest:
// WeatherServiceTest.java
@ExtendWith(MockitoExtension.class)
class WeatherServiceTest {
@Mock private WeatherApiClient weatherApiClient;
@Mock private WeatherRecordRepository weatherRecordRepository;
@Mock private LocationService locationService;
@Mock private WeatherMapper weatherMapper;
@InjectMocks private WeatherService weatherService;
private WeatherApiResponse apiResponse;
private WeatherDto weatherDto;
private Location testLocation;
private WeatherRecord weatherRecord;
@BeforeEach
void setUp() {
testLocation = TestDataFactory.createTestLocation();
weatherDto =
TestDataFactory.weatherDtoBuilder()
.id(null)
.locationId(null)
.temperature(15.5)
.humidity(65)
.windSpeed(12.5)
.condition("Partly cloudy")
.build();
weatherRecord =
WeatherRecord.builder()
.id(1L)
.location(testLocation)
.temperature(15.5)
.humidity(65)
.windSpeed(12.5)
.condition("Partly cloudy")
.timestamp(LocalDateTime.now())
.build();
apiResponse = createMockApiResponse();
}
}
Worth unpacking each choice:
@ExtendWith(MockitoExtension.class) instead of @SpringBootTest — This is the most important choice. MockitoExtension creates mock objects without starting a Spring context. A unit test with Mockito runs in ~10ms. A test with @SpringBootTest takes 3-5 seconds to start the context. Multiply that by 50 tests and you've added minutes to your build.
@Mock for every dependency — The WeatherService has four dependencies. Each gets a mock, giving us complete control over their behavior. We can make the repository return whatever we want, make the API client throw exceptions, or verify that specific methods were called.
@InjectMocks — Mockito creates the WeatherService and injects all the mocks into its constructor. This works because WeatherService uses constructor injection (remember our architecture rule from Part 1?). If it used field injection, we'd need more boilerplate to set up the mocks.
TestDataFactory — Test data comes from a centralized factory (we'll explore this in detail later). No magic strings scattered across test classes.
Testing the Happy Path
@Test
void getCurrentWeather_WithoutSaving_ReturnsWeatherDto() {
// Arrange
when(weatherApiClient.getCurrentWeather("London")).thenReturn(apiResponse);
when(weatherMapper.toDtoFromApi(apiResponse)).thenReturn(weatherDto);
// Act
WeatherDto result = weatherService.getCurrentWeather("London", false);
// Assert
assertThat(result).isNotNull();
assertThat(result.locationName()).isEqualTo("London");
assertThat(result.temperature()).isEqualTo(15.5);
verify(weatherApiClient).getCurrentWeather("London");
verify(weatherRecordRepository, never()).save(any(WeatherRecord.class));
}
This test follows the Arrange-Act-Assert pattern explicitly, with comments marking each section. This might seem pedantic for simple tests, but it becomes invaluable when tests grow complex:
Arrange: Set up mock behavior. "When the API client is called with 'London', return our prepared response. When the mapper converts the API response, return our prepared DTO."
Act: Call the method under test with specific inputs. One method call, one line.
Assert: Verify the result AND the interactions:
assertThat(result).isNotNull()— The result existsassertThat(result.locationName()).isEqualTo("London")— The data is correctverify(weatherApiClient).getCurrentWeather("London")— The API was calledverify(weatherRecordRepository, never()).save(any())— The record was NOT saved (becausesave=false)
That last assertion is critical. It's not just checking what did happen — it's asserting what didn't happen. When save is false, we verify that the repository was never touched. This catches bugs where someone accidentally removes the if (save) guard.
Testing the Sad Path
@Test
void getCurrentWeatherByLocationId_WhenLocationNotFound_ThrowsException() {
// Arrange
when(locationService.getLocationEntityById(999L))
.thenThrow(new LocationNotFoundException(999L));
// Act & Assert
assertThatThrownBy(() -> weatherService.getCurrentWeatherByLocationId(999L, false))
.isInstanceOf(LocationNotFoundException.class);
}
AssertJ's assertThatThrownBy is cleaner than JUnit's assertThrows because it chains naturally:
// You could also verify the exception message:
assertThatThrownBy(() -> weatherService.getCurrentWeatherByLocationId(999L, false))
.isInstanceOf(LocationNotFoundException.class)
.hasMessageContaining("999");
Testing Data Flow
@Test
void getCurrentWeatherByLocationId_WithSaving_SavesWeatherRecord() {
// Arrange
when(locationService.getLocationEntityById(1L)).thenReturn(testLocation);
when(weatherApiClient.getCurrentWeather("London,United Kingdom")).thenReturn(apiResponse);
when(weatherMapper.fromWeatherApi(apiResponse, testLocation)).thenReturn(weatherRecord);
when(weatherMapper.toDtoFromApi(apiResponse)).thenReturn(weatherDto);
when(weatherRecordRepository.save(any(WeatherRecord.class))).thenReturn(weatherRecord);
// Act
WeatherDto result = weatherService.getCurrentWeatherByLocationId(1L, true);
// Assert
assertThat(result).isNotNull();
verify(weatherRecordRepository).save(any(WeatherRecord.class));
}
When save=true, this test verifies the complete data flow: location lookup → API call → entity mapping → database save. The verify(weatherRecordRepository).save(any()) proves the service actually persists the data.
Testing Pagination
@Test
void getWeatherHistory_ReturnsPageOfWeatherDtos() {
// Arrange
Pageable pageable = PageRequest.of(0, 10);
List<WeatherRecord> records = Arrays.asList(weatherRecord);
Page<WeatherRecord> page = new PageImpl<>(records, pageable, 1);
when(locationService.getLocationEntityById(1L)).thenReturn(testLocation);
when(weatherRecordRepository.findByLocationId(1L, pageable)).thenReturn(page);
when(weatherMapper.toDto(any(WeatherRecord.class))).thenReturn(weatherDto);
// Act
Page<WeatherDto> result = weatherService.getWeatherHistory(1L, pageable);
// Assert
assertThat(result).isNotNull();
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).locationName()).isEqualTo("London");
}
Pagination tests are often skipped because they seem "too simple." But they verify that the service correctly passes the Pageable through to the repository and maps the returned Page<Entity> to Page<Dto>. I've seen bugs where the page number gets lost or the sort order is ignored — these tests catch them.
✅ Pro tip: Name your tests using the pattern methodName_condition_expectedResult. This reads like a specification: "getCurrentWeather, with saving, saves weather record." When a test fails, the name alone tells you what's broken.
🏗️ The Middle: Integration Tests with MockMvc
Unit tests verify logic in isolation. Integration tests verify that components work together — that Spring wiring is correct, that validation triggers, that HTTP responses have the right shape.
The WebMvcTest Approach
// WeatherControllerIntegrationTest.java
@WebMvcTest(WeatherController.class)
@Import({TestSecurityConfig.class, com.weatherspring.exception.GlobalExceptionHandler.class})
@ActiveProfiles("test")
@WithMockUser(roles = "USER")
class WeatherControllerIntegrationTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private WeatherService weatherService;
}
Every annotation here serves a specific purpose:
@WebMvcTest(WeatherController.class) — Starts a minimal Spring context with only the web layer. No database, no caching, no external clients. Only the specified controller, its validators, and the exception handler. This starts in ~1 second vs ~5 seconds for a full @SpringBootTest.
@Import({TestSecurityConfig.class, GlobalExceptionHandler.class}) — We explicitly import two classes:
TestSecurityConfig— A simplified security configuration for tests (more on this below)GlobalExceptionHandler— Because we want to test that validation errors produce RFC 7807 responses
@ActiveProfiles("test") — Activates the test profile, which sets log levels to minimal and configures test-specific behavior.
@WithMockUser(roles = "USER") — Provides a pre-authenticated user for every test in this class. Without this, every request would get a 401 because Spring Security is active.
@MockitoBean — Replaces WeatherService with a mock in the Spring context. This is different from @Mock — @MockitoBean is Spring-aware and replaces the actual bean in the application context.
Testing HTTP Contracts
@Test
void getCurrentWeather_WithValidLocation_ReturnsWeatherDto() throws Exception {
// Arrange
WeatherDto weatherDto = new WeatherDto(
1L, 1L, "London",
20.5, 18.0, 65, 10.0, "NW",
"Partly cloudy", "Partly cloudy with light winds",
1013.0, 0.0, 40, 3.0,
LocalDateTime.now()
);
when(weatherService.getCurrentWeather(anyString(), anyBoolean())).thenReturn(weatherDto);
// Act & Assert
mockMvc.perform(
get("/api/weather/current")
.param("location", "London")
.param("save", "true")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.temperature").value(20.5))
.andExpect(jsonPath("$.feelsLike").value(18.0))
.andExpect(jsonPath("$.humidity").value(65))
.andExpect(jsonPath("$.condition").value("Partly cloudy"));
}
This test verifies the complete HTTP contract:
- Request shape: GET to
/api/weather/currentwithlocationandsaveparameters - Response status: 200 OK
- Response body: JSON with the expected fields and values
- JSON path assertions: Field names match what the API contract specifies
Testing Validation
@Test
void getCurrentWeather_WithBlankLocation_ReturnsBadRequest() throws Exception {
mockMvc.perform(
get("/api/weather/current")
.param("location", "")
.param("save", "true")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest());
verify(weatherService, never()).getCurrentWeather(anyString(), anyBoolean());
}
This test proves two things:
- A blank location returns 400 Bad Request (validation works)
- The service was never called (validation happens before the service layer)
That second assertion is subtle but important. It verifies that the validation layer correctly short-circuits the request before it reaches business logic. If someone accidentally removes the @NotBlank annotation from the controller parameter, this test fails.
🤔 Design decision: Why @WebMvcTest instead of @SpringBootTest? Because @WebMvcTest only loads the web layer. It's faster and it proves that your controller is properly decoupled from the service layer. If your controller test needs a real database connection, that's a code smell — your controller is doing too much.
🏛️ The Top: Architecture Tests with ArchUnit
Architecture tests are the guardians of your codebase structure. They don't test business logic — they test that your code organization follows the rules you've established.
Layer Dependency Rules
// LayerArchitectureTest.java
@Test
void layersShouldRespectDependencies() {
ArchRule rule =
layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Controllers").definedBy("..controller..")
.layer("Services").definedBy("..service..")
.layer("Repositories").definedBy("..repository..")
.layer("Models").definedBy("..model..")
.layer("DTOs").definedBy("..dto..")
.layer("Mappers").definedBy("..mapper..")
.layer("Clients").definedBy("..client..")
.layer("Config").definedBy("..config..")
.layer("Exceptions").definedBy("..exception..")
.whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
.whereLayer("Controllers")
.mayOnlyAccessLayers("Services", "DTOs", "Exceptions", "Config")
.whereLayer("Services")
.mayOnlyAccessLayers("Repositories", "Mappers", "Clients",
"DTOs", "Models", "Exceptions", "Config")
.whereLayer("Repositories").mayOnlyAccessLayers("Models")
.whereLayer("Mappers").mayOnlyAccessLayers("DTOs", "Models")
.withOptionalLayers(true);
rule.check(importedClasses);
}
This single test enforces the entire layered architecture we designed in Part 1:
- Controllers can only access Services, DTOs, Exceptions, and Config
- Services can access Repositories, Mappers, Clients, DTOs, Models, Exceptions, and Config
- Repositories can only access Models
- Mappers can only access DTOs and Models
- No layer can access Controllers (controllers are the entry point)
If a developer imports a Repository directly into a Controller, this test fails immediately. It's like having an automated code reviewer that never takes a vacation.
Naming Convention Tests
@Test
void controllersShouldBeNamedCorrectly() {
ArchRule rule = classes()
.that().resideInAPackage("..controller..")
.and().areAnnotatedWith(RestController.class)
.should().haveSimpleNameEndingWith("Controller")
.allowEmptyShould(true);
rule.check(importedClasses);
}
@Test
void servicesShouldBeNamedCorrectly() {
ArchRule rule = classes()
.that().resideInAPackage("..service..")
.and().areAnnotatedWith(Service.class)
.should().haveSimpleNameEndingWith("Service")
.allowEmptyShould(true);
rule.check(importedClasses);
}
@Test
void repositoriesShouldBeNamedCorrectly() {
ArchRule rule = classes()
.that().resideInAPackage("..repository..")
.and().areAnnotatedWith(Repository.class)
.should().haveSimpleNameEndingWith("Repository")
.allowEmptyShould(true);
rule.check(importedClasses);
}
These tests enforce naming conventions: classes in the controller package with @RestController must end with "Controller." Services must end with "Service." Repositories with "Repository." This prevents the chaos of WeatherHandler, WeatherManager, WeatherProcessor all living in the service package.
Annotation Enforcement
@Test
void servicesShouldBeAnnotatedWithService() {
ArchRule rule = classes()
.that().resideInAPackage("..service..")
.and().areNotAnonymousClasses()
.and().areNotMemberClasses()
.and().areNotInterfaces()
.should().beMetaAnnotatedWith(Service.class)
.allowEmptyShould(true);
rule.check(importedClasses);
}
@Test
void controllersShouldBeAnnotatedWithRestController() {
ArchRule rule = classes()
.that().resideInAPackage("..controller..")
.and().areNotAnonymousClasses()
.and().areNotMemberClasses()
.should().beAnnotatedWith(RestController.class)
.allowEmptyShould(true);
rule.check(importedClasses);
}
If someone puts a class in the service package without @Service, it won't be managed by Spring — and it won't be injected anywhere. These tests catch that mistake before it becomes a runtime NoSuchBeanDefinitionException.
Package Location Enforcement
@Test
void dtosShouldResideInDtoPackage() {
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("Dto")
.or().haveSimpleNameEndingWith("Request")
.or().haveSimpleNameEndingWith("Response")
.should().resideInAPackage("..dto..")
.allowEmptyShould(true);
rule.check(importedClasses);
}
@Test
void entitiesShouldBeInModelPackage() {
ArchRule rule = classes()
.that().areAnnotatedWith(jakarta.persistence.Entity.class)
.should().resideInAPackage("..model..")
.allowEmptyShould(true);
rule.check(importedClasses);
}
@Test
void exceptionsShouldBeInExceptionPackage() {
ArchRule rule = classes()
.that().areAssignableTo(Exception.class)
.and().resideOutsideOfPackage("java..")
.should().resideInAPackage("..exception..")
.allowEmptyShould(true);
rule.check(importedClasses);
}
DTOs in the DTO package. Entities in the model package. Exceptions in the exception package. These seem obvious, but without enforcement, you'll eventually find a UserResponse class living in the util package because someone was in a hurry.
The No Field Injection Rule
@Test
void fieldsShouldNotBeAutowired() {
ArchRule rule = noFields()
.should().beAnnotatedWith(Autowired.class)
.allowEmptyShould(true)
.because("Field injection is discouraged, use constructor injection instead");
rule.check(importedClasses);
}
This is one of the most impactful architecture tests. Field injection (@Autowired on a field) makes classes hard to test, hides dependencies, and allows circular dependencies. This test ensures every class uses constructor injection. It's the architectural equivalent of a "no smoking" sign — simple, clear, non-negotiable.
Constructor Injection Verification
@Test
void servicesShouldUseConstructorInjection() {
ArchRule rule = classes()
.that().resideInAPackage("..service..")
.and().areAnnotatedWith(Service.class)
.should().haveOnlyFinalFields()
.allowEmptyShould(true)
.because("Services should use constructor injection with final fields");
rule.check(importedClasses);
}
Going beyond "no field injection," this test verifies that service fields are final. Final fields + constructor injection = immutable services. You can't accidentally reassign a dependency after construction. Lombok's @RequiredArgsConstructor generates the constructor from final fields automatically.
✅ Pro tip: The allowEmptyShould(true) on every rule prevents false failures when a package is empty (like early in development when you haven't created any repositories yet). Without it, an empty package would cause the test to fail with "no classes match."
📦 Maintainable Test Data: The TestDataFactory
The biggest test maintenance headache? Duplicated test data. When you change a DTO field, you update it in 15 test files. When you add a new required field, you hunt down every new WeatherDto(...) constructor call across the codebase.
The solution: a centralized TestDataFactory.
Centralized Constants
// TestDataFactory.java
public final class TestDataFactory {
private TestDataFactory() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
}
// Test IDs
public static final Long TEST_ID = 1L;
public static final Long TEST_ID_2 = 2L;
public static final Long NON_EXISTENT_ID = 999L;
// London test data
public static final String LONDON_NAME = "London";
public static final String LONDON_COUNTRY = "United Kingdom";
public static final Double LONDON_LATITUDE = 51.5074;
public static final Double LONDON_LONGITUDE = -0.1278;
// Weather test data constants
public static final Double DEFAULT_TEMPERATURE = 15.5;
public static final Integer DEFAULT_HUMIDITY = 65;
public static final Double DEFAULT_WIND_SPEED = 12.5;
public static final String DEFAULT_WEATHER_CONDITION = "Partly cloudy";
}
Constants are meaningful. LONDON_LATITUDE = 51.5074 is real geographic data for London. DEFAULT_TEMPERATURE = 15.5 is a realistic temperature. Tests should use realistic data — it makes failures easier to debug because you recognize the data.
The NON_EXISTENT_ID = 999L is particularly useful — it's the conventional ID for "this doesn't exist" across all tests.
Factory Methods for Entities
public static Location createTestLocation() {
Location location = Location.builder()
.id(TEST_ID)
.name(LONDON_NAME)
.country(LONDON_COUNTRY)
.latitude(LONDON_LATITUDE)
.longitude(LONDON_LONGITUDE)
.region(LONDON_REGION)
.build();
location.setCreatedAt(LocalDateTime.now());
location.setUpdatedAt(LocalDateTime.now());
return location;
}
public static CreateLocationRequest createLondonRequest() {
return new CreateLocationRequest(
LONDON_NAME, LONDON_COUNTRY,
LONDON_LATITUDE, LONDON_LONGITUDE,
LONDON_REGION);
}
One place to create a test Location. If the Location entity gains a new required field, you update createTestLocation() once, and all tests continue to work.
The Builder Pattern for Customization
public static WeatherDtoBuilder weatherDtoBuilder() {
return new WeatherDtoBuilder();
}
public static class WeatherDtoBuilder {
private Long id = TEST_ID;
private String locationName = LONDON_NAME;
private Double temperature = DEFAULT_TEMPERATURE;
private Integer humidity = DEFAULT_HUMIDITY;
private Double windSpeed = DEFAULT_WIND_SPEED;
private String condition = DEFAULT_WEATHER_CONDITION;
// ... all fields with defaults
public WeatherDtoBuilder temperature(Double temperature) {
this.temperature = temperature;
return this;
}
public WeatherDtoBuilder condition(String condition) {
this.condition = condition;
return this;
}
public WeatherDto build() {
return new WeatherDto(id, locationId, locationName,
temperature, feelsLike, humidity, windSpeed,
windDirection, condition, description,
pressureMb, precipitationMm, cloudCoverage,
uvIndex, timestamp);
}
}
The builder pattern lets you override only what your test cares about:
// Test that needs specific temperature
WeatherDto hot = TestDataFactory.weatherDtoBuilder()
.temperature(42.0)
.condition("Extreme heat")
.build();
// Test that needs null IDs (fresh data)
WeatherDto fresh = TestDataFactory.weatherDtoBuilder()
.id(null)
.locationId(null)
.build();
Every field has a sensible default. You only specify what matters for your specific test. If the DTO gains a new field, you add a default to the builder once, and no existing tests break.
🔥 Critical Insight: The TestDataFactory isn't just convenience — it's a contract. It says "this is what valid test data looks like." When you see TestDataFactory.createTestLocation() in a test, you know it returns a fully valid, realistic Location entity. No guessing.
🔒 Isolated Security: TestSecurityConfig
Testing with production security is painful. Every test needs authentication. Every role change breaks 30 tests. The solution: a separate security configuration for tests.
// TestSecurityConfig.java
@TestConfiguration
@EnableWebSecurity
public class TestSecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails user = User.builder()
.username("user")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password(passwordEncoder.encode("password"))
.roles("USER", "ADMIN")
.build();
UserDetails actuator = User.builder()
.username("actuator")
.password(passwordEncoder.encode("password"))
.roles("ACTUATOR_ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin, actuator);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
}
This configuration provides three key benefits:
@TestConfiguration — This is NOT loaded by default. It's only active when explicitly imported: @Import(TestSecurityConfig.class). This means it doesn't interfere with any test that doesn't need it.
Permissive filter chain — CSRF disabled, all requests permitted. This lets business logic tests focus on business logic, not authentication. If you want to test that only admins can DELETE, you write a specific security test — not 50 tests that all pass admin credentials.
Three test users — A regular user, an admin, and an actuator user. When you do need to test role-based access, you have pre-configured users ready. Combined with @WithMockUser(roles = "ADMIN"), testing role-specific behavior is one annotation.
BCrypt encoder — Uses the same encoder as production. This ensures tests behave realistically if any test directly interacts with password hashing.
📊 Automatic Coverage: JaCoCo Configuration
Code coverage without context is meaningless. 80% coverage on your business logic is great. 80% coverage because you tested your DTOs and config classes is noise. The Weather Microservice configures JaCoCo with smart exclusions:
<!-- pom.xml - JaCoCo configuration -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
<excludes>
<exclude>**/*Dto.class</exclude>
<exclude>**/*Request.class</exclude>
<exclude>**/*Response.class</exclude>
<exclude>**/*ApiResponse*.class</exclude>
<exclude>**/dto/**/*.class</exclude>
<exclude>**/model/**/*.class</exclude>
<exclude>**/config/**/*.class</exclude>
<exclude>**/listener/**/*.class</exclude>
<exclude>**/util/**/*.class</exclude>
<exclude>**/validation/**/*.class</exclude>
<exclude>**/WeatherApplication.class</exclude>
</excludes>
</configuration>
</execution>
</executions>
</plugin>
The 80% Gate
COVEREDRATIO: 0.80 means the build fails if line coverage drops below 80%. This isn't a suggestion — it's a gate. You can't merge code with insufficient tests.
Why 80% and not 100%? Because 100% coverage often leads to pointless tests that cover framework boilerplate, private constructors, or error paths that can't actually happen. 80% focuses your testing energy on the code that matters.
Smart Exclusions
The excludes list is just as important as the threshold:
| Excluded | Why |
|---|---|
**/dto/**/*.class |
DTOs are data carriers — records or POJOs. Testing getters/setters adds noise, not confidence. |
**/model/**/*.class |
JPA entities are mostly annotations and fields. Hibernate tests them for you. |
**/config/**/*.class |
Configuration classes create beans. Testing @Bean methods doesn't verify business logic. |
**/validation/**/*.class |
Custom validators are tested through integration tests that hit the validation layer. |
**/*ApiResponse*.class |
External API response DTOs match an external contract — nothing to test. |
**/WeatherApplication.class |
The main class has a main() method. Don't write a test for SpringApplication.run(). |
By excluding these, the 80% threshold applies to the code that actually needs testing: services, mappers, exception handlers, controllers, and clients.
✅ Pro tip: The same exclusions appear in both the report and check executions. This ensures the coverage report and the gate use the same scope. Without this, the report might show 85% but the gate fails at 78% because they're measuring different class sets.
🔧 The Test Profile: Deterministic Behavior
The test profile in logback-spring.xml ensures tests run cleanly:
<springProfile name="test">
<logger name="com.weatherspring" level="INFO"/>
<logger name="org.springframework" level="WARN"/>
<logger name="org.hibernate" level="WARN"/>
<logger name="com.weatherspring.client.WeatherApiClient" level="OFF"/>
<logger name="com.weatherspring.exception.GlobalExceptionHandler" level="OFF"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
Two loggers are set to OFF:
WeatherApiClient: OFF — When testing with mocked API clients, the real client's log messages about connection timeouts and retries would clutter test output. Turn them off.
GlobalExceptionHandler: OFF — Integration tests deliberately trigger exceptions (bad input, not found, etc.). The exception handler would log each one at WARN or ERROR level, making it look like tests are failing when they're actually passing. Silence it.
Only console output, no file appenders. Tests should run in seconds and leave no artifacts.
📝 The Testing Checklist
Use this checklist for every microservice:
Unit Tests
- [ ] All service methods tested with Mockito
- [ ] Happy paths AND sad paths covered
- [ ]
verify()used to assert interactions, not just results - [ ]
never()used to assert what DIDN'T happen - [ ] TestDataFactory used for all test objects
- [ ] Tests run in milliseconds (no Spring context)
Integration Tests
- [ ]
@WebMvcTestfor controller tests (not@SpringBootTest) - [ ] HTTP contracts verified (status codes, JSON paths)
- [ ] Validation tested at the HTTP layer
- [ ]
@ActiveProfiles("test")on all test classes - [ ]
TestSecurityConfigimported to isolate auth
Architecture Tests
- [ ] Layer dependencies enforced with ArchUnit
- [ ] Naming conventions enforced
- [ ] Annotation requirements verified
- [ ] No field injection allowed
- [ ] Constructor injection required for services
Coverage
- [ ] JaCoCo gate at 80% minimum
- [ ] DTOs, models, config excluded from coverage
- [ ] Same exclusions in report and check goals
- [ ] Coverage report uploaded to CI artifacts
🎓 Conclusion: Building Confidence, Not Just Coverage
Remember those 95%-coverage codebases that still break in production? The difference is what you test. Here's what makes this testing strategy work:
-
Build a pyramid — Many fast unit tests (Mockito), fewer focused integration tests (MockMvc), and a handful of architecture tests (ArchUnit).
-
Unit tests are about behavior, not coverage — Test what the method does, what it calls, and what it doesn't call.
Theverify(repo, never()).save(any())pattern is your friend. -
@WebMvcTestover@SpringBootTest— Faster startup, better isolation, proves your layers are decoupled. Only load what you need. -
Architecture tests prevent drift — 13 ArchUnit rules enforce layer boundaries, naming conventions, and injection patterns. They're your automated architecture review.
-
Centralize test data —
TestDataFactorywith builders gives you realistic defaults and easy customization. One place to update when DTOs change. -
Isolate security in tests —
TestSecurityConfiglets business tests focus on business logic. Test security separately. -
Coverage gates need smart exclusions — 80% on services and controllers is meaningful. 80% including DTOs and config is vanity.
-
Name tests as specifications —
methodName_condition_expectedResultreads like documentation. When it fails, you know exactly what broke. -
Quiet test profiles — Set expected-exception loggers to OFF. Console-only output. No file artifacts.
-
The CI pipeline enforces everything —
mvn clean verifyruns tests, checks coverage, and fails fast. No manual steps, no "forgot to run tests."
Coming Next Week:
Part 12: Ship It - Containerization with Docker and Docker Compose 🐳
📚 Series Progress
✅ Part 1: The Blueprint Before the Build
✅ Part 2: Spring Boot Alchemy
✅ Part 3: REST Assured
✅ Part 4: The Data Foundation
✅ Part 5: When the World Breaks
✅ Part 6: Cache Me If You Can
✅ Part 7: Guarding the Gates
✅ Part 8: Fail Gracefully
✅ Part 9: 10,000 Threads and a Dream
✅ Part 10: Can You See Me Now?
✅ Part 11: Trust, But Verify ← You just finished this!
⬜ Part 12: Ship It
⬜ Part 13: To Production and Beyond
Happy testing, and may your green bar always stay green. ☕