Testes de propriedade

O problema editar

Atualmente, grande parte dos desenvolvedores já conhece ou desfruta dos benefícios dos testes automatizados de software. Em particular, estes testes providenciam dois benefícios indispensáveis para grandes projetos:

  1. Encontrar entradas problemáticas para a funcionalidade do produto
  2. Detectar mudanças de comportamento indesejadas em versões futuras do produto (regressões)

O tipo de teste automatizado mais conhecido é o teste de unidade baseado em exemplos, que felizmente resolve o segundo critério muito bem. Quando entradas problemáticas são encontrados pelos usuários do produto, é possível adicionar novos testes de exemplo que lidam com estas entradas .

Tome por exemplo, o linter de clojure chamado clj-kondo cujo arquivo principal de testes contém mais de 3500 linhas de código, consistindo de testes em grande parte provinientes de feedback dos usuários do projeto, para que modificações na codebase não quebrem o comportamento esperado.

Entretanto, testes de exemplo muitas vezes acabam falhando em atingir o outro objetivo: encontrar os problemas em primeiro lugar. Testes de unidade, podem muitas vezes falhar em encontrar casos problemáticos de entrada, o que pode ser especialmente problemático para libraries.

Para mitigar isso, existe uma outra categoria conhecida de testes automatizados: Os testes de propriedade, originalmente popularizados pela library QuickCheck de Haskell.

Usos editar

Em testes de unidade normais (que aqui chamaremos de testes de exemplo), o programador escreve vários exemplos, testa o comportamento da unidade em teste com as entradas dos exemplos, e verifica se a saída é a esperada.

Por exemplo, suponhamos que nós temos uma função parseDateOrNull, para converter datas da forma "XXXX-XX-XX", como "2001-09-11":

data class SimpleDate(val year: Int, val month: Int, val day: Int)

fun parseDateOrNull(input: String): SimpleDate? {
    if (input.length != 10) {
        return null
    }
    
    val year = input.substring(0, 4).toIntOrNull() ?: return null
    val month = input.substring(6,7).toIntOrNull() ?: return null
    val day = input.substring(8, 10).toIntOrNull() ?: return null

    return SimpleDate(year, month, day)
}

Esta função é relativamente simples, retornando null caso a entrada esteja inválida. No caso, esta função não deve se preocupar com os valores numéricos da data, eles devem ser aceitos contanto que estejam no formato solicitado.

Para testar esta função, podemos testar alguns casos para nos convencermos de que ela está correta (no caso, utilizando o framework de teste kotest):

class MainTest : StringSpec({
    "works with basic examples" {
        parseDateOrNull("1999-06-25") shouldBe SimpleDate(1999, 6, 25)
        parseDateOrNull("1995-05-02") shouldBe SimpleDate(1995, 5, 2)
        parseDateOrNull("25-06-1999") shouldBe null
        parseDateOrNull("1999-6-25") shouldBe null
        parseDateOrNull("19990625") shouldBe null
        parseDateOrNull("meh") shouldBe null
    }
})

O coração dos testes de propriedade é, ao invés de testar se a unidade em questão produz saídas esperadas para um conjunto conhecido de entradas, testar se a unidade em teste satisfaz certas propriedades, independentemente da entrada. O leitor atento pode notar que essa é uma tarefa efetivamente impossível de se fazer para todas as possíveis entradas, sem provar a corretude do código. Ao invés disso, o que fazemos em testes de propriedade, é testar o algorítmo para muitas entradas aleatórias, das quais temos controle.

Por exemplo, a propriedade mais simples que podemos testar, é que a função parseDateOrNull não solta alguma exception inesperada, para entradas quaisquer. Para testar uma propriedade, iremos usar o operador checkAll da library kotest, que recebe um gerador de valores aleatórios arbitrários e executa o código no bloco seguinte, com centenas de entradas geradas pelo gerador providenciado (por padrão, 1000).

class MainTest : StringSpec({
    // ... other tests
    "does not crash with garbage" {
        checkAll(Arb.string()) { input ->
            parseDateOrNull(input)
        }
    }
})

Nesse exemplo, o gerador Arb.string() (Arb significando arbitrário) irá gerar diversas strings de diversos tamanhos, e em nosso código, verificamos se parseDateOrNull solta alguma exception em alguma das entradas geradas. No caso, apesar do teste ser simples e passar, este teste em particular não é muito sofisticado, já que não gera entradas relevantes para nossa função. De todo modo, este ainda é um teste relevante, já que existem contextos, como o da linguagem Rust, em que certas strings pequenas podem causar grandes problemas.

