Todos os artigos

// Knowledge.log — 技術記事

JaCoCo no Maven: o gate de cobertura em linha que deixa o ramo de erro sem teste

JaCoCo 0.8.15 / JDK 25: jacoco:check LINE 0.80 passa com 85,7% e o if de cobrança inválida nunca roda. BRANCH 0.80 falha com 0,50 no mesmo .exec.

O pipeline está verde. O jacoco:check exige 80% de cobertura de linha, o relatório mostra 85,7% e o merge passa. Mas o if que recusa cobrança com valor zero ou negativo nunca entrou no lado verdadeiro em nenhum teste. No HTML, a linha do throw aparece em vermelho. Com o build verde, ninguém abre o HTML.

O JaCoCo não está com defeito. O counter LINE mede exatamente o que promete, que é se alguma instrução daquela linha rodou. A comparação do if é avaliada em toda chamada, então a linha do if conta como coberta. O que fica sem execução é o ramo que importa.

O experimento é pequeno de propósito. São uma classe Java SE, um teste JUnit 6 que só cobre o caminho feliz e um POM com dois profiles. Com o gate em LINE 0.80, o resultado é BUILD SUCCESS. Com o gate em BRANCH 0.80, o mesmo código e o mesmo teste dão BUILD FAILURE com ratio 0.50. O CSV e o XML do relatório confirmam os dois resultados.

O artigo sobre mutation testing com PIT já mostrou que 100% de cobertura de linha ainda pode deixar mutantes vivos. Aqui não entra PIT. O assunto é só o jacoco:check com LINE e com BRANCH sobre os dados gravados no .exec.

Versões: 0.8.15 do Central, não o snapshot da documentação

As versões foram conferidas em 05/10/2026. O jacoco-maven-plugin mais novo no Maven Central (latest e release) é o 0.8.15, junto com o org.jacoco.core 0.8.15. As páginas de mojo em jacoco.org/trunk mostram 0.8.16-SNAPSHOT, porque a documentação acompanha o trunk. Esse snapshot não é GA e não entra no POM. Copiar a coordenada da documentação sem olhar o Central dá um build que funciona na máquina de quem tem o snapshot em cache.

O resto do conjunto ficou assim: JUnit Jupiter 6.1.3, maven-surefire-plugin 3.6.0, maven-compiler-plugin 3.16.0, Temurin 25.0.4 e Maven 3.9.12. Para Java 25 (classfile 69), o changelog do JaCoCo declara suporte oficial a partir do 0.8.14. No 0.8.13 o suporte ainda era experimental, então esse fica de fora junto com o snapshot.

O fixture: uma classe, um teste, dois profiles

A classe de produção tem um if de validação e algumas linhas no caminho feliz:

package academy.devdojo.cobranca;

public final class CobrancaServico {
  public int cobrarEmCentavos(int centavos) {
    if (centavos <= 0) {
      throw new IllegalArgumentException("valor invalido");
    }
    int taxaFixa = 10;
    int valorComTaxa = centavos + taxaFixa;
    int descontoFidelidade = 1;
    return valorComTaxa - descontoFidelidade;
  }
}

O teste é o que costuma aparecer quando a feature foi entregue com pressa. Ele cobre um valor positivo, confere o resultado e termina:

package academy.devdojo.cobranca;

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

import org.junit.jupiter.api.Test;

class CobrancaServicoTest {
  @Test
  void cobraValorPositivo() {
    assertEquals(109, new CobrancaServico().cobrarEmCentavos(100));
  }
}

cobrarEmCentavos(100) devolve 109. Nenhum teste passa zero nem valor negativo.

