Frequentemente, trabalho com programas numéricos / matemáticos, nos quais é difícil prever com antecedência o resultado exato de uma função.
Ao tentar aplicar o TDD com esse tipo de código, geralmente acho mais fácil escrever o código em teste do que escrever testes de unidade para esse código, porque a única maneira de saber o resultado esperado é aplicar o próprio algoritmo (seja no meu no papel ou no computador). Parece errado, porque estou efetivamente usando o código em teste para verificar meus testes de unidade, e não o contrário.
Existem técnicas conhecidas para escrever testes de unidade e aplicar TDD quando o resultado do código sob teste é difícil de prever?
Um exemplo (real) de código com resultados difíceis de prever:
Função weightedTasksOnTime
que, dada a quantidade de trabalho realizado por dia workPerDay
no intervalo (0, 24], o horário atual initialTime
> 0 e uma lista de tarefas taskArray
; cada uma com um tempo para concluir a propriedade time
> 0, data de vencimento due
e valor de importância importance
; retorna um valor normalizado no intervalo [0, 1] que representa a importância das tarefas que podem ser concluídas antes da due
data se cada tarefa for concluída na ordem dada por taskArray
, começando em initialTime
.
O algoritmo para implementar esta função é relativamente simples: repita as tarefas no taskArray
. Para cada tarefa, adicione time
a initialTime
. Se o novo horário < due
, adicione importance
a um acumulador. O tempo é ajustado pelo workPerDay inverso. Antes de devolver o acumulador, divida pela soma das importâncias da tarefa para normalizar.
function weightedTasksOnTime(workPerDay, initialTime, taskArray) {
let simulatedTime = initialTime
let accumulator = 0;
for (task in taskArray) {
simulatedTime += task.time * (24 / workPerDay)
if (simulatedTime < task.due) {
accumulator += task.importance
}
}
return accumulator / totalImportance(taskArray)
}
Acredito que o problema acima pode ser simplificado, mantendo o núcleo, removendo workPerDay
e o requisito de normalização, para fornecer:
function weightedTasksOnTime(initialTime, taskArray) {
let simulatedTime = initialTime
let accumulator = 0;
for (task in taskArray) {
simulatedTime += task.time
if (simulatedTime < task.due) {
accumulator += task.importance
}
}
return accumulator
}
Esta pergunta aborda situações em que o código em teste não é uma reimplementação de um algoritmo existente. Se o código é uma reimplementação, é intrinsecamente fácil prever os resultados, porque as implementações confiáveis existentes do algoritmo agem como um oráculo de teste natural.
fonte
Respostas:
Há duas coisas que você pode testar em código difícil de testar. Primeiro, os casos degenerados. O que acontece se você não tiver elementos em sua matriz de tarefas, ou apenas um ou dois, mas um já tiver vencido, etc. Qualquer coisa que seja mais simples que o seu problema real, mas ainda razoável para calcular manualmente.
O segundo são os testes de sanidade. Essas são as verificações que você faz onde não sabe se uma resposta está certa , mas você definitivamente saberia se está errada . São coisas como o tempo deve avançar, os valores devem estar em um intervalo razoável, as porcentagens devem somar 100, etc.
Sim, isso não é tão bom quanto um teste completo, mas você ficaria surpreso com a frequência com que você estraga as verificações de sanidade e os casos degenerados, o que revela um problema no seu algoritmo completo.
fonte
Eu costumava escrever testes para software científico com resultados difíceis de prever. Fizemos muito uso de relações metamórficas. Essencialmente, há coisas que você sabe sobre como seu software deve se comportar, mesmo que você não saiba as saídas numéricas exatas.
Um possível exemplo para o seu caso: se você diminuir a quantidade de trabalho que pode realizar todos os dias, a quantidade total de trabalho que você poderá realizar permanecerá na mesma, mas provavelmente diminuirá. Portanto, execute a função para vários valores de
workPerDay
e verifique se a relação é válida.fonte
As outras respostas têm boas idéias para o desenvolvimento de testes para casos de borda ou erro. Para os outros, o uso do algoritmo em si não é ideal (obviamente), mas ainda é útil.
Ele detectará se o algoritmo (ou dados dos quais depende) mudou
Se a alteração for um acidente, você poderá reverter uma confirmação. Se a alteração foi deliberada, você precisa revisitar o teste de unidade.
fonte
Da mesma maneira que você escreve testes de unidade para qualquer outro tipo de código:
A menos que seu código envolva algum elemento aleatório ou não seja determinístico (ou seja, não produzirá a mesma saída com a mesma entrada), é testável por unidade.
Evite efeitos colaterais ou funções afetadas por forças externas. As funções puras são mais fáceis de testar.
fonte
Unless your code involves some random element
O truque aqui é transformar seu gerador de números aleatórios em uma dependência injetada, para que você possa substituí-lo por um gerador de números que forneça o resultado exato que você deseja. Isso permite que você teste com precisão novamente - contando os números gerados como parâmetros de entrada.not deterministic (i.e. it won't produce the same output given the same input)
Como um teste de unidade deve começar a partir de uma situação controlada , só pode ser não determinístico se tiver um elemento aleatório - que você poderá injetar. Não consigo pensar em outras possibilidades aqui.if(x == x)
, é uma comparação inútil. Você precisa que seus dois resultados ( reais : provêm do código; esperado : provêm de seu conhecimento externo) sejam independentes um do outro.Atualização devido a comentários postados
A resposta original foi removida por uma questão de brevidade - você pode encontrá-la no histórico de edições.
Primeiro, um TL; DR para evitar uma resposta demorada:
O principal problema aqui é que você não está fazendo uma separação entre o cliente e o desenvolvedor (e o analista - embora essa função também possa ser representada por um desenvolvedor).
Você precisa distinguir entre testar o código e testar os requisitos de negócios.
Por exemplo, o cliente deseja que ele funcione assim [isso] . No entanto, o desenvolvedor entende mal, e ele escreve um código que faz isso .
O desenvolvedor, portanto, escreverá testes de unidade que testam se [isso] funciona conforme o esperado. Se ele desenvolveu o aplicativo corretamente, seus testes de unidade serão aprovados, mesmo que o aplicativo não faça isso , o que o cliente esperava.
Se você deseja testar as expectativas do cliente (os requisitos de negócios), isso precisa ser feito em uma etapa separada (e posterior).
Um fluxo de trabalho de desenvolvimento simples para mostrar quando esses testes devem ser executados:
Você pode se perguntar qual é o sentido de fazer dois testes separados quando o cliente e o desenvolvedor são um e o mesmo. Como não há "entrega" do desenvolvedor para o cliente, os testes são executados um após o outro, mas ainda são etapas separadas.
Se você deseja testar se o seu algoritmo está correto, isso não faz parte do trabalho do desenvolvedor . Essa é a preocupação do cliente, e o cliente testará isso usando o aplicativo.
Como empreendedor e acadêmico, você pode estar perdendo uma distinção importante aqui, que destaca as diferentes responsabilidades.
fonte
Teste de propriedade
Às vezes, as funções matemáticas são mais bem servidas pelo "Teste de propriedade" do que pelos testes de unidade tradicionais baseados em exemplos. Por exemplo, imagine que você está escrevendo testes de unidade para algo como uma função inteira "multiplicar". Embora a função em si possa parecer muito simples, se é a única maneira de se multiplicar, como você a testará completamente sem a lógica da própria função? Você pode usar tabelas gigantes com entradas / saídas esperadas, mas isso é limitado e propenso a erros.
Nesses casos, você pode testar propriedades conhecidas da função, em vez de procurar resultados esperados específicos. Para multiplicação, você deve saber que multiplicar um número negativo e um número positivo deve resultar em um número negativo, e que multiplicar dois números negativos deve resultar em um número positivo, etc. Usando valores aleatórios e verificando se essas propriedades são preservadas para todos testar valores é uma boa maneira de testar essas funções. Você geralmente precisa testar mais de uma propriedade, mas geralmente pode identificar um conjunto finito de propriedades que, juntos, validam o comportamento correto de uma função sem necessariamente conhecer o resultado esperado para cada caso.
Uma das melhores introduções ao teste de propriedades que eu já vi é essa em F #. Esperamos que a sintaxe não seja um obstáculo para entender a explicação da técnica.
fonte
É tentador escrever o código e depois ver se o resultado "parece certo", mas, como você corretamente intui, não é uma boa idéia.
Quando o algoritmo é difícil, você pode fazer várias coisas para facilitar o cálculo manual do resultado.
Use o Excel. Configure uma planilha que faça parte ou todo o cálculo para você. Mantenha-o simples o suficiente para poder ver as etapas.
Divida seu método em métodos testáveis menores, cada um com seus próprios testes. Quando tiver certeza de que as peças menores funcionam, use-as para trabalhar manualmente na próxima etapa.
Use propriedades agregadas para verificar a sanidade. Por exemplo, digamos que você tenha uma calculadora de probabilidade; talvez você não saiba quais devem ser os resultados individuais, mas sabe que todos eles precisam somar 100%.
Força bruta. Escreva um programa que gere todos os resultados possíveis e verifique se nenhum deles é melhor do que o que o seu algoritmo gera.
fonte
TL; DR
Vá para a seção "testes comparativos" para obter conselhos que não estão em outras respostas.
Começos
Comece testando os casos que devem ser rejeitados pelo algoritmo (zero ou negativo
workPerDay
, por exemplo) e os casos que são triviais (por exemplo,tasks
matriz vazia ).Depois disso, você deseja testar os casos mais simples primeiro. Para a
tasks
entrada, precisamos testar comprimentos diferentes; deve ser suficiente testar 0, 1 e 2 elementos (2 pertence à categoria "muitos" para este teste).Se você pode encontrar entradas que podem ser calculadas mentalmente, é um bom começo. Uma técnica que às vezes uso é começar do resultado desejado e voltar ao trabalho (nas especificações) para as entradas que devem produzir esse resultado.
Teste comparativo
Às vezes, a relação da saída com a entrada não é óbvia, mas você tem uma relação previsível entre diferentes saídas quando uma entrada é alterada. Se entendi o exemplo corretamente, a adição de uma tarefa (sem alterar outras entradas) nunca aumentará a proporção do trabalho realizado dentro do prazo, para que possamos criar um teste que chame a função duas vezes - uma vez com e uma vez sem a tarefa extra - e afirma a desigualdade entre os dois resultados.
Fallbacks
Às vezes, tive que recorrer a um longo comentário mostrando um resultado calculado manualmente nas etapas correspondentes à especificação (esse comentário geralmente é mais longo que o caso de teste). O pior caso é quando você precisa manter a compatibilidade com uma implementação anterior em um idioma diferente ou em um ambiente diferente. Às vezes, você apenas precisa rotular os dados de teste com algo parecido
/* derived from v2.6 implementation on ARM system */
. Isso não é muito satisfatório, mas pode ser aceitável como teste de fidelidade ao transportar ou como muleta de curto prazo.Lembretes
O atributo mais importante de um teste é sua legibilidade - se as entradas e saídas são opacas ao leitor, o teste tem um valor muito baixo, mas se o leitor for ajudado a entender as relações entre eles, o teste terá dois propósitos.
Não se esqueça de usar um "aproximadamente igual a" apropriado para resultados inexatos (por exemplo, ponto flutuante).
Evite o excesso de teste - adicione apenas um teste se ele cobrir algo (como um valor limite) que não é atingido por outros testes.
fonte
Não há nada de muito especial nesse tipo de função difícil de testar. O mesmo se aplica ao código que usa interfaces externas (por exemplo, uma API REST de um aplicativo de terceiros que não está sob seu controle e certamente não pode ser testado pelo seu conjunto de testes; ou usando uma biblioteca de terceiros em que você não tem certeza sobre o formato exato de bytes dos valores de retorno).
É uma abordagem bastante válida para simplesmente executar seu algoritmo para alguma entrada sã, ver o que ele faz, garantir que o resultado esteja correto e encapsular a entrada e o resultado como um caso de teste. Você pode fazer isso em alguns casos e, assim, obter várias amostras. Tente tornar os parâmetros de entrada o mais diferentes possível. No caso de uma chamada de API externa, você faria algumas chamadas no sistema real, rastreou-as com alguma ferramenta e depois zombou delas em seus testes de unidade para ver como o programa reage - o que é o mesmo que escolher algumas executa seu código de planejamento de tarefas, verificando-os manualmente e depois codificando o resultado em seus testes.
Obviamente, traga casos extremos como (no seu exemplo) uma lista vazia de tarefas; coisas assim.
Talvez sua suíte de testes não seja tão boa quanto para um método em que você possa prever facilmente resultados; mas ainda 100% melhor do que nenhuma suíte de testes (ou apenas um teste de fumaça).
Se o seu problema, porém, é que você acha difícil decidir se um resultado está correto, esse é um problema completamente diferente. Por exemplo, digamos que você tenha um método que detecte se um número arbitrariamente grande é primo. Você dificilmente pode lançar qualquer número aleatório e, em seguida, apenas "olhar" se o resultado estiver correto (supondo que você não possa decidir o que é primordial em sua cabeça ou em um pedaço de papel). Nesse caso, há muito pouco que você pode fazer - você precisaria obter resultados conhecidos (por exemplo, alguns números primos grandes) ou implementar a funcionalidade com um algoritmo diferente (talvez até uma equipe diferente - a NASA parece gostar de isso) e espero que, se alguma implementação for com erros, pelo menos o bug não leve aos mesmos resultados errados.
Se esse é um caso regular para você, é necessário conversar bem com os engenheiros de requisitos. Se eles não puderem formular seus requisitos de uma maneira fácil (ou possível) de verificar, então quando você sabe se terminou?
fonte
Outras respostas são boas, então tentarei abordar alguns pontos que eles coletivamente perderam até agora.
Eu escrevi (e testei exaustivamente) um software para processar imagens usando o Radar de Abertura Sintética (SAR). É de natureza científica / numérica (há muita geometria, física e matemática envolvidas).
Algumas dicas (para testes científicos / numéricos gerais):
1) Use inversos. Qual é o
fft
de[1,2,3,4,5]
? Nenhuma idéia. O que éifft(fft([1,2,3,4,5]))
? Deve ser[1,2,3,4,5]
(ou próximo a isso, podem ocorrer erros de ponto flutuante). O mesmo vale para o caso 2D.2) Use afirmações conhecidas. Se você escrever uma função determinante, pode ser difícil dizer qual é o determinante de uma matriz 100x100 aleatória. Mas você sabe que o determinante da matriz de identidade é 1, mesmo que seja 100x100. Você também sabe que a função deve retornar 0 em uma matriz não invertível (como um 100x100 cheio de todos os 0s).
3) Use afirmações grosseiras em vez de afirmações exatas . Eu escrevi um código para o referido processamento SAR que registraria duas imagens, gerando pontos de ligação que criam um mapeamento entre as imagens e, em seguida, fazendo uma distorção entre elas para fazer com que correspondessem. Ele pode se registrar em um nível de sub-pixel. A priori, é difícil dizer algo sobre como pode ser o registro de duas imagens. Como você pode testá-lo? Coisas como:
como você só pode se registrar em partes sobrepostas, a imagem registrada deve ser menor ou igual à sua menor imagem e também:
como uma imagem registrada deve estar PRÓXIMA, mas você pode experimentar um pouco mais do que erros de ponto flutuante devido ao algoritmo em questão. Verifique se cada pixel está dentro de +/- 5% do intervalo que os pixels podem assumir (0-255 é em escala de cinza, comum no processamento de imagens). O resultado deve ter pelo menos o mesmo tamanho da entrada.
Você pode até mesmo testar o fumo (ou seja, chamá-lo e certificar-se de que não trava). Em geral, essa técnica é melhor para testes maiores, nos quais o resultado final não pode ser (facilmente) calculado a priori para a execução do teste.
4) Use OU ARMAZENUE uma semente de número aleatório para o seu RNG.
Runs não precisa ser reprodutível. É falso, no entanto, que a única maneira de obter uma execução reproduzível é fornecer uma semente específica a um gerador de números aleatórios. Às vezes, o teste aleatório é valioso. Eu vi / ouvi sobre bugs no código científica que surgem em casos degenerados que foram gerados aleatoriamente (em algoritmos complicados, pode ser difícil ver o que o caso degenerado mesmo é) Em vez de sempre chamar sua função com a mesma semente, gere uma semente aleatória, use essa semente e registre o valor da semente. Dessa forma, cada execução tem uma semente aleatória diferente, mas se ocorrer uma falha, você poderá executar novamente o resultado usando a semente que você registrou para depurar. Na verdade, eu usei isso na prática e ele esmagou um bug, então pensei em mencioná-lo. É certo que isso aconteceu apenas uma vez, e tenho certeza de que nem sempre vale a pena fazer, então use essa técnica com prudência. Aleatório com a mesma semente é sempre seguro, no entanto. Desvantagem (ao contrário de usar sempre a mesma semente o tempo todo): é necessário registrar suas execuções de teste. De cabeça para baixo: correção e correção de erros.
Seu caso particular
1) Teste se um vazio
taskArray
retorna 0 (afirmação conhecida).2) Gerar aleatória de entrada de tal modo que
task.time > 0
,task.due > 0
, etask.importance > 0
para todostask
s, e afirmar o resultado é maior do que0
(afirmar áspera, entrada aleatória) . Você não precisa enlouquecer e gerar sementes aleatórias; seu algoritmo simplesmente não é complexo o suficiente para justificá-lo. Há cerca de 0 chances de valer a pena: basta manter o teste simples.3) Teste se
task.importance == 0
para todos ostask
s, então o resultado é0
(afirmação conhecida)4) Outras respostas abordaram isso, mas pode ser importante para o seu caso em particular : se você estiver criando uma API para ser consumida por usuários fora da sua equipe, será necessário testar os casos degenerados. Por exemplo, se
workPerDay == 0
, certifique-se de gerar um erro adorável que informa ao usuário que é entrada inválida. Se você não está criando uma API e é apenas para você e sua equipe, provavelmente pode pular esta etapa e recusar chamá-la com o caso degenerado.HTH.
fonte
Incorpore o teste de asserção ao seu conjunto de testes de unidade para testar o algoritmo com base em propriedades. Além de escrever testes de unidade que verificam saída específica, escreva testes projetados para falhar, acionando falhas de asserção no código principal.
Muitos algoritmos contam com suas provas de correção na manutenção de certas propriedades ao longo dos estágios do algoritmo. Se você puder verificar essas propriedades de maneira sensata, observando a saída de uma função, apenas o teste de unidade é suficiente para testar suas propriedades. Caso contrário, o teste baseado em asserção permite testar se uma implementação mantém uma propriedade toda vez que o algoritmo a assume.
O teste baseado em asserção expõe falhas de algoritmo, erros de codificação e falhas de implementação devido a problemas como instabilidade numérica. Muitas linguagens possuem mecanismos que extraem asserções em tempo de compilação ou antes que o código seja interpretado, de modo que, quando executadas no modo de produção, as asserções não incorrem em uma penalidade de desempenho. Se o seu código passar nos testes de unidade, mas falhar em um caso da vida real, você poderá ativar as asserções novamente como uma ferramenta de depuração.
fonte
Algumas das outras respostas aqui são muito boas:
... Eu acrescentaria algumas outras táticas:
A decomposição permite garantir que os componentes do seu algoritmo façam o que você espera que eles façam. E uma decomposição "boa" permite também garantir que elas sejam coladas corretamente. Uma ótima decomposição generaliza e simplifica o algoritmo na medida em que você pode prever os resultados (do (s) algoritmo (s) genérico (s) simplificado)) manualmente, o suficiente para escrever testes completos.
Se você não puder se decompor nessa extensão, prove o algoritmo fora do código por qualquer meio que seja suficiente para satisfazer você e seus colegas, partes interessadas e clientes. E então, apenas se decomponha o suficiente para provar que sua implementação corresponde ao design.
fonte
Isso pode parecer uma resposta idealista, mas ajuda a identificar diferentes tipos de teste.
Se respostas estritas são importantes para a implementação, exemplos e respostas esperadas realmente devem ser fornecidos nos requisitos que descrevem o algoritmo. Esses requisitos devem ser revisados em grupo e, se você não obtiver os mesmos resultados, o motivo precisa ser identificado.
Mesmo se você estiver desempenhando o papel de analista e implementador, você deve realmente criar requisitos e revisá-los muito antes de escrever testes de unidade; nesse caso, você conhecerá os resultados esperados e poderá escrever seus testes de acordo.
Por outro lado, se esta é uma parte que você está implementando que não faz parte da lógica de negócios ou suporta uma resposta da lógica de negócios, convém executar o teste para ver quais são os resultados e modificá-lo para esperar esses resultados. Os resultados finais já são verificados em relação aos seus requisitos, portanto, se estiverem corretos, todo o código que alimenta esses resultados finais deve estar numericamente correto e, nesse ponto, seus testes de unidade são mais para detectar casos de falha de borda e futuras alterações de refatoração do que para provar que um determinado algoritmo produz resultados corretos.
fonte
Eu acho que é perfeitamente aceitável em algumas ocasiões seguir o processo:
Essa é uma abordagem razoável em qualquer situação em que verificar a exatidão de uma resposta manualmente seja mais fácil do que calcular a resposta manualmente a partir dos primeiros princípios.
Conheço pessoas que escrevem software para renderizar páginas impressas e têm testes para verificar se exatamente os pixels corretos estão definidos na página impressa. A única maneira sensata de fazer isso é escrever o código para renderizar a página, verificar visualmente que ela fica bem e capturar o resultado como um teste de regressão para versões futuras.
Só porque você lê em um livro que uma metodologia específica incentiva a escrever os casos de teste primeiro, não significa que você sempre deve fazer dessa maneira. Regras existem para serem quebradas.
fonte
As respostas de outras respostas já possuem técnicas para a aparência de um teste quando o resultado específico não pode ser determinado fora da função testada.
O que faço adicionalmente e que não vi nas outras respostas é gerar automaticamente testes de alguma forma:
Por exemplo, se a função usar três parâmetros, cada um com faixa de entrada permitida [-1,1], teste todas as combinações de cada parâmetro, {-2, -1,01, -1, -0,99, -0,5, -0,01, 0,0,01 , 0,5,0,99,1,1,01,2, um pouco mais aleatório em (-1,1)}
Em resumo: Às vezes, a má qualidade pode ser subsidiada pela quantidade.
fonte