Todos os artigos

// Knowledge.log — 技術記事

@Transactional no teste de integração: o rollback que esconde o commit

Rollback automático no teste Spring Boot 4.1 deixou passar um SKU duplicado que o commit rejeita com 409. Onde ele isola e quando limpar explicitamente.

Quase todo guia interno de testes Spring tem aquele trecho que alguém copiou há anos e ninguém mais questiona: @SpringBootTest em cima, @Transactional logo abaixo, e um comentário garantindo que o banco volta limpo no fim de cada teste. A suíte fica verde, rápida, e ninguém escreve limpeza de dados.

A regra que circula é esta: "anota o teste de integração com @Transactional; o rollback automático limpa o banco e isola, então não precisa limpar nada". É ela que vamos julgar. Ela não é invenção de blog. Vem da documentação oficial e funciona bem em vários cenários. O problema é que o rollback desfaz exatamente o momento que a produção mais usa, que é o commit. E, com servidor em porta aleatória, ele deixa de isolar o que diz isolar.

No fim, você vai ter dois falsos verdes reproduzidos com saída real, o teste alternativo que chega ao commit e um critério para decidir em quais classes da sua suíte o @Transactional pode ficar.

O que rodou e com quais versões

O experimento é um projeto Maven pequeno, executado em 28/09/2026 com Spring Boot 4.1.1 (a estável atual), Spring Framework 7.0.9, Hibernate ORM 7.4.5.Final, H2 2.4.240 em memória, JUnit Jupiter 6.0.3, Temurin JDK 25.0.4 LTS e Maven 3.9.12. A página de requisitos do Spring Boot diz que o 4.1.1 exige no mínimo Java 17 e é compatível até o Java 26, então o JDK 25 está dentro da matriz.

O resultado final do mvn test foi Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 e BUILD SUCCESS. Todos verdes, inclusive os que existem para mostrar que o verde não quer dizer muita coisa.

O cliente HTTP dos testes é o RestTestClient, do Spring Framework 7, apontado para a porta aleatória. No Boot 4.1 o TestRestTemplate saiu do spring-boot-starter-test e foi para o módulo opcional spring-boot-resttestclient (org.springframework.boot.resttestclient.TestRestTemplate, com @AutoConfigureTestRestTemplate). Se o seu guia interno ainda importa o pacote antigo, ele vai quebrar na compilação antes de chegar a qualquer rollback.

De onde vem a tese

A tese tem recibo, e ele é oficial. A documentação de transações do TestContext Framework diz:

> "Annotating a test method with @Transactional causes the test to be run within a transaction that is, by default, automatically rolled back after completion of the test."

E, no exemplo que acompanha a explicação:

> "there is no need to clean up the database after the createUser() method runs, since any changes made to the database are automatically rolled back by the TransactionalTestExecutionListener."

A documentação da anotação @Rollback confirma o default: "Rollback for integration tests in the Spring TestContext Framework defaults to true even if @Rollback is not explicitly declared." A documentação de testes do Boot 4.1, na parte das fatias, completa: "By default, data JPA tests are transactional and roll back at the end of each test."

Fora da documentação oficial, a ideia se espalhou do jeito de sempre. Um texto de Marco Behler de 2014 resume o arranjo: "every test method in your suite is surrounded by an overarching Spring transaction. This transaction will be rolled back at the end of the test method regardless of it's outcome." É uma descrição correta do mecanismo, e foi essa descrição que virou regra de copiar e colar. Um post de Philip Riecks de 2025 já faz a distinção que o guia interno costuma perder: "in slice tests, rollback is automatic with @Transactional; in full integration tests, we must take responsibility for our test data cleanup."

O aviso que vem na mesma documentação

O detalhe que quase ninguém copia está nas mesmas páginas. A documentação de testes do Spring Boot 4.1 diz:

> "If your test is @Transactional, it rolls back the transaction at the end of each test method by default. However, as using this arrangement with either RANDOM_PORT or DEFINED_PORT implicitly provides a real servlet environment, the HTTP client and server run in separate threads and, thus, in separate transactions. Any transaction initiated on the server does not roll back in this case."

