O TDD torna redundante a programação defensiva?


104

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?


120
Você entrega sua biblioteca totalmente testada em unidade sem verificações de construtor para um cliente usar, e elas quebram o contrato de classe. De que serve esses testes de unidade agora?
Robert Harvey

42
OMI é o contrário. A programação defensiva, as pré e pró-condições adequadas e um sistema de tipo avançado tornam os testes redundantes.
gardenhead

37
Posso postar uma resposta que apenas diga "Bom pesar?" A programação defensiva protege o sistema em tempo de execução. Os testes verificam todas as condições potenciais de tempo de execução que o testador pode pensar, incluindo argumentos inválidos transmitidos aos construtores e outros métodos. Os testes, se concluídos, confirmarão que o comportamento do tempo de execução será o esperado, incluindo exceções apropriadas lançadas ou outro comportamento intencional que ocorre quando argumentos inválidos são transmitidos. Mas os testes não fazem nada para proteger o sistema em tempo de execução.
Craig

16
"testes de unidade devem pegar qualquer uso incorreto da classe" - como, como? Os testes de unidade mostrarão o comportamento dado argumentos corretos e quando dados argumentos incorretos; eles não podem mostrar todos os argumentos que serão dados.
OJFord 24/09/16

34
Eu não acho que tenha visto um exemplo melhor de como o pensamento dogmático sobre desenvolvimento de software pode levar a conclusões prejudiciais.
precisa saber é o seguinte

Respostas:


196

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.

Ou as duas técnicas se complementam?

TDD irá desenvolver os testes. A implementação da validação de parâmetros fará com que eles sejam aprovados.


7
Não discordo da crença de que a validação de pré-condição deve ser testada, mas discordo da opinião de meu colega de que o trabalho extra causado pela necessidade de testar a validação de pré-condição é um argumento para não criar a validação de pré-condição no primeiro Lugar, colocar. Eu editei minha postagem para esclarecer.
user2180613

20
@ user2180613 Crie um teste que teste se uma falha da pré-condição é tratada adequadamente: agora, adicionar a verificação não é um trabalho "extra", é um trabalho exigido pelo TDD para tornar o teste verde. Se a opinião do seu colega é de que você deve fazer o teste, observá-lo com falha e, em seguida, e somente então implementar a verificação de pré-condição, ele pode ter razão do ponto de vista do purista do TDD. Se ele está dizendo apenas para ignorar a verificação completamente, então ele está sendo bobo. Não há nada no TDD que diga que você não pode ser proativo ao escrever testes para possíveis modos de falha.
RM

4
@ RM Você não está escrevendo um teste para testar a verificação de pré-condição. Você está escrevendo um teste para testar o comportamento correto esperado do código chamado. As verificações de pré-condição são, do ponto de vista do teste, um detalhe de implementação opaco que garante o comportamento correto. Se você pensar em uma maneira melhor de garantir o estado correto no código chamado, faça-o dessa maneira, em vez de usar uma verificação de pré-condição tradicional. O teste confirmará se você foi ou não bem-sucedido e ainda não saberá nem se importará com o que fez.
Craig

@ user2180613 Essa é uma justificativa impressionante: D se seu objetivo ao escrever software é reduzir o número de testes necessários para criar e executar, não escreva nenhum software - zero testes!
Gusdor 25/09/16

3
Essa última sentença desta resposta está correta.
Robert Grant

32

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.


É correto dizer que a validação de parâmetro é uma forma de validação de pré-condição e os testes de unidade são validações pós-condição, e é por isso que eles se complementam?
user2180613

1
"É o mesmo que testar qualquer outro código; você deseja garantir que todos os usos, mesmo os inválidos, tenham um resultado esperado". Este. Nenhum código deve passar apenas quando sua entrada passada não foi projetada para lidar. Isso viola o princípio "falha rápida" e pode tornar a depuração um pesadelo.
Jpmc26

@ user2180613 - não realmente, mas mais testes de unidade verificam as condições de falha que o desenvolvedor está esperando, enquanto as técnicas de programação defensiva verificam as condições que o desenvolvedor não está esperando. Os testes de unidade podem ser usados ​​para validar pré-condições (usando um objeto simulado injetado no chamador que verifica a pré-condição).
Periata Breatta

1
@ jpmc26 Sim, a falha é o "resultado esperado" para o teste. Você testa para mostrar que ela falha, em vez de exibir silenciosamente algum comportamento indefinido (inesperado).
precisa saber é o seguinte

6
O TDD captura erros no seu próprio código, a programação defensiva captura erros no código de outras pessoas. TDD, portanto, pode ajudar a garantir que você está :) bastante defensiva
jwenting

30

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.


16

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.


2
Isso é engraçado, o voto negativo veio tão rápido que agora existe uma maneira absolutamente absoluta de o leitor ter lido além do primeiro parágrafo.
Craig

