You have a test suite that breaks on every refactoring, or a slow build because every test starts Spring and a database. In both cases the cause is the same: the tests do not follow the shape of the code. Clean Architecture gives you the criterion to line them up, because each layer has a different boundary and therefore a different way of being tested: the domain without doubles, the use cases with doubles for the ports only, the adapters with a real database. Here you see it on a single case (registering a user), with tests I ran, including two that show where mocks are not enough.
The pyramid: few slow tests on top, many fast tests below
The "Test Pyramid" is a metaphor that invites you to group tests by granularity and to write many more at the bottom than at the top; the reference article is "The Practical Test Pyramid" by Ham Vocke on Martin Fowler's site. It sets no percentage, and no percentage is justified by a source: what matters is the shape, many cheap tests at the base, costly and rare tests at the top. If end-to-end tests become the majority, every change waits minutes before telling you whether it broke something.
The layers of Clean Architecture tell you where to cut the pyramid. At the base are the domain tests: pure objects, no I/O, no doubles. In the middle are the use case tests, where the ports are replaced by doubles and you verify the orchestration. At the top are the adapter tests against a real database and, if needed, a few end-to-end tests on the main path. The layer structure does not decide how many tests to write, it decides where the boundary lies between what you can replace and what you must really test.
The supporting types
The tests in this article use the domain from the article on the infrastructure layer (user, email, role, repository port) plus a few types for registration. To compile the listings without any other material I list them here in full: a few records, two interfaces and three exceptions.
public record UserId(String value) {
public UserId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("the user id cannot be empty");
}
}
}
public record Email(String value) {
public Email {
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException("invalid email: " + value);
}
}
}
public enum Role { USER, ADMIN }
public record User(UserId id, Email email, String passwordHash, Role role) { }
import java.util.Optional;
public interface UserRepository {
Optional<User> findByEmail(Email email);
User save(User user);
}
public interface PasswordEncoder {
String encode(String plainPassword);
}
public record RegisterUserRequest(String email, String password) { }
public record RegisterUserResponse(String userId, String email) { }
public class WeakPasswordException extends RuntimeException {
public WeakPasswordException(String message) { super(message); }
}
public class UserAlreadyExistsException extends RuntimeException {
public UserAlreadyExistsException(Email email) { super("email already registered: " + email.value()); }
}
/** Thrown by the infrastructure when the database UNIQUE constraint fires. */
public class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException(String email) { super("unique constraint violated: " + email); }
}
DuplicateEmailException is the exception the infrastructure throws when the database UNIQUE constraint fires; UserAlreadyExistsException is the domain exception the use case translates it into. The rest is just enough for the following listings to compile together.
Level 1: the domain is tested without doubles
The security rule on password length is a registration rule: it applies the moment someone chooses a plaintext password, and makes no sense on a user that is already saved, which keeps only the hash. That is why it lives in its own object, PasswordPolicy, and not as a method of User. Testing it needs nothing: you instantiate, call, assert.
public record PasswordPolicy(int minLength) {
public void check(String plainPassword) {
if (plainPassword == null || plainPassword.length() < minLength) {
throw new WeakPasswordException("at least " + minLength + " characters required");
}
}
}
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class PasswordPolicyTest {
private final PasswordPolicy policy = new PasswordPolicy(12);
@Test
void accepts_the_minimum_length() {
assertThatCode(() -> policy.check("123456789012")).doesNotThrowAnyException();
}
@Test
void rejects_one_character_short() {
assertThatThrownBy(() -> policy.check("12345678901"))
.isInstanceOf(WeakPasswordException.class)
.hasMessageContaining("12");
}
@Test
void rejects_null() {
assertThatThrownBy(() -> policy.check(null)).isInstanceOf(WeakPasswordException.class);
}
}
The three tests cover the cases where thresholds go wrong: exactly the minimum length, one character fewer, and null. They are the boundaries, not the comfortable values in the middle. There is nothing slow to start, because they use neither Spring nor Mockito, and they are the reason the pure domain is the cheapest place to put the rules that change most often.
Level 2: the use case, with a mock or with a fake
The use case coordinates three things: the policy, the repository and the encoder. Here is the version we will use, with the dependencies as ports. Note the line that catches DuplicateEmailException: I explain it further on, because it is the part that mocks cannot test.
public class RegisterUserUseCase {
private final UserRepository users;
private final PasswordEncoder encoder;
private final PasswordPolicy policy;
public RegisterUserUseCase(UserRepository users, PasswordEncoder encoder, PasswordPolicy policy) {
this.users = users;
this.encoder = encoder;
this.policy = policy;
}
public RegisterUserResponse execute(RegisterUserRequest request) {
policy.check(request.password());
Email email = new Email(request.email());
if (users.findByEmail(email).isPresent()) {
throw new UserAlreadyExistsException(email);
}
User user = new User(new UserId("u-" + email.value()), email,
encoder.encode(request.password()), Role.USER);
try {
users.save(user);
} catch (DuplicateEmailException e) {
throw new UserAlreadyExistsException(email);
}
return new RegisterUserResponse(user.id().value(), email.value());
}
}
A test double is an object that takes the place of a dependency. In the essay "Mocks Aren't Stubs", Fowler takes from Gerard Meszaros five categories, which are worth telling apart because the names are used loosely.
- Dummy: passed to fill a parameter, never used.
- Fake: has a working but simplified implementation, such as an in-memory database.
- Stub: returns predetermined answers, without reacting to anything else.
- Spy: a stub that records how it was called.
- Mock: is programmed with expectations about the calls it must receive, and is the only one that enforces a behaviour verification.
The distinction that matters is Fowler's last sentence: only mocks verify behaviour (who called what), the others verify state (what is left afterwards). The same check can be written both ways, and the difference shows when the code changes. This is the test with Mockito 5.14.2, which stubs findByEmail and verifies the calls to save.
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class RegisterUserMockitoTest {
private UserRepository users;
private PasswordEncoder encoder;
private RegisterUserUseCase useCase;
@BeforeEach
void setUp() {
users = mock(UserRepository.class);
encoder = mock(PasswordEncoder.class);
useCase = new RegisterUserUseCase(users, encoder, new PasswordPolicy(12));
}
@Test
void registers_a_new_user() {
when(users.findByEmail(new Email("giulia@example.com"))).thenReturn(Optional.empty());
when(encoder.encode("a-long-password")).thenReturn("hash");
RegisterUserResponse response =
useCase.execute(new RegisterUserRequest("giulia@example.com", "a-long-password"));
assertThat(response.email()).isEqualTo("giulia@example.com");
verify(users).save(any(User.class));
}
@Test
void rejects_a_duplicate_without_saving() {
User existing = new User(new UserId("u-1"), new Email("giulia@example.com"), "h", Role.USER);
when(users.findByEmail(new Email("giulia@example.com"))).thenReturn(Optional.of(existing));
assertThatThrownBy(() ->
useCase.execute(new RegisterUserRequest("giulia@example.com", "a-long-password")))
.isInstanceOf(UserAlreadyExistsException.class);
verify(users, never()).save(any());
}
@Test
void weak_password_does_not_touch_the_repository() {
assertThatThrownBy(() -> useCase.execute(new RegisterUserRequest("giulia@example.com", "short")))
.isInstanceOf(WeakPasswordException.class);
verifyNoInteractions(users);
}
}
The three tests show sensible uses of mocks: the first confirms that save was called, the second that with a duplicate save is never called (never()), the third that with a weak password the repository is not even queried (verifyNoInteractions). In all three the result that matters is precisely "it was not touched" or "it was touched", so the behaviour verification is the most natural. The same use case with an in-memory fake gives tests that look at state.
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class InMemoryUsers implements UserRepository {
private final List<User> saved = new ArrayList<>();
@Override public Optional<User> findByEmail(Email email) {
return saved.stream().filter(u -> u.email().equals(email)).findFirst();
}
@Override public User save(User user) {
saved.removeIf(u -> u.id().equals(user.id()));
saved.add(user);
return user;
}
public List<User> all() { return List.copyOf(saved); }
}
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class RegisterUserFakeTest {
private final InMemoryUsers users = new InMemoryUsers();
private final PasswordEncoder encoder = plain -> "hash:" + plain;
private final RegisterUserUseCase useCase =
new RegisterUserUseCase(users, encoder, new PasswordPolicy(12));
@Test
void registers_a_new_user() {
useCase.execute(new RegisterUserRequest("giulia@example.com", "a-long-password"));
assertThat(users.all()).hasSize(1);
assertThat(users.all().get(0).passwordHash()).isEqualTo("hash:a-long-password");
}
@Test
void rejects_a_duplicate_without_saving() {
useCase.execute(new RegisterUserRequest("giulia@example.com", "a-long-password"));
assertThatThrownBy(() ->
useCase.execute(new RegisterUserRequest("giulia@example.com", "another-password")))
.isInstanceOf(UserAlreadyExistsException.class);
assertThat(users.all()).hasSize(1);
}
}
// Output:
// ├─ JUnit Jupiter ✔
// │ ├─ PasswordPolicyTest ✔
// │ │ ├─ rejects_null() ✔
// │ │ ├─ accepts_the_minimum_length() ✔
// │ │ └─ rejects_one_character_short() ✔
// │ ├─ RegisterUserMockitoTest ✔
// │ │ ├─ weak_password_does_not_touch_the_repository() ✔
// │ │ ├─ rejects_a_duplicate_without_saving() ✔
// │ │ └─ registers_a_new_user() ✔
// │ └─ RegisterUserFakeTest ✔
// │ ├─ rejects_a_duplicate_without_saving() ✔
// │ └─ registers_a_new_user() ✔
// 8 tests successful
// 0 tests failed
The output at the end of the block is that of running the three test classes seen so far, eight tests in all, every one green. InMemoryUsers is the fake: a list with the minimum logic to search by email and overwrite by id. The two tests ask how many users exist after the operation and which hash was saved. The advantage is not brevity, but the fact that these tests never name a repository method, and so they survive changes in how the use case uses it. The next experiment shows it.
Imagine a refactoring in which the use case saves the user and then saves it a second time with the final data (RegisterUserTwoStepUseCase, shown below). The result for the caller is identical: a registered user. I wrote two tests on this variant, one with verify(users, times(1)).save(...) and one that looks at the state of the fake.
/** Variant after a refactoring: it saves the user and then saves it again with the final role. */
public class RegisterUserTwoStepUseCase {
private final UserRepository users;
private final PasswordEncoder encoder;
private final PasswordPolicy policy;
public RegisterUserTwoStepUseCase(UserRepository users, PasswordEncoder encoder, PasswordPolicy policy) {
this.users = users;
this.encoder = encoder;
this.policy = policy;
}
public RegisterUserResponse execute(RegisterUserRequest request) {
policy.check(request.password());
Email email = new Email(request.email());
if (users.findByEmail(email).isPresent()) {
throw new UserAlreadyExistsException(email);
}
User draft = new User(new UserId("u-" + email.value()), email,
encoder.encode(request.password()), Role.USER);
users.save(draft);
users.save(new User(draft.id(), draft.email(), draft.passwordHash(), Role.USER));
return new RegisterUserResponse(draft.id().value(), email.value());
}
}
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class FragileVerifyTest {
@Test
void verifies_how_many_times_save_is_called() {
UserRepository users = mock(UserRepository.class);
when(users.findByEmail(any())).thenReturn(Optional.empty());
var useCase = new RegisterUserTwoStepUseCase(users, plain -> "hash", new PasswordPolicy(12));
useCase.execute(new RegisterUserRequest("giulia@example.com", "a-long-password"));
verify(users, times(1)).save(any(User.class));
}
}
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class BehaviourTest {
@Test
void verifies_the_final_state() {
InMemoryUsers users = new InMemoryUsers();
var useCase = new RegisterUserTwoStepUseCase(users, plain -> "hash", new PasswordPolicy(12));
useCase.execute(new RegisterUserRequest("giulia@example.com", "a-long-password"));
assertThat(users.all()).hasSize(1);
}
}
// Output:
// ├─ JUnit Jupiter ✔
// │ ├─ FragileVerifyTest ✔
// │ │ └─ verifies_how_many_times_save_is_called() ✘
// │ │ userRepository.save(<any User>);
// │ │ Wanted 1 time:
// │ │ -> at FragileVerifyTest.verifies_how_many_times_save_is_called(FragileVerifyTest.java:17)
// │ │ But was 2 times:
// │ │ -> at RegisterUserTwoStepUseCase.execute(RegisterUserTwoStepUseCase.java:22)
// │ │ -> at RegisterUserTwoStepUseCase.execute(RegisterUserTwoStepUseCase.java:23)
// │ └─ BehaviourTest ✔
// │ └─ verifies_the_final_state() ✔
// └─ JUnit Vintage ✔
The behaviour test breaks, and Mockito's message is clear ("Wanted 1 time ... But was 2 times"), while the state test keeps passing because there is one registered user. Neither is wrong in absolute terms: the first confirms an implementation detail, the second the result for the caller. The practical rule I draw from it is to use verify when the interaction is the requirement (do not save a duplicate, do not touch the repository with invalid input) and a fake when you care about the result.
What mocks do not see: the race between two requests
The use case checks whether the email exists and then saves. Between the two steps there is a window: two simultaneous requests can both read "free" and both save. No test with a mock of findByEmail discovers this, because the mock always answers the same way. I built a fake that forces the interleaving with a barrier (CyclicBarrier), so both requests read before either writes, and I used it in two variants: without a uniqueness constraint and with a constraint that imitates the database UNIQUE.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Fake repository that forces two requests to read "email is free" before
* either one writes: in production this interleaving is rare, here it is forced.
*/
public class RacyUsers implements UserRepository {
private final List<User> saved = Collections.synchronizedList(new ArrayList<>());
private final CyclicBarrier bothHaveChecked = new CyclicBarrier(2);
private final boolean uniqueConstraint;
public RacyUsers(boolean uniqueConstraint) { this.uniqueConstraint = uniqueConstraint; }
@Override public Optional<User> findByEmail(Email email) {
Optional<User> found = saved.stream().filter(u -> u.email().equals(email)).findFirst();
try {
bothHaveChecked.await(5, TimeUnit.SECONDS);
} catch (InterruptedException | BrokenBarrierException | TimeoutException e) {
throw new IllegalStateException(e);
}
return found;
}
@Override public synchronized User save(User user) {
if (uniqueConstraint && saved.stream().anyMatch(u -> u.email().equals(user.email()))) {
throw new DuplicateEmailException(user.email().value());
}
saved.add(user);
return user;
}
public int count() { return saved.size(); }
}
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.List;
import org.junit.jupiter.api.Test;
class RaceTest {
private static int[] registerTwiceConcurrently(RacyUsers users) throws Exception {
var useCase = new RegisterUserUseCase(users, plain -> "hash", new PasswordPolicy(12));
Callable<Boolean> attempt = () -> {
try {
useCase.execute(new RegisterUserRequest("giulia@example.com", "a-long-password"));
return true;
} catch (UserAlreadyExistsException e) {
return false;
}
};
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
List<Future<Boolean>> results = pool.invokeAll(List.of(attempt, attempt));
int ok = 0;
for (Future<Boolean> f : results) if (f.get()) ok++;
return new int[] {ok, 2 - ok};
} finally {
pool.shutdown();
}
}
@Test
void without_unique_constraint_both_succeed() throws Exception {
RacyUsers users = new RacyUsers(false);
int[] outcome = registerTwiceConcurrently(users);
System.out.println("without constraint: succeeded=" + outcome[0] + " rejected=" + outcome[1]
+ " users saved=" + users.count());
assertThat(users.count()).isEqualTo(2);
}
@Test
void with_unique_constraint_only_one_succeeds() throws Exception {
RacyUsers users = new RacyUsers(true);
int[] outcome = registerTwiceConcurrently(users);
System.out.println("with constraint: succeeded=" + outcome[0] + " rejected=" + outcome[1]
+ " users saved=" + users.count());
assertThat(users.count()).isEqualTo(1);
assertThat(outcome).containsExactly(1, 1);
}
}
// Output:
// without constraint: succeeded=2 rejected=0 users saved=2
// with constraint: succeeded=1 rejected=1 users saved=1
Without the constraint two users with the same email are saved, even though the use case did its check. With the constraint, one of the two writes fails with DuplicateEmailException and the use case translates it into UserAlreadyExistsException: it is the catch line we saw earlier. The lesson is not in the fake, which is an imitation, but in the boundary: the uniqueness guarantee belongs to the database, and the use case only has to translate the error. To know that the database really provides it you need the next level.
Level 3: adapters are tested with a real database
An in-memory database such as H2 can behave differently from PostgreSQL on types, functions and constraints, so a green test on H2 does not prove that the same query works on PostgreSQL. Testcontainers starts a PostgreSQL container for the duration of the tests. The following block is a schema to adapt: it requires Docker and Spring Boot, which I could not use here, so I did not run it.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserJpaAdapterIT {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
JpaUserRepository jpa;
@Test
void the_same_email_twice_violates_the_constraint() {
jpa.saveAndFlush(entity("u-1", "giulia@example.com"));
assertThatThrownBy(() -> jpa.saveAndFlush(entity("u-2", "giulia@example.com")))
.isInstanceOf(DataIntegrityViolationException.class);
}
}
Two choices matter. The first is @DataJpaTest instead of @SpringBootTest: it loads only the JPA slice instead of the whole application, and it is transactional with a final rollback by default, as the Spring Boot documentation says. The second is @ServiceConnection, which makes Boot connect to the container without a @DynamicPropertySource method. For the test to use the real PostgreSQL and not an embedded database that may be on the classpath you need @AutoConfigureTestDatabase(replace = NONE). The test writes the same email twice and expects DataIntegrityViolationException: it is the check that the constraint really exists in the schema, and that the Flyway migrations create it.
Isolation between tests is not automatic when the test is not transactional. The container is shared by the class, and if two tests insert the same email the result depends on the execution order. Use a rollback (@DataJpaTest does it by itself, a @SpringBootTest test requires an explicit @Transactional) or clean the tables before each test.
This site's backend: one container for the whole suite
With several integration classes sharing the same Spring context, a container declared static with @Container restarts for each subclass, while Spring reuses the context it already created, still bound to the previous container's port: the result is a refused connection. The backend avoids this with an abstract base class that starts the containers once in the static initializer, as the Javadoc of IntegrationTestBase explains. Here is the simplified schema (the real file uses the image postgis/postgis:16-3.5 for the PostGIS the project uses, and also starts a Redis container).
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class IntegrationTestBase {
static final PostgreSQLContainer<?> POSTGRES;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
POSTGRES.start(); // once per JVM; Ryuk stops it on shutdown
}
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
The price of this singleton container pattern is isolation: all tests share the same database, so cleaning the data becomes each test's responsibility. The gain is speed, because PostgreSQL starts once per run and not once per class. In the backend, measured on 2026-11-12 with grep -rl: 135 test files extend IntegrationTestBase and 105 files carry @Tag("integration"). The two numbers do not coincide, so not every integration test can be selected with mvn test -Dgroups="integration": it is an inconsistency to know about before trusting that filter.
The architecture gate with a frozen baseline
ArchUnit checks the dependencies between packages, and the FreezingArchRule technique is for when you introduce a rule into code that already violates it: the existing violations are recorded in a store and tolerated, new ones make the test fail. In the backend, CleanArchitectureTest freezes two global rules (presentation does not depend on infrastructure, domain without Spring or JPA). I reproduced the mechanism with a minimal project of three classes: app.infrastructure.Repo (empty), app.domain.Old with the field Repo repo = new Repo(); and, from the third run, app.domain.New, identical to Old. On the classpath there is an archunit.properties with freeze.store.default.path=store, freeze.store.default.allowStoreCreation=true and freeze.store.default.allowStoreUpdate=true. I ran it four times, to see with my own eyes what happens: a domain class that violates the rule, then a second class that violates it, then a change to the text passed to .as(...).
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.library.freeze.FreezingArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
public class CheckFrozen {
public static void main(String[] args) {
ArchRule rule = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..")
.as(args[0]);
var classes = new ClassFileImporter().importPackages("app");
try {
FreezingArchRule.freeze(rule).check(classes);
System.out.println("PASSES");
} catch (AssertionError e) {
System.out.println("FAILS");
e.getMessage().lines().limit(4).forEach(l -> System.out.println(" " + l));
}
}
}
// Output:
// run 1 (Old.java violates, empty store): PASSES
// run 2 (same code): PASSES
// run 3 (New.java added with the same violation):
// FAILS
// Architecture Violation [Priority: MEDIUM] - Rule 'the domain does not depend on the infrastructure' was violated (2 times):
// Constructor <app.domain.New.<init>()> calls constructor <app.infrastructure.Repo.<init>()> in (New.java:6)
// Field <app.domain.New.repo> has type <app.infrastructure.Repo> in (New.java:0)
// run 4 (New.java present, text of .as(...) changed): PASSES
The "run N" labels are mine, the other lines are the printed output. In the first and second runs the test passes: the violation already present was recorded in the store on the first pass and tolerated. In the third, adding New.java makes the test fail, and the message reports only the violations of New: the old one does not appear. In the fourth, with the same code and a different text in .as(...), the test passes again even though New violates the rule. The text of the rule is the key of the store: changing it resets the baseline without warning, and the project put a warning comment above each of the two tests.
The backend's store (backend/src/test/resources/archunit-store) today holds 224 violation lines for the presentation-infrastructure rule, referring to about thirty classes, and 6 lines for the domain rule, referring to 3 classes (the class counts are my own estimate with a regular expression on the file; the test's Javadoc says "about 30 classes" for the first rule). The key of the latter still says "4 historical violations": it is fixed text and not a counter, so it must not be read as the current state. The same test uses ordinary @Test methods instead of the @ArchTest field, for the reason explained in Infrastructure layer: JPA and Redis, and the case of an ArchUnit gate that really runs is in The ArchUnit gate that actually runs.
Coverage: a threshold where the error costs
A global coverage threshold can be reached even by tests that execute lines without verifying anything. In the backend's pom.xml the JaCoCo check is limited to the packages of a single domain (VAT number, in the real file with two other inclusions that I removed here), with an 80% minimum on instructions.
<execution>
<id>check-partitaiva</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>PACKAGE</element>
<includes>
<include>dev.federicocalo.portfolio.partitaiva.*</include>
</includes>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
The comment in the file says the gate was expected to be red until the implementation phases had covered the packages. The rest of the code has no minimum: the choice concentrates effort where an error costs most (invoicing, personal data), at the price that discipline elsewhere depends on code review. The per-layer thresholds like 95/85/70 that you often read have no source and are not a measure of this project: if you want one, derive it from the layer's risk, not from a table.
Frequently asked questions
Should I mock DataSource and EntityManager?
No. They are framework details: either you test the adapter with a real database, or you test the use case with a fake of the port. A test that stubs the EntityManager verifies how you wrote the code, not that the query works.
Do Testcontainers tests slow down the build?
Yes, and the compromise is to mark the slow tests with @Tag("integration") and select them with mvn test -Dgroups=integration or exclude them with -DexcludedGroups=integration. In this site's backend 105 files carry the tag while 135 extend the base class that starts the containers: the two sets do not coincide, so excluding the tag does not reliably exclude every test that starts a database.
When do I really need a mock instead of a fake?
When the call is the requirement: a payment service that must be invoked exactly once, an email send that must not go out on error. For these checks verify is the right tool. For everything else, a fake breaks less under refactoring. How the use case you are testing comes about is in Use Case Pattern in Spring Boot Clean Architecture, and integration with real databases is covered in Testing Spring Boot with Testcontainers. The picture of the four layers these tests refer to is in the series pillar.
What you can do now
You can choose the right type of test for each layer: domain without doubles, use case with doubles for the ports only, adapter with a real database. You can decide between a mock and a fake based on what you want to verify (behaviour or state), and you know that a race between two requests is discovered by a database constraint, not by a mock. You can also introduce an architecture rule into code that already violates it, and you know which trap, the text of .as(...), silently resets your baseline.
Sources
- The Practical Test Pyramid - Ham Vocke, martinfowler.com - the pyramid metaphor and its lack of fixed percentages; consulted on 2026-11-12
- Mocks Aren't Stubs - Martin Fowler - the five categories of test double and the difference between state and behaviour verification; consulted on 2026-11-12
- Testing Spring Boot Applications - Spring Boot Reference -
@DataJpaTest, transactions with rollback and@AutoConfigureTestDatabase; consulted on 2026-11-12 - Testcontainers - Spring Boot Reference -
@ServiceConnection; consulted on 2026-11-12 - JUnit 5 Quickstart - Testcontainers for Java - static containers shared across the tests of a class; consulted on 2026-11-12
- ArchUnit User Guide - Freezing Arch Rules; consulted on 2026-11-12
- Measurements and readings on this site's backend:
IntegrationTestBase.java,CleanArchitectureTest.java,archunit-store,pom.xmland thegrepcommands reported in the text, 2026-11-12

Commenti
Caricamento commenti...
Sign in to comment