Skip to content

A linguagem molda o teste

Duas propriedades de uma linguagem mudam, antes de qualquer decisão de estratégia, o que sequer precisa ser testado. A primeira é quanto o compilador prova sozinho, antes de o programa rodar. A segunda é quanto do código é puro, livre de efeitos que obrigam a montar o mundo em volta para testar. Linguagens diferentes fazem apostas diferentes nesses dois eixos, e a suíte de testes que faz sentido em cada uma é, em boa parte, o negativo dessas apostas.

O compilador como primeiro conjunto de testes

Section titled “O compilador como primeiro conjunto de testes”

Um sistema de tipos estático é, olhado de perto, um verificador que roda a cada compilação e prova uma classe inteira de afirmações sobre o programa: que nenhuma função recebe uma string onde espera um número, que nenhum método é chamado num objeto que não o tem, que nenhum campo é acessado com o nome errado. A formulação clássica é de Robin Milner, em 1978: “programas bem tipados não podem dar errado”. A frase é célebre e vale desarmá-la na mesma respiração, porque ela é rotineiramente exagerada. O “dar errado” de Milner tem sentido técnico estreito: significa alcançar um erro de tipo em tempo de execução. O teorema garante ausência de erros de tipo, não correção geral, não ausência de bug de lógica, não terminação. Um programa bem tipado pode calcular a resposta errada com todo o rigor de tipos do mundo.

Feita a ressalva, o que fica é substancial: aquilo que o compilador prova é aquilo que não é preciso testar à mão. Numa linguagem de tipagem estática forte, não se escreve um teste para “e se alguém passar o argumento do tipo errado?”, porque o código com o tipo errado não compila. O teste seria morto no nascedouro pelo compilador, e escrevê-lo é redundante.

A tipagem dinâmica faz a aposta oposta, e a conta reaparece do outro lado. Em Python, JavaScript ou Ruby, aquela classe inteira de erros só se manifesta em tempo de execução, o que significa que a suíte de testes é a única rede que a pega. Renomear um campo e esquecer um uso é, numa linguagem tipada, um erro de compilação apontado na hora; numa dinâmica, é um bug que só um teste (se ele existir) revela. O efeito prático é que uma linguagem dinâmica precisa de mais testes só para chegar ao piso de confiança que a tipagem estática dá de graça, e parte da cultura forte de TDD em Ruby e Python vem daí: sem compilador segurando a classe de erros de tipo, a suíte precisa segurá-la.

A fronteira entre o que o compilador prova e o que a suíte cobre não é fixa, e as duas décadas recentes foram, em boa medida, um esforço de movê-la. A tipagem gradual é o instrumento: TypeScript para JavaScript, mypy para Python, sistemas que deixam adicionar checagem estática aos poucos a uma base dinâmica. Cada anotação de tipo que se acrescenta é trabalho que migra da suíte de testes para o verificador de tipos, um erro que antes só um teste pegaria e que agora falha na compilação, mais cedo e mais barato.

Há uma forma mais profunda de mover a linha, que não apenas antecipa o teste, e sim o apaga. Se o tipo for preciso o bastante para que o estado inválido não possa nem ser construído, não sobra o que testar sobre ele. É a ideia que Yaron Minsky sintetizou como “tornar estados ilegais irrepresentáveis”, por volta de 2010, e que Alexis King reformulou em 2019 como “parse, don’t validate”: em vez de aceitar dados frouxos e validá-los com checagens espalhadas por todo canto, transforme-os na fronteira num tipo que carrega a garantia, de modo que o resto do código não possa sequer expressar o caso inválido.

Um exemplo torna o efeito concreto. Suponha um pagamento que, quando aprovado, sempre tem um comprovante, e quando pendente, nunca tem. A modelagem frouxa deixa os dois campos soltos e convida ao estado impossível:

// Frouxo: o estado "aprovado sem comprovante" é construível,
// então é preciso um teste (e uma checagem em runtime) que o proíba.
type Pagamento = {
status: "pendente" | "aprovado";
comprovante?: Comprovante;
};
// Preciso: o comprovante só existe no ramo "aprovado".
// O estado ilegal não compila, e o teste que o vigiava deixa de existir.
type Pagamento =
| { status: "pendente" }
| { status: "aprovado"; comprovante: Comprovante };

A segunda modelagem não passou o teste “aprovado precisa de comprovante”: ela dissolveu a necessidade dele. Ninguém escreve um caso para verificar o que o compilador recusa a construir. Esse é o limite superior do que os tipos fazem pela suíte, transformar uma checagem que se pagaria a cada chamada numa garantia provada uma vez, na definição do tipo.