E a página de transações do Framework explica o porquê: o suporte a testes "binds transaction state to the current thread (via a java.lang.ThreadLocal variable)". O que roda em outra thread fica fora da transação gerenciada pelo teste e, nas palavras da própria documentação, "such actions will be committed to the persistent store".

Ou seja, a documentação que prescreve o rollback também diz onde ele para de valer. O guia interno copiou só a primeira metade.

O caminho de produção

A aplicação tem um Product com gerador SEQUENCE e uma constraint única em sku:

@Entity
@Table(name = "products", uniqueConstraints = @UniqueConstraint(name = "uk_products_sku", columnNames = "sku"))
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
    @SequenceGenerator(name = "product_seq", sequenceName = "product_seq", allocationSize = 50)
    private Long id;

    @Column(name = "sku", nullable = false, length = 40)
    private String sku;

    @Column(name = "name", nullable = false, length = 120)
    private String name;

    // ... construtores e getters omitidos
}

O POST /products chama um serviço transacional. Essa transação roda na thread HTTP e faz commit quando o método retorna:

@Service
public class ProductService {

    private final ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public Product create(String sku, String name) {
        return repository.save(new Product(sku, name));
    }
}

Quando o banco recusa, um advice converte a exceção em 409 com a mensagem do driver:

@RestControllerAdvice
public class CatalogErrorAdvice {

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<Map<String, String>> duplicate(DataIntegrityViolationException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(Map.of("error", "constraint_violation", "detail", rootMessage(ex)));
    }

    // rootMessage percorre getCause() até a raiz e devolve "Classe: mensagem"
}

Para ver o que chega ao banco e em que momento, o application.properties liga o SQL e os binds:

spring.datasource.generate-unique-name=true
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
logging.level.org.hibernate.orm.jdbc.bind=trace

Falso verde nº 1: o INSERT que nunca chegou ao H2

A classe de teste segue o padrão do guia interno: RANDOM_PORT, @Transactional na classe e métodos ordenados de propósito para montar o cenário.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class RollbackHidesDuplicateTest {

    static final String SKU = "SKU-DUP";

    @LocalServerPort
    int port;

    @Autowired
    ProductRepository repository;

    private RestTestClient client;

    @BeforeEach
    void setUp() {
        client = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
    }

    @Test
    @Order(1)
    void committedRowExists() {
        client.post().uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("sku", SKU, "name", "Camiseta DevDojo"))
                .exchange()
                .expectStatus().isCreated();
    }

    @Test
    @Order(2)
    void duplicateInsideTheTestTransactionEndsGreen() {
        Product pending = repository.save(new Product(SKU, "Camiseta duplicada"));
        assertThat(pending.getId()).isNotNull();
    }

    @Test
    @Order(3)
    void sameDuplicateThroughTheCommittedPathIsRejected() {
        client.post().uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("sku", SKU, "name", "Camiseta duplicada"))
                .exchange()
                .expectStatus().isEqualTo(409);
    }

    @Test
    @Order(4)
    void flushExposesTheSameViolationImmediately() {
        repository.save(new Product(SKU, "Camiseta duplicada"));
        repository.flush(); // o teste real captura a exceção e registra a causa raiz
    }
}

A ordem 2 é o teste que o time escreveria: salva um produto com um SKU que já existe e confere o id. O log dessa etapa:

[EVIDENCE] order1 POST committed row -> 201 CREATED {"id":1,"sku":"SKU-DUP","name":"Camiseta DevDojo"}
Hibernate: select next value for product_seq
[EVIDENCE] order2 persisted id=2 sku=SKU-DUP with NO exception raised; the rollback discards the pending INSERT

Tem o select next value for product_seq na thread do teste e nenhum insert into products antes de o método terminar. O teste afirma que o id não é nulo. Isso é verdade, e é a única coisa que ele prova.

