Como testo um sistema em que os objetos são difíceis de zombar?

34

Estou trabalhando com o seguinte sistema:

Network Data Feed -> Third Party Nio Library -> My Objects via adapter pattern

Recentemente, tivemos um problema em que atualizei a versão da biblioteca que estava usando, o que, entre outras coisas, causou que os carimbos de data / hora (retornados pela biblioteca de terceiros long) fossem alterados de milissegundos após a época para nanossegundos após a época.

O problema:

Se eu escrever testes que zombam dos objetos da biblioteca de terceiros, meu teste estará errado se eu cometer um erro sobre os objetos da biblioteca de terceiros. Por exemplo, eu não percebi que os carimbos de data e hora alteravam a precisão, o que resultava em uma necessidade de alteração no teste de unidade, porque minha simulação retornou os dados incorretos. Isso não é um bug na biblioteca , aconteceu porque eu perdi algo na documentação.

O problema é que não posso ter certeza sobre os dados contidos nessas estruturas de dados porque não consigo gerar dados reais sem um feed de dados real. Esses objetos são grandes e complicados e possuem muitos dados diferentes. A documentação para a biblioteca de terceiros é ruim.

A questão:

Como posso configurar meus testes para testar esse comportamento? Não tenho certeza de que posso resolver esse problema em um teste de unidade, porque o teste em si pode facilmente estar errado. Além disso, o sistema integrado é grande e complicado e é fácil perder alguma coisa. Por exemplo, na situação acima, eu havia ajustado corretamente o tratamento do carimbo de data e hora em vários lugares, mas perdi um deles. O sistema parecia estar fazendo principalmente as coisas certas no meu teste de integração, mas quando o implantei na produção (que tem muito mais dados), o problema se tornou óbvio.

Não tenho um processo para meus testes de integração no momento. O teste é essencialmente: tente manter os testes de unidade bons, adicione mais testes quando as coisas quebrarem, implante no meu servidor de teste e verifique se as coisas parecem saudáveis, depois implante na produção. Esse problema de carimbo de data e hora passou nos testes de unidade porque as simulações foram criadas incorretamente; em seguida, passou no teste de integração porque não causou problemas imediatos e óbvios. Eu não tenho um departamento de controle de qualidade.

durron597
fonte
3
Você pode "gravar" um feed de dados real e "reproduzi-lo" posteriormente na biblioteca de terceiros?
Idan Arye
2
Alguém poderia escrever um livro sobre problemas como este. De fato, Michael Feathers escreveu exatamente esse livro: c2.com/cgi/wiki?WorkingEffectivelyWithLegacyCode Nele, ele descreve várias técnicas para quebrar dependências difíceis, para que o código possa se tornar mais testável.
Cbojar #
2
O adaptador em torno da biblioteca de terceiros? Sim, é exatamente isso que eu recomendo. Esses testes de unidade não melhorarão seu código. Eles não o tornarão mais confiável ou mais sustentável. Você está apenas duplicando parcialmente o código de outra pessoa naquele momento; Nesse caso, você está duplicando algum código mal escrito do som. Isso é uma perda líquida. Algumas das respostas sugerem fazer alguns testes de integração; é uma boa ideia se você quiser apenas um "Isso está funcionando?" verificação de sanidade. Um bom teste é difícil e requer tanta habilidade e intuição quanto um bom código.
jpmc26
4
Uma ilustração perfeita do mal dos embutidos. Por que a biblioteca não retorna um Timestampclasse (contendo qualquer representação que eles querem) e fornecer métodos chamados ( .seconds(), .milliseconds(), .microseconds(), .nanoseconds()) e de construtores curso nomeados. Então não haveria problemas.
Matthieu M.
2
O ditado "todos os problemas na codificação pode ser resolvido por uma camada de engano (exceto, é claro, o problema de muitas camadas de engano)" vem à mente aqui ..
Dan Pantry

Respostas:

27

Parece que você já está fazendo a devida diligência. Mas ...

No nível mais prático, sempre inclua uma boa quantidade de testes de integração "de loop completo" no seu conjunto para seu próprio código e escreva mais asserções do que você pensa que precisa. Em particular, você deve ter um punhado de testes que executam um ciclo completo de criação, leitura e doação de validação.

