Uma coisa que aprendi nos últimos anos é que testes unitários são vistos como "problema resolvido". Com tanta disponibilidade de informações, livros e ferramentas, a percepção é que basta baixar algo como o NUnit e pronto. Mas não é bem assim. Antes mesmo de decidir escrever testes unitários, devemos aproveitar a experiência real de outras pessoas: histórias boas, histórias ruins, de horror, de sucesso.
Gostaria de levar o leitor por uma breve viagem pelo mundo dos testes unitários. Nossa equipe na Typemock tem explorado por anos essa área, e essas experiências definitivamente afetaram nosso processo de desenvolvimento de produtos. Nosso produto principal, o Isolator, começou como framework para criação de mocks. No entanto, à medida que descobríamos mais a respeito dos problemas práticos enfrentados com testes unitários, mais desenvolvíamos novas funcionalidades para aliviá-los. Porém, posso garantir que ainda há muito por se fazer e para se descobrir em testes unitários.
Nossa visão na Typemock é: facilitar o desenvolvimento de testes unitários para todos. Simples. Fácil de alcançar? Bem... O desenvolvimento de testes unitários não é fácil. Os benefícios são enormes, porém é muito trabalhoso obtê-los.
A maioria dos desenvolvedores já tem uma base de código em que está trabalhando. Alguns têm a sorte de trabalhar em projetos novos, porém a maioria lida com código legado. Quando decidimos escrever testes, é para o código legado que os escrevemos. O problema é que isso não é tão fácil.
Quando a Typemock começou, não era possível escrever testes para código legado sem ter que alterar o código para adequá-lo aos testes. Esse era o principal objetivo do Isolator: prover a capacidade de escrever testes unitários sem ter que alterar o código.
APIs em evolução
Uma coisa que aprendemos ao longo tempo é observar atentamente a maneira como nossa API é utilizada. Por exemplo: nosso primeiro conjunto de APIs era baseado em strings, de forma que para simular um DateTime.now era necessário fazer algo assim:
Mock mockDateTime = MockManager.MockAll<datetime>(); mockDateTime.ExpectGetAlways("Now", new DateTime(2000, 1, 1));
Apesar de não ser perfeito, funciona. Mas sabemos que coisas como essas são fáceis de quebrar durante uma refatoração; por isso passamos para o modelo de gravação-reprodução, que é amigável a refatorações, apesar de parecer um tanto estranho:
using (RecordExpectations recorder = RecorderManager.StartRecording()) { DateTime.Now = new DateTime(2000, 1, 1); }
Isso funciona e já tinha sido visto como maneira revolucionária de escrever testes, mas o estilo gravação-reprodução estava saindo de moda. Essa versão tinha alguns problemas técnicos dos quais queríamos nos livrar. Assim, quando as expressões lambda surgiram, nossas APIs tomaram outro rumo, tanto em relação à legibilidade quanto à capacidade de se aplicar refatorações.
Isolate.WhenCalled(() => DateTime.Now).WillReturn(new DateTime(2000, 1, 1));
Com o conjunto atual de APIs, decidimos fazer outra simplificação. Abandonamos o termo "mock" e o substituímos por "fake" para as instâncias. "Mock" e "stub" são termos com múltiplos significados, que têm sido mal utilizados e confundidos. Ao invés de tentar ensinar as nuances desses conceitos para os iniciantes, decidimos contornar o problema em definitivo.
Bons vizinhos
O Isolator, como um complemento do Visual Studio, não era o suficiente - precisávamos integrá-lo a outras ferramentas e outros fornecedores. Ferramentas de cobertura de código, profilers de desempenho, mecanismos de build, e mais. O Isolator precisava conviver bem com outras soluções para que se pudesse executar testes facilmente, utilizando diversas ferramentas com configurações variadas.
A respeito da execução de testes, como executá-los fora do Visual Studio? Quando se começa a implantar ferramentas de integração contínua numa equipe, logo se aprende sobre muitas ferramentas diferentes no universo Microsoft, inclusive a respeito do poderoso TFS (Team Foundation System). A tecnologia de profiling do Isolator exige muito trabalho de integração para permitir a execução dos testes dentro de processos de integração contínua. Equipes diferentes usam conjuntos diferentes de ferramentas e diferentes servidores de Integração Contínua, e precisávamos criar uma solução que pudesse se adequar facilmente à maneira que as pessoas trabalham.
APIs robustas
Pergunte a qualquer pessoa que já até mesmo cogitou escrever testes unitários, e você ouvirá o seguinte: sei que meu código vai mudar, mas não quero ter que consertar meus testes o tempo inteiro. Há algo que possa ser feito a respeito?
Em frameworks de mock, a habilidade de se alterar comportamentos vem do conhecimento do que ocorre dentro de um objeto. Mas essa "visão de raio-X" tem o seu calcanhar de Aquiles - alterações no código interno afetam os testes.
Testes unitários têm também relação com manutenção de código. Durante o desenvolvimento das nossas APIs, sempre levamos isso em consideração. Considere, por exemplo, este construtor de um objeto (de um projeto open source chamado ERPStore):
public AnonymousCheckoutController( ISalesService salesService , ICartService cartService , IAccountService accountService , IEmailerService emailerService , IDocumentService documentService , ICacheService cacheService , IAddressService addressService , CryptoService cryptoService , IIncentiveService IncentiveService)
O construtor utiliza muitas interfaces como entradas. Em meus testes, posso simular essas dependências desta maneira:
var fakeSalesService = Isolate.Fake.Instance<SalesController>(); var fakeCartService = Isolate.Fake.Instance<ICartService>(); var fakeAccountService = Isolate.Fake.Instance<IAccountService>(); var fakeEmailerService = Isolate.Fake.Instance<IEmailerService>(); var fakeDocumentService= Isolate.Fake.Instance<IDocumentService>(); var fakeCacheService = Isolate.Fake.Instance<ICacheService>(); var fakeAddressService = Isolate.Fake.Instance<IAddressService>(); var fakeCryptoService = Isolate.Fake.Instance<CryptoService>(); var fakeIncentiveService = Isolate.Fake.Instance<IncentiveService>(); var controller = new AnonymousCheckoutController( fakeSalesService, fakeCartService, fakeAccountService, fakeEmailerService, fakeDocumentService, fakeCacheService, fakeAddressService, fakeCryptoService, fakeIncentiveService);
O que aconteceria se precisássemos que o construtor aceitasse outro tipo? Ou se removêssemos um argumento? Inevitavelmente teríamos que alterar os testes.
Então criamos uma API que desacopla a assinatura do construtor da maneira que ele é utilizado nos testes:
var controller = Isolate.Fake.Dependencies();
Isso é tudo. A API Fake.Dependencies cria um objeto real do tipo AnonymousCheckoutController, e passa implementações "falsas" das dependências, mas sem mencionar seus tipos. Se o construtor mudar, os testes continuarão funcionado. Conseguimos reduzir o acoplamento entre o teste e o código e ainda aumentamos a legibilidade.
Testes melhores
Quem tem experiência com desenvolvimento de testes unitários sabe que essa é uma habilidade que se desenvolve com o tempo. Todos podem aprender a escrever testes melhores, mas normalmente aprendemos da maneira mais difícil. Pensamos em tornar mais fácil essa experiência; como podemos ajudar as pessoas a evitar os mesmos erros que cometemos?
Era o momento de adicionar uma nova funcionalidade ao Isolator. A capacidade de examinar os testes e indicar erros comuns (por exemplo, um teste sem asserts) dentro do Visual Studio. O Isolator oferece uma oportunidade de melhorar o código por meio de sugestões.
Melhorando o ciclo de feedback
Por muito tempo, o Isolator não teve um executor de testes próprio. Essa era nossa maneira de dizer ao usuário que ele poderia escolher a melhor ferramenta, e que iríamos nos adaptar à escolha dele. Ao enfrentar novos desafios, começamos a pensar a respeito do processo de desenvolvimento contínuo.
Pessoas experientes, depois de escreverem conjuntos de testes grandes, começaram a nos solicitar mais velocidade. Ao longo do tempo, fizemos o Isolator trabalhar mais rápido, mas ainda assim achávamos que não era o suficiente. Conjuntos de testes levam um tempo para executar, mas nem sempre é preciso executar todo o conjunto de testes. Na verdade, em uma sessão de desenvolvimento, apenas os testes relacionados ao código alterado deveriam ser executados. Todos os demais poderiam ser executados em outro momento, como, por exemplo, em um servidor ou antes de um check in.
Mas o problema não acabava aí. Desenvolvedores de testes experientes olham o que escreveram há três anos e não acreditam que criaram testes tão ruins. Testes de baixa qualidade não são apenas facilmente quebrados; às vezes, nem são verdadeiros testes unitários, mas sim testes de integração disfarçados. Por isso não podem ser executados rapidamente. Um grande conjunto de testes não executa lentamente apenas por conta do seu tamanho - mas também por incluir testes lentos por natureza.
O empurrão final que motivou o desenvolvimento de um executor de testes especializado veio de uma área completamente diferente: a correção de defeitos. Quando os testes falham, procura-se determinar quais mudanças recentes podem ter causado a falha. Tenta-se resolver o quebra-cabeça mentalmente: Qual foi o último lugar que fiz alterações? O que mudei? Por que esse teste está falhando, enquanto outros cenários continuam funcionando? E normalmente, só depois de muito tempo de depuração, consegue-se corrigir o problema.
Como em muitos outros lugares, estávamos buscando uma solução mais eficiente para melhorar toda a experiência de desenvolvimento e de testes. O problema não se limitava a encontrar maneiras de escrever testes mais rapidamente, ou executá-los com mais velocidade. Envolvia todo o processo iterativo de criação de testes: escrever os testes, rodar os testes, corrigir os testes que falharam; repetir.
O executor de testes do Isolator procura resolver todo esse conjunto de problemas. Ele executa automaticamente apenas os testes relevantes: aqueles que executam o código alterado. Para fornecer feedback o mais rápido possível, exclui automaticamente os testes de execução mais demorados e já exibe no código o que está coberto e por quais testes. O Isolator também leva em consideração os trechos nos quais ocorreram mudanças recentes, e pode indicar locais onde o defeito pode ter sido incluído. Isso encoraja o aumento da cobertura dos testes e fornece mais feedback relevante, facilitando a sustentação da melhoria contínua do código.
Conclusões
Essa é a história do Isolator até agora. No início queríamos resolver um único problema, mas à medida que mais pessoas passaram a utilizar testes unitários, descobrimos que podíamos ajudar mais ao examinar os desafios enfrentados. O desenvolvimento de testes unitários não é fácil - e ainda há muito por fazer.