O mecanismo está no guia do Hibernate 7.4. No modo padrão, AUTO, o flush acontece "prior to committing a Transaction" e antes de queries que se sobrepõem às ações na fila. Com SEQUENCE, o id vem da sequência, então o INSERT pode esperar pelo flush do commit: "This is valid for the SEQUENCE and TABLE identifier generators." Só que o teste não faz commit. Faz rollback. Nada disparou flush, então o INSERT ficou na fila e foi descartado junto com a transação. O H2 nunca viu a linha duplicada, portanto nunca teve motivo para checar a constraint.

O mesmo dado pelo caminho que faz commit

A ordem 3 manda exatamente o mesmo SKU pelo POST, que passa pelo serviço transacional e chega ao commit:

[EVIDENCE] order3 POST committed path -> 409 CONFLICT {"detail":"JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation: \"PUBLIC.UK_PRODUCTS_SKU ... VALUES ( /* 1 */ 'SKU-DUP' )\"; SQL statement:\ninsert into products (name,sku,id) values (?,?,?) [23505-240]","error":"constraint_violation"}

Mesma operação, mesmos dados. A única diferença é que dessa vez houve commit, o Hibernate fez flush e o H2 respondeu 23505.

A violação existe; faltou o flush

A ordem 4 repete o save() dentro da transação do teste e chama repository.flush() logo depois:

[EVIDENCE] order4 flush raised DataIntegrityViolationException | root=JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation: "PUBLIC.UK_PRODUCTS_SKU ... 'SKU-DUP'

O verde da ordem 2 não queria dizer "não há violação". Queria dizer "não houve flush".

Com IDENTITY o falso verde some

A demora no INSERT vem do gerador, não do @Transactional. A entidade Coupon tem a mesma estrutura, mas usa GenerationType.IDENTITY e a constraint uk_coupons_code. O guia do Hibernate avisa que "The IDENTITY generator must execute the insert right after calling persist()", porque o id só existe depois que a linha é inserida. No log, os dois INSERTs rodam na thread principal, dentro da transação do teste, e o segundo é recusado ali mesmo:

Hibernate: insert into coupons (code,id) values (?,default)
[EVIDENCE] order5 first IDENTITY coupon insert executed inside the test transaction
Hibernate: insert into coupons (code,id) values (?,default)
[EVIDENCE] order5 IDENTITY duplicate raised DataIntegrityViolationException | root=JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation: "PUBLIC.UK_COUPONS_CODE ... 'CPN-DUP'

Isso ajuda a entender por que a regra "sobrevive" em tantos projetos. Com entidades IDENTITY, o teste com rollback pega a constraint por acaso. Basta alguém migrar para SEQUENCE com allocationSize para ganhar batch de inserts, e a mesma suíte passa a ficar verde sem exercitar nada disso. Ninguém mexeu no teste, e ele parou de testar a constraint.

Falso verde nº 2: RANDOM_PORT, duas threads, duas transações

A segunda classe deixa a constraint de lado e olha para a isolação:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class RandomPortIsolationTest {

    // port, repository e client como na classe anterior

    @Test
    @Order(1)
    void pendingInsertIsInvisibleToTheServerThread() {
        repository.save(new Product("SKU-PENDING", "Criado na transacao do teste"));
        client.get().uri("/products/{sku}", "SKU-PENDING")
                .exchange()
                .expectStatus().isNotFound();
    }

    @Test
    @Order(2)
    void serverSideWriteIsCommittedByItsOwnThread() {
        client.post().uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("sku", "SKU-SERVER", "name", "Escrito pelo servidor"))
                .exchange()
                .expectStatus().isCreated();
        client.get().uri("/products/{sku}", "SKU-SERVER")
                .exchange()
                .expectStatus().isOk();
    }

    @Test
    @Order(3)
    void rowCommittedByTheServerSurvivesTheTestRollback() {
        client.get().uri("/products/{sku}", "SKU-SERVER")
                .exchange()
                .expectStatus().isOk();
    }
}

O log:

[EVIDENCE] order1 GET SKU-PENDING while pending in the test transaction -> 404 NOT_FOUND
[EVIDENCE] order2 POST 201 | GET SKU-SERVER -> 200 OK (committed by the server thread)
[EVIDENCE] order3 after the rollback of order2 GET SKU-SERVER -> 200 OK (the test rollback did not clean it)

