Testes unitários, como escrever código testável e por que isso é importante

Publicados: 2022-03-11

O teste de unidade é um instrumento essencial na caixa de ferramentas de qualquer desenvolvedor de software sério. No entanto, às vezes pode ser muito difícil escrever um bom teste de unidade para uma parte específica do código. Tendo dificuldade em testar seu próprio código ou o código de outra pessoa, os desenvolvedores geralmente pensam que suas dificuldades são causadas pela falta de algum conhecimento de teste fundamental ou técnicas secretas de teste de unidade.

Neste tutorial de teste de unidade, pretendo demonstrar que os testes de unidade são bastante fáceis; os problemas reais que complicam o teste de unidade e introduzem complexidade cara são resultado de código mal projetado e não testável . Discutiremos o que torna o código difícil de testar, quais antipadrões e práticas ruins devemos evitar para melhorar a testabilidade e quais outros benefícios podemos obter escrevendo código testável. Veremos que escrever testes de unidade e gerar código testável não é apenas tornar os testes menos problemáticos, mas tornar o próprio código mais robusto e mais fácil de manter.

Tutorial de teste de unidade: ilustração da capa

O que é Teste Unitário?

Essencialmente, um teste de unidade é um método que instancia uma pequena parte do nosso aplicativo e verifica seu comportamento independentemente de outras partes . Um teste de unidade típico contém 3 fases: primeiro, ele inicializa uma pequena parte de um aplicativo que deseja testar (também conhecido como sistema em teste ou SUT), depois aplica algum estímulo ao sistema em teste (geralmente chamando um método nele) e, finalmente, observa o comportamento resultante. Se o comportamento observado for consistente com as expectativas, o teste unitário passa, caso contrário, ele falha, indicando que há um problema em algum lugar do sistema sob teste. Essas três fases de teste de unidade também são conhecidas como Arrange, Act e Assert, ou simplesmente AAA.

Um teste de unidade pode verificar diferentes aspectos comportamentais do sistema em teste, mas provavelmente se enquadrará em uma das duas categorias a seguir: baseado em estado ou baseado em interação . A verificação de que o sistema em teste produz resultados corretos, ou que seu estado resultante está correto, é chamado de teste de unidade baseado em estado , enquanto verificar se ele invoca corretamente determinados métodos é chamado de teste de unidade baseado em interação .

Como metáfora para o teste de unidade de software adequado, imagine um cientista louco que quer construir alguma quimera sobrenatural, com pernas de sapo, tentáculos de polvo, asas de pássaro e cabeça de cachorro. (Esta metáfora é muito próxima do que os programadores realmente fazem no trabalho). Como esse cientista garantiria que cada parte (ou unidade) que ele escolhesse realmente funcionasse? Bem, ele pode pegar, digamos, uma única perna de rã, aplicar um estímulo elétrico a ela e verificar a contração muscular adequada. O que ele está fazendo é essencialmente os mesmos passos Organizar-Agir-Afirmar do teste de unidade; a única diferença é que, neste caso, unit se refere a um objeto físico, não a um objeto abstrato a partir do qual construímos nossos programas.

o que é teste de unidade: ilustração

Usarei C# para todos os exemplos deste artigo, mas os conceitos descritos se aplicam a todas as linguagens de programação orientadas a objetos.

Um exemplo de um teste de unidade simples pode ser assim:

 [TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }

Teste de unidade versus teste de integração

Outra coisa importante a considerar é a diferença entre teste de unidade e teste de integração.

O objetivo de um teste de unidade em engenharia de software é verificar o comportamento de um pedaço de software relativamente pequeno, independentemente de outras partes. Os testes unitários são de escopo restrito e nos permitem cobrir todos os casos, garantindo que cada peça funcione corretamente.

Por outro lado, os testes de integração demonstram que diferentes partes de um sistema trabalham juntas no ambiente da vida real . Eles validam cenários complexos (podemos pensar em testes de integração como um usuário executando alguma operação de alto nível em nosso sistema) e geralmente exigem que recursos externos, como bancos de dados ou servidores web, estejam presentes.

Voltemos à nossa metáfora do cientista louco e suponhamos que ele combinou com sucesso todas as partes da quimera. Ele quer realizar um teste de integração da criatura resultante, certificando-se de que ela pode, digamos, andar em diferentes tipos de terreno. Em primeiro lugar, o cientista deve emular um ambiente para a criatura andar. Em seguida, ele joga a criatura naquele ambiente e a cutuca com um bastão, observando se ela anda e se move conforme o planejado. Depois de terminar um teste, o cientista maluco limpa toda a sujeira, areia e pedras que agora estão espalhadas em seu adorável laboratório.

