Considere uma função como esta:
function savePeople(dataStore, people) {
people.forEach(person => dataStore.savePerson(person));
}
Pode ser usado assim:
myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);
Vamos supor que ele Store
tenha seus próprios testes de unidade ou seja fornecido pelo fornecedor. De qualquer forma, confiamos Store
. E vamos assumir ainda que o tratamento de erros - por exemplo, erros de desconexão de banco de dados - não é de responsabilidade de savePeople
. De fato, suponhamos que a loja em si seja um banco de dados mágico que não pode ter erros de forma alguma. Dadas essas suposições, a questão é:
Deveria savePeople()
ser testado em unidade, ou esses testes equivaleriam a testar a construção de forEach
linguagem interna?
É claro que poderíamos passar uma zombaria dataStore
e afirmar que dataStore.savePerson()
é chamada uma vez para cada pessoa. Você certamente poderia argumentar que esse teste fornece segurança contra alterações na implementação: por exemplo, se decidirmos substituir forEach
por um for
loop tradicional ou algum outro método de iteração. Portanto, o teste não é totalmente trivial. E, no entanto, parece muito perto ...
Aqui está outro exemplo que pode ser mais proveitoso. Considere uma função que não faz nada além de coordenar outros objetos ou funções. Por exemplo:
function bakeCookies(dough, pan, oven) {
panWithRawCookies = pan.add(dough);
oven.addPan(panWithRawCookies);
oven.bakeCookies();
oven.removePan();
}
Como uma função como essa deve ser testada em unidade, supondo que você pense que deveria? É difícil para mim imaginar qualquer tipo de teste de unidade que não é simplesmente zombar dough
, pan
e oven
, em seguida, afirmar que os métodos são chamados sobre eles. Mas esse teste nada mais faz do que duplicar a implementação exata da função.
Essa incapacidade de testar a função de maneira significativa em caixa preta indica uma falha de design com a própria função? Se sim, como poderia ser melhorado?
Para dar ainda mais clareza à pergunta que motivou o bakeCookies
exemplo, adicionarei um cenário mais realista, que encontrei ao tentar adicionar testes e refatorar o código legado.
Quando um usuário cria uma nova conta, várias coisas precisam acontecer: 1) um novo registro do usuário precisa ser criado no banco de dados 2) um email de boas-vindas precisa ser enviado 3) o endereço IP do usuário precisa ser registrado por fraude propósitos.
Então, queremos criar um método que vincule todas as etapas do "novo usuário":
function createNewUser(validatedUserData, emailService, dataStore) {
userId = dataStore.insertUserRecord(validateduserData);
emailService.sendWelcomeEmail(validatedUserData);
dataStore.recordIpAddress(userId, validatedUserData.ip);
}
Observe que, se algum desses métodos gerar um erro, queremos que o erro atinja o código de chamada, para que ele possa lidar com o erro como achar melhor. Se estiver sendo chamado pelo código da API, poderá converter o erro em um código de resposta http apropriado. Se estiver sendo chamado por uma interface da web, poderá converter o erro em uma mensagem apropriada a ser exibida ao usuário, e assim por diante. O ponto é que essa função não sabe como lidar com os erros que podem ser gerados.
A essência da minha confusão é que, para testar essa função por unidade, parece necessário repetir a implementação exata no próprio teste (especificando que os métodos são chamados em zombarias em uma certa ordem) e isso parece errado.
fonte
Respostas:
Deve
savePeople()
ser testado em unidade? Sim. Você não está testando sedataStore.savePerson
funciona ou se a conexão db funciona, ou mesmo seforeach
funciona. Você está testando quesavePeople
cumpre a promessa que faz por meio de seu contrato.Imagine este cenário: alguém faz um grande refator da base de código e remove acidentalmente a
forEach
parte da implementação para que ela sempre salve apenas o primeiro item. Você não gostaria de um teste de unidade para pegar isso?fonte
Normalmente, esse tipo de pergunta surge quando as pessoas desenvolvem o "teste após". Aborde este problema do ponto de vista do TDD, onde os testes vêm antes da implementação, e faça a si mesmo essa pergunta novamente como um exercício.
Pelo menos na minha aplicação do TDD, que geralmente é de fora para dentro, eu não estaria implementando uma função como
savePeople
depois de ter implementadosavePerson
. As funçõessavePeople
esavePerson
começariam como uma e sendo testadas a partir dos mesmos testes de unidade; a separação entre os dois surgiria após alguns testes, na etapa de refatoração. Esse modo de trabalho também colocaria a questão de onde a funçãosavePeople
deveria estar - se é uma função livre ou parte dadataStore
.No final, os testes não só verificar se você pode salvar corretamente uma
Person
noStore
, mas também muitas pessoas. Isso também me levaria a questionar se outras verificações são necessárias, por exemplo: "Preciso garantir que asavePeople
função seja atômica, salvando tudo ou nenhum?", "Isso pode, de alguma forma, retornar erros para as pessoas que não conseguiram ' t ser salvo? Como seriam esses erros? "e assim por diante. Tudo isso equivale a muito mais do que apenas verificar o uso de umaforEach
ou outras formas de iteração.No entanto, se o requisito de salvar mais de uma pessoa de uma só vez surgir apenas depois de
savePerson
já ter sido entregue, eu atualizaria os testes existentessavePerson
para executar a nova funçãosavePeople
, certificando-me de que ainda possa salvar uma pessoa simplesmente delegando primeiro, depois, teste o comportamento de mais de uma pessoa através de novos testes, pensando se seria necessário tornar o comportamento atômico ou não.fonte
savePeople
? Como eu descrevi no último parágrafo do OP ou de alguma outra maneira?savePerson
função, como você sugeriu, em vez disso, testaria através da mais geralsavePeople
. Os testes de unidadeStore
seriam alterados para serem executados emsavePeople
vez de serem chamados diretamentesavePerson
, portanto, para isso, nenhuma simulação é usada. Mas o banco de dados, é claro, não deve estar presente, pois gostaríamos de isolar os problemas de codificação dos vários problemas de integração que ocorrem com os bancos de dados reais; portanto, ainda temos uma simulação.Sim deveria. Mas tente escrever suas condições de teste de maneira independente da implementação. Por exemplo, transformando seu exemplo de uso em um teste de unidade:
Este teste faz várias coisas:
savePeople()
savePeople()
savePeople()
Observe que você ainda pode simular / stub / fake o armazenamento de dados. Nesse caso, eu não verificaria chamadas de função explícitas, mas o resultado da operação. Dessa forma, meu teste está preparado para futuras alterações / refatores.
Por exemplo, a implementação do armazenamento de dados pode fornecer um
saveBulkPerson()
método no futuro - agora, uma alteração na implementação dosavePeople()
usosaveBulkPerson()
não interromperia o teste de unidade enquantosaveBulkPerson()
funcionasse conforme o esperado. E sesaveBulkPerson()
de alguma forma não funciona como esperado, o teste de unidade vai pegar isso.Como dito, tente testar os resultados esperados e a interface da função, não a implementação (a menos que você esteja executando testes de integração - é possível capturar chamadas de funções específicas). Se houver várias maneiras de implementar uma função, todas elas deverão funcionar com seu teste de unidade.
Em relação à sua atualização da pergunta:
Teste para alterações de estado! Por exemplo, parte da massa será usada. De acordo com sua implementação, afirme que a quantidade de
dough
itens usados se encaixapan
ou afirme que os itens foramdough
usados. Afirme quepan
contém cookies após a chamada da função. Afirme queoven
está vazio / no mesmo estado de antes.Para testes adicionais, verifique os casos extremos: O que acontece se o
oven
item não estiver vazio antes da chamada? O que acontece se não houver o suficientedough
? Se opan
já estiver cheio?Você deve poder deduzir todos os dados necessários para esses testes dos próprios objetos de massa, panela e forno. Não há necessidade de capturar as chamadas de função. Trate a função como se sua implementação não estivesse disponível para você!
De fato, a maioria dos usuários do TDD escreve seus testes antes de escrever a função, para que não dependam da implementação real.
Para sua mais recente adição:
Para uma função como essa eu zombaria / stub / fake (o que parece mais geral) os parâmetros
dataStore
eemailService
. Esta função não realiza transições de estado por conta própria, mas as delega a métodos de algumas delas. Eu tentaria verificar se a chamada para a função fez 4 coisas:As três primeiras verificações podem ser feitas com zombarias, stubs ou falsificações de
dataStore
eemailService
(você realmente não deseja enviar e-mails durante o teste). Desde que eu tive que procurar por alguns dos comentários, estas são as diferenças:dataStore
que apenas implementa uma versão adequada deinsertUserRecord()
erecordIpAddress()
.Exceções / erros esperados são casos de teste válidos: Você confirma que, no caso de um evento desse tipo, a função se comporta da maneira que você espera. Isso pode ser alcançado deixando o objeto mock / fake / stub correspondente ser lançado quando desejado.
Às vezes, isso precisa ser feito (embora você se preocupe com isso principalmente nos testes de integração). Mais frequentemente, existem outras maneiras de verificar os efeitos colaterais / alterações de estado esperados.
Verificar as chamadas exatas das funções resulta em testes de unidade bastante frágeis: apenas pequenas alterações na função original causam falhas. Isso pode ser desejado ou não, mas requer uma alteração nos testes de unidade correspondentes sempre que você altera uma função (seja refatoração, otimização, correção de erros, ...).
Infelizmente, nesse caso, o teste de unidade perde parte de sua credibilidade: desde que foi alterado, ele não confirma a função após a alteração se comportar da mesma maneira que antes.
Por exemplo, considere alguém adicionando uma chamada para
oven.preheat()
(otimização!) No seu exemplo de assadeira de biscoitos:Nos meus testes de unidade, tento ser o mais geral possível: se a implementação mudar, mas o comportamento visível (da perspectiva do chamador) ainda for o mesmo, meus testes deverão passar. Idealmente, o único caso em que preciso alterar um teste de unidade existente deve ser uma correção de bug (do teste, não da função em teste).
fonte
myDataStore.containsPerson('Joe')
você assume a existência de um banco de dados de teste funcional. Depois de fazer isso, você está escrevendo um teste de integração e não um teste de unidade.savePeople()
realmente adicione essas pessoas a qualquer repositório de dados que você fornecer, desde que esse repositório implemente a interface esperada. Um teste de integração seria, por exemplo, verificar se meu wrapper de banco de dados realmente faz o banco de dados correto solicita uma chamada de método.myDataStore.containsPerson('Joe')
, é necessário usar algum db funcional de algum tipo. Depois de dar esse passo, não é mais um teste de unidade.myPeople
) estão na matriz. IMHO uma simulação ainda deve ter o mesmo comportamento observável esperado de um objeto real; caso contrário, você está testando a conformidade com a interface simulada, não com a interface real.O principal valor que esse teste fornece é que ele torna sua implementação refatorável.
Eu costumava fazer muitas otimizações de desempenho em minha carreira e frequentemente encontrava problemas com o padrão exato que você demonstrava: para salvar N entidades no banco de dados, executar N inserções. Geralmente é mais eficiente fazer uma inserção em massa usando uma única instrução.
Por outro lado, também não queremos otimizar prematuramente. Se você normalmente salva apenas 1 a 3 pessoas por vez, a gravação de um lote otimizado pode ser um exagero.
Com um teste de unidade adequado, você pode escrevê-lo da maneira que o implementou acima e, se achar que precisa otimizá-lo, poderá fazê-lo com a rede de segurança de um teste automatizado para detectar erros. Naturalmente, isso varia de acordo com a qualidade dos testes; portanto, teste liberalmente e bem.
A vantagem secundária do teste unitário desse comportamento é servir como documentação para qual é seu objetivo. Este exemplo trivial pode ser óbvio, mas, dado o próximo ponto abaixo, pode ser muito importante.
A terceira vantagem, apontada por outros, é que você pode testar detalhes ocultos que são muito difíceis de testar com testes de integração ou aceitação. Por exemplo, se houver um requisito de que todos os usuários sejam salvos atomicamente, você poderá escrever um caso de teste para isso, o que lhe dará uma maneira de saber se ele se comporta conforme o esperado e também serve como documentação para um requisito que pode não ser óbvio para novos desenvolvedores.
Vou acrescentar um pensamento que recebi de um instrutor de TDD. Não teste o método. Teste o comportamento. Em outras palavras, você não testa se
savePeople
funciona, está testando que vários usuários podem ser salvos em uma única chamada.Eu descobri que minha capacidade de fazer testes de unidade de qualidade e o TDD melhoram quando parei de pensar nos testes de unidade para verificar se um programa funciona, mas eles verificam se uma unidade de código faz o que eu esperava . Aqueles são diferentes. Eles não verificam se funciona, mas verificam se faz o que acho que faz. Quando comecei a pensar assim, minha perspectiva mudou.
fonte
savePerson
chamou para cada pessoa da lista - seria interrompido com uma refatoração de inserção em massa, no entanto. O que para mim indica que é um teste de unidade ruim. No entanto, não vejo uma alternativa que passe nas implementações em massa e de uma economia por pessoa, sem usar um banco de dados de teste real e afirmar com base nisso, o que parece errado. Você poderia fornecer um teste que funcione para as duas implementações?savePerson
método foi chamado para cada entrada e, se você substituísse o loop por uma inserção em massa, não chamaria mais esse método. Então seu teste seria interrompido. Se você tem algo em mente, estou aberto a isso, mas ainda não o vejo. (E não vendo que era o meu ponto.)Deve
bakeCookies()
ser testado? Sim.Na verdade não. Observe atentamente o que a função deve fazer - ela deve definir o
oven
objeto para um estado específico. Observando o código, parece que os estados dos objetospan
edough
realmente não importam muito. Portanto, você deve passar umoven
objeto (ou zombar dele) e afirmar que ele está em um estado específico no final da chamada da função.Em outras palavras, você deve afirmar que
bakeCookies()
assou os cookies .Para funções muito curtas, os testes de unidade podem parecer pouco mais que tautologia. Mas não se esqueça, seu programa vai durar muito mais do que o tempo que você está empregado escrevendo-o. Essa função pode ou não mudar no futuro.
Os testes de unidade têm duas funções:
Testa que tudo funciona. Esse é o teste de unidade de função menos útil que serve e parece que você parece considerar apenas essa funcionalidade ao fazer a pergunta.
Ele verifica se futuras modificações do programa não quebram a funcionalidade implementada anteriormente. Essa é a função mais útil dos testes de unidade e evita a introdução de erros em programas grandes. É útil na codificação normal ao adicionar recursos ao programa, mas é mais útil na refatoração e otimizações em que os principais algoritmos que implementam o programa são dramaticamente alterados sem alterar o comportamento observável do programa.
Não teste o código dentro da função. Em vez disso, teste se a função faz o que diz que faz. Quando você olha para os testes de unidade dessa maneira (funções de teste, não código), percebe que nunca testa construções de linguagem ou mesmo lógica de aplicativo. Você está testando uma API.
fonte
bakeCookies
são testadas dessa maneira, elas tendem a quebrar durante refatores que não afetariam o comportamento observável do aplicativo.Sim. Mas você poderia fazer isso de uma maneira que apenas testasse novamente a construção.
O que deve ser observado aqui é como essa função se comporta quando uma
savePerson
falha ocorre no meio? Como é que isso funciona?Esse é o tipo de comportamento sutil que a função fornece que você deve aplicar nos testes de unidade.
fonte
savePeople
não deve ser responsável pelo tratamento de erros. Para esclarecer novamente, supondo quesavePeople
seja responsável apenas pela iteração na lista e delegação do salvamento de cada item em outro método, ele ainda deve ser testado?foreach
construção, e não a quaisquer condições, efeitos colaterais ou comportamentos fora dela, então você está certo; o novo teste de unidade não é realmente tão interessante.A chave aqui é a sua perspectiva sobre uma função específica como trivial. A maior parte da programação é trivial: atribua um valor, faça algumas contas, tome uma decisão: se isso acontecer, continue um loop até ... Isoladamente, tudo trivial. Você acabou de ler os 5 primeiros capítulos de qualquer livro que ensinam uma linguagem de programação.
O fato de escrever um teste ser tão fácil deve ser um sinal de que seu design não é tão ruim assim. Você prefere um design que não seja fácil de testar?
"Isso nunca vai mudar." é assim que a maioria dos projetos com falha começa. Um teste de unidade determina apenas se a unidade funciona conforme o esperado em um determinado conjunto de circunstâncias. Faça-o passar e, em seguida, você poderá esquecer os detalhes de implementação e apenas usá-lo. Use esse espaço cerebral para a próxima tarefa.
Saber que as coisas funcionam como esperado é muito importante e não é trivial em grandes projetos e, especialmente, em grandes equipes. Se há algo que os programadores têm em comum, é o fato de todos termos lidado com o terrível código de outra pessoa. O mínimo que podemos fazer é fazer alguns testes. Em caso de dúvida, escreva um teste e siga em frente.
fonte
Isso já foi respondido por @BryanOakley, mas eu tenho alguns argumentos extras (eu acho):
Primeiro, um teste de unidade é para testar o cumprimento de um contrato, não a implementação de uma API; o teste deve definir pré-condições, ligar e, em seguida, verificar efeitos, efeitos colaterais, invariáveis e pós-condições. Quando você decide o que testar, a implementação da API não importa (e não deve) .
Segundo, seu teste estará lá para verificar os invariantes quando a função mudar . O fato de não mudar agora não significa que você não deva fazer o teste.
Terceiro, é importante ter implementado um teste trivial, tanto em uma abordagem de TDD (que o exige) quanto fora dela.
Ao escrever C ++, para minhas aulas, costumo escrever um teste trivial que instancia um objeto e verifica invariantes (atribuíveis, regulares etc.). Achei surpreendente quantas vezes esse teste foi interrompido durante o desenvolvimento (por exemplo - adicionando um membro não móvel em uma classe, por engano).
fonte
Acho que sua pergunta se resume a:
Como teste unitário uma função nula sem que seja um teste de integração?
Se alterarmos a função de assar cookies para retornar cookies, por exemplo, torna-se imediatamente óbvio qual deve ser o teste.
Se precisarmos chamar pan.GetCookies depois de chamar a função, podemos questionar se é realmente um teste de integração ou se é apenas um teste de objeto pan.
Eu acho que você está certo em ter testes de unidade com tudo zombado e apenas verificar as funções xy e z foram chamadas de "falta de valor".
Mas! Eu diria que, nesse caso, você deve refatorar suas funções nulas para retornar um resultado testável OU usar objetos reais e fazer um teste de integração
--- Atualização para o exemplo createNewUser
OK, desta vez o resultado da função não é retornado facilmente. Queremos mudar o estado dos parâmetros.
É aqui que fico um pouco controverso. Crio implementações simuladas concretas para os parâmetros com estado
por favor, queridos leitores, tentem controlar sua raiva!
tão...
Isso separa os detalhes de implementação do método em teste do comportamento desejado. Uma implementação alternativa:
Ainda passará no teste de unidade. Além disso, você tem a vantagem de poder reutilizar os objetos simulados nos testes e também injetá-los em seu aplicativo para testes de interface do usuário ou integração.
fonte
bakeCookies
devolução dos biscoitos está no ponto certo, e pensei um pouco depois de publicá-lo. Então eu acho que mais uma vez não é um ótimo exemplo. Adicionei mais uma atualização que, esperançosamente, fornece um exemplo mais realista do que está motivando minha pergunta. Agradeço sua opinião.Você também deve testar
bakeCookies
- o que deveria / deveria e..gbakeCookies(egg, pan, oven)
render? Ovo frito ou uma exceção? Por si só,pan
nemoven
se importam com os ingredientes reais, pois nenhum deles deveria, masbakeCookies
geralmente deve produzir cookies. De modo mais geral, pode depender da forma comodough
é obtido e se existe é alguma chance de se tornar meraegg
ou, por exemplo,water
em vez disso.fonte