[TestMethod]
public void MyFormatter_FormatsTimesCorrectly() {

  // this test isn't necessarily about the stream or the external interpreter.
  // but ... we depend on them working how we think they work:
  var stream = new StreamThingy();
  var interpreter = new InterpreterThingy(stream);
  stream.Write("id-123, some description, 12345");

  // this is what you're actually testing. but, it'll also hiccup
  // if your 3rd party dependencies introduce a breaking change.
  var formatter = new MyFormatter(interpreter);
  var line = formatter.getLine();
  Assert.equal(
    "some description took 123.45 seconds to complete (id-123)", line
  );
}

E parece que você já está fazendo esse tipo de coisa. Você está apenas lidando com uma biblioteca esquisita e / ou complicada. E, nesse caso, é bom lançar alguns tipos de testes "é assim que a biblioteca funciona", que verificam sua compreensão da biblioteca e servem como exemplos de como usá-la.

Suponha que você precise entender e depender de como um analisador JSON interpreta cada "tipo" em uma sequência JSON. É útil e trivial incluir algo como isso em sua suíte:

[TestMethod]
public void JSONParser_InterpretsTypesAsExpected() {
  String datastream = "{nbr:11,str:"22",nll:null,udf:undefined}";
  var o = (new JSONParser()).parse(datastream);

  Assert.equal(11, o.nbr);
  Assert.equal(Int32.getType(), o.nbr.getType());
  Assert.equal("22", o.str);
  Assert.equal(null, o.nll);
  Assert.equal(Object.getType(), o.nll.getType());
  Assert.isFalse(o.KeyExists(udf));
}

Mas, em segundo lugar, lembre-se de que testes automatizados de qualquer tipo e com quase qualquer nível de rigor ainda não conseguirão protegê-lo contra todos os erros. É perfeitamente comum adicionar testes à medida que você descobre problemas. Não ter um departamento de controle de qualidade, significa que muitos desses problemas serão descobertos pelos usuários finais.

E em um grau significativo, isso é normal.

E em terceiro lugar, quando uma biblioteca altera o significado de um valor ou campo de retorno sem renomear o campo ou método ou "quebrar" o código dependente (talvez alterando seu tipo), eu ficaria muito infeliz com esse editor. E eu argumentaria que, mesmo que você devesse ter lido o changelog, se houver, provavelmente também deveria passar um pouco do seu estresse para o editor. Eu diria que eles precisam da crítica esperançosamente construtiva ...

svidgen
fonte
Ugh, eu gostaria que fosse tão simples quanto alimentar uma string json na biblioteca. Não é. Eu não posso fazer o equivalente a (new JSONParser()).parse(datastream), pois eles capturam os dados diretamente de NetworkInterfacee todas as classes que realizam a análise real são privadas e programadas.
durron597
Além disso, o changelog não incluiu o fato de que eles alteraram os carimbos de data e hora de ms para ns, entre as outras dores de cabeça que não documentaram. Sim, estou muito infeliz com eles e expressei isso a eles.
durron597
@ durron597 Oh, quase nunca é. Mas, geralmente, você pode falsificar a fonte de dados subjacente - como no primeiro exemplo de código. ... O ponto é: fazer testes de integração completa de loop quando possível, testar sua compreensão da biblioteca quando possível, e apenas estar ciente de que você vai ainda permitir que erros na natureza. E seus fornecedores terceirizados devem ser responsáveis ​​por fazer alterações invisíveis e inovadoras.
Svidgen
@ durron597 Não estou familiarizado com o NetworkInterface... é algo em que você pode alimentar dados conectando a interface a uma porta no host local ou algo assim?
svidgen
NetworkInterface. É um objeto de baixo nível para trabalhar diretamente com uma placa de rede e abrindo soquetes nele, etc.
durron597
11

Resposta curta: é difícil. Você provavelmente está sentindo que não há boas respostas, e é porque não há respostas fáceis.

Resposta longa: como o @ptyx diz , você precisa de testes do sistema e testes de integração, além de testes de unidade:

  • Os testes de unidade são rápidos e fáceis de executar. Eles capturam bugs em seções individuais do código e usam zombarias para torná-los possíveis. Por necessidade, eles não podem detectar incompatibilidades entre partes do código (como milissegundos versus nanossegundos).
  • Os testes de integração e de sistema são lentos (er) e difíceis de executar, mas detectam mais erros.

