Salta al contenuto principale

Testing completo in Clean Architecture: dalle unit del domain all'integrazione

Come provare ogni layer di Clean Architecture: domain senza doppi, use case con mock o fake, adapter su PostgreSQL vero, gate ArchUnit con baseline congelata.

Cosa ne pensi di questo articolo?

Testing completo in Clean Architecture: dalle unit del domain all'integrazione
Clean ArchitectureFoto:RDNE Stock projectPexels

In Breve

Come provare ogni layer di Clean Architecture: domain senza doppi, use case con mock o fake, adapter su PostgreSQL vero, gate ArchUnit con baseline congelata.

Hai una suite di test che si rompe a ogni refactoring, oppure una build lenta perché ogni test avvia Spring e un database. Nei due casi la causa è la stessa: i test non seguono la forma del codice. Clean Architecture ti offre il criterio per allinearli, perché ogni layer ha un confine diverso e quindi un modo diverso di essere provato: il domain senza doppi, i use case con doppi per i soli port, gli adapter con un database vero. Qui lo vedi su un caso (la registrazione di un utente), con test che ho eseguito, compresi due che dimostrano dove i mock non bastano.

La piramide: pochi test lenti sopra, molti test veloci sotto

La «Test Pyramid» è una metafora che invita a raggruppare i test per granularità e a farne molti di più in basso che in alto; l'articolo di riferimento è «The Practical Test Pyramid» di Ham Vocke sul sito di Martin Fowler. Non fissa una percentuale e non ne esiste una giustificata da una fonte: conta la forma, test economici in grande numero alla base, test costosi e rari in cima. Se i test end-to-end diventano la maggioranza, ogni modifica attende minuti prima di dirti se ha rotto qualcosa.

I layer di Clean Architecture dicono dove tagliare la piramide. Alla base ci sono i test del domain: oggetti puri, nessun I/O, nessun doppio. A metà ci sono i test dei use case, in cui i port sono sostituiti da doppi e si verifica l'orchestrazione. In cima ci sono i test degli adapter contro un database vero e, se servono, pochi test end-to-end sul percorso principale. La struttura per layer non decide quanti test scrivere, decide dove sta il confine tra ciò che puoi sostituire e ciò che devi provare davvero.

I tipi di supporto

I test di questo articolo usano il dominio dell'articolo sull'infrastructure (utente, email, ruolo, port del repository) più qualche tipo per la registrazione. Per compilare i listati senza altro materiale li riporto qui per intero: sono pochi record, due interfacce e tre eccezioni.

public record UserId(String value) {
    public UserId {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("l'id utente non può essere vuoto");
        }
    }
}

public record Email(String value) {
    public Email {
        if (value == null || !value.contains("@")) {
            throw new IllegalArgumentException("email non valida: " + 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 già registrata: " + email.value()); }
}

/** Lanciata dall'infrastructure quando il vincolo UNIQUE del database scatta. */
public class DuplicateEmailException extends RuntimeException {
    public DuplicateEmailException(String email) { super("vincolo unico violato: " + email); }
}

DuplicateEmailException è l'eccezione che l'infrastructure lancia quando il vincolo UNIQUE del database scatta; UserAlreadyExistsException è quella di dominio in cui il use case la traduce. Il resto è quanto basta perché i listati seguenti compilino insieme.

Livello 1: il domain si prova senza doppi

La regola di sicurezza sulla lunghezza della password è una regola di registrazione: vale nel momento in cui qualcuno sceglie una password in chiaro, e non ha senso su un utente già salvato, che conserva solo l'hash. Per questo vive in un oggetto a sé, PasswordPolicy, e non come metodo di User. Provarla non richiede nulla: si istanzia, si chiama, si asserisce.

public record PasswordPolicy(int minLength) {

    public void check(String plainPassword) {
        if (plainPassword == null || plainPassword.length() < minLength) {
            throw new WeakPasswordException("servono almeno " + minLength + " caratteri");
        }
    }
}

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 accetta_la_lunghezza_minima() {
        assertThatCode(() -> policy.check("123456789012")).doesNotThrowAnyException();
    }

    @Test
    void rifiuta_un_carattere_in_meno() {
        assertThatThrownBy(() -> policy.check("12345678901"))
            .isInstanceOf(WeakPasswordException.class)
            .hasMessageContaining("12");
    }

    @Test
    void rifiuta_null() {
        assertThatThrownBy(() -> policy.check(null)).isInstanceOf(WeakPasswordException.class);
    }
}

I tre test coprono i casi che fanno sbagliare le soglie: esattamente la lunghezza minima, un carattere in meno, e null. Sono i confini, non i valori comodi nel mezzo. Non hanno nulla di lento da avviare, perché non usano né Spring né Mockito, e sono la ragione per cui il domain puro è il posto più economico per mettere le regole che cambiano più spesso.

Livello 2: il use case, con un mock o con un fake

Il use case coordina tre cose: la policy, il repository e l'encoder. Ecco la versione che useremo, con le dipendenze come port. Nota la riga che cattura DuplicateEmailException: la spiego più avanti, perché è la parte che i mock non possono provare.

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());
    }
}