No POM, o plugin roda os três goals usuais. prepare-agent fica na fase initialize, grava em target/jacoco.exec e ajusta o argLine para o Surefire subir com o agente. report fica em verify e gera HTML, XML e CSV em target/site/jacoco/. check também fica em verify e lê o .exec. A regra usa propriedades para o counter e o mínimo trocarem por profile:

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.15</version>
  <executions>
    <execution>
      <id>prepare-agent</id>
      <goals><goal>prepare-agent</goal></goals>
    </execution>
    <execution>
      <id>report</id>
      <goals><goal>report</goal></goals>
    </execution>
    <execution>
      <id>check</id>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules>
          <rule>
            <element>BUNDLE</element>
            <limits>
              <limit>
                <counter>${jacoco.check.counter}</counter>
                <value>COVEREDRATIO</value>
                <minimum>${jacoco.check.minimum}</minimum>
              </limit>
            </limits>
          </rule>
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>
<profiles>
  <profile>
    <id>line-gate</id>
    <properties>
      <jacoco.check.counter>LINE</jacoco.check.counter>
      <jacoco.check.minimum>0.80</jacoco.check.minimum>
    </properties>
  </profile>
  <profile>
    <id>branch-gate</id>
    <properties>
      <jacoco.check.counter>BRANCH</jacoco.check.counter>
      <jacoco.check.minimum>0.80</jacoco.check.minimum>
    </properties>
  </profile>
</profiles>

O maven.compiler.release é 25 e o JUnit entra como junit-jupiter 6.1.3 em escopo test. Não há Spring nem container, e o build não depende de nada além do Maven Central.

Gate em LINE: verde com 85,7%

mvn -B -e -Pline-gate verify
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
All coverage checks have been met.
BUILD SUCCESS

O exit code foi 0 e o build terminou em 2026-10-05T05:13:43Z. A linha do CSV gerado para a classe:

GROUP,PACKAGE,CLASS,INSTRUCTION_MISSED,INSTRUCTION_COVERED,BRANCH_MISSED,BRANCH_COVERED,LINE_MISSED,LINE_COVERED,COMPLEXITY_MISSED,COMPLEXITY_COVERED,METHOD_MISSED,METHOD_COVERED
jacoco-line-branch,academy.devdojo.cobranca,CobrancaServico,5,17,1,1,1,6,1,2,0,2

São 6 linhas cobertas de 7, ratio 0,8571, acima de 0.80. A única linha perdida é a 6, a do throw. O XML mostra o detalhe linha a linha:

<line nr="5" mi="0" ci="2" mb="1" cb="1"/>
<line nr="6" mi="5" ci="0" mb="0" cb="0"/>

A linha 5, do if, tem as duas instruções cobertas (ci="2"), um ramo coberto (cb="1") e um ramo perdido (mb="1"). A página de counters explica por que ela conta como coberta: "A source line is considered executed when at least one instruction that is assigned to this line has been executed." No HTML, essa linha ganha o diamante amarelo, que significa "Only a part of the branches in the line have been executed". A linha 6 tem cinco instruções e nenhuma executou.

Ou seja, o gate de LINE aprovou um throw que nunca foi lançado.

Gate em BRANCH: o mesmo teste, build vermelho

O código e o teste são os mesmos. Só muda o profile:

mvn -B -e -Pbranch-gate verify
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[WARNING] Rule violated for bundle jacoco-line-branch: branches covered ratio is 0.50, but expected minimum is 0.80
[ERROR] Failed to execute goal org.jacoco:jacoco-maven-plugin:0.8.15:check (check) on project jacoco-line-branch: Coverage checks have not been met. See log for details.
BUILD FAILURE

O exit code foi 1 e o build terminou em 2026-10-05T05:13:47Z. O teste continua verde, e quem reprova é o check. O haltOnFailure vem true por padrão, então a violação derruba o build em vez de virar só um aviso no log.

O ratio é 0.50 porque o bundle inteiro tem um único if binário. O lado falso rodou e o lado verdadeiro não, o que dá 1 ramo coberto de 2. Pela documentação, o JaCoCo calcula branch "for all if and switch statements", e o diamante amarelo da linha 5 é esse 1/2.

Os counters do bundle lado a lado:

CounterCoberto / totalRatioCom mínimo 0.80
LINE6 / 70,8571passa (executado)
BRANCH1 / 20,50falha (executado)
INSTRUCTION17 / 220,7727falharia (não executado como gate)

Armadilhas: INSTRUCTION, o .exec, os excludes e o 80% que ninguém configurou

INSTRUCTION não é LINE. O exemplo da documentação do check usa INSTRUCTION com COVEREDRATIO e mínimo 0.80. Neste fixture, esse exemplo copiado sem alteração teria reprovado com 77%, enquanto LINE passava com 86%. As cinco instruções do throw pesam mais em INSTRUCTION do que uma linha pesa em LINE. Isso parece acidente a favor, mas não é garantia. Nenhum dos dois counters foi feito para dizer se um ramo de negócio rodou. Quem diz isso é BRANCH.

80% não é default. O 0.80 aparece no exemplo da documentação, e só ali. Uma regra sem minimum não tem limite numérico nenhum. O valor pode ser escrito como 0.80 ou 80%, que são equivalentes. Se o time tem 80% no POM, foi alguém que colocou esse número lá.

O check lê o .exec, não o XML. O dataFile do check é target/jacoco.exec. O XML e o CSV são relatórios para gente e para ferramentas, e o gate não os consulta. Como o check fica em verify, mvn test não roda o gate. Também não adianta mover o check para uma fase anterior aos testes, porque o .exec ainda não existe nesse ponto.

includes/excludes no check podem esconder a classe. Os filtros do check removem class files da análise. Um <exclude> largo demais, posto para tirar DTOs gerados da conta, pode levar junto o pacote de cobrança. O gate fica verde porque não olhou para a classe. Revise os excludes com a mesma atenção que o mínimo.

Para observar isso no CI, guarde target/site/jacoco/jacoco.xml e jacoco.csv como artefatos do job, inclusive quando o build passa. A coluna BRANCH_MISSED do CSV e o atributo mb do XML dizem onde está o ramo sem teste. Uma busca simples por mb maior que zero nas classes que têm regra de negócio mostra o diamante amarelo sem precisar abrir o HTML.

Limites do experimento

É uma classe, um método e um teste, e o bundle tem exatamente um if. Num projeto real, um mínimo de BRANCH no BUNDLE dilui o problema do mesmo jeito que LINE dilui. Um if de cobrança sem teste no meio de centenas de ramos cobertos ainda passa em 0.80. Para classes em que o ramo de erro é a regra de negócio, a regra precisa ser mais específica, com element CLASS ou com o atributo mb acompanhado no XML.

BRANCH também tem os seus pontos cegos. A documentação avisa que "exception handling is not considered as branches", então um try/catch não aparece como ramo. E 100% de BRANCH prova que o código rodou, não que o teste conferiu o resultado. Para essa segunda pergunta, o caminho é o artigo de PIT. Também não houve medição de tempo de build nem de overhead do agente. A execução foi funcional, no JDK 25 com o bytecode que o javac gerou para esse fonte.

Recomendação

A DevDojo não usaria um jacoco:check só com LINE como gate de merge em código cujo comportamento importante mora num if: validação de valor, autorização, limite de crédito, transição de estado. Nesses casos, o gate precisa de BRANCH, ou o ratio de LINE não está medindo o que o time acha que mede.

LINE sozinho ainda serve como alarme grosseiro em código sem decisão relevante, como mapeamento, configuração ou adaptadores finos, ou como piso para impedir que a cobertura despenque. Revise essa escolha se o CSV do artefato mostrar BRANCH_MISSED maior que zero numa classe de domínio com o build verde, ou se alguém propuser baixar o mínimo de BRANCH porque estava atrapalhando o merge.

Próximo passo

Acrescente um segundo limit na mesma regra e mantenha o de LINE:

<limits>
  <limit>
    <counter>LINE</counter>
    <value>COVEREDRATIO</value>
    <minimum>0.80</minimum>
  </limit>
  <limit>
    <counter>BRANCH</counter>
    <value>COVEREDRATIO</value>
    <minimum>0.80</minimum>
  </limit>
</limits>

Essa combinação não foi executada neste experimento. Rode mvn verify e confirme que o build falha com a mesma mensagem branches covered ratio is 0.50. Depois escreva o teste que faltava, algo como assertThrows(IllegalArgumentException.class, () -> new CobrancaServico().cobrarEmCentavos(0)), e rode de novo. O build deve voltar a passar, e a linha 5 do XML deve mostrar mb="0". Confira isso no jacoco.xml em vez de deduzir pelo verde.

javatestingmavenjacoco

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

Conhecimento só conta quando vira prática.

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

Explorar mais artigos