Algumas sugestões específicas:

  • Há algum benefício em simplesmente fazer um teste do sistema para executar o máximo possível do sistema. Mesmo que não consiga validar muito do comportamento ou muito bem em identificar o problema. (Micheal Feathers discute isso mais em Trabalhando efetivamente com o código legado .)
  • Investir na testabilidade ajuda. Há um grande número de técnicas que você pode usar aqui: integração contínua, scripts, VMs, ferramentas para reproduzir, proxy ou redirecionar o tráfego de rede.
  • Uma das vantagens (pelo menos para mim) de investir em testabilidade pode não ser óbvia: se os testes são entediantes, irritantes ou complicados de escrever ou executar, é fácil demais ignorá-los se sou pressionado ou cansado. Manter seus testes abaixo do limite "É tão fácil que não há desculpa para não fazer isso" é importante.
  • Um software perfeito não é viável. Como todo o resto, o esforço despendido em testes é uma troca e, às vezes, não vale a pena. Existem restrições (como a falta de um departamento de controle de qualidade). Aceite que os erros ocorram, se recuperem e aprendam.

Vi a programação descrita como a atividade de aprender sobre um problema e um espaço de solução. Obter tudo perfeito antes do tempo pode não ser possível, mas você pode aprender depois disso. ("Corrigi o tratamento do registro de data e hora em vários lugares, mas perdi um. Posso alterar meus tipos de dados ou classes para tornar o tratamento de registro de data e hora mais explícito e difícil de perder, ou para torná-lo mais centralizado, para que eu tenha apenas um lugar para alterar? Posso modificar meus testes para verificar mais aspectos da manipulação do carimbo de data / hora? Posso simplificar meu ambiente de teste para facilitar isso no futuro? Posso imaginar alguma ferramenta que tornaria isso mais fácil? Em caso afirmativo, posso encontrar essa ferramenta no Google? "Etc.)

Josh Kelley
fonte
7

Atualizei a versão da biblioteca… o que… causou os carimbos de data / hora (retornados pela biblioteca de terceiros long), que foram alterados de milissegundos após a época para nanossegundos após a época.

...

Este não é um bug na biblioteca

Eu discordo totalmente de você aqui. É um bug na biblioteca , um pouco insidioso na verdade. Eles alteraram o tipo semântico do valor de retorno, mas não o tipo programático do valor de retorno. Isso pode causar todos os tipos de estragos, especialmente se esse for um problema de versão menor, mas também se for um problema de versão principal.

Digamos que a biblioteca retornou um tipo de MillisecondsSinceEpoch, um invólucro simples que contém a long. Quando eles o alteravam para um NanosecondsSinceEpochvalor, seu código não seria compilado e obviamente o indicaria os locais onde você precisa fazer alterações. A alteração não pôde corromper silenciosamente seu programa.

Melhor ainda, seria um TimeSinceEpochobjeto que poderia adaptar sua interface à medida que mais precisão fosse adicionada, como adicionar um #toLongNanosecondsmétodo ao lado do #toLongMillisecondsmétodo, sem exigir nenhuma alteração no seu código.

O próximo problema é que você não possui um conjunto confiável de testes de integração na biblioteca. Você deveria escrever isso. Melhor seria criar uma interface em torno dessa biblioteca para encapsulá-la para o resto do seu aplicativo. Várias outras respostas abordam isso (e outras continuam aparecendo enquanto digito). Os testes de integração devem ser executados com menos frequência do que os testes de unidade. É por isso que ter uma camada de buffer ajuda. Separe seus testes de integração em uma área separada (ou nomeie-os de maneira diferente) para poder executá-los conforme necessário, mas não sempre que executar seu teste de unidade.

cbojar
fonte
2
@ durron597 Eu ainda diria que é um bug. Além da falta de documentação, por que mudar o comportamento esperado? Por que não um novo método que fornece a nova precisão e deixa o método antigo ainda fornecer millis? E por que não fornecer uma maneira para o compilador alertá-lo através de uma alteração no tipo de retorno? Não é preciso muito para tornar isso muito mais claro, não apenas na documentação, mas no próprio código.
Cbojar
11
@gbjbaanb “que têm práticas de libertação pobres” parece ser um bug para mim
Arturo Torres Sánchez
2
@gbjbaanb Uma biblioteca de terceiros [deveria] fazer um "contrato" com seus usuários. Quebrar esse contrato - documentado ou não - pode / deve ser considerado um bug. Como já foi dito, se você precisar alterar algo, adicione ao contrato uma nova função / método (consulte todos os ...Ex()métodos no Win32API). Se isso não for viável, "quebrar" o contrato renomeando a função (ou seu tipo de retorno) teria sido melhor do que alterar o comportamento.
TripeHound
11
Este é um erro na biblioteca. O uso de nanossegundos em um longo está pressionando.
Joshua
11
@gbjbaanb Você diz que não é um bug, pois é um comportamento pretendido, mesmo que inesperado. Nesse sentido, não é um bug de implementação , mas é o mesmo. Pode ser chamado de defeito de design ou bug de interface . As falhas estão no fato de expor uma obsessão primitiva por unidades longas, em vez de explícitas, sua abstração está com vazamento ao exportar detalhes de sua implementação interna (que os dados são armazenados como uma longa unidade de uma determinada unidade) e que viola o princípio de menos espanto com uma mudança sutil de unidade.
Cbojar
5

