Todos os artigos

// Knowledge.log — 技術記事

Recibo Surefire no coding agent: o testes-ok que não gerou XML

O agente escreve 'testes ok' no chat, mas o CI quebra porque o Maven não rodou. Gate local só aceita TEST-*.xml recente do Surefire com failures=0 e errors=0.

O coding agent termina a tarefa, escreve "testes ok" no chat e você abre o PR. O CI quebra dois minutos depois porque o Maven nunca rodou — ou rodou em outro módulo. Nada no disco provava que a suíte tinha sido executada; havia só uma frase otimista no transcript.

Esse artigo trata de uma coisa simples e chata: exigir um artefato verificável antes de tratar uma tarefa como testada. Não é sobre confiar mais ou menos no agente — é sobre parar de aceitar texto como prova de execução quando existe um arquivo que a própria ferramenta de build já gera para isso. O target/surefire-reports/TEST-*.xml do Maven Surefire é esse arquivo. Se ele não existe, está velho ou mostra falha, a tarefa não está pronta, não importa o que o chat diga.

Todo o código e os números abaixo vêm de uma execução real neste host: Java 25.0.4 Temurin, Maven 3.9.12, maven-surefire-plugin 3.6.0, maven-compiler-plugin 3.16.0 e JUnit BOM/Jupiter 6.1.3. Não uso maven-compiler-plugin 4.0.0-beta-5 porque ainda é beta no Central; 3.16.0 é o último não-beta disponível hoje.

O problema: "testes ok" não é um artefato

Um agente de código pode devolver texto de qualquer jeito, inclusive um resumo alegre da suíte que ele nunca rodou. Para deixar isso concreto, o script abaixo simula esse comportamento — ele não chama o Maven em nenhum momento:

#!/usr/bin/env bash
# Simulates a coding agent that prints a pass in chat without running Maven.
set -euo pipefail
echo "testes ok"
exit 0

Rodando em 2026-09-22T05:20:45Z:

CMD: ./agent_says_ok.sh
testes ok
EXIT=0

Saída limpa, EXIT=0, tudo com cara de sucesso. Só que target/surefire-reports/ continua vazio — não existe TEST-*.xml nenhum para inspecionar. Esse é o recibo falso: uma alegação em texto livre, sem artefato correspondente no filesystem. Não é diferente, na prática, de um humano escrever "rodei aqui, passou" num comentário de PR sem colar o log.

O gate: só aceita o relatório do Surefire, com os atributos oficiais

A correção não é pedir para o agente "ter mais cuidado". É colocar um verificador entre a alegação e a decisão de mesclar, que só aceita um relatório real. O Surefire, quando roda o goal test, grava um TEST-*.xml por classe de teste em target/surefire-reports/ (caminho padrão documentado na referência do plugin e no test-mojo). O elemento raiz testsuite desse XML segue o XSD oficial do relatório, com os atributos obrigatórios tests, errors, skipped e failures, mais um timestamp opcional.

O gate lê exatamente esses atributos — nada inventado:

#!/usr/bin/env python3
"""Local receipt gate: only a recent Surefire TEST-*.xml with failures=0 and errors=0 counts.

Attributes read from the official Surefire XML Report Schema (testsuite):
  tests, errors, skipped, failures (required); timestamp (optional xs:dateTime).
Recency uses file mtime always, plus testsuite/@timestamp when the attribute is present.
"""
...
    for path in files:
        age = now - path.stat().st_mtime
        if age > args.max_age_seconds:
            problems.append(
                f"{path.name}: file mtime age {age:.0f}s exceeds window {args.max_age_seconds}s"
            )
            continue
        root = ET.parse(path).getroot()
        failures = int(root.attrib["failures"])
        errors = int(root.attrib["errors"])
        tests = int(root.attrib["tests"])
        skipped = int(root.attrib["skipped"])
        xml_ts = root.attrib.get("timestamp")
        if failures != 0 or errors != 0:
            problems.append(
                f"{path.name}: testsuite failures={failures} errors={errors} "
                f"tests={tests} skipped={skipped}"
            )
            continue
        print(f"OK {path.name}: tests={tests} failures={failures} errors={errors} skipped={skipped} ...")