ilustração de exemplo de teste de unidade

Observe a diferença significativa entre os testes unitários e de integração: um teste unitário verifica o comportamento de uma pequena parte da aplicação, isolada do ambiente e de outras partes, e é bastante fácil de implementar, enquanto um teste de integração abrange interações entre diferentes componentes, no ambiente próximo da vida real e requer mais esforço, incluindo fases adicionais de configuração e desmontagem.

Uma combinação razoável de testes de unidade e integração garante que cada unidade funcione corretamente, independentemente das outras, e que todas essas unidades funcionem bem quando integradas, dando-nos um alto nível de confiança de que todo o sistema funciona conforme o esperado.

No entanto, devemos nos lembrar de sempre identificar que tipo de teste estamos implementando: uma unidade ou um teste de integração. A diferença às vezes pode enganar. Se pensarmos que estamos escrevendo um teste de unidade para verificar algum caso de borda sutil em uma classe de lógica de negócios e percebermos que isso requer recursos externos, como serviços da Web ou bancos de dados, algo não está certo - essencialmente, estamos usando uma marreta para quebrar uma noz. E isso significa design ruim.

O que faz um bom teste de unidade?

Antes de mergulhar na parte principal deste tutorial e escrever testes de unidade, vamos discutir rapidamente as propriedades de um bom teste de unidade. Os princípios de teste unitário exigem que um bom teste seja:

  • Fácil de escrever. Os desenvolvedores geralmente escrevem muitos testes de unidade para cobrir diferentes casos e aspectos do comportamento do aplicativo, portanto, deve ser fácil codificar todas essas rotinas de teste sem muito esforço.

  • Legível. A intenção de um teste de unidade deve ser clara. Um bom teste de unidade conta uma história sobre algum aspecto comportamental de nosso aplicativo, portanto, deve ser fácil entender qual cenário está sendo testado e - se o teste falhar - fácil detectar como resolver o problema. Com um bom teste de unidade, podemos corrigir um bug sem realmente depurar o código!

  • De confiança. Os testes de unidade devem falhar apenas se houver um bug no sistema em teste. Isso parece bastante óbvio, mas os programadores geralmente se deparam com um problema quando seus testes falham, mesmo quando nenhum bug foi introduzido. Por exemplo, os testes podem passar ao executar um por um, mas falhar ao executar todo o conjunto de testes ou passar em nossa máquina de desenvolvimento e falhar no servidor de integração contínua. Essas situações são indicativas de uma falha de projeto. Bons testes de unidade devem ser reprodutíveis e independentes de fatores externos, como ambiente ou ordem de execução.

  • Rápido. Os desenvolvedores escrevem testes de unidade para que possam executá-los repetidamente e verificar se nenhum bug foi introduzido. Se os testes de unidade forem lentos, é mais provável que os desenvolvedores ignorem a execução deles em suas próprias máquinas. Um teste lento não fará diferença significativa; adicione mais mil e certamente ficaremos presos esperando por um tempo. Testes de unidade lentos também podem indicar que o sistema em teste, ou o próprio teste, interage com sistemas externos, tornando-o dependente do ambiente.

  • Verdadeiramente unidade, não integração. Como já discutimos, os testes unitários e de integração têm propósitos diferentes. Tanto o teste de unidade quanto o sistema em teste não devem acessar os recursos de rede, bancos de dados, sistema de arquivos, etc., para eliminar a influência de fatores externos.

É isso - não há segredos para escrever testes de unidade . No entanto, existem algumas técnicas que nos permitem escrever código testável .

Código testável e não testável

Algum código é escrito de tal forma que é difícil, ou mesmo impossível, escrever um bom teste de unidade para ele. Então, o que torna o código difícil de testar? Vamos revisar alguns antipadrões, cheiros de código e práticas ruins que devemos evitar ao escrever código testável.

Envenenando a base de código com fatores não determinísticos

Vamos começar com um exemplo simples. Imagine que estamos escrevendo um programa para um microcontrolador doméstico inteligente, e um dos requisitos é acender automaticamente a luz do quintal se algum movimento for detectado lá durante a tarde ou à noite. Começamos de baixo para cima implementando um método que retorna uma representação em string da hora aproximada do dia (“Noite”, “Manhã”, “Tarde” ou “Noite”):

 public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }

Essencialmente, esse método lê a hora atual do sistema e retorna um resultado com base nesse valor. Então, o que há de errado com este código?

Se pensarmos a partir da perspectiva do teste de unidade, veremos que não é possível escrever um teste de unidade baseado em estado adequado para esse método. DateTime.Now é, essencialmente, uma entrada oculta, que provavelmente mudará durante a execução do programa ou entre execuções de teste. Assim, as chamadas subsequentes a ele produzirão resultados diferentes.

Esse comportamento não determinístico torna impossível testar a lógica interna do método GetTimeOfDay() sem realmente alterar a data e a hora do sistema. Vamos dar uma olhada em como esse teste precisaria ser implementado:

 [TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }

Testes como esse violariam muitas das regras discutidas anteriormente. Seria caro escrever (por causa da configuração não trivial e lógica de desmontagem), não confiável (pode falhar mesmo se não houver bugs no sistema em teste, devido a problemas de permissão do sistema, por exemplo) e não garantido para corra rápido. E, finalmente, esse teste não seria realmente um teste de unidade — seria algo entre um teste de unidade e um teste de integração, porque ele pretende testar um caso extremo simples, mas requer que um ambiente seja configurado de uma maneira específica. O resultado não vale o esforço, né?

Acontece que todos esses problemas de testabilidade são causados ​​pela API GetTimeOfDay() de baixa qualidade. Em sua forma atual, esse método sofre de vários problemas:

  • Ele é fortemente acoplado à fonte de dados concreta. Não é possível reutilizar este método para processar data e hora recuperadas de outras fontes ou passadas como argumento; o método funciona apenas com a data e hora da máquina específica que executa o código. O acoplamento forte é a raiz principal da maioria dos problemas de testabilidade.

  • Viola o Princípio da Responsabilidade Única (SRP). O método tem múltiplas responsabilidades; ele consome a informação e também a processa. Outro indicador de violação de SRP é quando uma única classe ou método tem mais de um motivo para mudar . A partir dessa perspectiva, o método GetTimeOfDay() pode ser alterado devido a ajustes lógicos internos ou porque a fonte de data e hora deve ser alterada.

  • Ele mente sobre as informações necessárias para realizar seu trabalho. Os desenvolvedores devem ler cada linha do código-fonte real para entender quais entradas ocultas são usadas e de onde elas vêm. A assinatura do método por si só não é suficiente para entender o comportamento do método.

  • É difícil prever e manter. O comportamento de um método que depende de um estado global mutável não pode ser previsto pela mera leitura do código-fonte; é necessário levar em conta seu valor atual, juntamente com toda a sequência de eventos que poderiam tê-lo alterado anteriormente. Em um aplicativo do mundo real, tentar desvendar tudo isso se torna uma verdadeira dor de cabeça.

Depois de revisar a API, vamos finalmente corrigi-la! Felizmente, isso é muito mais fácil do que discutir todas as suas falhas – só precisamos quebrar as preocupações fortemente acopladas.

Corrigindo a API: Apresentando um argumento de método

A maneira mais óbvia e fácil de corrigir a API é introduzindo um argumento de método:

 public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }

Agora, o método exige que o chamador forneça um argumento DateTime , em vez de procurar secretamente essas informações por si só. Do ponto de vista do teste de unidade, isso é ótimo; o método agora é determinístico (ou seja, seu valor de retorno depende totalmente da entrada), então o teste baseado em estado é tão fácil quanto passar algum valor DateTime e verificar o resultado:

 [TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }

Observe que essa refatoração simples também resolveu todos os problemas de API discutidos anteriormente (acoplamento forte, violação de SRP, API pouco clara e difícil de entender) ao introduzir uma linha clara entre quais dados devem ser processados ​​e como isso deve ser feito.

Excelente — o método é testável, mas e seus clientes ? Agora é responsabilidade do chamador fornecer data e hora para o GetTimeOfDay(DateTime dateTime) , o que significa que eles podem se tornar não testáveis ​​se não prestarmos atenção suficiente. Vamos dar uma olhada em como podemos lidar com isso.

Corrigindo a API do cliente: injeção de dependência

Digamos que continuamos trabalhando no sistema de casa inteligente e implementamos o seguinte cliente do GetTimeOfDay(DateTime dateTime) - o código do microcontrolador de casa inteligente acima mencionado responsável por acender ou apagar a luz, com base na hora do dia e na detecção de movimento :

 public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }

Ai! Temos o mesmo tipo de problema de entrada DateTime.Now oculto — a única diferença é que ele está localizado um pouco mais alto em um nível de abstração. Para resolver esse problema, podemos introduzir outro argumento, delegando novamente a responsabilidade de fornecer um valor DateTime ao chamador de um novo método com assinatura ActuateLights(bool motionDetected, DateTime dateTime) . Mas, em vez de mover o problema para um nível mais alto na pilha de chamadas mais uma vez, vamos empregar outra técnica que nos permitirá manter tanto ActuateLights(bool motionDetected) quanto seus clientes testáveis: Inversão de Controle, ou IoC.

A inversão de controle é uma técnica simples, mas extremamente útil, para desacoplar código e, em particular, para testes de unidade. (Afinal, manter as coisas frouxamente acopladas é essencial para poder analisá-las independentemente umas das outras.) O ponto-chave da IoC é separar o código de tomada de decisão ( quando fazer algo) do código de ação ( o que fazer quando algo acontece ). Essa técnica aumenta a flexibilidade, torna nosso código mais modular e reduz o acoplamento entre os componentes.

A inversão de controle pode ser implementada de várias maneiras; vamos dar uma olhada em um exemplo específico — Injeção de Dependência usando um construtor — e como isso pode ajudar na construção de uma API SmartHomeController testável.

Primeiro, vamos criar uma interface IDateTimeProvider , contendo uma assinatura de método para obter alguma data e hora:

 public interface IDateTimeProvider { DateTime GetDateTime(); }

Em seguida, faça SmartHomeController referenciar uma implementação de IDateTimeProvider e delegue a responsabilidade de obter data e hora:

 public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }

Agora podemos ver por que a Inversão de Controle é assim chamada: o controle de qual mecanismo usar para leitura de data e hora foi invertido , e agora pertence ao cliente do SmartHomeController , não ao próprio SmartHomeController . Assim, a execução do ActuateLights(bool motionDetected) depende totalmente de duas coisas que podem ser facilmente gerenciadas de fora: o argumento motionDetected e uma implementação concreta de IDateTimeProvider , passada para um construtor SmartHomeController .

Por que isso é significativo para testes de unidade? Isso significa que diferentes implementações IDateTimeProvider podem ser usadas no código de produção e no código de teste de unidade. No ambiente de produção, alguma implementação da vida real será injetada (por exemplo, uma que leia a hora real do sistema). No teste de unidade, no entanto, podemos injetar uma implementação “falsa” que retorna um valor DateTime constante ou predefinido adequado para testar o cenário específico.

Uma implementação falsa de IDateTimeProvider pode ser assim:

 public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }

Com a ajuda desta classe, é possível isolar o SmartHomeController de fatores não determinísticos e realizar um teste de unidade baseado em estado. Vamos verificar que, se foi detectado movimento, o tempo desse movimento é registrado na propriedade LastMotionTime :

 [TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }

Excelente! Um teste como esse não era possível antes da refatoração. Agora que eliminamos os fatores não determinísticos e verificamos o cenário baseado em estado, você acha que o SmartHomeController é totalmente testável?

Envenenando a base de código com efeitos colaterais

Apesar do fato de termos resolvido os problemas causados ​​pela entrada oculta não determinística, e pudemos testar certas funcionalidades, o código (ou, pelo menos, parte dele) ainda não pode ser testado!

Vamos revisar a seguinte parte do ActuateLights(bool motionDetected) responsável por ligar ou desligar a luz:

 // If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }

Como podemos ver, SmartHomeController delega a responsabilidade de ligar ou desligar a luz para um objeto BackyardLightSwitcher , que implementa um padrão Singleton. O que há de errado com este projeto?

Para testar totalmente a unidade do ActuateLights(bool motionDetected) , devemos realizar testes baseados em interação além dos testes baseados em estado; isto é, devemos garantir que os métodos para acender ou apagar a luz sejam chamados se, e somente se, as condições apropriadas forem atendidas. Infelizmente, o design atual não nos permite fazer isso: os TurnOn() e TurnOff() do BackyardLightSwitcher acionam algumas mudanças de estado no sistema, ou seja, produzem efeitos colaterais . A única maneira de verificar se esses métodos foram chamados é verificar se os efeitos colaterais correspondentes realmente aconteceram ou não, o que pode ser doloroso.