Todo esse poder tem uma borda nítida, e ignorá-la é o erro simétrico ao de exagerar a frase de Milner. Tipos provam a forma; testes provam o comportamento. O compilador garante que raiz_quadrada recebe um número e devolve um número, e nada mais; que raiz_quadrada(4) devolve 2, e não 2,1 ou o próprio 4, só um teste garante. Quanto mais expressivo o sistema de tipos, mais da especificação cabe na forma e menos sobra para o teste, mas a fração que sobra, a lógica de fato, é justamente a parte que mais importa e que nenhum tipo prático alcança. A linguagem move a fronteira; ela não a elimina.

O segundo eixo é ortogonal ao primeiro e às vezes mais decisivo: quanto do código é puro. Uma função pura tem transparência referencial, sua saída depende só das entradas, sem estado escondido e sem efeito colateral, de modo que a mesma chamada devolve sempre o mesmo resultado. Isso é o que torna testá-la trivial. Não há mundo para arranjar antes, nem para limpar depois; não há colaborador para falsificar, nem ordem de execução que importe. Chama-se a função e afirma-se o resultado. Boa parte do que torna o teste difícil, montar o cenário, fingir dependências, desfazer o estado, simplesmente evapora quando o código é puro.

Não por acaso, o teste baseado em propriedades nasceu no mundo funcional. Uma propriedade é uma afirmação sobre a relação entre entrada e saída, e é a pureza que mantém essa relação estável o bastante para valer sobre entradas aleatórias: se a função guardasse estado ou dependesse do relógio, “para toda entrada, tal coisa vale” perderia o sentido. Pureza e teste de propriedades são a mesma disciplina vista de dois ângulos.

A dificuldade, claro, mora nos efeitos. Falar com o banco, ler o relógio, sortear um número, chamar a rede: tudo isso é não determinístico e tem efeito colateral, e é o que obriga ou a subir a coisa real (teste de integração) ou a falsificá-la (mock). A resposta que o estilo funcional dá a esse problema é arquitetural, e vale mesmo fora de linguagens funcionais: empurrar os efeitos para a borda. Mantém-se um núcleo puro, grande e trivialmente testável, e concentra-se o código efetivo numa casca fina em volta, onde os testes são poucos e caros mas há pouco a cobrir. O padrão é conhecido como núcleo funcional, casca imperativa, e é uma forma de maximizar a parte do sistema que cai no caso fácil de testar.

Essas duas apostas se sedimentam em culturas de teste distintas por comunidade. Haskell leva os tipos ao limite e apoia-se no QuickCheck para o que os tipos não pegam, com uma inclinação a “tipos primeiro” que desloca para a compilação parte do que noutra linguagem seria teste. Clojure, dinâmica e funcional, respondeu com o clojure.spec e o test.check, que geram dados a partir de especificações para teste generativo, na mesma linhagem do QuickCheck. Elixir traz o ExUnit embutido, o StreamData para propriedades, e os doctests, que rodam como teste os exemplos escritos na própria documentação, unindo documentar e verificar num gesto só. São três respostas ao mesmo par de perguntas, quanto provar por tipo e quanto isolar de efeito, e a forma da suíte em cada uma segue dessa resposta.

Fecha o quadro uma observação sobre o TDD, com a ressalva de que aqui se entra em terreno de opinião mais do que de fato documentado. Em linguagem funcional fortemente tipada, parte do que o ciclo vermelho-verde do TDD faz é feito pelo verificador de tipos, e a comunidade tende ao “type-driven development”, escrever os tipos primeiro e deixá-los restringir o espaço antes de preencher a lógica. Há quem sustente que, nesse mundo, o mock dói mais e serve menos, já que os tipos e a pureza cobrem o que ele cobriria. É uma inclinação real, não um resultado fechado: mockar código efetivo em Haskell também se faz, e há quem defenda. Na direção oposta, o TDD pesa mais nas linguagens dinâmicas justamente porque a suíte ali é a rede que os tipos não estendem, e escrever o teste antes vira menos uma questão de design e mais a única checagem que vai existir. O paradigma não decide se você faz TDD; ele decide quanto trabalho o TDD ainda tem a fazer depois que o compilador fez a parte dele.

Tipagem: estática, dinâmica, gradual, sistema de tipos, TypeScript, mypy, tipos como prova Conceitos: transparência referencial, função pura, efeito colateral, estados ilegais irrepresentáveis, parse don’t validate, núcleo funcional casca imperativa Paradigmas: funcional, imperativo, type-driven development, TDD Ferramentas: QuickCheck, Hypothesis, fast-check, clojure.spec, test.check, ExUnit, StreamData, doctests Pessoas: Robin Milner, Yaron Minsky, Alexis King, Koen Claessen, John Hughes, David MacIver