Un doppio di test è un oggetto che prende il posto di una dipendenza. Fowler, nel saggio «Mocks Aren't Stubs», riprende da Gerard Meszaros cinque categorie, che conviene distinguere perché i nomi si usano a sproposito.

  • Dummy: passato per riempire un parametro, mai usato.
  • Fake: ha un'implementazione funzionante ma semplificata, come un database in memoria.
  • Stub: restituisce risposte già decise, senza reagire ad altro.
  • Spy: uno stub che registra come è stato chiamato.
  • Mock: è programmato con aspettative sulle chiamate che deve ricevere, ed è l'unico che impone una verifica del comportamento.

La distinzione che pesa è l'ultima frase di Fowler: solo i mock verificano il comportamento (chi ha chiamato che cosa), gli altri verificano lo stato (che cosa è rimasto dopo). La stessa prova si può scrivere in entrambi i modi, e la differenza si vede quando il codice cambia. Questo è il test con Mockito 5.14.2, che stubba findByEmail e verifica le chiamate a 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 registra_un_utente_nuovo() {
        when(users.findByEmail(new Email("giulia@example.com"))).thenReturn(Optional.empty());
        when(encoder.encode("una-password-lunga")).thenReturn("hash");

        RegisterUserResponse response =
            useCase.execute(new RegisterUserRequest("giulia@example.com", "una-password-lunga"));

        assertThat(response.email()).isEqualTo("giulia@example.com");
        verify(users).save(any(User.class));
    }

    @Test
    void rifiuta_un_duplicato_senza_salvare() {
        User esistente = 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(esistente));

        assertThatThrownBy(() ->
            useCase.execute(new RegisterUserRequest("giulia@example.com", "una-password-lunga")))
            .isInstanceOf(UserAlreadyExistsException.class);
        verify(users, never()).save(any());
    }

    @Test
    void password_debole_non_tocca_il_repository() {
        assertThatThrownBy(() -> useCase.execute(new RegisterUserRequest("giulia@example.com", "corta")))
            .isInstanceOf(WeakPasswordException.class);
        verifyNoInteractions(users);
    }
}

I tre test mostrano usi sensati dei mock: il primo conferma che save è stato chiamato, il secondo che con un duplicato save non viene mai chiamato (never()), il terzo che con una password debole il repository non viene nemmeno interrogato (verifyNoInteractions). In tutti e tre il risultato che interessa è proprio «non è stato toccato» o «è stato toccato», quindi la verifica di comportamento è la più naturale. Lo stesso use case con un fake in memoria dà test che guardano lo stato.

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 registra_un_utente_nuovo() {
        useCase.execute(new RegisterUserRequest("giulia@example.com", "una-password-lunga"));

        assertThat(users.all()).hasSize(1);
        assertThat(users.all().get(0).passwordHash()).isEqualTo("hash:una-password-lunga");
    }

    @Test
    void rifiuta_un_duplicato_senza_salvare() {
        useCase.execute(new RegisterUserRequest("giulia@example.com", "una-password-lunga"));

        assertThatThrownBy(() ->
            useCase.execute(new RegisterUserRequest("giulia@example.com", "un-altra-password")))
            .isInstanceOf(UserAlreadyExistsException.class);
        assertThat(users.all()).hasSize(1);
    }
}

// Output:
// ├─ JUnit Jupiter ✔
// │  ├─ PasswordPolicyTest ✔
// │  │  ├─ rifiuta_null() ✔
// │  │  ├─ rifiuta_un_carattere_in_meno() ✔
// │  │  └─ accetta_la_lunghezza_minima() ✔
// │  ├─ RegisterUserMockitoTest ✔
// │  │  ├─ rifiuta_un_duplicato_senza_salvare() ✔
// │  │  ├─ registra_un_utente_nuovo() ✔
// │  │  └─ password_debole_non_tocca_il_repository() ✔
// │  └─ RegisterUserFakeTest ✔
// │     ├─ rifiuta_un_duplicato_senza_salvare() ✔
// │     └─ registra_un_utente_nuovo() ✔
// 8 tests successful
// 0 tests failed