De fato, vamos supor que o sensor de movimento, a lanterna do quintal e o microcontrolador doméstico inteligente estejam conectados a uma rede da Internet das Coisas e se comuniquem usando algum protocolo sem fio. Nesse caso, um teste de unidade pode tentar receber e analisar esse tráfego de rede. Ou, se os componentes de hardware estiverem conectados com um fio, o teste de unidade pode verificar se a tensão foi aplicada ao circuito elétrico apropriado. Ou, afinal, pode verificar se a luz realmente acendeu ou apagou usando um sensor de luz adicional.

Como podemos ver, os métodos de efeito colateral de teste de unidade podem ser tão difíceis quanto os de teste de unidade não determinísticos, e podem até ser impossíveis. Qualquer tentativa levará a problemas semelhantes aos que já vimos. O teste resultante será difícil de implementar, não confiável, potencialmente lento e não realmente unitário. E, depois de tudo isso, o piscar da luz toda vez que executamos o conjunto de testes acabará nos deixando loucos!

Novamente, todos esses problemas de testabilidade são causados ​​pela API ruim, não pela capacidade do desenvolvedor de escrever testes de unidade. Não importa quão exatamente o controle de luz seja implementado, a API SmartHomeController sofre desses problemas já familiares:

  • É fortemente acoplado à implementação concreta. A API depende da instância concreta e codificada de BackyardLightSwitcher . Não é possível reutilizar o ActuateLights(bool motionDetected) para alternar qualquer luz que não seja a do quintal.

  • Viola o Princípio da Responsabilidade Única. A API tem dois motivos para mudar: primeiro, mudanças na lógica interna (como optar por fazer a luz acender apenas à noite, mas não à noite) e segundo, se o mecanismo de comutação de luz for substituído por outro.

  • Ela mente sobre suas dependências. Não há como os desenvolvedores saberem que o SmartHomeController depende do componente BackyardLightSwitcher embutido em código, além de investigar o código-fonte.

  • É difícil de entender e manter. E se a luz se recusar a acender quando as condições forem adequadas? Poderíamos gastar muito tempo tentando consertar o SmartHomeController sem sucesso, apenas para perceber que o problema foi causado por um bug no BackyardLightSwitcher (ou, ainda mais engraçado, uma lâmpada queimada!).

A solução dos problemas de testabilidade e API de baixa qualidade é, não surpreendentemente, separar componentes fortemente acoplados uns dos outros. Assim como no exemplo anterior, empregar a injeção de dependência resolveria esses problemas; basta adicionar uma dependência ILightSwitcher ao SmartHomeController , delegar a ele a responsabilidade de ligar o interruptor de luz e passar uma implementação ILightSwitcher falsa e somente para teste que registrará se os métodos apropriados foram chamados nas condições corretas. No entanto, em vez de usar a injeção de dependência novamente, vamos revisar uma abordagem alternativa interessante para dissociar as responsabilidades.

Corrigindo a API: funções de ordem superior

Essa abordagem é uma opção em qualquer linguagem orientada a objetos que suporte funções de primeira classe . Vamos aproveitar os recursos funcionais do C# e fazer com que o ActuateLights(bool motionDetected) aceite mais dois argumentos: um par de Action delegates, apontando para métodos que devem ser chamados para ligar e desligar a luz. Esta solução converterá o método em uma função de ordem superior :

 public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }

Essa é uma solução mais funcional do que a abordagem clássica de injeção de dependência orientada a objetos que vimos antes; no entanto, ele nos permite obter o mesmo resultado com menos código e mais expressividade do que a injeção de dependência. Não é mais necessário implementar uma classe que esteja em conformidade com uma interface para fornecer ao SmartHomeController a funcionalidade necessária; em vez disso, podemos apenas passar uma definição de função. Funções de ordem superior podem ser pensadas como outra maneira de implementar a Inversão de Controle.

Agora, para realizar um teste de unidade baseado em interação do método resultante, podemos passar ações falsas facilmente verificáveis ​​para ele:

 [TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }

Por fim, tornamos a API SmartHomeController totalmente testável e podemos realizar testes de unidade baseados em estado e baseados em interação para ela. Novamente, observe que, além da testabilidade aprimorada, a introdução de uma junção entre a tomada de decisão e o código de ação ajudou a resolver o problema de acoplamento rígido e levou a uma API mais limpa e reutilizável.

Agora, para alcançar a cobertura total do teste de unidade, podemos simplesmente implementar vários testes de aparência semelhante para validar todos os casos possíveis - não é grande coisa, já que os testes de unidade agora são muito fáceis de implementar.

Impureza e Testabilidade

O não determinismo descontrolado e os efeitos colaterais são semelhantes em seus efeitos destrutivos na base de código. Quando usados ​​de forma descuidada, eles levam a códigos enganosos, difíceis de entender e manter, fortemente acoplados, não reutilizáveis ​​e não testáveis.

Por outro lado, métodos que são determinísticos e livres de efeitos colaterais são muito mais fáceis de testar, raciocinar e reutilizar para construir programas maiores. Em termos de programação funcional, tais métodos são chamados de funções puras . Raramente teremos um problema testando uma função pura; tudo o que temos a fazer é passar alguns argumentos e verificar se o resultado está correto. O que realmente torna o código não testável são fatores impuros e codificados que não podem ser substituídos, substituídos ou abstraídos de alguma outra forma.

A impureza é tóxica: se o método Foo() depender de um método não determinístico ou de efeito colateral Bar() , então Foo() também se tornará não determinístico ou de efeito colateral. Eventualmente, podemos acabar envenenando toda a base de código. Multiplique todos esses problemas pelo tamanho de um aplicativo complexo da vida real e nos encontraremos sobrecarregados com uma base de código difícil de manter cheia de odores, antipadrões, dependências secretas e todo tipo de coisas feias e desagradáveis.

exemplo de teste de unidade: ilustração

No entanto, a impureza é inevitável; qualquer aplicativo da vida real deve, em algum momento, ler e manipular o estado interagindo com o ambiente, bancos de dados, arquivos de configuração, serviços da Web ou outros sistemas externos. Portanto, em vez de eliminar completamente a impureza, é uma boa ideia limitar esses fatores, evitar deixá-los envenenar sua base de código e quebrar dependências codificadas o máximo possível, para poder analisar e testar as coisas de forma independente.

Sinais de aviso comuns de código difícil de testar

Problemas para escrever testes? O problema não está no seu conjunto de testes. Está no seu código.
Tweet

Por fim, vamos revisar alguns sinais de aviso comuns que indicam que nosso código pode ser difícil de testar.

Propriedades Estáticas e Campos

Propriedades e campos estáticos ou, simplesmente, estado global, podem complicar a compreensão e testabilidade do código, ocultando as informações necessárias para que um método faça seu trabalho, introduzindo não determinismo ou promovendo o uso extensivo de efeitos colaterais. As funções que lêem ou modificam o estado global mutável são inerentemente impuras.

Por exemplo, é difícil raciocinar sobre o código a seguir, que depende de uma propriedade globalmente acessível:

 if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }

E se o método HeatWater() não for chamado quando tivermos certeza de que deveria ter sido? Como qualquer parte do aplicativo pode ter alterado o valor CostSavingEnabled , devemos encontrar e analisar todos os locais que modificam esse valor para descobrir o que está errado. Além disso, como já vimos, não é possível definir algumas propriedades estáticas para fins de teste (por exemplo, DateTime.Now ou Environment.MachineName ; elas são somente leitura, mas ainda não determinísticas).

Por outro lado, o estado global imutável e determinista é totalmente aceitável. Na verdade, há um nome mais familiar para isso – uma constante. Valores constantes como Math.PI não introduzem nenhum determinismo e, como seus valores não podem ser alterados, não permitem nenhum efeito colateral:

 double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!

Singletons

Essencialmente, o padrão Singleton é apenas outra forma de estado global. Singletons promovem APIs obscuras que se referem a dependências reais e introduzem um acoplamento desnecessariamente apertado entre componentes. Eles também violam o Princípio da Responsabilidade Única porque, além de suas funções primárias, eles controlam sua própria inicialização e ciclo de vida.

Os singletons podem facilmente tornar os testes de unidade dependentes da ordem porque carregam o estado durante toda a vida útil de todo o aplicativo ou conjunto de testes de unidade. Dê uma olhada no exemplo a seguir:

 User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }

In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache after each unit test run.

Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.

The new Operator

Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.

For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:

 using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }

However, sometimes new is absolutely harmless: for example, it is OK to create simple entity objects:

 var person = new Person("John", "Doe", new DateTime(1970, 12, 31));

It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack methods were called or not — we just check if the end result is correct:

 string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }

Static Methods

Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.

For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:

 void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }

However, pure static functions are OK: any combination of them will still be a pure function. Por exemplo:

 double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }

Benefits of Unit Testing

Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.

As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.