Na ordem 1, o dado que o teste "criou" não existe para a requisição HTTP. Nesta execução há dois motivos juntos. O primeiro é o mesmo do falso verde nº 1: o log não mostra nenhum insert na thread do teste antes do select que o servidor fez na thread o-auto-1-exec-5. O segundo não foi executado aqui, mas está documentado: mesmo com flush, a linha continuaria sem commit, e o H2 roda por padrão em read committed (o log de inicialização mostra Isolation level: READ_COMMITTED). Nesse nível, a documentação do H2 diz que "Dirty reads aren't possible". De um jeito ou de outro, preparar dados no teste para o servidor consumir não funciona com essa configuração.

As ordens 2 e 3 mostram o outro lado. O servidor gravou SKU-SERVER na própria thread e fez commit. O teste da ordem 2 terminou com rollback, e a ordem 3 continua recebendo 200. O rollback do teste não desfez nada do que o servidor escreveu.

A primeira classe já tinha mostrado a mesma coisa sem chamar atenção. O 409 da ordem 3 só aconteceu porque a linha SKU-DUP gravada pelo servidor na ordem 1 sobreviveu ao rollback da ordem 1. A demonstração depende desse vazamento de propósito. Numa suíte real, essa mesma dependência é o teste que passa sozinho e falha quando roda depois de outro.

Tem um agravante. O log mostra um único banner do Spring Boot para as três classes, e os ids continuam de uma classe para a outra (5 e 6 na classe de limpeza, 8 na de porta aleatória). O contexto ficou em cache e foi reaproveitado, então o H2 em memória era o mesmo para todas. O que uma classe deixa commitado, a próxima encontra.

A alternativa executada: sem transação no teste, limpeza explícita

O teste alternativo tira o @Transactional, limpa a tabela antes de cada método e passa pelo commit de propósito:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ExplicitCleanupTest {

    @LocalServerPort
    int port;

    @Autowired
    JdbcTemplate jdbcTemplate;

    private RestTestClient client;

    @BeforeEach
    void cleanDatabase() {
        jdbcTemplate.update("delete from products");
        client = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
    }

    @Test
    @Order(1)
    void committedPathRejectsTheDuplicate() {
        client.post().uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("sku", "SKU-EXPLICIT", "name", "Camiseta"))
                .exchange()
                .expectStatus().isCreated();
        client.post().uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("sku", "SKU-EXPLICIT", "name", "Camiseta"))
                .exchange()
                .expectStatus().isEqualTo(409);
    }

    @Test
    @Order(2)
    void cleanupRunsBeforeTheNextTest() {
        Long rows = jdbcTemplate.queryForObject("select count(*) from products", Long.class);
        assertThat(rows).isZero();
    }
}

O log:

[EVIDENCE] cleanup rows after delete = 0
[EVIDENCE] order1 duplicate POST -> 409 CONFLICT {"detail":"JdbcSQLIntegrityConstraintViolationException: ... 'SKU-EXPLICIT' ... [23505-240]","error":"constraint_violation"}
[EVIDENCE] cleanup rows after delete = 0
[EVIDENCE] order2 rows visible at test start = 0

A duplicata agora falha dentro do teste, pelo mesmo caminho da produção, e cada método começa com a tabela vazia sem depender de rollback. O custo é real: alguém precisa escrever e manter a limpeza. Em troca, o teste passa a verificar o que diz verificar.

As ferramentas e o custo de cada uma

APIO que resolveCusto ou armadilha
repository.deleteAll() em @BeforeEach/@AfterEachLimpeza pelo repositório, sem SQLCom várias entidades relacionadas, você precisa cuidar da ordem das tabelas
JdbcTemplate (delete from ...)Limpeza direta, como no experimentoSQL escrito à mão; a ordem das tabelas por causa das FKs é responsabilidade sua
@Sql com @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)Script em transação própria, "immediately committed" segundo o javadoc; executionPhase aceita BEFORE_TEST_METHOD (padrão) ou AFTER_TEST_METHODMais um arquivo para manter em sincronia com o schema
@Commit / @Rollback(false)O teste transacional faz commit no fim, e o flush aconteceOs dados ficam no banco; a limpeza explícita volta a ser obrigatória
TestTransaction.flagForCommit() + TestTransaction.end()Commit no meio do método, com asserções depoisMesmo custo do @Commit, só que de forma pontual
saveAndFlush / saveAllAndFlushO INSERT sai na hora e a constraint é checada dentro do testeNão faz commit: resolve o falso verde nº 1, mas não o nº 2
@DirtiesContextFecha o contexto e o remove do cacheNão limpa linhas. Serve para contexto sujo, não para dados