L'uscita in coda al blocco è quella dell'esecuzione delle tre classi di test viste finora, otto test in tutto, tutti verdi. InMemoryUsers è il fake: una lista con la logica minima per cercare per email e sovrascrivere per id. I due test chiedono quanti utenti ci sono dopo l'operazione e che hash è stato salvato. Il vantaggio non è nella brevità, ma nel fatto che questi test non nominano mai un metodo del repository, e quindi sopravvivono a cambiamenti nel modo in cui il use case lo usa. Lo mostra la prova seguente.

Immagina un refactoring in cui il use case salva l'utente e poi lo salva una seconda volta con i dati definitivi (RegisterUserTwoStepUseCase, riportato qui sotto). Il risultato per il chiamante è identico: un utente registrato. Ho scritto due test su questa variante, uno con verify(users, times(1)).save(...) e uno che guarda lo stato del fake.

/** Variante dopo un refactoring: salva l'utente e poi lo salva di nuovo con il ruolo definitivo. */
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 verifica_quante_volte_si_chiama_save() {
        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", "una-password-lunga"));

        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 verifica_lo_stato_finale() {
        InMemoryUsers users = new InMemoryUsers();
        var useCase = new RegisterUserTwoStepUseCase(users, plain -> "hash", new PasswordPolicy(12));

        useCase.execute(new RegisterUserRequest("giulia@example.com", "una-password-lunga"));

        assertThat(users.all()).hasSize(1);
    }
}

// Output:
// ├─ JUnit Jupiter ✔
// │  ├─ FragileVerifyTest ✔
// │  │  └─ verifica_quante_volte_si_chiama_save() ✘
// │  │           userRepository.save(<any User>);
// │  │           Wanted 1 time:
// │  │           -> at FragileVerifyTest.verifica_quante_volte_si_chiama_save(FragileVerifyTest.java:17)
// │  │           But was 2 times:
// │  │           -> at RegisterUserTwoStepUseCase.execute(RegisterUserTwoStepUseCase.java:22)
// │  │           -> at RegisterUserTwoStepUseCase.execute(RegisterUserTwoStepUseCase.java:23)
// │  └─ BehaviourTest ✔
// │     └─ verifica_lo_stato_finale() ✔
// └─ JUnit Vintage ✔

Il test sul comportamento si rompe, e il messaggio di Mockito è chiaro («Wanted 1 time ... But was 2 times»), mentre il test sullo stato continua a passare perché l'utente registrato è uno. Nessuno dei due ha torto in assoluto: il primo conferma un dettaglio di implementazione, il secondo il risultato per il chiamante. La regola pratica che ne ricavo è di usare verify quando l'interazione è il requisito (non salvare un duplicato, non toccare il repository con input invalido) e un fake quando ti interessa il risultato.

Cosa non vedono i mock: la corsa tra due richieste

Il use case controlla se l'email esiste e poi salva. Tra i due passi c'è una finestra: due richieste simultanee possono leggere entrambe «libera» e salvare entrambe. Nessun test con un mock di findByEmail lo scopre, perché il mock risponde sempre allo stesso modo. Ho costruito un fake che forza l'intreccio con una barriera (CyclicBarrier), così entrambe le richieste leggono prima che una scriva, e l'ho usato in due varianti: senza vincolo di unicità e con un vincolo che imita il UNIQUE del database.

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;

/**
 * Repository finto che costringe due richieste a leggere "email libera" prima che
 * una delle due scriva: è l'intreccio che in produzione capita di rado, qui è forzato.
 */
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", "una-password-lunga"));
                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 senza_vincolo_unico_entrambe_riescono() throws Exception {
        RacyUsers users = new RacyUsers(false);
        int[] esito = registerTwiceConcurrently(users);

        System.out.println("senza vincolo: riuscite=" + esito[0] + " rifiutate=" + esito[1]
            + " utenti salvati=" + users.count());
        assertThat(users.count()).isEqualTo(2);
    }

    @Test
    void con_vincolo_unico_una_sola_riesce() throws Exception {
        RacyUsers users = new RacyUsers(true);
        int[] esito = registerTwiceConcurrently(users);

        System.out.println("con vincolo: riuscite=" + esito[0] + " rifiutate=" + esito[1]
            + " utenti salvati=" + users.count());
        assertThat(users.count()).isEqualTo(1);
        assertThat(esito).containsExactly(1, 1);
    }
}

// Output:
// senza vincolo: riuscite=2 rifiutate=0 utenti salvati=2
// con vincolo: riuscite=1 rifiutate=1 utenti salvati=1