Você precisa de testes de integração e sistema.

Os testes de unidade são ótimos para verificar se seu código se comporta conforme o esperado. Como você percebe, não faz nada para desafiar suas suposições ou garantir que suas expectativas sejam saudáveis.

A menos que seu produto tenha pouca interação com sistemas externos ou interaja com sistemas tão conhecidos, estáveis ​​e documentados, eles podem ser ridicularizados com confiança (isso raramente acontece no mundo real) - testes de unidade não são suficientes.

Quanto maior o nível de seus testes, mais eles o protegerão contra o inesperado. Isso tem um custo (conveniência, velocidade, fragilidade ...), portanto, os testes de unidade devem permanecer a base dos seus testes, mas você precisa de outras camadas, incluindo - eventualmente - um pouquinho de testes em humanos que ajudam bastante na captura coisas estúpidas que ninguém pensou.

ptyx
fonte
2

O melhor seria criar um protótipo mínimo e entender como a biblioteca funciona exatamente. Ao fazer isso, você obterá algum conhecimento sobre a biblioteca com documentação insuficiente. Um protótipo pode ser um programa minimalista que usa essa biblioteca e executa a funcionalidade.

Caso contrário, não faz sentido escrever testes de unidade, com requisitos semi-definidos e fraca compreensão do sistema.

Quanto ao seu problema específico - sobre o uso de métricas incorretas: eu trataria isso como uma alteração nos requisitos. Depois de reconhecer o problema, altere os testes de unidade e o código.

BЈовић
fonte
1

Se você estivesse usando uma biblioteca popular e estável, talvez pudesse supor que ela não trará truques desagradáveis ​​para você. Mas se coisas como o que você descreveu acontecerem com esta biblioteca, então obviamente não será uma. Após essa experiência ruim, sempre que algo der errado em sua interação com esta biblioteca, você precisará examinar não apenas a possibilidade de ter cometido um erro, mas também a possibilidade de que a biblioteca tenha cometido um erro. Então, digamos que esta é uma biblioteca sobre a qual você não tenha certeza.

Uma das técnicas empregadas com as bibliotecas sobre as quais não temos certeza é criar uma camada intermediária entre o nosso sistema e as referidas bibliotecas, que abstraem a funcionalidade oferecida pelas bibliotecas, afirma que nossas expectativas em relação à biblioteca são corretas e também simplifica bastante nossa vida no futuro, devemos decidir dar a inicialização a essa biblioteca e substituí-la por outra biblioteca que se comporte melhor.

Mike Nakis
fonte
Isso realmente não responde à pergunta. Eu já tenho uma camada que separa a biblioteca do meu sistema, mas o problema é que minha camada de abstração pode ter "bugs" quando a biblioteca é alterada sem aviso prévio.
durron597
11
@ durron597 Então talvez a camada não esteja isolando suficientemente a biblioteca do restante do seu aplicativo. Se você está achando difícil testar essa camada, talvez seja necessário simplificar o comportamento e isolar mais fortemente os dados subjacentes.
Cbojar
O que @cbojar disse. Além disso, deixe-me repetir algo que pode ter passado despercebido no texto acima: A assertpalavra-chave (ou função ou recurso, dependendo do idioma que você está usando) é sua amiga. Não estou falando de asserções nos testes de unidade / integração, estou dizendo que a camada de isolamento deve ser muito pesada com asserções, afirmando tudo o que é assertável sobre o comportamento da biblioteca.
Mike Nakis
Essas asserções não são necessariamente executadas nas execuções de produção, mas são executadas durante o teste, tendo uma visão em branco da sua camada de isolamento e, portanto, garantindo (o máximo possível) que as informações que sua camada recebe da biblioteca é som.
Mike Nakis