1
:-) Eu apenas up-votado sem ler além do primeiro parágrafo, por isso espero que irá equilibrar isso ...
SusanW

1
Parecia o mínimo que eu poderia fazer :-) (Na verdade, eu queria ler o resto só para ter certeza não deve ser desleixado -.! Especialmente sobre um tema como este)
SusanW

1
Achei que você provavelmente tivesse. :)
Craig

verificações defensivas podem ser feitas em tempo de compilação com ferramentas como contratos de código.
Matthew Whited

9

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:

  1. As asserções são opcionais; portanto, se você não gostar, basta executar o sistema com as desabilitadas.

  2. 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.)

  3. 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.

  4. 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?

  5. 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.

  6. 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.

  7. 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.

  8. 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ção x == 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"


Acho que discordo principalmente desta resposta. (5) No TDD, a suíte de testes é a especificação. Você deveria escrever o código mais simples para fazer os testes passarem, nada mais. (4) O fluxo de trabalho vermelho-verde garante que o teste falhe quando deveria e passe quando a funcionalidade pretendida estiver presente. As afirmações não ajudam muito aqui. (3,7) Documentação é documentação, asserções não. Mas, ao tornar explícitas as suposições, o código se torna mais auto-documentado. Eu pensaria neles como comentários executáveis. (2) O teste de caixa branca pode fazer parte de uma estratégia de teste válida.
amon

5
"No TDD, a suíte de testes é a especificação. Você deve escrever o código mais simples para fazer os testes passarem, nada mais.": Eu não acho que isso sempre seja uma boa idéia: como indicado na resposta, existem suposição interna adicional no código que se deseja verificar. E os erros internos que se cancelam? Seus testes passam, mas algumas suposições dentro do seu código estão erradas, o que pode levar a erros insidiosos mais tarde.
Giorgio

5

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.


4
Se o procedimento chamado não verificar a validade das entradas (que é o debate original), seus testes de unidade não poderão garantir que o módulo em questão seja chamado apenas com entrada válida. Em particular, pode ser chamado com entrada inválida, mas retorna um resultado correto de qualquer maneira nos casos testados - existem vários tipos de comportamento indefinido, manipulação de estouro, etc. que podem retornar o resultado esperado em um ambiente de teste com otimizações desabilitadas, mas falha na produção.
Peteris

@ Peteris: Você está pensando em comportamento indefinido como em C? A invocação de comportamentos indefinidos com resultados diferentes em ambientes diferentes é obviamente um bug, mas também não pode ser evitada por verificações de pré-condição. Por exemplo, como você verifica os argumentos de um ponteiro para memória válida?
precisa saber é o seguinte

3
Isso funcionará apenas nas menores lojas. Quando sua equipe ultrapassar, digamos, seis pessoas, você precisará das verificações de validação de qualquer maneira.
Robert Harvey

1
@RobertHarvey: nesse caso, o sistema deve ser particionado em subsistemas com interfaces bem definidas e a validação de entrada realizada na interface.
precisa saber é o seguinte

esta. Depende do código, esse código deve ser usado pela equipe? A equipe tem acesso ao código fonte? Se o seu código puramente interno, a verificação de argumentos pode ser um fardo, por exemplo, você verifica 0 e, em seguida, lança a exceção, e o chamador analisa o código oh, essa classe pode lançar a exceção etc. etc. e aguarde .. neste caso, O objeto nunca receberá 0, pois são filtrados 2 lvls antes. Se esse é um código de biblioteca a ser usado por terceiros, isso é outra história. Nem todo o código é escrito para ser usado pelo mundo inteiro.
Aleksander Fular

3

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.


2

Acho que interpreto as observações do seu colega de maneira diferente da maioria das demais respostas.

Parece-me que o argumento é:

  • Todo o nosso código é testado por unidade.
  • Todo o código que usa seu componente é o nosso código, ou, se não, é testado por outra pessoa (não declarado explicitamente, mas é o que eu entendo em "testes de unidade devem pegar qualquer uso incorreto da classe").
  • Portanto, para cada chamador de sua função, há um teste de unidade em algum lugar que zomba do seu componente, e o teste falha se o chamador passar um valor inválido para esse mock.
  • Portanto, não importa o que sua função faz quando passa um valor inválido, porque nossos testes dizem que isso não pode acontecer.

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!


Esse negócio de enfatizar o teste dos chamadores para garantir que eles não passem valores ruins parece prestar-se a códigos frágeis, com muitas dependências de reconhecimento de graves e sem separação clara de preocupações. Realmente não acho que gostaria do código que resultaria do pensamento por trás dessa abordagem.
Craig

@ Craig: olhe dessa maneira, se você isolou um componente para teste zombando de suas dependências, por que você não testaria se ele apenas passa valores corretos para essas dependências? E se você não pode isolar o componente, realmente separou as preocupações? Não discordo da codificação defensiva, mas se as verificações defensivas são o meio pelo qual você está testando a correção da chamada de código, isso é uma bagunça. Então eu acho que o colega do questionador está correto que os controlos são redundantes, mas errado ver isso como uma razão para não escrevê-los :-)
Steve Jessop

