Hoje tive uma discussão interessante com um colega.
Eu sou um programador defensivo. Eu acredito que a regra " uma classe deve garantir que seus objetos tenham um estado válido quando interagidos com fora da classe " deve sempre ser respeitada. O motivo dessa regra é que a classe não sabe quem são seus usuários e que ela deve falhar previsivelmente quando for interagida de maneira ilegal. Na minha opinião, essa regra se aplica a todas as classes.
Na situação específica em que tive uma discussão hoje, escrevi um código que valida que os argumentos para o meu construtor estão corretos (por exemplo, um parâmetro inteiro deve ser> 0) e se a pré-condição não for atendida, uma exceção será lançada. Meu colega, por outro lado, acredita que essa verificação é redundante, porque os testes de unidade devem detectar qualquer uso incorreto da classe. Além disso, ele acredita que as validações de programação defensiva também devem ser testadas por unidade, de modo que a programação defensiva acrescenta muito trabalho e, portanto, não é ideal para TDD.
É verdade que o TDD é capaz de substituir a programação defensiva? A validação de parâmetro (e não quero dizer a entrada do usuário) é desnecessária como consequência? Ou as duas técnicas se complementam?
fonte
Respostas:
Isso é ridículo. O TDD força o código a passar nos testes e obriga todo o código a ter alguns testes em torno dele. Isso não impede que seus consumidores chamem código incorretamente, nem magicamente impede que os programadores percam os casos de teste.
Nenhuma metodologia pode forçar os usuários a usar o código corretamente.
Não é uma ligeira argumento a ser feito que, se você perfeitamente fez TDD você teria pego seu> 0 check-in um caso de teste, antes de implementá-la, e abordou este - provavelmente por você adicionando o cheque. Mas se você fizesse TDD, seu requisito (> 0 no construtor) apareceria primeiro como uma caixa de teste que falha. Assim, você fará o teste depois de adicionar seu cheque.
Também é razoável testar algumas das condições defensivas (você adicionou lógica, por que não deseja testar algo tão facilmente testável?). Não sei por que você parece discordar disso.
TDD irá desenvolver os testes. A implementação da validação de parâmetros fará com que eles sejam aprovados.
fonte
A programação defensiva e os testes de unidade são duas maneiras diferentes de detectar erros e cada uma tem pontos fortes diferentes. Usar apenas uma maneira de detectar erros torna seus mecanismos de detecção de erros frágeis. O uso de ambos detectará erros que podem ter sido perdidos por um ou outro, mesmo no código que não é uma API voltada ao público; por exemplo, alguém pode ter esquecido de adicionar um teste de unidade para dados inválidos passados para a API pública. Verificar tudo em locais apropriados significa mais chances de detectar o erro.
Em segurança da informação, isso é chamado de Defesa Profunda. Ter várias camadas de defesa garante que, se uma falhar, ainda haverá outras para capturá-la.
Seu colega está certo sobre uma coisa: você deve testar suas validações, mas isso não é "trabalho desnecessário". É o mesmo que testar qualquer outro código. Você deseja garantir que todos os usos, mesmo os inválidos, tenham um resultado esperado.
fonte
O TDD não substitui absolutamente a programação defensiva. Em vez disso, você pode usar o TDD para garantir que todas as defesas estejam no lugar e funcionem conforme o esperado.
No TDD, você não deve escrever código sem antes escrever um teste - siga religiosamente o ciclo de refatoração verde-vermelho. Isso significa que, se você deseja adicionar a validação, primeiro escreva um teste que exija essa validação. Chame o método em questão com números negativos e com zero e espere que ele gere uma exceção.
Além disso, não esqueça a etapa "refatorar". Enquanto o TDD é conduzido por teste , isso não significa apenas teste . Você ainda deve aplicar o design adequado e escrever um código sensato. Escrever código defensivo é um código sensato, porque torna as expectativas mais explícitas e seu código, em geral, mais robusto - detectar possíveis erros mais cedo facilita a depuração.
Mas não devemos usar testes para localizar erros? Afirmações e testes são complementares. Uma boa estratégia de teste combinará várias abordagens para garantir que o software seja robusto. Somente testes de unidade ou somente testes de integração ou apenas afirmações no código são todos insatisfatórios. Você precisa de uma boa combinação para atingir um grau suficiente de confiança em seu software com esforço aceitável.
Depois, há um grande mal-entendido conceitual de seu colega de trabalho: Os testes de unidade nunca podem testar os usos de sua classe, apenas que a própria classe funciona como o esperado isoladamente. Você usaria testes de integração para verificar se a interação entre vários componentes funciona, mas a explosão combinatória de possíveis casos de teste torna impossível testar tudo. Os testes de integração devem, portanto, restringir-se a alguns casos importantes. Testes mais detalhados que também abrangem casos extremos e erros são mais adequados para testes unitários.
fonte
Existem testes para apoiar e garantir uma programação defensiva
A programação defensiva protege a integridade do sistema em tempo de execução.
Os testes são ferramentas de diagnóstico (principalmente estáticas). Em tempo de execução, seus testes não estão à vista. São como andaimes usados para erguer uma parede alta de tijolos ou uma cúpula de pedra. Você não deixa peças importantes fora da estrutura porque possui um andaime segurando-o durante a construção. Você tem um andaime segurando-o durante a construção para facilitar a colocação de todas as peças importantes.
EDIT: Uma analogia
Que tal uma analogia com os comentários no código?
Os comentários têm seu objetivo, mas podem ser redundantes ou até prejudiciais. Por exemplo, se você colocar conhecimentos intrínsecos sobre o código nos comentários , altere o código, os comentários se tornarão irrelevantes na melhor das hipóteses e prejudiciais na pior.
Então, digamos que você coloque muito conhecimento intrínseco da sua base de código nos testes, como o Método A não pode aceitar um nulo e o argumento do Método B deve ser
> 0
. Então o código muda. Nulo é aceitável para A agora e B pode assumir valores tão pequenos quanto -10. Os testes existentes agora estão funcionalmente errados, mas continuarão a ser aprovados.Sim, você deve atualizar os testes ao mesmo tempo em que atualiza o código. Você também deve atualizar (ou remover) os comentários ao mesmo tempo em que atualiza o código. Mas todos sabemos que essas coisas nem sempre acontecem e que erros são cometidos.
Os testes verificam o comportamento do sistema. Esse comportamento real é intrínseco ao próprio sistema, não intrínseco aos testes.
O que poderia dar errado?
O objetivo dos testes é pensar em tudo o que pode dar errado, escrever um teste para verificar o comportamento correto e criar o código de tempo de execução para que ele passe em todos os testes.
O que significa que a programação defensiva é o ponto .
O TDD aciona a programação defensiva, se os testes forem abrangentes.
Mais testes, gerando uma programação mais defensiva
Quando erros são inevitavelmente encontrados, mais testes são escritos para modelar as condições que manifestam o erro. Em seguida, o código é corrigido, com o código para fazer esses testes passarem, e os novos testes permanecem no conjunto de testes.
Um bom conjunto de testes passa os argumentos bons e ruins para uma função / método e espera resultados consistentes. Por sua vez, isso significa que o componente testado usará verificações de pré-condição (programação defensiva) para confirmar os argumentos passados para ele.
Genericamente falando ...
Por exemplo, se um argumento nulo para um procedimento específico é inválido, pelo menos um teste passa em nulo e espera uma exceção / erro "argumento nulo inválido" de algum tipo.
Pelo menos um outro teste passa um argumento válido , é claro - ou passa por uma grande matriz e passa por vários argumentos válidos - e confirma que o estado resultante é apropriado.
Se um teste não passa nesse argumento nulo e recebe um tapa com a exceção esperada (e essa exceção foi lançada porque o código verificou defensivamente o estado passado a ele), o nulo pode acabar atribuído à propriedade de uma classe ou enterrado em uma coleção de algum tipo onde não deveria estar.
Isso pode causar comportamento inesperado em alguma parte totalmente diferente do sistema para a qual a instância da classe é passada, em algum local geográfico distante após o envio do software . E esse é o tipo de coisa que realmente estamos tentando evitar, certo?
Pode até ser pior. A instância da classe com o estado inválido pode ser serializada e armazenada, apenas para causar uma falha quando for reconstituída para uso posterior. Nossa, eu não sei, talvez seja um sistema de controle mecânico de algum tipo que não possa reiniciar após um desligamento, porque não pode desserializar seu próprio estado de configuração persistente. Ou a instância da classe pode ser serializada e passada para um sistema totalmente diferente criado por outra entidade, e esse sistema pode falhar.
Especialmente se os programadores do outro sistema não codificassem defensivamente.
fonte
Em vez de TDD, vamos falar sobre "teste de software" em geral, e em vez de "programação defensiva" em geral, vamos falar sobre minha maneira favorita de fazer programação defensiva, que é usando afirmações.
Então, como fazemos testes de software, devemos parar de colocar as declarações de asserção no código de produção, certo? Deixe-me contar as maneiras pelas quais isso está errado:
As asserções são opcionais; portanto, se você não gostar, basta executar o sistema com as desabilitadas.
As asserções verificam coisas que o teste não pode (e não deveria). Como o teste deve ter uma visão de caixa preta do seu sistema, enquanto as asserções têm uma visão de caixa branca. (É claro, já que eles moram nela.)
As asserções são uma excelente ferramenta de documentação. Nenhum comentário foi ou será tão inequívoco quanto um pedaço de código que afirma a mesma coisa. Além disso, a documentação tende a ficar desatualizada à medida que o código evolui e não é de forma alguma aplicável pelo compilador.
As asserções podem detectar erros no código de teste. Você já se deparou com uma situação em que um teste falha e não sabe quem está errado - o código de produção ou o teste?
As asserções podem ser mais pertinentes que o teste. Os testes verificarão o que é prescrito pelos requisitos funcionais, mas o código geralmente precisa fazer certas suposições muito mais técnicas que isso. As pessoas que escrevem documentos de requisitos funcionais raramente pensam em divisão por zero.
As asserções identificam erros nos quais apenas os testes sugerem amplamente. Portanto, seu teste configura algumas pré-condições extensas, invoca um longo pedaço de código, reúne os resultados e descobre que eles não são o esperado. Dada a solução de problemas suficiente, você acabará descobrindo exatamente onde as coisas deram errado, mas as afirmações geralmente o encontrarão primeiro.
As asserções reduzem a complexidade do programa. Cada linha de código que você escreve aumenta a complexidade do programa. As asserções e a palavra-chave
final
(readonly
) são as únicas duas construções que eu conheço que na verdade reduzem a complexidade do programa. Isso não tem preço.As asserções ajudam o compilador a entender melhor seu código. Por favor, tente fazer isso em casa:
void foo( Object x ) { assert x != null; if( x == null ) { } }
seu compilador deve emitir um aviso informando que a condiçãox == null
é sempre falsa. Isso pode ser muito útil.O acima foi um resumo de uma postagem do meu blog, 21/09/2014 "Assertions and Testing"
fonte
Acredito que a maioria das respostas está sem uma distinção crítica: depende de como o seu código será usado.
O módulo em questão será usado por outros clientes independentemente do aplicativo que você está testando? Se você estiver fornecendo uma biblioteca ou API para uso de terceiros, não há como garantir que eles chamem seu código apenas com entrada válida. Você precisa validar todas as entradas.
Mas se o módulo em questão for usado apenas pelo código que você controla, seu amigo pode ter razão. Você pode usar testes de unidade para verificar se o módulo em questão é chamado apenas com entrada válida. As verificações de pré-condição ainda podem ser consideradas uma boa prática, mas é uma troca: se você desarruma o código que verifica a condição que você sabe que nunca pode surgir, apenas obscurece a intenção do código.
Não concordo que as verificações de pré-condição exijam mais testes de unidade. Se você decidir que não precisa testar algumas formas de entrada inválidas, não deve importar se a função contém verificações de pré-condição ou não. Lembre-se de que os testes devem verificar o comportamento, não os detalhes da implementação.
fonte
Esse argumento me deixa desconcertado, porque quando comecei a praticar TDD, meus testes de unidade do formulário "objeto respondem <certa maneira> quando <entrada inválida>" aumentou 2 ou 3 vezes. Gostaria de saber como seu colega está conseguindo passar com êxito nesses tipos de testes de unidade sem que suas funções façam a validação.
O caso inverso, que os testes de unidade mostram que você nunca está produzindo resultados ruins que serão passados para os argumentos de outras funções, é muito mais difícil de provar. Como o primeiro caso, isso depende muito da cobertura completa de casos extremos, mas você tem o requisito adicional de que todas as suas entradas de funções venham das saídas de outras funções cujas saídas você testou pela unidade e não de, digamos, entrada do usuário ou módulos de terceiros.
Em outras palavras, o que o TDD faz não impede que você precise de um código de validação, mas ajuda a evitar esquecê- lo.
fonte
Acho que interpreto as observações do seu colega de maneira diferente da maioria das demais respostas.
Parece-me que o argumento é:
Para mim, esse argumento tem alguma lógica, mas confia demais em testes de unidade para cobrir todas as situações possíveis. O fato simples é que 100% de cobertura de linha / filial / caminho não exercem necessariamente todo valor que o chamador possa transmitir, enquanto 100% de cobertura de todos os estados possíveis do chamador (ou seja, todos os valores possíveis de suas entradas) e variáveis) é computacionalmente inviável.
Portanto, eu preferiria testar a unidade dos chamadores para garantir que (na medida em que os testes sejam realizados) nunca passem com valores ruins e, além disso, exigir que seu componente falhe de alguma maneira reconhecível quando um valor ruim for passado ( pelo menos na medida do possível reconhecer valores ruins no idioma de sua escolha). Isso ajudará na depuração quando ocorrerem problemas nos testes de integração e também ajudará qualquer usuário da sua classe que seja menos rigoroso ao isolar sua unidade de código dessa dependência.
No entanto, esteja ciente de que, se você documentar e testar o comportamento de sua função quando um valor <= 0 for passado, os valores negativos não serão mais inválidos (pelo menos, não serão mais inválidos do que qualquer outro argumento
throw
, pois também está documentado para lançar uma exceção!). Os chamadores têm o direito de confiar nesse comportamento defensivo. Se o idioma permitir, pode ser que esse seja o melhor cenário - a função não possui "entradas inválidas", mas os chamadores que esperam não provocar a função a lançar uma exceção devem ser testados em unidade o suficiente para garantir que não sejam " Não passe nenhum valor que cause isso.Apesar de pensar que seu colega está um pouco menos errado do que a maioria das respostas, chego à mesma conclusão, que é que as duas técnicas se complementam. Programe defensivamente, documente seus cheques defensivos e teste-os. O trabalho é apenas "desnecessário" se os usuários do seu código não puderem se beneficiar de mensagens de erro úteis quando cometerem erros. Em teoria, se eles testarem exaustivamente todo o código antes de integrá-lo ao seu, e nunca houver erros nos testes, eles nunca verão as mensagens de erro. Na prática, mesmo se eles estiverem fazendo TDD e injeção total de dependência, eles ainda podem explorar durante o desenvolvimento ou pode haver um lapso nos testes. O resultado é que eles chamam seu código antes que ele seja perfeito!
fonte
Interfaces públicas podem e serão mal utilizadas
A afirmação do seu colega de trabalho "testes de unidade devem detectar qualquer uso incorreto da classe" é estritamente falsa para qualquer interface que não seja privada. Se uma função pública pode ser chamada com argumentos inteiros, ela pode e será chamada com quaisquer argumentos inteiros, e o código deve se comportar adequadamente. Se uma assinatura de função pública aceitar, por exemplo, o tipo Java Double, nulo, NaN, MAX_VALUE, -Inf serão todos os valores possíveis. Seus testes de unidade não podem capturar usos incorretos da classe porque esses testes não podem testar o código que usará essa classe, porque esse código ainda não foi gravado, talvez não tenha sido gravado por você e estará definitivamente fora do escopo de seus testes de unidade. .
Por outro lado, essa abordagem pode ser válida para as propriedades privadas (espero que muito mais numerosas) - se uma classe puder garantir que algum fato sempre seja verdadeiro (por exemplo, a propriedade X nunca poderá ser nula, a posição inteira não excederá o comprimento máximo , quando a função A é chamada, todas as estruturas de dados de pré-requisito são bem formadas), pode ser apropriado evitar verificar isso repetidamente por motivos de desempenho e, em vez disso, confiar em testes de unidade.
fonte
A defesa contra o uso indevido é um recurso desenvolvido devido a um requisito. (Nem todas as interfaces exigem verificações rigorosas contra uso indevido; por exemplo, interfaces internas usadas de maneira muito restrita.)
O recurso requer teste: a defesa contra uso indevido realmente funciona? O objetivo de testar esse recurso é tentar mostrar que não: inventar algum uso indevido do módulo que não é detectado por suas verificações.
Se verificações específicas são um recurso necessário, é realmente absurdo afirmar que a existência de alguns testes as torna desnecessárias. Se é um recurso de alguma função que (digamos) lança uma exceção quando o parâmetro três é negativo, isso não é negociável; deve fazer isso.
No entanto, suspeito que seu colega esteja realmente fazendo sentido do ponto de vista de uma situação em que não há requisitos para verificações específicas de insumos, com respostas específicas a insumos inadequados: uma situação em que há apenas um requisito geral entendido para robustez.
As verificações de entrada em alguma função de nível superior existem, em parte, para proteger algum código interno fraco ou mal testado de combinações inesperadas de parâmetros (de modo que, se o código for bem testado, as verificações não serão necessárias: o código pode simplesmente " clima "os parâmetros ruins).
Existe verdade na ideia do colega, e o que ele provavelmente quer dizer é o seguinte: se criarmos uma função com partes de nível inferior muito robustas, que são codificadas defensivamente e testadas individualmente contra todos os usos indevidos, é possível que a função de nível superior seja robusto sem ter suas próprias auto-verificações extensas.
Se seu contrato for violado, ele se traduzirá em algum uso indevido das funções de nível inferior, talvez lançando exceções ou o que for.
O único problema é que as exceções de nível inferior não são específicas para a interface de nível superior. Se isso é um problema depende de quais são os requisitos. Se o requisito for simplesmente "a função deve ser robusta contra uso indevido e gerar algum tipo de exceção em vez de travar ou continuar calculando com dados de lixo", na verdade, ela pode ser coberta por toda a robustez das peças de nível inferior nas quais está construído.
Se a função tiver um requisito para relatórios de erro detalhados e muito específicos relacionados aos seus parâmetros, as verificações de nível inferior não atenderão totalmente a esses requisitos. Eles garantem apenas que a função exploda de alguma forma (não continua com uma má combinação de parâmetros, produzindo um resultado de lixo). Se o código do cliente for escrito para capturar especificamente certos erros e manipulá-los, ele pode não funcionar corretamente. O código do cliente pode estar obtendo, como entrada, os dados nos quais os parâmetros se baseiam, e pode estar esperando que a função verifique isso e traduza valores incorretos nos erros específicos conforme documentados (para que ele possa lidar com aqueles erros corretamente) em vez de outros erros que não são tratados e talvez interrompam a imagem do software.
TL; DR: seu colega provavelmente não é um idiota; vocês estão apenas conversando um com o outro com perspectivas diferentes em torno da mesma coisa, porque os requisitos não estão totalmente definidos e cada um de vocês tem uma idéia diferente do que são os "requisitos não escritos". Você pensa que, quando não houver requisitos específicos na verificação de parâmetros, você deve codificar a verificação detalhada de qualquer maneira; o colega pensa, deixe o código de nível inferior robusto explodir quando os parâmetros estiverem errados. É um tanto improdutivo discutir sobre requisitos não escritos por meio do código: reconheça que você discorda sobre requisitos e não sobre código. Sua maneira de codificar reflete quais são os requisitos; a maneira do colega representa sua visão dos requisitos. Se você vê dessa maneira, fica claro que o que é certo ou errado não é ' t no próprio código; o código é apenas um proxy para sua opinião sobre qual deve ser a especificação.
fonte
Os testes definem o contrato da sua classe.
Como corolário, a ausência de um teste define um contrato que inclui comportamento indefinido . Portanto, quando você passa
null
paraFoo::Frobnicate(Widget widget)
, e um incontável caos em tempo de execução ocorre, você ainda está dentro do contrato de sua classe.Mais tarde, você decide: "não queremos a possibilidade de comportamento indefinido", que é uma escolha sensata. Isso significa que você precisa ter um comportamento esperado para passar
null
paraFoo::Frobnicate(Widget widget)
.E você documenta essa decisão incluindo um
fonte
Um bom conjunto de testes exercitará a interface externa da sua classe e garantirá que tais abusos gerem a resposta correta (uma exceção ou o que você definir como "correto"). De fato, o primeiro caso de teste que eu escrevo para uma classe é chamar seu construtor com argumentos fora do intervalo.
O tipo de programação defensiva que tende a ser eliminada por uma abordagem totalmente testada em unidade é a validação desnecessária de invariantes internos que não podem ser violados por código externo.
Uma idéia útil que às vezes emprego é fornecer um método que testa os invariantes do objeto; seu método de desmontagem pode chamá-lo para validar que suas ações externas no objeto nunca quebram os invariantes.
fonte
Os testes do TDD detectarão erros durante o desenvolvimento do código .
A verificação de limites que você descreve como parte da programação defensiva detectará erros durante o uso do código .
Se os dois domínios forem iguais, ou seja, o código que você está escrevendo é usado apenas internamente por esse projeto específico, pode ser verdade que o TDD impedirá a necessidade dos limites defensivos de programação que você descreve, mas somente se esses tipos A verificação de limites é realizada especificamente em testes TDD .
Como um exemplo específico, suponha que uma biblioteca de código financeiro tenha sido desenvolvida usando TDD. Um dos testes pode afirmar que um valor específico nunca pode ser negativo. Isso garante que os desenvolvedores da biblioteca não usem acidentalmente as classes à medida que implementam os recursos.
Mas depois que a biblioteca é lançada e eu a uso em meu próprio programa, esses testes TDD não me impedem de atribuir um valor negativo (supondo que seja exposto). A verificação de limites seria.
Meu argumento é que, embora uma declaração TDD possa resolver o problema de valor negativo se o código for usado apenas internamente como parte do desenvolvimento de um aplicativo maior (no TDD), se será uma biblioteca usada por outros programadores sem o TDD estrutura e testes , verificação de limites.
fonte
TDD e programação defensiva andam de mãos dadas. Usar os dois não é redundante, mas de fato complementar. Quando você tem uma função, deseja garantir que a função funcione conforme descrito e faça testes para ela; se você não cobrir o que acontece quando, no caso de uma entrada ruim, retorno ruim, estado ruim, etc., você não está escrevendo seus testes com robustez suficiente e seu código será frágil, mesmo que todos os seus testes estejam passando.
Como engenheiro incorporado, eu gosto de usar o exemplo de escrever uma função para simplesmente adicionar dois bytes e retornar o resultado da seguinte forma:
Agora, se você simplesmente fizesse
*(sum) = a + b
isso funcionaria, mas apenas com algumas entradas.a = 1
eb = 2
fariasum = 3
; no entanto, porque o tamanho da soma é um bytea = 100
eb = 200
seriasum = 44
devido ao estouro. Em C, você retornaria um erro neste caso para indicar que a função falhou; lançar uma exceção é a mesma coisa no seu código. Não considerar as falhas ou testar como lidar com elas não funcionará a longo prazo, porque, se essas condições ocorrerem, elas não serão tratadas e poderão causar vários problemas.fonte
sum
é um ponteiro nulo?).