Senza vincolo si salvano due utenti con la stessa email, anche se il use case ha fatto il suo controllo. Con il vincolo, una delle due scritture fallisce con DuplicateEmailException e il use case la traduce in UserAlreadyExistsException: è la riga catch che avevamo visto. La lezione non è nel fake, che è un'imitazione, ma nel confine: la garanzia di unicità appartiene al database, e il use case deve solo tradurre l'errore. Per sapere che il database la dà davvero serve il livello successivo.

Livello 3: gli adapter si provano con un database vero

Un database in memoria come H2 può comportarsi diversamente da PostgreSQL su tipi, funzioni e vincoli, quindi un test verde su H2 non dimostra che la stessa query funzioni su PostgreSQL. Testcontainers avvia un container PostgreSQL per la durata dei test. Il blocco seguente è uno schema da adattare: richiede Docker e Spring Boot, che qui non ho potuto usare, quindi non l'ho eseguito.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserJpaAdapterIT {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    JpaUserRepository jpa;

    @Test
    void la_stessa_email_due_volte_viola_il_vincolo() {
        jpa.saveAndFlush(entity("u-1", "giulia@example.com"));

        assertThatThrownBy(() -> jpa.saveAndFlush(entity("u-2", "giulia@example.com")))
            .isInstanceOf(DataIntegrityViolationException.class);
    }
}

Due scelte contano. La prima è @DataJpaTest al posto di @SpringBootTest: carica solo la parte JPA invece dell'intera applicazione, ed è transazionale con rollback finale per default, come dice la documentazione di Spring Boot. La seconda è @ServiceConnection, che fa collegare Boot al container senza un metodo @DynamicPropertySource. Perché il test usi PostgreSQL vero e non un eventuale database embedded presente nel classpath serve @AutoConfigureTestDatabase(replace = NONE). Il test scrive la stessa email due volte e si aspetta DataIntegrityViolationException: è il controllo che il vincolo esista davvero nello schema, e che le migration Flyway lo creino.

L'isolamento tra test non è automatico quando il test non è transazionale. Il container è condiviso dalla classe, e se due test inseriscono la stessa email il risultato dipende dall'ordine di esecuzione. Usa un rollback (@DataJpaTest lo fa da solo, un test @SpringBootTest richiede @Transactional esplicito) oppure ripulisci le tabelle prima di ogni test.

Il backend di questo sito: un container per tutta la suite

Con più classi di integrazione che condividono lo stesso contesto Spring, un container dichiarato static con @Container si riavvia per ogni sottoclasse, mentre Spring riusa il contesto già creato, ancora legato alla porta del container precedente: il risultato è una connessione rifiutata. Il backend lo evita con una classe base astratta che avvia i container una sola volta nell'inizializzatore statico, come spiega il Javadoc di IntegrationTestBase. Ecco lo schema semplificato (il file reale usa l'immagine postgis/postgis:16-3.5 per il PostGIS usato dal progetto e avvia anche un container Redis).

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class IntegrationTestBase {

    static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
        POSTGRES.start();   // una volta per JVM; Ryuk lo ferma alla chiusura
    }

    @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);
    }
}

Il prezzo di questo singleton container pattern è l'isolamento: tutti i test condividono lo stesso database, quindi la pulizia dei dati diventa responsabilità di ogni test. Il guadagno è la velocità, perché PostgreSQL parte una volta per esecuzione e non una per classe. Nel backend, misurati il 2026-11-12 con grep -rl: 135 file di test estendono IntegrationTestBase e 105 file portano @Tag("integration"). I due numeri non coincidono, quindi non tutti i test d'integrazione sono selezionabili con mvn test -Dgroups="integration": è un'incoerenza da conoscere prima di fidarsi di quel filtro.

Il gate architetturale con baseline congelata

ArchUnit controlla le dipendenze fra package, e la tecnica del FreezingArchRule serve quando introduci una regola in un codice che già la viola: le violazioni esistenti vengono registrate in un archivio e tollerate, quelle nuove fanno fallire il test. Nel backend, CleanArchitectureTest congela due regole globali (presentation che non dipende da infrastructure, domain senza Spring né JPA). Ho riprodotto il meccanismo con un progetto minimo di tre classi: app.infrastructure.Repo (vuota), app.domain.Old con il campo Repo repo = new Repo(); e, dal terzo giro, app.domain.New, identica a Old. Nel classpath c'è un archunit.properties con freeze.store.default.path=store, freeze.store.default.allowStoreCreation=true e freeze.store.default.allowStoreUpdate=true. L'ho eseguito quattro volte, per vedere con i miei occhi che cosa succede: una classe del domain che viola la regola, poi una seconda classe che la viola, poi un cambio del testo passato a .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("PASSA");
        } catch (AssertionError e) {
            System.out.println("FALLISCE");
            e.getMessage().lines().limit(4).forEach(l -> System.out.println("  " + l));
        }
    }
}