o único buraco flagrante que vejo é que ainda estou testando apenas se meus próprios componentes não podem passar valores inválidos para essas dependências, o que eu concordo totalmente que devem ser feitas, mas quantas decisões são tomadas por quantos gerentes de negócios tornam uma empresa privada público componente para que os parceiros possam chamá-lo? Na verdade, isso está me lembrando o design do banco de dados e todo o atual caso amoroso com ORMs, resultando em muitas pessoas (principalmente jovens) declarando que os bancos de dados são apenas armazenamento de rede idiota e não devem se proteger com restrições, chaves estrangeiras e procedimentos armazenados.
Craig

A outra coisa que vejo é que nesse cenário, é claro, é que você está apenas testando chamadas para zombarias, não para as dependências reais. Em última análise, é o código nessas dependências que podem ou não funcionar adequadamente com um valor passado específico, não o código no chamador. Portanto, a dependência precisa fazer a coisa certa e deve haver cobertura de teste independente suficiente da dependência para garantir que ela o faça. Lembre-se, esses testes sobre os quais estamos falando são chamados de testes "unitários". Cada dependência é uma unidade. :)
Craig

1

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.


O cabeçalho e o primeiro parágrafo disso são verdadeiros, porque não são os testes de unidade que estarão exercitando o código em tempo de execução. Qualquer que seja o outro código de tempo de execução, as condições do mundo real, as más entradas do usuário e as tentativas de hacking interagem com o código.
Craig

1

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.


Isso está associado a uma dificuldade filosófica geral de lidar com o que podem ser requisitos frouxos. Se uma função tiver permissão para que um domínio livre total significativo, mas não total, se comporte arbitrariamente quando receber entradas malformadas (por exemplo, se um decodificador de imagem atender aos requisitos, se for garantido que - à vontade - produza alguma combinação arbitrária de pixels ou termina anormalmente , mas não se permitir que entradas criadas com códigos maliciosos executem código arbitrário), pode não estar claro quais casos de teste seriam apropriados para garantir que nenhuma entrada produza um comportamento inaceitável.
Supercat

1

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 nullpara Foo::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 nullpara Foo::Frobnicate(Widget widget).

E você documenta essa decisão incluindo um

[Test]
void Foo_FrobnicatesANullWidget_ThrowsInvalidArgument() 
{
    Given(Foo foo);
    When(foo.Frobnicate(null));
    Then(Expect_Exception(InvalidArgument));
}

1

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.


0

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.


1
Não fiz voto negativo, mas concordo com os votos negativos com a premissa de que adicionar distinções sutis a esse tipo de argumento atrapalha a água.
Craig

@ Craig Eu estaria interessado em seus comentários sobre o exemplo específico que adicionei.
Blackhawk

Eu gosto da especificidade do exemplo. A única preocupação que ainda tenho é geral para todo o argumento. Por exemplo; vem um novo desenvolvedor na equipe e escreve um novo componente que usa esse módulo financeiro. O novo cara não está ciente de todas as complexidades do sistema, muito menos o fato de que todo tipo de conhecimento especializado sobre como o sistema deve operar está incorporado nos testes, e não no código que está sendo testado.
Craig

Portanto, o novo sujeito / garota deixa de criar alguns testes vitais, E você acaba com redundância em seus testes - testes em diferentes partes do sistema estão verificando as mesmas condições e se tornam inconsistentes com o passar do tempo, em vez de apenas colocar afirmações e a pré-condição verifica o código onde está a ação.
Craig

1
Algo parecido. Exceto que muitos dos argumentos aqui foram sobre a realização de todos os testes para o código de chamada. Mas se você tem algum grau de fan-in, acaba fazendo as mesmas verificações de vários lugares diferentes, e isso é um problema de manutenção por si só. E se o intervalo de entradas válidas para um procedimento for alterado, mas você tiver o conhecimento de domínio desse intervalo incorporado em testes que exercitam componentes diferentes? Ainda sou totalmente a favor da programação defensiva e uso de perfis para determinar se e quando você tem problemas de desempenho para resolver.
Craig

0

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:

uint8_t AddTwoBytes(uint8_t a, uint8_t b, uint8_t *sum); 

Agora, se você simplesmente fizesse *(sum) = a + bisso funcionaria, mas apenas com algumas entradas. a = 1e b = 2faria sum = 3; no entanto, porque o tamanho da soma é um byte a = 100e b = 200seria sum = 44devido 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.


Isso parece um bom exemplo de pergunta-entrevista (por que ele tem um valor de retorno e um parâmetro "out" - e o que acontece quando sumé um ponteiro nulo?).
Toby Speight
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.