TDD e BDD são ótimos. Como ferramentas de desenvolvimento, eles definem uma linguagem comum para a equipe, criam uma documentação ativa e controlam o status do projeto. Essas vantagens fizeram do BDD / TDD uma parte indispensável do meu processo, mas alguma experiência com elas me fez perceber que ainda há problemas a serem enfrentados com a prática do BDD / TDD para que seja viável para todo tipo de projeto. Especificamente, alguns incômodos relacionados ao TDD / BDD o tornam abaixo do ideal para projetos com grandes níveis de incerteza e ainda pior para projetos em que o domínio deve ser descoberto durante e após a implementação.
Em minha experiência, a prática de BDD / TDD frequentemente resultou anti-enxuta, tornando mais difícil girar e exigindo muita energia extra para manter. Limitei o porquê:
Os testes são difíceis de ler
Este é o desafio mais sério com a prática de BDD / TDD. A maioria das ferramentas ( RSpec , Jasmine , JUnit ) fornece formatos de saída excelentes (até lindos) para os resultados dos testes e, embora isso seja ótimo, não resolve o problema subjacente: os testes são invariavelmente difíceis de ler. Existem vários fatos que contribuem para isso:
Metaprogramação não faz parte do design das linguagens
A maioria dos frameworks de teste depende, pelo menos parcialmente, de recursos de metaprogramação para poder capturar execuções de métodos, estender objetos dinamicamente ou até mesmo criar classes completas em tempo de execução. Enquanto frameworks habilmente elaborados (como RSpec) estruturam a API para que asserções e expectativas de mensagens pareçam inglês simples para o leitor, a ginástica que está sendo feita por trás das cortinas é revelada pelo uso muito pouco convencional dos recursos da linguagem.
A propósito, isso torna os frameworks BDD / TDD difíceis de projetar e quebrar, já que para fazê-los funcionar você precisa de um ninja mexendo no motor.
Dependências externas são difíceis de simular
Serviços não testáveis (como uma API da web que não oferece alternativa inofensiva) e bibliotecas externas com APIs extensas e expressivas (como um ORM) são realmente burocráticos para serem simulados e, às vezes, exigem uma cópia completa da API.
Diante de tal problema, alguns desenvolvedores presumem que a única maneira de contornar é renunciar ao teste de unidade e saltar diretamente para o teste de BDD / integração com o serviço externo ao vivo – ou seja, eles recorrem a realizar testes perigosos com a ferramenta real ao vivo. Testar ao vivo sem um fallback inerte é uma solução desesperada. Pode ser o único ajuste em algumas ocasiões, uma vez que simular um serviço inteiro nem sempre é possível, mas, por outro lado, os serviços de missão crítica devem ter uma boa cobertura de código para serem mantidos e esta abordagem falha em prevenir eventuais pesadelos quando os serviços externos são atualizados .
Felizmente, mais e mais serviços e bibliotecas fornecem versões inofensivas ou prontas para teste, como para os serviços da web Ruby, ou um sinalizador de teste no serviço da web conforme fornecido pelo bom pessoal do Mandrillapp . Eu considero este desafio, no entanto, como um sintoma de um problema diferente, implícito no design de linguagem e protocolo da geração atual de linguagens de programação e protocolos de comunicação:rack-test
A programação reflexiva é antiparadigmática
Tanto a asserção quanto os preparativos para os testes são, de certa forma, observando o código real de dentro para fora. O paradigma de programação imperativo (e, de certa forma, muito de nossa maneira de pensar) torna difícil para o cérebro mudar de perspectiva tão drasticamente quanto muitos cenários de teste da vida real exigem para cobrir efetivamente o código testado.
Os últimos anos foram ricos em melhorias de usabilidade nas ferramentas de desenvolvimento, incluindo linguagens e as próprias estruturas principais. Ainda assim, embora seja amplamente suportado, o teste não parece integrado , mas adicionado posteriormente, como um extra que é bom ter, e principalmente na forma de asserções. Acho que provavelmente virá uma revisão dessa atitude.
Os testes são repetitivos
O cenário: uma entrada do usuário que deve ser higienizada. Os exemplos de teste: muitas strings inválidas, algumas strings válidas e afirmações sobre os resultados.
Quantas cordas são suficientes? Quantos casos os testes devem cobrir?
Em uma abordagem de caixa branca, a resposta pode ser: tantos casos quanto exceções reais são codificados no algoritmo (que pode ser posteriormente otimizado para ter o menor número de exemplos possível). Embora nesta abordagem os exemplos de teste sejam poucos e passíveis de manutenção, não é favorável a um bom design, e o teste de caixa branca é geralmente reconhecido como trapaça.
Em uma abordagem de caixa preta, a estratégia geralmente é a intuição ou alguma heurística para descoberta de algoritmo (como a ainda incompleta, mas bastante esclarecedora, Premissa de Prioridade de Transformação ). Como atirar dardos, a quantidade de exemplos de teste necessários para cobrir cada método pode ser bastante atraente sem que os exemplos difiram muito entre si. É aqui que geralmente começa a repetição.
Ferramentas inteligentes como o Cucumber possibilitam que certos dados nos testes sejam aprovados em um formato de fácil leitura, mas a consequência indesejada é que o teste real agora é ainda mais contra-intuitivo, uma vez que os dados passados estão em um formato inteiramente arquivo diferente. Isso também torna muito confuso para depuração, já que casos individuais são mais difíceis de distinguir e interceptar em tempo de execução.
A simulação de dados é uma disciplina recém-nascida
Concedido, existem várias ferramentas de simulação de dados por aí, mas a maioria é adequada apenas para fins específicos, seja muito restrito (preencher um banco de dados MySQL com uma configuração semelhante ao WordPress) ou muito geral (gerar strings aleatórias de um determinado comprimento). Uma boa ferramenta de simulação de dados deve ter alguns recursos ainda ausentes na maioria das ferramentas:
- Os dados usados em testes devem ser fáceis de salvar em um formato intuitivo e prático.
- O desenvolvedor deve ser capaz de interagir com os casos de teste específicos para cada dado.
- Para cenários específicos, uma abordagem de plug-ins em cascata pode ser usada: por exemplo, a configuração do MySQL do WordPress deve ser realizada com um plug-in SQL estendido por um plug- in SQL.WordPress .
- As regras para geração de dados não devem ser misteriosas: fornecer um ABNF das entradas possíveis é exatamente o que um codificador não é capaz de fazer nos estágios iniciais de desenvolvimento, quando as entradas válidas às vezes não são definidas vagamente.
- Deve ser possível coletar dados de testes existentes. É uma preocupação crucial para a manutenção da base de código que o desenvolvedor deve ser capaz, não apenas de gerar casos de teste automaticamente e interagir com cada um deles manualmente, mas também de coletar dados de casos de teste existentes e ser capaz de regenerar casos de teste a partir dos dados . Isso pode ser usado em uma camada diferente do aplicativo, como o navegador em vez do servidor, ou em uma nova implementação escrita em um idioma diferente.
Os testes são difíceis de mudar
Você já tentou refatorar uma base de código alterando nomes e funções em um conjunto de classes amplamente testado? Se não for o caso: que isso nunca aconteça com você. É o mais difícil desafio intelectual que já enfrentei. Eu suava, eu juro, fazia diagramas, bebia muito café. Perdi muito sono. Refatorações de tamanho médio acontecem e o momento não é bem-vindo: elas geralmente acontecem no meio do projeto, quando o design deve ter se cristalizado, a equipe não está tão motivada como nos estágios iniciais e a base de código atingiu um tamanho não desprezível; o estágio em que pode levar até quinze minutos para revisitar o código a fim de lembrar o que aquele método fez. Na maioria das vezes, geralmente acabo saindo para uma caminhada e um café com leite apenas para decidir construir a nova implementação principalmente do zero,
O fato é que, quando confrontados com a perspectiva de refatoração, os testes pioraram tudo. Os testes são os reflexos finais do design da classe, mas eles não são tão maleáveis quanto a UML ou o proverbial guardanapo – na verdade, se não fosse pelos testes, os reprojetos parciais seriam mais como uma dor de cabeça em vez da escala completa tempestades, eles realmente são.
São as coisas triviais que te pegam. Quantas tarefas demoradas, entorpecentes e aparentemente inúteis você pode esperar ao fazer tal refatoração? Alguns deles:
- Grande quantidade de renomeação de arquivos
- Pesquise e substitua em toda a base de código
- Pesquise e substitua a mesma palavra com distinção entre maiúsculas e minúsculas em nomes de classes, nomes de métodos e referências
- Correção de consistência de comentários (por exemplo: se a classe chamada Cachorro agora se chama Gato, os comentários não devem falar sobre o latido do Gato)
- Pesquisa exigente e substituição: pesquise uma palavra-chave que representa um elemento da arquitetura cuja função mudou e corrija cada instância para o novo comportamento, removendo onde estiver obsoleto e adicionando-o posteriormente em novos lugares.
O potencial para erros é enorme. Inferno, mesmo para ser capaz de escrever um roteiro de todas as mudanças necessárias, você precisa revisitar toda a base de código várias vezes – e ter o tempo e a sabedoria para construir um roteiro é uma bênção rara. Se você planeja fazer isso pelo livro, cada pequena refatoração (como uma renomeação de método) requer um commit dedicado no SVC e uma execução de todos os testes disponíveis. É extremamente demorado.
A má notícia é que já enfrentei esse mesmo cenário várias vezes, e é bem provável que o enfrente novamente em um futuro próximo. E uma vez que BDD / TDD geram repetição e esforço cognitivo, meu próprio entusiasmo em manter o código coberto de forma adequada amplifica o esforço a ser investido em adaptá-lo aos requisitos em mudança.
BDD / TDD gerando inércia extra e tornando o código menos maleável é uma situação bastante paradoxal, já que um dos principais objetivos do teste é permitir a manutenção . Felizmente, esse problema está na implementação atual da estrutura de base do código (a hierarquia de arquivos, o detalhamento das linguagens de programação) e não na prática de teste em si, então posso imaginar um momento em que projetos enxutos serão viáveis com tempo e recursos limitados sem sacrificar uma prática útil como o teste.
Enquanto estiver no clima de descrever problemas revelados pelas lutas na prática de BDD / TDD:
APIs modernas não se refletem em uma hierarquia de classes
Este é um problema que atinge o âmago do BDD / TDD e do design de software em geral. Como você descreveria uma API que consiste parcialmente em métodos incorporados na Object
superclasse (como RSpec) em termos tradicionais de hierarquia de classes? Embora seja possível, é bastante artificial (como pode ser visto na implementação real do RSpec). No final, outro conjunto de testes pode ser necessário para fornecer exemplos de uma perspectiva mais semelhante ao uso real pretendido. Essa separação cria uma dor de cabeça para os implementadores, que devem ter em mente a API peculiar e amigável, ao mesmo tempo em que criam uma arquitetura contra-intuitiva para suportá-la.
Pior ainda: você já tentou testar uma biblioteca Ruby ou JavaScript que depende muito de funções de callback anônimas passadas como argumento e pode fazer várias chamadas de método intermediário passando callbacks anônimos também? Se você fez isso, sabe que a hierarquia de classes dificilmente é capaz de refletir o comportamento do código assíncrono e introduz uma enorme quantidade de burocracia.
Um dos princípios do teste de unidade TDD, conforme aplicado no JUnit, é que cada classe tenha sua classe de teste correspondente. Isso transpareceu na maioria das estruturas de teste modernas como a abordagem padrão, mesmo quando a abordagem orientada a objetos clássica está sendo desafiada ou submetida a diferentes paradigmas. Há um problema aqui e não é secundário.
Cucumber visa implicitamente resolver esse problema com os cenários de teste de texto simples, mas, como eu disse antes neste artigo, ele funciona com a desvantagem de tornar o código de teste real mais obscuro e menos automatizável.
Até agora, a única solução eficaz para este desafio que encontrei é apresentada no meu framework de teste favorito, Vows.js , que retrabalha a estrutura do exemplo de teste: em vez de antes do teste , teste e depois dos testes , você obtém o tópico (o exemplo) e as afirmações . Os tópicos podem ser aninhados para que os exemplos possam ser construídos em várias etapas. Mas não é uma solução completa porque os outros problemas (repetição, esforço cognitivo, inércia) não são resolvidos.
O que eu quero ver é uma linguagem de próxima geração
Eu sinto que a declaração de um problema está incompleta a menos que eu tente descrever pelo menos alguns trabalhos em direção a uma solução viável. Como seria uma ferramenta BDD / TDD de nova geração?
Meu ponto é que o problema não está nas ferramentas, mas no próprio paradigma de programação. Eu imagino uma linguagem na qual reflexão e metaprogramação são uma parte natural, e não um hack. Imagino uma linguagem em que os retornos de chamada não sejam considerados cidadãos de segunda classe. Eu imagino uma linguagem na qual a estrutura do código é versátil o suficiente para refletir APIs heterodoxas, como as populares Linguagens Específicas de Domínio. Eu imagino que a biblioteca central da linguagem tenha métodos de asserção úteis, incluindo expectativas de mensagens assíncronas .
Posso imaginar um ambiente que permite que as asserções sejam incorporadas ao código real para testes leves durante a codificação. Imagino ferramentas para encapsular exemplos de teste gerados no REPL com descrições significativas geradas automaticamente.
Isso seria legal.