A regra é curta: sem TEST-*.xml sob reportsDirectory, falha. Com XML, mas failures ou errors diferente de zero, falha. Com XML válido e zerado, mas mais velho que a janela da tarefa, falha. Só o cruzamento de "existe" + "recente" + "zerado" libera a tarefa.

Rodando o gate depois do agent_says_ok.sh, sem nenhum mvn test ter acontecido:

CMD: check_surefire_receipt.py --reports-dir target/surefire-reports --max-age-seconds 120
FAIL: no TEST-*.xml under .../target/surefire-reports
EXIT=1

O agente disse verde; o gate disse EXIT=1. É exatamente o comportamento que se quer: a alegação em texto não muda o resultado do gate, porque o gate não lê o chat.

Relatório velho no disco também não conta

Um XML existir não basta — ele precisa pertencer à execução atual. Dois casos plantados deixam isso claro.

Caso com XML de duas horas atrás. Plantei um TEST-academy.devdojo.ReceiptTest.xml válido (failures="0" errors="0" tests="1", sem atributo timestamp) com mtime forçado para 2026-09-22 03:20:45Z, enquanto o gate rodou às 05:20:45Z — 7200 segundos de diferença contra uma janela de 120s:

FAIL: TEST-academy.devdojo.ReceiptTest.xml: file mtime age 7200s exceeds window 120s
EXIT=1

Esse é o "relatório de ontem ainda sentado em target/" — passou quando alguém rodou a suíte de manhã, ninguém limpou o diretório, e agora ele fica ali como prova fantasma para qualquer tarefa nova. O gate recusa pelo mtime do arquivo, que sempre está disponível, e usaria também o testsuite/@timestamp do XML se o atributo estivesse presente — o Surefire só grava esse timestamp quando reportTestTimestamp está ligado, e o padrão do plugin é false (documentado no test-mojo).

Caso com XML fresco, mas com falha real. Plantei outro TEST-*.xml com mtime atual, porém failures="1":

FAIL: TEST-academy.devdojo.ReceiptTest.xml: testsuite failures=1 errors=0 tests=1 skipped=0
EXIT=1

Fresco não é sinônimo de verde. É preciso as três coisas juntas: existe, é recente, e failures=0 errors=0.

O que isso não é

Vale separar de um mecanismo parecido que este blog já cobriu, gating de tools no servidor MCP com política execute-verify-stop, publicado em 31/08. Aquele artigo recusa uma chamada tools/call dentro do servidor MCP antes de um side-effect acontecer — o servidor diz "não" no protocolo, com política versionada e auditoria, sem negociar com o modelo.

Aqui não há nada disso. O agente pode nunca ter falado MCP; pode ter rodado num terminal qualquer, sem tool-calling estruturado. O único ponto de verificação é depois: existe um arquivo Surefire no disco, gerado pelo goal test do Maven, com os atributos certos e a idade certa? Um gate na fronteira de uma tool decide o que pode rodar. Este gate decide o que pode ser chamado de "testado" — são dois problemas diferentes que só coincidem em serem checagens locais e determinísticas.

O CI continua rodando Maven

O gate local não substitui o pipeline — ele é uma checagem rápida antes de abrir o PR, para pegar o "esqueci de rodar" ou o "rodei o módulo errado" antes que vire um ciclo de CI perdido. Quem decide de verdade continua sendo o mvn test (ou mvn verify) rodando num checkout limpo no CI, porque só lá se sabe que não existe XML plantado nem cache de módulo desatualizado.

Uma armadilha comum aqui é -DskipTests ou -Dmaven.test.skip=true entrando por engano num profile ou num script de build "para acelerar". Os dois pulam a execução — o segundo pula até a compilação dos testes — e nenhum dos dois produz um recibo confiável; na melhor das hipóteses, não sobra XML nenhum, e o gate falha do jeito certo (caso A). Na pior, sobra o XML da execução anterior parado em target/, e é a checagem de idade que evita ele ser aceito como prova da tarefa atual.

O caso verde de verdade

Para fechar o contraste, a suíte real: uma classe de teste, um assertEquals, nada de exótico.

package academy.devdojo;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

class ReceiptTest {

    @Test
    void onePlusOneIsTwo() {
        assertEquals(2, 1 + 1);
    }
}

Com o POM configurando maven.compiler.release=25, o BOM do JUnit 6.1.3 e o Surefire 3.6.0 — mais reportTestTimestamp=true ligado explicitamente, já que o padrão é false e eu queria o @timestamp opcional presente no XML para este exemplo:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.6.0</version>
  <configuration>
    <reportTestTimestamp>true</reportTestTimestamp>
  </configuration>
</plugin>

Rodando mvn -B test: Surefire 3.6.0 usa o JUnitPlatformProvider (o exemplo oficial de JUnit Platform confirma que essa é hoje a via unificada de execução), reporta Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 e termina com BUILD SUCCESS, EXIT=0. O XML gerado:

<testsuite ... name="academy.devdojo.ReceiptTest" time="0.049"
           timestamp="2026-09-22T05:20:48.951Z"
           tests="1" errors="0" skipped="0" failures="0" flakes="0">
  ...
  <testcase name="onePlusOneIsTwo" classname="academy.devdojo.ReceiptTest"
            time="0.031" timestamp="2026-09-22T05:20:48.970Z"/>
</testsuite>

E o gate, rodando logo depois:

OK TEST-academy.devdojo.ReceiptTest.xml: tests=1 failures=0 errors=0 skipped=0 mtime_age=0.1s timestamp=2026-09-22T05:20:48.951Z
EXIT=0

Quatro execuções, quatro resultados coerentes com o que cada uma deveria provar:

CasoO que rodouResultado
Ascript simula "testes ok" sem Mavenagente EXIT=0, gate EXIT=1
BXML válido plantado, mtime de 2h atrásgate EXIT=1
CXML plantado, failures=1, mtime frescogate EXIT=1
Dmvn -B test realmvn EXIT=0, gate EXIT=0

O único caso que passa é o único em que o Maven de fato rodou, gerou o XML na hora e a suíte fechou sem falha.

Onde isso entra na prática

O gate cabe em qualquer ponto onde uma tarefa de agente é marcada como concluída antes de virar PR: um hook local, um passo de pre-commit, ou um script que o próprio fluxo do agente chama antes de reportar sucesso. Ele não precisa de infraestrutura nova — é um script Python lendo um XML que o Maven já produz. A observabilidade de produção continua sendo a mesma de sempre: o mvn test/mvn verify do CI rodando em checkout limpo, com seu relatório de build arquivado, que é a fonte de verdade final. O gate local só reduz o número de vezes que essa fonte de verdade precisa reprovar um PR por um motivo bobo.

Vale adotar quando o time já depende de agentes para abrir PRs e viu pelo menos um caso de "chat disse verde, CI disse vermelho" — o custo de escrever e manter o gate é baixo comparado a um ciclo de CI desperdiçado. Não vale a pena hard-codar isso em projetos onde nenhum agente decide sozinho quando uma tarefa está pronta, ou em builds onde target/ já é limpo automaticamente antes de cada tentativa — nesse caso o caso B nem existe, e metade da complexidade some. E se algum dia o time perceber PRs passando no gate local mas falhando no CI por motivo diferente de teste (compilação, lint, empacotamento), é sinal de que o escopo do recibo precisa crescer além do Surefire — não de que o gate deveria ser removido.

javaai-agents

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

Conhecimento só conta quando vira prática.

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

Explorar mais artigos