Em libraries de testes de propriedade, é muito comum customizar geradores de valores aleatórios, para produzir entradas relevantes para a unidade em teste. Por exemplo, em geral assumimos que todas as strings da forma "XXXX-XX-XX" (onde os X representam dígitos distintos) devem ser aceitas. Então, podemos gerar apenas strings que satisfazem esse critério (utilizando expressões regulares), e verificar se a função converte essas entradas corretamente:

class MainTest : StringSpec({
   // ...
    "parses all valid dates" {
        checkAll(Arb.stringPattern("""\d\d\d\d-\d\d-\d\d""")) { input ->
            parseDateOrNull(input) shouldNotBe null
        }
    }
})

Nesse exemplo, estamos usando a função Arb.stringPattern com a expressão regular \d\d\d\d-\d\d-\d\d, gerar os valores desejados. Dentro de nosso bloco do checkAll, verificamos que parseDateOrNull nunca devolve null para esse tipo de entrada. Esse teste passa, com mil entradas executadas, o que indica que essa propriedade provavelmente vale para todas as possíveis entradas. Entretanto, as únicas propriedades que testamos até agora são 1. A função não solta exceptions para entradas erradas simples 2. A função nunca devolve null para datas válidas

Entretanto, nós ainda não testamos nada relacionado ao objeto retornado pela função, quando a entrada é válida (note que se a função parseDateOrNull sempre retornasse SimpleDate(0,0,0), ela satisfaria as propriedades que testamos até agora) . Para mitigar isso, podemos gerar 3 inteiros aleatórios, converte-los numa string, e verificar se o retorno da função é a data contendo estes 3 inteiros. Felizmente, o nosso gerador de entradas consegue nos ajudar com isso:

class MainTest : StringSpec({
    // ...
    "parses back to original" {
        checkAll(Arb.triple(
            Arb.int(0..9999),
            Arb.int(1..12),
            Arb.int(1..31)
        )) { (x, y, z) ->
            parseDateOrNull(String.format("%04d-%02d-%02d", x, y, z)) shouldBe SimpleDate(x, y, z)
        }
    }
})

Na entrada acima, utilizamos Arb.triple, e passamos 3 outros geradores, que no caso são de inteiros entre 0 e 9999, entre 1 e 12 e entre 1 e 31. Em nosso bloco de código de teste, geramos e testamos uma data com estes números, e verificamos se a função devolve a saída esperada. É possível notar que esse teste cobre muito dos casos que um programador escreveria manualmente em testes de exemplo. De todo modo, executando este teste com kotest, o framework nos informa que encontrou um caso em que a condição não é satisfeita:

java.lang.AssertionError: Property failed after 12 attempts

	Arg 0: (0, 10, 1) (shrunk from (3112, 11, 19))

Repeat this test by using seed 6588141479135993802

Caused by AssertionFailedError: data class diff for org.example.SimpleDate
└ month: expected:<10> but was:<0>

expected:<SimpleDate(year=0, month=10, day=1)> but was:<SimpleDate(year=0, month=0, day=1)>

Neste exemplo, a library nos informa que a propriedade não é satisfeita para a entrada (0, 10, 1), e que a saída produzida (0, 0, 1) não foi a data esperada. Em particular, a mensagem de erro nos diz que a entrada (0, 10, 1) foi "encolhida", a partir de (3112, 11, 19). Isso significa que a primeira entrada problemática encontrada foi (3112, 11, 19), entretanto, a library é convenientemente capaz de procurar entradas menores que satisfazem o problema encontrado.

De todo modo, este problema - que não foi encontrado originalmente nos testes de exemplo - é causado por conta do cálculo do mês na função parseDateOrNull:

val month = input.substring(6,7).toIntOrNull() ?: return null

deveria ser

val month = input.substring(5,7).toIntOrNull() ?: return null

Após encontrar esse caso problemático em particular, o programador pode agora definir um teste de exemplo para essa entrada, e ter o melhor tanto dos testes de propriedade, quanto de exemplo.

Considerações editar

Infelizmente, testes de propriedade não são a cura para todos os problemas. Apesar de serem particularmente úteis para encontrar entradas problemáticas, eles ainda estão limitados a quais propriedades o programador é capaz de imaginar e quais propriedades ele é capaz de expressar em código. Pegue por exemplo, o fato da função aceitar qualquer delimitador de data, como @ ou x, e não só -. De todo modo, eles ainda podem dar grande valor aos produtos certos, quando utilizado corretamente.


Referências editar