Todos os artigos

// Knowledge.log — 技術記事

Property-based testing com jqwik: o caso extremo que o exemplo fixo não cobre

Como o jqwik 1.10.1 gera entradas aleatórias, acha overflow que exemplo fixo não pega e reduz a falha ao caso mínimo, em Java 25 puro.

Todo teste de exemplo tem a mesma limitação estrutural: ele só cobre o que o autor lembrou de escrever. assertEquals(5, soma(2, 3)) passa, o PR é aprovado, e ninguém testou o que acontece quando um dos dois números está perto do limite do tipo. Em produção, esse "quase nunca" aparece de vez em quando — um valor grande demais, uma string vazia, um Unicode que ninguém previu — e quebra uma invariante que nenhum teste jamais checou, porque nenhum teste jamais gerou aquele valor.

Property-based testing (PBT) ataca esse ponto cego de um jeito diferente: em vez de escrever given/when/then para um valor fixo, você declara uma propriedade — algo que deve valer para qualquer entrada válida — e a ferramenta gera centenas de entradas tentando derrubá-la. Neste artigo o exemplo é jqwik 1.10.1, motor de testes para a JUnit Platform, rodando em Java SE puro (sem Spring, sem Testcontainers) sobre Java 25. O objetivo é reproduzir, com números reais de uma execução, um bug de overflow que um teste de exemplo não pega e que uma propriedade encontra em segundos — e depois reduz ao caso mínimo.

Pré-requisitos

  • JDK Temurin 25.0.4+7 (maven.compiler.release=25).
  • Maven 3.9.12, maven-compiler-plugin 3.14.1, maven-surefire-plugin 3.6.0.
  • net.jqwik:jqwik:1.10.1, que traz jqwik-engine 1.10.1 e fixa JUnit Platform 1.14.4 como dependência mínima.

Um detalhe de versão que vale declarar uma vez: o JUnit atual é a linha 6.x (Platform e Jupiter compartilham numeração desde a 6.0.0), mas o jqwik 1.10.1 ainda roda sobre a Platform 1.x — as notas de release chamam essa de provavelmente a última versão do jqwik sobre a Platform 1.x, com uma futura migração para a Platform 6 ainda sem artefato publicado. Não existe combinação net.jqwik:jqwik:1.10.1 + junit-bom:6.x que funcione; a matriz suportada é jqwik 1.10.1 com Platform 1.14.4, ponto.

Exemplo é propriedade com uma tentativa só

No modelo do jqwik, @Example e @Property não são coisas tão diferentes quanto parecem. Um método anotado com @Example é, internamente, tratado como uma propriedade com o número de tentativas fixado em 1. Um método @Property roda, por padrão, 1000 tentativas com valores gerados aleatoriamente, mais um conjunto de edge cases combinados automaticamente (EdgeCasesMode.MIXIN por padrão).

Isso explica por que um teste de exemplo pode ficar verde para sempre enquanto esconde um bug: ele testa exatamente o par de valores que o autor escolheu, nada além disso. Considere esta classe de domínio:

package academy.devdojo.jqwik;

public record Money(long cents) {
  public static Money ofCents(long cents) {
    if (cents < 0) {
      throw new IllegalArgumentException("cents");
    }
    return new Money(cents);
  }

  /** Broken: narrows to int, so sums that fit in long overflow int. */
  public Money plusBroken(Money other) {
    return ofCents(Math.addExact((int) this.cents, (int) other.cents));
  }

  public Money plus(Money other) {
    return ofCents(Math.addExact(this.cents, other.cents));
  }
}

E este teste de exemplo:

@Example
boolean twoPlusThreeIsFive() {
  return Money.ofCents(2).plusBroken(Money.ofCents(3)).cents() == 5;
}

twoPlusThreeIsFive passa, e vai continuar passando enquanto ninguém somar dois valores que, juntos, estourem um int. É o mesmo mecanismo por trás de uma pegadinha clássica do próprio guia do jqwik: Math.abs(Integer.MIN_VALUE) não é positivo — no JDK, Math.abs de Integer.MIN_VALUE retorna o próprio Integer.MIN_VALUE, ainda negativo, porque não existe representação positiva desse valor em int. Um teste de exemplo com Math.abs(-5) nunca vai encontrar essa exceção ao contrato; só um gerador que eventualmente produz Integer.MIN_VALUE encontra.

Geradores sob medida: @Provide e Arbitrary

Uma propriedade recebe seus parâmetros via @ForAll, e o jqwik precisa saber gerar valores desse tipo. Para tipos primitivos existe suporte pronto (Arbitraries.integers(), Arbitraries.strings()); para um tipo de domínio como Money, você escreve um método @Provide que devolve um Arbitrary<Money>:

@Provide
Arbitrary<Money> money() {
  return Arbitraries.longs().between(0, Integer.MAX_VALUE + 10_000L).map(Money::ofCents);
}

O intervalo escolhido de propósito atravessa Integer.MAX_VALUE (2147483647): parte dos valores gerados cabe em um int, parte não. É exatamente esse tipo de gerador — que cruza o limite do tipo menor em vez de ficar confortavelmente dentro dele — que faz uma propriedade valer a pena. Para tipos compostos com mais de um campo, Combinators.combine(...).as(...) monta o objeto a partir de vários Arbitrary menores; ele tende a encolher (shrink) melhor do que flatMap encadeado, que só vale a pena quando um valor realmente depende do outro gerado antes.

Com o gerador acima, a propriedade que expõe o bug de plusBroken fica assim:

@Property
boolean brokenPlusDoesNotOverflow(@ForAll("money") Money a, @ForAll("money") Money b) {
  a.plusBroken(b);
  return true;
}

A propriedade não afirma nada sofisticado — só que somar dois Money válidos não deveria lançar exceção. É o suficiente.

O que a execução real mostrou

Rodando mvn test sobre a classe com plusBroken, plusIsCommutative, plusIsAssociative, twoPlusThreeIsFive e uma propriedade golden (goldenLedgerRows, mais adiante) — cinco testes ao todo — o resultado foi:

Tests run: 5, Failures: 0, Errors: 1, Skipped: 0

twoPlusThreeIsFive ficou verde, como esperado. brokenPlusDoesNotOverflow falhou depois de 63 tentativas (seed = -8242242476536634201), com:

java.lang.IllegalArgumentException: cents
  at academy.devdojo.jqwik.Money.ofCents(Money.java:6)
  at academy.devdojo.jqwik.Money.plusBroken(Money.java:13)

Repare no tipo da exceção: não é ArithmeticException do Math.addExact — é IllegalArgumentException do Money.ofCents, lançada duas camadas depois do overflow. O mecanismo, confirmado rodando o cálculo isoladamente neste host:

  1. a.cents() gerado como 2147483648 (long) — um a mais que Integer.MAX_VALUE.
  2. (int) 2147483648L estoura o cast e vira -2147483648 (Integer.MIN_VALUE), porque o bit de sinal simplesmente reaparece.
  3. Math.addExact(-2147483648, 187030144) (ambos já int) não estoura — o resultado, -1960453504, cabe em int — então addExact devolve normalmente um número negativo.
  4. Money.ofCents(-1960453504) rejeita o valor negativo e lança IllegalArgumentException.

Ou seja: addExact está fazendo exatamente o que promete (detectar overflow de int), só que o dano já tinha acontecido um passo antes, no cast silencioso de long para int. addExact protege a soma; não protege o cast que a antecede.

O relatório de falha: sample original e sample reduzido

Quando uma propriedade falsifica, o jqwik tenta "encolher" (shrink) o contra-exemplo para um mais simples antes de reportar. Nas palavras do guia oficial: se uma propriedade pôde ser falsificada com um conjunto de valores gerados, o jqwik tenta reduzir (shrink) essa amostra para encontrar uma amostra "menor" que também falsifique a propriedade. O jqwik usa shrinking integrado (não shrinking baseado em tipo), o que costuma produzir contra-exemplos mais relevantes ao domínio.

Na execução real, o par original gerado foi:

Original Sample
---------------
  a: Money[cents=2147493646]
  b: Money[cents=187030144]

E depois de reduzir:

Shrunk Sample (5 steps)
-----------------------
  a: Money[cents=2147483648]
  b: Money[cents=187030144]

Cinco passos levaram a de 2147493646 para exatamente 2147483648 — um a mais que Integer.MAX_VALUE, o menor valor que ainda estoura o cast. b não mudou porque, no shrinking parâmetro-a-parâmetro do jqwik, cada valor é reduzido isoladamente, e 187030144 já era necessário para o cenário falhar.

Um aviso apareceu no log dessa mesma execução:

WARNING: Shrinking timeout reached after 10 seconds.
You can switch on full shrinking with '@Property(shrinking = ShrinkingMode.FULL)'

O modo padrão é ShrinkingMode.BOUNDED, com um teto de 10 segundos (propriedade jqwik.shrinking.bounded.seconds) — e nessa execução o teto foi atingido de verdade, não é um cenário hipotético. Isso significa que o "Shrunk Sample" reportado é o melhor que o shrinker conseguiu dentro do prazo, não necessariamente o menor contra-exemplo possível; o próprio guia deixa claro que a amostra mínima nem é única — depende da seed, e diferentes sementes podem encolher para formas igualmente pequenas, mas diferentes. Trocar para ShrinkingMode.FULL remove o teto de tempo às custas de rodar até esgotar as reduções possíveis; vale para investigação pontual, não como padrão de CI.

Corrigindo e reproduzindo com a mesma seed

A correção troca o cast por aritmética de long de ponta a ponta — o método plus do Money já fazia isso, plusBroken que estava errado por desenho:

public Money plus(Money other) {
  return ofCents(Math.addExact(this.cents, other.cents));
}

Trocando a propriedade para usar plus em vez de plusBroken e rodando de novo:

MoneyPropertiesFixed:plusIsCommutative — tries = 1000, seed = -925629694754968486
MoneyPropertiesFixed:plusDoesNotOverflow — tries = 1000, seed = 6257245524895003827
MoneyPropertiesFixed:goldenLedgerRows — tries = 2, generation = DATA_DRIVEN

mvn test saiu com código 0. Reduzir o bug a um caso mínimo é útil, mas o dado que fecha o ciclo é a seed: com seed = -8242242476536634201, qualquer um no time reproduz exatamente a mesma sequência de tentativas rodando de novo com @Property(seed = "-8242242476536634201"), sem depender de sorte ou de screenshot do CI.

Maven, JUnit Platform e o detalhe do include

jqwik não é uma extensão da Jupiter — é outro motor de testes rodando sobre a mesma JUnit Platform, descoberto via ServiceLoader. Duas consequências práticas para o pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>academy.devdojo</groupId>
  <artifactId>jqwik-money</artifactId>
  <version>1.0-SNAPSHOT</version>
  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>25</maven.compiler.release>
    <jqwik.version>1.10.1</jqwik.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>net.jqwik</groupId>
      <artifactId>jqwik</artifactId>
      <version>${jqwik.version}</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
        <configuration>
          <compilerArgs>
            <arg>-parameters</arg>
          </compilerArgs>
        </configuration>
      </plugin>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.6.0</version>
        <configuration>
          <includes>
            <include>**/*Properties.java</include>
            <include>**/*Tests.java</include>
            <include>**/*Examples.java</include>
          </includes>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

Primeiro: o padrão de include do Surefire é **/*Test.java (e variações com Test no nome). Uma classe chamada MoneyProperties.java não bate com esse padrão — sem o <includes> explícito acima, o Maven roda zero testes e reporta sucesso, o que é bem pior do que uma falha. Segundo: -parameters no compilador não é estritamente obrigatório para o jqwik funcionar, mas sem ele os relatórios de falha mostram arg0, arg1 em vez de a, b — vale o custo de compilação.

Não existe combinação onde adicionar org.junit:junit-bom na versão 6.x ajuda aqui: só amplia a superfície de dependências sem resolver nada, já que o jqwik-engine 1.10.1 já traz junit-platform-commons e junit-platform-engine 1.14.4 fixados via POM. Se algum dia precisar misturar jqwik com testes Jupiter no mesmo módulo, aí sim entra junit-jupiter:5.14.4 — não a linha 6.x, que troca de Platform.

Quando propriedade não substitui exemplo

Nem toda asserção é "isso vale para qualquer entrada". Regras de negócio pontuais — um valor de tabela regulatória, um caso de regressão específico, o clássico FizzBuzz onde 3 deve virar exatamente "Fizz" — não são invariantes gerais, são resultados esperados e conhecidos. Para esses casos o jqwik oferece @Example (um valor fixo) e propriedades data-driven com @FromData:

@Data
Iterable<Tuple.Tuple2<Long, Long>> ledgerGoldens() {
  return Table.of(Tuple.of(0L, 0L), Tuple.of(1L, 99L));
}

@Property
@FromData("ledgerGoldens")
boolean goldenLedgerRows(@ForAll long a, @ForAll long b) {
  return Money.ofCents(a).plus(Money.ofCents(b)).cents() == a + b;
}

Na execução, goldenLedgerRows rodou com tries = 2 e generation = DATA_DRIVEN — os dois pares vieram exatamente da tabela, sem geração aleatória e sem shrinking (o guia é direto: propriedades data-driven não passam por shrinking, porque não há "menor" versão de uma linha registrada manualmente). Isso é uma característica, não uma limitação a contornar: uma linha de ledger com zero centavos de cada lado é um caso conhecido que você quer travado, não amostrado.

Diagnóstico, correção e como observar isso depois

ProblemaCorreção executávelObservação em produção
Cast silencioso de long para int antes de somar (plusBroken)Trocar para Math.addExact(long, long) de ponta a ponta, sem cast intermediárioRelatório do Surefire com seed da propriedade que falhou; reexecutar com @Property(seed = "...") reproduz o mesmo contra-exemplo
Classe de teste não bate com include do Surefire (*Properties.java vs *Test.java)Ajustar <includes> no maven-surefire-plugin para o padrão de nome usadoContar tries/checks no relatório: zero tentativas com build verde é sinal de include quebrado, não de suíte saudável
Shrinking parou no teto de 10s sem garantir mínimo@Property(shrinking = ShrinkingMode.FULL) só durante investigação pontualLog do PropertyShrinker (WARNING: Shrinking timeout reached) indica quando o "Shrunk Sample" não é definitivo

Vale reforçar a diferença com mutation testing: mutação pergunta se os testes que já existem percebem uma mudança deliberada de comportamento no código; property-based testing pergunta que entrada o autor nunca chegou a escrever. São verificações complementares, não a mesma pergunta com nome diferente.

Recomendação

Vale adotar jqwik quando a lógica testada é sobre uma invariante matemática ou estrutural — soma comuta, parse é inverso de format, serialização é idempotente — porque aí uma propriedade genérica vale por centenas de exemplos que ninguém teria paciência de escrever à mão. Não vale forçar propriedade em regra de negócio que é, por natureza, uma lista fechada de casos com resultado esperado específico: aí @Example e @FromData continuam sendo a ferramenta certa, e tentar generalizar isso em propriedade só produz geradores artificiais que simulam uma tabela.

Sinal de que está na hora de recuar: se você está escrevendo filter ou Assume.that para descartar a maior parte dos valores gerados, o gerador está errado, não a técnica — o guia do jqwik já avisa que taxas altas de descarte terminam em "exhausted after tries", e a resposta é modelar um Arbitrary que só produz valores válidos, não filtrar o inválido depois de gerado. Fora isso, a combinação jqwik 1.10.1 + Platform 1.14.4 + Java 25 é sólida para Java SE puro hoje; a próxima decisão de versão só chega quando o jqwik publicar algo sobre Platform 6, e essa migração não é para ensinar antes de existir.

javatesting

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

Conhecimento só conta quando vira prática.

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

Explorar mais artigos