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.
Mover a linha
Section titled “Mover a linha”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.
O que os tipos não fazem
Section titled “O que os tipos não fazem”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.
Pureza e a facilidade de testar
Section titled “Pureza e a facilidade de testar”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.
Culturas de teste
Section titled “Culturas de teste”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.
TDD entre paradigmas
Section titled “TDD entre paradigmas”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.
Palavras-chave
Section titled “Palavras-chave”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