A armadilha que pega quem tenta consertar pela metade: se a classe continua @Transactional, o deleteAll() no @BeforeEach roda dentro da transação do teste e é desfeito no rollback, junto com o resto. Ele não apaga nada do que o servidor commitou. Tem duas saídas: tirar o @Transactional da classe (numa fatia que já é transacional por padrão, @Transactional(propagation = Propagation.NOT_SUPPORTED)) ou fazer a limpeza com @Sql em modo ISOLATED.

Para ter observabilidade no teste, deixe spring.jpa.show-sql=true ligado na classe que testa constraints. Se o log não mostra insert into entre o save() e o fim do método, a constraint não foi exercitada, mesmo que o teste esteja verde. Em produção, o 409 com o nome da constraint na resposta, como faz o CatalogErrorAdvice, é o sinal que vale registrar e contar. A duplicata que escapou do teste aparece ali.

Uma ressalva sobre o alcance: tudo isso foi observado num único H2 embarcado, em comportamento funcional. Não há medição de desempenho. Com outro banco, principalmente um que aceite constraints adiadas, repita o experimento antes de generalizar o detalhe do momento em que a violação é acusada.

Quando a tese ainda vale

A regra copiada funciona bem num território definido: testes de repositório e fatias @DataJpaTest, tudo numa única thread, com as asserções feitas dentro da mesma transação. Ali não existe servidor em outra thread gravando por conta própria. O rollback desfaz de verdade tudo o que o teste fez, e "there is no need to clean up the database" é literalmente verdade. Refazer o setup e escrever limpeza à mão nesse cenário só gasta tempo e deixa a suíte mais lenta.

Mesmo nesse território, vale uma regra: se o teste existe para verificar uma constraint, force o flush com saveAndFlush ou TestEntityManager.flush(). Sem isso, com SEQUENCE, você está dependendo de alguma query sobreposta disparar o auto-flush por acaso.

A tese deixa de valer quando entra RANDOM_PORT ou DEFINED_PORT, quando qualquer código roda em outra thread ou quando o teste precisa verificar um comportamento que só aparece no commit.

Recomendação

A DevDojo manteria @Transactional com rollback nas fatias @DataJpaTest e nos testes de repositório de thread única, com flush explícito sempre que o assunto for constraint. Em @SpringBootTest com servidor real, tiraria o @Transactional da classe, limparia explicitamente com JdbcTemplate ou @Sql(ISOLATED) e escreveria pelo menos um teste por constraint crítica passando pelo caminho que faz commit. O grosso da suíte continua nas fatias, e só alguns testes vão até o fim, de ponta a ponta.

Os sinais de que a suíte está confiando no rollback onde ele não chega são concretos: um teste que passa sozinho e falha quando roda depois de outro; um 404 ao buscar via HTTP o dado que o próprio teste acabou de salvar; uma duplicata que aparece como 409 em produção enquanto o teste da constraint continua verde; ou uma troca de IDENTITY para SEQUENCE seguida de uma suíte que ficou "mais estável". Qualquer um deles pede para revisar a classe e decidir a limpeza de forma explícita.

Próximo passo

Procure na suíte as classes que combinam @SpringBootTest(webEnvironment = RANDOM_PORT) com @Transactional. Escolha uma que teste uma constraint, ligue spring.jpa.show-sql=true e rode. Se não aparecer insert into antes do fim do método, troque o save por saveAndFlush ou mova o teste para o caminho com commit e veja se ele continua verde.

javaspring-boottesting

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos