O coding agent termina a tarefa, roda o build, recebe BUILD SUCCESS e escreve "testes passando". Aí você abre o transcript e a linha executada foi mvn test -DskipTests. Ou o agente não mexeu no comando e colocou <maven.test.skip>true</maven.test.skip> no pom.xml. O Maven saiu com exit 0 e nenhum teste rodou. O JUnit nem foi carregado para dizer que estava tudo bem.
A saída é um gate pequeno, em Python stdlib, que recusa skipTests e maven.test.skip em três lugares: no comando que o agente rodou, no log capturado e no diff de pom.xml e .mvn/maven.config. Tudo abaixo foi executado neste host em 2026-10-21.
Onde isso entra no que já publicamos
Já cobrimos o recibo do Surefire, que exige um TEST-*.xml recente e zerado. Também tem o gate contra asserção afrouxada e @Disabled, que lê o diff de src/test, e o \*IT que o Failsafe nunca rodou num mvn test. O caso de hoje acontece antes de tudo isso. O teste não foi enfraquecido nem ficou numa fase posterior do ciclo. A suíte inteira foi desligada por uma flag, e a flag está escrita no comando ou no diff. É ali que o gate procura.
Ambiente
- Temurin JDK 25.0.4, Maven 3.9.12,
maven.compiler.release=25, sem--enable-preview maven-compiler-plugin3.16.0. Olatestdo Central é 4.0.0-beta-5, que ficou de fora por ainda ser beta.maven-surefire-plugin3.6.0junit-bom/junit-jupiter6.1.3- Packaging
jar, sem Spring, sem Failsafe, sem Docker - Python 3, só biblioteca padrão, para o gate
skipTests e maven.test.skip não fazem a mesma coisa
A página Skipping Tests do Surefire mostra os dois caminhos. O skipTests pode ser configurado no POM com <skipTests>true</skipTests> ou passado na linha de comando com mvn install -DskipTests. O maven.test.skip aparece com um "if you absolutely must" e vai mais longe: pula também a compilação dos testes. A página diz que essa propriedade é respeitada pelo Surefire, pelo Failsafe e pelo Compiler Plugin, e o exemplo é mvn install -Dmaven.test.skip=true.
A referência do goal surefire:test separa os dois parâmetros:
| parâmetro | user property | efeito documentado | padrão |
|---|---|---|---|
skipTests (desde 2.4) | skipTests | pula a execução, mas ainda compila os testes | false |
skip | maven.test.skip | pula a execução e a compilação dos testes | false |
O lado da compilação está no compiler:testCompile: o parâmetro skip, com user property maven.test.skip, faz o plugin não compilar as fontes de teste.
Na prática, -DskipTests mantém o AdderTest.class em target/test-classes, só que ninguém executa a classe. -Dmaven.test.skip=true nem gera o .class. O exit code é 0 nos dois casos.
Repro: um teste, cinco exit 0
O módulo é o menor possível. POM:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>devdojo</groupId>
<artifactId>skip-tests-gate</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.16.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</build>
</project>
Produção, em src/main/java/devdojo/Adder.java:
package devdojo;
public final class Adder {
private Adder() {}
public static int add(int a, int b) {
return a + b;
}
}
Teste, em src/test/java/devdojo/AdderTest.java:
package devdojo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class AdderTest {
@Test
void onePlusOneEqualsTwo() {
assertEquals(2, Adder.add(1, 1), "skipTestsGateUniqueString");
}
}
Os comandos rodaram nesta ordem:
| comando | exit | o que ficou em target/ |
|---|---|---|
mvn -q test | 0 (05:08:32Z) | TEST-devdojo.AdderTest.xml com tests=1 failures=0 errors=0 skipped=0, testcase onePlusOneEqualsTwo |
mvn -q test -DskipTests (sem clean) | 0 (05:08:36Z) | teste não executado, XML anterior intacto, mesmo mtime 05:08:32.439Z |
mvn -B clean test -DskipTests | 0 (05:08:39Z) | AdderTest.class compilado, sem target/surefire-reports |
mvn -q clean test -Dmaven.test.skip=true | 0 | sem target/test-classes, sem relatórios |
mvn -B clean test -Dmaven.test.skip=true | 0 (05:08:44Z) | idem, com o motivo no log |
Com -B e sem -q, o Maven diz o que está fazendo. No clean test -DskipTests:
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ skip-tests-gate ---
[INFO] Compiling 1 source file with javac [debug release 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.6.0:test (default-test) @ skip-tests-gate ---
[INFO] Tests are skipped.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
No clean test -Dmaven.test.skip=true, o skip começa antes:
[INFO] --- resources:3.3.1:testResources (default-testResources) @ skip-tests-gate ---
[INFO] Not copying test resources
[INFO]
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ skip-tests-gate ---
[INFO] Not compiling test sources
[INFO]
[INFO] --- surefire:3.6.0:test (default-test) @ skip-tests-gate ---
[INFO] Tests are skipped.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
Duas armadilhas aparecem na tabela.
O -q esconde a única pista. Tests are skipped. é uma linha [INFO], e o modo quiet some com ela. Um agente que roda mvn -q test -DskipTests devolve um log vazio e exit 0, que é exatamente o que um build verde e silencioso devolveria. O quiet cumpre o que promete.
O XML velho continua lá. Sem clean, o -DskipTests não apaga target/surefire-reports. O TEST-devdojo.AdderTest.xml da execução das 05:08:32 continuou no disco com o mesmo mtime e failures=0. Quem lê o diretório sem olhar a idade do arquivo aceita como recibo uma execução que não é a da tarefa. Essa checagem de idade é o assunto do recibo de 22/09. Aqui o ponto é outro: dá para recusar a flag antes de abrir qualquer XML.
O gate: comando, log e diff
O gate procura tokens de texto. Não interpreta o modelo do Maven, não lê XML do Surefire e não procura @Disabled. Sai com 1 se encontrar algum token e com 0 se não encontrar nenhum.
#!/usr/bin/env python3
"""Fail closed when a coding-agent command, log, or diff introduces skipTests / maven.test.skip.
Does not parse Surefire XML as the primary receipt.
Does not inspect Failsafe / *IT / skipITs.
Does not look for @Disabled or loosened assertions.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
# Tokens that zero the unit-test suite without failing the Maven process.
# Keep skipITs out: that is Failsafe-only.
PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("-DskipTests", re.compile(r"-DskipTests\b")),
("skipTests=true", re.compile(r"\bskipTests\s*=\s*true\b", re.IGNORECASE)),
("-Dmaven.test.skip", re.compile(r"-Dmaven\.test\.skip\b")),
("maven.test.skip=true", re.compile(r"\bmaven\.test\.skip\s*=\s*true\b", re.IGNORECASE)),
("<skipTests>true</skipTests>", re.compile(r"<skipTests>\s*true\s*</skipTests>", re.IGNORECASE)),
(
"<maven.test.skip>true</maven.test.skip>",
re.compile(r"<maven\.test\.skip>\s*true\s*</maven\.test\.skip>", re.IGNORECASE),
),
]
def findings(text: str) -> list[str]:
hits: list[str] = []
for label, pattern in PATTERNS:
if pattern.search(text):
hits.append(label)
return hits
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--command", default="", help="Agent command string")
parser.add_argument("--log", type=Path, help="Captured Maven / agent log")
parser.add_argument("--diff", type=Path, help="Unified diff (pom.xml / .mvn/maven.config)")
parser.add_argument("--file", action="append", default=[], type=Path, help="File contents to scan")
args = parser.parse_args()
problems: list[str] = []
if args.command:
for hit in findings(args.command):
problems.append(f"command: {hit}")
if args.log is not None:
for hit in findings(read_text(args.log)):
problems.append(f"log {args.log}: {hit}")
if args.diff is not None:
for hit in findings(read_text(args.diff)):
problems.append(f"diff {args.diff}: {hit}")
for path in args.file:
for hit in findings(read_text(path)):
problems.append(f"file {path}: {hit}")
if problems:
for item in problems:
print("FAIL:", item)
return 1
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
Uso básico:
python3 gate.py --command "mvn -q test" # exit 0
python3 gate.py --command "mvn -q test -DskipTests" # exit 1
python3 gate.py --diff pom-skipTests.diff # exit 1
Resultado de todas as execuções neste host:
| entrada | exit | saída |
|---|---|---|
--command "mvn -q test -DskipTests" | 1 | FAIL: command: -DskipTests |
--command "mvn test -DskipTests=true" | 1 | FAIL: command: -DskipTests |
--command "mvn -q test -Dmaven.test.skip=true" | 1 | FAIL: command: -Dmaven.test.skip |
--log de agente com o comando de skip registrado | 1 | FAIL: log ...: -Dmaven.test.skip |
--diff com <skipTests>true</skipTests> no Surefire | 1 | FAIL: diff ...: <skipTests>true</skipTests> |
--diff com <maven.test.skip>true</maven.test.skip> em <properties> | 1 | FAIL: diff ...: <maven.test.skip>true</maven.test.skip> |
--diff de .mvn/maven.config com -DskipTests | 1 | FAIL: diff ...: -DskipTests |
--command "mvn -q test" | 0 | PASS |
--log de agente limpo | 0 | PASS |
--diff de POM sem skip | 0 | PASS |
--file pom.xml (o POM acima) | 0 | PASS |
--command "mvn -q test -DskipITs" | 0 | PASS |
Por onde a flag entra
O agente não precisa digitar -DskipTests no terminal. Os caminhos que o gate cobre:
- Linha de comando.
-DskipTests,-DskipTests=true,-Dmaven.test.skip=true. pom.xml. Pode ser<skipTests>true</skipTests>na configuração do Surefire, que é a forma da página oficial, ou<skipTests>/<maven.test.skip>em<properties>, inclusive dentro de um profile..mvn/maven.config. No Maven 3.9 o arquivo tem um argumento por linha. Um-DskipTestsadicionado ali vale para todomvnrodado no projeto, inclusive o seu.MAVEN_ARGS. A partir do Maven 3.9.0, o scriptmvnlê essa variável e pode receber-DskipTestspor ela. JáMAVEN_OPTSé para opções da JVM. Como o gate só lê texto, o mais simples é concatenar o ambiente no--commande deixar o regex resolver.
No harness, o esboço fica assim (este trecho de shell não foi executado neste run):
git diff "$BASE" -- pom.xml .mvn/maven.config > agent.diff
python3 gate.py \
--command "$AGENT_MVN_CMD ${MAVEN_ARGS:-} ${MAVEN_OPTS:-}" \
--log agent.log \
--diff agent.diff \
|| { echo "tarefa recusada: suite desligada por flag" >&2; exit 1; }
O git diff "$BASE" -- ... sem segundo commit compara o working tree com o base. Assim entra também a mudança que o agente deixou sem commitar, que costuma ser justamente a que ninguém revisou.
Vale um detalhe sobre o log. A saída do Maven com -B não repete a flag, só imprime Tests are skipped.. O --log pega o token quando o log do agente registra o comando executado, como no fixture da tabela. Se o seu harness guarda só o stdout do Maven, o --command é o que pega a flag. Acrescentar um padrão para Tests are skipped. é uma extensão razoável, mas ela não fez parte deste gate nem dos runs acima, e com -q a linha nem aparece.
Limites do gate
- Failsafe fica de fora de propósito. O
-DskipITspassa no gate porque só afeta o Failsafe, e a página de skipping do Failsafe avisa que, desde o 3.6.0,-DskipTestsnão pula mais os ITs. Esse caso está no artigo do \*IT e do Failsafe. - XML ausente ou velho é assunto do recibo do Surefire. @Disabled e assert afrouxado são do gate de diff de src/test. Os três gates se somam, nenhum substitui o outro.
-DskipTests=falsetambém reprova. O regex-DskipTests\bnão olha o valor. Isso é intencional, porque um harness de agente deve falhar fechado. Se o projeto usa o padrão da documentação, com skip ligado por padrão no POM e religado com-DskipTests=false, coloque exatamente essa string numa allowlist explícita do harness. Afrouxar o regex não resolve.- Token não é modelo. Um comentário no POM com
<skipTests>true</skipTests>dispara o gate. Uma linha removida no diff também, porque o gate lê o diff inteiro. Os dois casos dão falso positivo do lado seguro. No sentido contrário, um<skip>true</skip>direto na configuração do Surefire não bate com nenhum padrão. Se isso aparece no seu repositório, adicione o padrão.
Próximo passo
Coloque o gate.py ao lado do harness do agente e rode o gate antes de a tarefa ser marcada como testada, passando o comando (com MAVEN_ARGS), o log e o git diff de pom.xml e .mvn/maven.config contra o commit base. Se sair 1, a tarefa volta para o agente com o stdout do gate e não vira PR. A recomendação da DevDojo é recusar sempre e liberar só pela allowlist que uma pessoa aprovou. Enquanto o comando tiver skipTests ou maven.test.skip, um BUILD SUCCESS não mostra nada sobre os testes, porque eles não rodaram.