// Output:
// run 1 (Old.java viola, archivio vuoto): PASSA
// run 2 (stesso codice): PASSA
// run 3 (aggiunta New.java con la stessa violazione):
// FALLISCE
//   Architecture Violation [Priority: MEDIUM] - Rule 'il domain non dipende dall'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 presente, testo di .as(...) cambiato): PASSA

Le etichette «run N» sono mie, le altre righe sono l'uscita stampata. Nella prima e nella seconda esecuzione il test passa: la violazione già presente è stata registrata nell'archivio al primo giro e tollerata. Nella terza, l'aggiunta di New.java fa fallire il test, e il messaggio riporta solo le violazioni di New: quella vecchia non compare. Nella quarta, con lo stesso codice e un testo diverso in .as(...), il test passa di nuovo anche se New viola la regola. Il testo della regola è la chiave dell'archivio: cambiarlo azzera la baseline senza avvisare, e il progetto ha messo un commento di avvertimento sopra ciascuno dei due test.

L'archivio del backend (backend/src/test/resources/archunit-store) contiene oggi 224 righe di violazione per la regola presentation-infrastructure, riferite a una trentina di classi, e 6 righe per la regola del domain, riferite a 3 classi (i conteggi delle classi sono una mia stima con un'espressione regolare sul file; il Javadoc del test dice «circa 30 classi» per la prima regola). La chiave di quest'ultima dice ancora «4 violazioni storiche»: è testo fisso e non un contatore, quindi non va letto come stato attuale. Lo stesso test usa metodi @Test ordinari invece del campo @ArchTest, per il motivo spiegato in Infrastructure layer: JPA e Redis, e il caso di un gate ArchUnit che gira davvero è in Il gate ArchUnit che gira davvero.

Copertura: una soglia dove l'errore costa

Una soglia globale di copertura si raggiunge anche con test che eseguono le righe senza verificare nulla. Nel pom.xml del backend il controllo JaCoCo è limitato ai package di un singolo dominio (partita IVA, nel file reale con altre due inclusioni che qui ho tolto), con un minimo dell'80% sulle istruzioni.

<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>

Il commento nel file dice che il gate era atteso rosso finché le fasi di implementazione non avessero coperto i package. Il resto del codice non ha nessun minimo: la scelta concentra lo sforzo dove un errore costa di più (fatturazione, dati personali), al prezzo che la disciplina nel resto dipende dalla revisione del codice. Le soglie per layer del tipo 95/85/70 che si leggono spesso non hanno una fonte e non sono una misura di questo progetto: se ne vuoi una, deriva dal rischio del layer, non da una tabella.

Domande che si fanno spesso

Devo mockare DataSource ed EntityManager?

No. Sono dettagli del framework: o provi l'adapter con un database vero, o provi il use case con un fake del port. Un test che stubba l'EntityManager verifica come hai scritto il codice, non che la query funzioni.

I test con Testcontainers rallentano la build?

Sì, e il compromesso è dichiarare i test lenti con @Tag("integration") e selezionarli con mvn test -Dgroups=integration oppure escluderli con -DexcludedGroups=integration. Nel backend di questo sito 105 file portano il tag mentre 135 estendono la classe base che avvia i container: i due insiemi non coincidono, quindi escludere il tag non esclude con certezza tutti i test che avviano un database.

Quando mi serve davvero un mock invece di un fake?

Quando la chiamata è il requisito: un servizio di pagamento che deve essere invocato una sola volta, un invio di email che non deve partire in caso di errore. Per queste verifiche verify è lo strumento giusto. Per tutto il resto, un fake si rompe meno al refactoring. Come nasce il use case che stai provando è in Use Case Pattern in Spring Boot Clean Architecture, e l'integrazione con database reali è approfondita in Testing Spring Boot con Testcontainers. Il quadro dei quattro layer a cui questi test si riferiscono è nel pilastro della serie.

Che cosa sai fare adesso

Sai scegliere per ogni layer il tipo di test giusto: domain senza doppi, use case con doppi per i soli port, adapter con database vero. Sai decidere tra un mock e un fake in base a ciò che vuoi verificare (comportamento o stato) e sai che una corsa tra due richieste si scopre con un vincolo del database, non con un mock. Sai anche introdurre una regola di architettura in un codice che già la viola, e sai quale trappola, il testo di .as(...), azzera in silenzio la tua baseline.

Fonti

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti