Introdução ao property-based testing com fast-check

1. O que é Property-Based Testing e por que ele é essencial na sua lista de temas

O property-based testing representa uma mudança fundamental na forma como pensamos sobre testes de software. Diferente do approach tradicional (example-based testing), onde escrevemos casos específicos como assert(soma(2, 2) === 4), o property-based testing trabalha com propriedades invariantes que devem ser verdadeiras para todas as entradas válidas.

Enquanto os testes baseados em exemplos verificam comportamentos pontuais, o property-based testing gera centenas ou milhares de casos automaticamente, explorando combinações que um desenvolvedor humano jamais consideraria. Isso é particularmente valioso para detectar bugs em bordas de domínio, cenários de concorrência e comportamentos inesperados em sistemas complexos.

A principal vantagem está na cobertura de cenários imprevistos. Um teste tradicional pode verificar se uma função de ordenação funciona para uma lista de 3 elementos; o property-based testing testará listas de tamanhos variados, com elementos repetidos, negativos, vazias e muito mais — tudo automaticamente.

2. Instalação e configuração inicial do fast-check em projetos JavaScript/TypeScript

Para começar, instale o fast-check via npm ou yarn:

npm install --save-dev fast-check
# ou
yarn add --dev fast-check

Requisitos mínimos: Node.js 12+ e um framework de teste como Jest, Mocha ou Vitest.

A estrutura básica de um teste com fast-check segue este padrão:

import * as fc from 'fast-check';

describe('Propriedades matemáticas', () => {
  it('soma deve ser comutativa', () => {
    fc.assert(
      fc.property(
        fc.integer(),
        fc.integer(),
        (a, b) => a + b === b + a
      )
    );
  });
});

Aqui, fc.property define a propriedade a ser testada, e fc.assert executa o teste com múltiplas execuções aleatórias. Por padrão, são realizadas 100 execuções, mas podemos configurar:

fc.assert(
  fc.property(
    fc.integer(),
    fc.integer(),
    (a, b) => a + b === b + a
  ),
  { numRuns: 1000, seed: 42 }
);

O parâmetro seed permite reproduzir exatamente a mesma sequência de testes, essencial para debugging.

3. Criando suas primeiras propriedades: geradores e asserções

O fast-check oferece geradores nativos para tipos comuns:

  • fc.integer() — inteiros aleatórios
  • fc.string() — strings aleatórias
  • fc.array(fc.integer()) — arrays de inteiros
  • fc.boolean() — valores booleanos
  • fc.float() — números de ponto flutuante

Para objetos complexos, combinamos geradores com fc.tuple e fc.record:

const usuarioGenerator = fc.record({
  nome: fc.string({ minLength: 1, maxLength: 50 }),
  idade: fc.integer({ min: 0, max: 150 }),
  email: fc.string({ minLength: 5, maxLength: 100 }),
  ativo: fc.boolean()
});

Exemplo de propriedade verificando idempotência (aplicar a função duas vezes produz o mesmo resultado):

it('removerEspacosDuplicados deve ser idempotente', () => {
  fc.assert(
    fc.property(
      fc.string(),
      (str) => {
        const resultado = removerEspacosDuplicados(str);
        return removerEspacosDuplicados(resultado) === resultado;
      }
    )
  );
});

4. Lidando com falhas: shrinking e debugging

Quando uma propriedade falha, o fast-check automaticamente reduz o caso ao mínimo necessário para reproduzir a falha — isso é o shrinking. Por exemplo, se uma função falha para um array de 1000 elementos, o fast-check tentará reduzir para o menor array que ainda cause a falha (talvez apenas 2 elementos).

A saída de erro típica:

Property failed after 42 tests
Seed: 123456789
Counterexample: ["abc!@#", 3]
Shrunk 12 times

Você pode usar fc.sample para visualizar dados gerados:

const amostras = fc.sample(fc.integer({ min: 1, max: 100 }), { numRuns: 5 });
console.log(amostras); // [42, 17, 89, 3, 66]

Para reproduzir falhas, use a seed exata do erro:

fc.assert(
  fc.property(...),
  { seed: 123456789 }
);

5. Propriedades clássicas para testar funções puras

Reversibilidade: Uma operação deve ser reversível:

it('reverse deve ser reversível', () => {
  fc.assert(
    fc.property(
      fc.array(fc.anything()),
      (arr) => arr.reverse().reverse().toString() === arr.toString()
    )
  );
});

Idempotência de ordenação: Ordenar duas vezes produz o mesmo resultado:

it('sort deve ser idempotente', () => {
  fc.assert(
    fc.property(
      fc.array(fc.integer()),
      (arr) => {
        const sorted = [...arr].sort((a, b) => a - b);
        return sorted.toString() === [...sorted].sort((a, b) => a - b).toString();
      }
    )
  );
});

Invariantes de transformação de strings: Verificar que uma função de normalização mantém o comprimento ou remove caracteres específicos:

it('removerPontuacao não deve alterar letras', () => {
  fc.assert(
    fc.property(
      fc.string({ minLength: 1, maxLength: 100 }),
      (str) => {
        const limpa = removerPontuacao(str);
        return limpa.split('').every(c => /[a-zA-Z0-9\s]/.test(c));
      }
    )
  );
});

6. Propriedades avançadas com geradores customizados

Crie geradores customizados usando fc.map, fc.filter e fc.chain:

// Gerador de CPFs válidos (apenas formato)
const cpfGenerator = fc.string({ minLength: 11, maxLength: 11 })
  .map(digits => `${digits.slice(0,3)}.${digits.slice(3,6)}.${digits.slice(6,9)}-${digits.slice(9)}`);

// Gerador de emails com restrições
const emailGenerator = fc.record({
  localPart: fc.string({ minLength: 1, maxLength: 20 }),
  domain: fc.constantFrom('gmail.com', 'yahoo.com', 'empresa.com.br')
}).map(({ localPart, domain }) => `${localPart}@${domain}`);

// Gerador de datas no formato ISO
const dataGenerator = fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') })
  .map(date => date.toISOString().split('T')[0]);

Para cenários de borda, use fc.anything (qualquer tipo) e fc.constantFrom (valores específicos):

const valoresBorda = fc.constantFrom(null, undefined, NaN, Infinity, '');

7. Integrando property-based testing em testes de integração e API

Para testar endpoints REST, gere payloads aleatórios:

it('API de cadastro deve aceitar usuários válidos', async () => {
  await fc.assert(
    fc.asyncProperty(
      fc.record({
        nome: fc.string({ minLength: 1, maxLength: 100 }),
        email: emailGenerator,
        idade: fc.integer({ min: 18, max: 120 })
      }),
      async (usuario) => {
        const response = await fetch('/api/usuarios', {
          method: 'POST',
          body: JSON.stringify(usuario),
          headers: { 'Content-Type': 'application/json' }
        });
        return response.status === 201;
      }
    )
  );
});

Para verificar invariantes de estado após operações encadeadas:

it('operações bancárias devem manter saldo consistente', async () => {
  await fc.assert(
    fc.asyncProperty(
      fc.integer({ min: 100, max: 10000 }),
      fc.array(fc.integer({ min: 1, max: 1000 }), { minLength: 1, maxLength: 10 }),
      async (saldoInicial, saques) => {
        const saldoFinal = await executarSaques(saldoInicial, saques);
        return saldoFinal >= 0 && saldoFinal <= saldoInicial;
      }
    )
  );
});

8. Boas práticas e armadilhas comuns ao adotar fast-check

Quando NÃO usar property-based testing:
- Testes visuais (UI, renderização gráfica)
- Testes de integração complexos com dependências externas lentas
- Funcionalidades com poucas variações de entrada

Documentando propriedades: Nomeie as propriedades de forma descritiva:

it('a ordenação não deve alterar o conjunto de elementos')
it('a função de hash deve produzir saída de comprimento fixo')

Balanceando cobertura: Use property-based testing como complemento, não substituto. Combine com testes example-based para cenários críticos:

// Teste example-based para casos específicos
it('soma de 1 + 1 = 2', () => expect(soma(1, 1)).toBe(2));

// Teste property-based para propriedades gerais
it('soma deve ser comutativa', () => {
  fc.assert(fc.property(fc.integer(), fc.integer(), (a, b) => soma(a, b) === soma(b, a)));
});

Lembre-se: property-based testing é uma ferramenta poderosa, mas exige pensar em termos de propriedades invariantes do seu sistema. Com prática, você descobrirá bugs que jamais encontraria com testes tradicionais.


Referências