Um guia Node.js para realmente fazer testes de integração
Publicados: 2022-03-11Testes de integração não são algo que deve ser temido. Eles são uma parte essencial de ter seu aplicativo totalmente testado.
Ao falar sobre testes, geralmente pensamos em testes de unidade onde testamos um pequeno pedaço de código isoladamente. No entanto, seu aplicativo é maior do que esse pequeno pedaço de código e quase nenhuma parte de seu aplicativo funciona isoladamente. É aqui que os testes de integração comprovam sua importância. Os testes de integração identificam onde os testes de unidade falham e preenchem a lacuna entre os testes de unidade e os testes de ponta a ponta.
Neste artigo, você aprenderá a escrever testes de integração legíveis e combináveis com exemplos em aplicativos baseados em API.
Embora usemos JavaScript/Node.js para todos os exemplos de código neste artigo, a maioria das ideias discutidas pode ser facilmente adaptada para testes de integração em qualquer plataforma.
Testes unitários x testes de integração: você precisa dos dois
Os testes de unidade se concentram em uma unidade específica de código. Muitas vezes, este é um método específico ou uma função de um componente maior.
Esses testes são feitos isoladamente, onde todas as dependências externas são tipicamente stub ou mocked.
Em outras palavras, as dependências são substituídas por comportamentos pré-programados, garantindo que o resultado do teste seja determinado apenas pela correção da unidade testada.
Você pode aprender mais sobre testes de unidade aqui.
Testes de unidade são usados para manter código de alta qualidade com bom design. Eles também nos permitem cobrir facilmente casos de canto.
A desvantagem, no entanto, é que os testes de unidade não podem cobrir a interação entre os componentes. É aqui que os testes de integração se tornam úteis.
Testes de integração
Se os testes de unidade são definidos testando as menores unidades de código isoladamente, os testes de integração são exatamente o oposto.
Os testes de integração são usados para testar várias unidades maiores (componentes) em interação e, às vezes, podem até abranger vários sistemas.
O objetivo dos testes de integração é encontrar bugs nas conexões e dependências entre vários componentes, como:
- Passando argumentos inválidos ou ordenados incorretamente
- Esquema de banco de dados quebrado
- Integração de cache inválida
- Falhas na lógica de negócios ou erros no fluxo de dados (porque o teste agora é feito a partir de uma visão mais ampla).
Se os componentes que estamos testando não tiverem nenhuma lógica complicada (por exemplo, componentes com complexidade ciclomática mínima), os testes de integração serão muito mais importantes do que os testes de unidade.
Nesse caso, os testes de unidade serão usados principalmente para impor um bom design de código.
Enquanto os testes de unidade ajudam a garantir que as funções sejam escritas corretamente, os testes de integração ajudam a garantir que o sistema esteja funcionando corretamente como um todo. Portanto, tanto os testes de unidade quanto os testes de integração servem a seus próprios propósitos complementares e ambos são essenciais para uma abordagem de teste abrangente.
Testes unitários e testes de integração são como dois lados da mesma moeda. A moeda não é válida sem ambos.
Portanto, o teste não é concluído até que você tenha concluído os testes de integração e de unidade.
Configure o Suite para Testes de Integração
Embora configurar um conjunto de testes para testes de unidade seja bastante simples, configurar um conjunto de testes para testes de integração muitas vezes é mais desafiador.
Por exemplo, componentes em testes de integração podem ter dependências que estão fora do projeto, como bancos de dados, sistemas de arquivos, provedores de e-mail, serviços de pagamento externos e assim por diante.
Ocasionalmente, os testes de integração precisam usar esses serviços e componentes externos e, às vezes, podem ser stubs.
Quando eles são necessários, pode levar a vários desafios.
- Execução de teste frágil: serviços externos podem estar indisponíveis, retornar uma resposta inválida ou estar em um estado inválido. Em alguns casos, isso pode resultar em um falso positivo, outras vezes pode resultar em um falso negativo.
- Execução lenta: A preparação e a conexão com serviços externos podem ser lentas. Normalmente, os testes são executados em um servidor externo como parte do CI.
- Configuração de teste complexa: os serviços externos precisam estar no estado desejado para teste. Por exemplo, o banco de dados deve ser pré-carregado com os dados de teste necessários, etc.
Instruções a seguir ao escrever testes de integração
Os testes de integração não têm regras rígidas como testes de unidade. Apesar disso, existem algumas orientações gerais a serem seguidas ao escrever testes de integração.
Testes repetíveis
A ordem de teste ou as dependências não devem alterar o resultado do teste. Executar o mesmo teste várias vezes deve sempre retornar o mesmo resultado. Isso pode ser difícil de conseguir se o teste estiver usando a Internet para se conectar a serviços de terceiros. No entanto, esse problema pode ser contornado por meio de stubbing e mocking.
Para dependências externas sobre as quais você tem mais controle, configurar etapas antes e depois de um teste de integração ajudará a garantir que o teste seja sempre executado a partir de um estado idêntico.
Testando ações relevantes
Para testar todos os casos possíveis, os testes de unidade são uma opção muito melhor.
Os testes de integração são mais orientados à conexão entre os módulos, portanto, testar cenários felizes geralmente é o caminho a percorrer, pois abrangerá as conexões importantes entre os módulos.
Teste e Asserção Compreensíveis
Uma visualização rápida do teste deve informar ao leitor o que está sendo testado, como o ambiente está configurado, o que é stub, quando o teste é executado e o que é afirmado. As asserções devem ser simples e fazer uso de auxiliares para melhor comparação e registro.
Configuração de teste fácil
Levar o teste ao estado inicial deve ser o mais simples e compreensível possível.
Evite testar código de terceiros
Embora os serviços de terceiros possam ser usados em testes, não há necessidade de testá-los. E se você não confia neles, provavelmente não deveria usá-los.
Deixar o código de produção livre do código de teste
O código de produção deve ser limpo e direto. A mistura de código de teste com código de produção resultará no acoplamento de dois domínios não conectáveis.
Registro relevante
Testes com falha não são muito valiosos sem um bom registro.
Quando os testes são aprovados, nenhum registro extra é necessário. Mas quando eles falham, o registro extensivo é vital.
O registro deve conter todas as consultas ao banco de dados, solicitações e respostas da API, bem como uma comparação completa do que está sendo declarado. Isso pode facilitar significativamente a depuração.
Bons testes parecem limpos e compreensíveis
Um teste simples que segue as diretrizes aqui pode ser assim:
const co = require('co'); const test = require('blue-tape'); const factory = require('factory'); const superTest = require('../utils/super_test'); const testEnvironment = require('../utils/test_environment_preparer'); const path = '/v1/admin/recipes'; test(`API GET ${path}`, co.wrap(function* (t) { yield testEnvironment.prepare(); const recipe1 = yield factory.create('recipe'); const recipe2 = yield factory.create('recipe'); const serverResponse = yield superTest.get(path); t.deepEqual(serverResponse.body, [recipe1, recipe2]); }));
O código acima está testando uma API ( GET /v1/admin/recipes
) que espera que ela retorne uma matriz de receitas salvas como resposta.
Você pode ver que o teste, por mais simples que seja, conta com muitos utilitários. Isso é comum para qualquer bom conjunto de testes de integração.
Os componentes auxiliares facilitam a escrita de testes de integração compreensíveis.
Vamos revisar quais componentes são necessários para o teste de integração.
Componentes auxiliares
Um conjunto de testes abrangente tem alguns ingredientes básicos, incluindo: controle de fluxo, estrutura de teste, manipulador de banco de dados e uma maneira de se conectar a APIs de back-end.
Controle de fluxo
Um dos maiores desafios no teste de JavaScript é o fluxo assíncrono.
Callbacks podem causar estragos no código e promessas não são suficientes. É aqui que os auxiliares de fluxo se tornam úteis.
Enquanto aguarda o suporte total de async/await, bibliotecas com comportamento semelhante podem ser usadas. O objetivo é escrever código legível, expressivo e robusto com a possibilidade de ter fluxo assíncrono.
Co permite que o código seja escrito de uma maneira agradável enquanto o mantém sem bloqueio. Isso é feito através da definição de uma função co-geradora e, em seguida, produzindo resultados.
Outra solução é usar o Bluebird. Bluebird é uma biblioteca de promessas que possui recursos muito úteis, como manipulação de arrays, erros, tempo, etc.
A corrotina Co e Bluebird se comporta de maneira semelhante ao async/await no ES7 (aguardando resolução antes de continuar), a única diferença é que sempre retornará uma promessa, o que é útil para lidar com erros.

Estrutura de teste
A escolha de uma estrutura de teste se resume à preferência pessoal. Minha preferência é um framework que seja fácil de usar, não tenha efeitos colaterais e cuja saída seja facilmente legível e canalizada.
Há uma grande variedade de estruturas de teste em JavaScript. Em nossos exemplos, estamos usando Tape. A fita, na minha opinião, não apenas atende a esses requisitos, mas também é mais limpa e simples do que outras estruturas de teste como Mocha ou Jasmin.
A fita é baseada no Test Anything Protocol (TAP).
TAP tem variações para a maioria das linguagens de programação.
O Tape recebe os testes como entrada, executa-os e, em seguida, gera os resultados como um TAP. O resultado do TAP pode então ser canalizado para o relator de teste ou pode ser enviado para o console em um formato bruto. A fita é executada a partir da linha de comando.
A fita tem alguns recursos interessantes, como definir um módulo para carregar antes de executar todo o conjunto de testes, fornecer uma biblioteca de asserções pequena e simples e definir o número de asserções que devem ser chamadas em um teste. Usar um módulo para pré-carregar pode simplificar a preparação de um ambiente de teste e remover qualquer código desnecessário.
Biblioteca de fábrica
Uma biblioteca de fábrica permite que você substitua seus arquivos de equipamentos estáticos por uma maneira muito mais flexível de gerar dados para um teste. Essa biblioteca permite definir modelos e criar entidades para esses modelos sem escrever código confuso e complexo.
JavaScript tem factory_girl para isso - uma biblioteca inspirada em uma gem com um nome semelhante, que foi originalmente desenvolvida para Ruby on Rails.
const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, { username: 'Bob', number_of_recipes: 50 }); const user = factory.build('user');
Para começar, um novo modelo deve ser definido em factory_girl.
É especificado com um nome, um modelo do seu projeto e um objeto a partir do qual uma nova instância é gerada.
Alternativamente, em vez de definir o objeto a partir do qual uma nova instância é gerada, pode ser fornecida uma função que retornará um objeto ou uma promessa.
Ao criar uma nova instância de um modelo, podemos:
- Substituir qualquer valor na instância recém-gerada
- Passe valores adicionais para a opção de função de construção
Vamos ver um exemplo.
const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, (buildOptions) => { return { name: 'Mike', surname: 'Dow', email: buildOptions.email || '[email protected]' } }); const user1 = factory.build('user'); // {"name": "Mike", "surname": "Dow", "email": "[email protected]"} const user2 = factory.build('user', {name: 'John'}, {email: '[email protected]'}); // {"name": "John", "surname": "Dow", "email": "[email protected]"}
Conectando-se a APIs
Iniciar um servidor HTTP completo e fazer uma solicitação HTTP real, apenas para derrubá-lo alguns segundos depois – especialmente ao realizar vários testes – é totalmente ineficiente e pode fazer com que os testes de integração demorem significativamente mais do que o necessário.
SuperTest é uma biblioteca JavaScript para chamar APIs sem criar um novo servidor ativo. É baseado no SuperAgent, uma biblioteca para criar solicitações TCP. Com esta biblioteca, não há necessidade de criar novas conexões TCP. As APIs são chamadas quase instantaneamente.
O SuperTest, com suporte para promessas, é o superteste conforme prometido. Quando essa solicitação retorna uma promessa, ela permite evitar várias funções de retorno de chamada aninhadas, tornando muito mais fácil lidar com o fluxo.
const express = require('express') const request = require('supertest-as-promised'); const app = express(); request(app).get("/recipes").then(res => assert(....));
SuperTest foi feito para o framework Express.js, mas com pequenas mudanças ele pode ser usado com outros frameworks também.
Outros utilitários
Em alguns casos, é necessário zombar de alguma dependência em nosso código, testar a lógica em torno de funções usando espiões ou usar stubs em determinados locais. É aqui que alguns desses pacotes de utilitários são úteis.
SinonJS é uma ótima biblioteca que suporta espiões, stubs e mocks para testes. Ele também suporta outros recursos de teste úteis, como tempo de dobra, sandbox de teste e asserção expandida, bem como servidores e solicitações falsos.
Em alguns casos, há a necessidade de zombar de alguma dependência em nosso código. As referências a serviços que gostaríamos de simular são usadas por outras partes do sistema.
Para resolver esse problema, podemos usar injeção de dependência ou, se isso não for uma opção, podemos usar um serviço de mocking como o Mockery.
Mockery ajuda a simular código que possui dependências externas. Para usá-lo corretamente, Mockery deve ser chamado antes de carregar testes ou código.
const mockery = require('mockery'); mockery.enable({ warnOnReplace: false, warnOnUnregistered: false }); const mockingStripe = require('lib/services/internal/stripe'); mockery.registerMock('lib/services/internal/stripe', mockingStripe);
Com essa nova referência (neste exemplo, mockingStripe
), fica mais fácil simular serviços posteriormente em nossos testes.
const stubStripeTransfer = sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null));
Com a ajuda da biblioteca Sinon, é fácil zombar. O único problema aqui é que esse stub se propagará para outros testes. Para sandbox, o sandbox sinon pode ser usado. Com ele, testes posteriores podem trazer o sistema de volta ao seu estado inicial.
const sandbox = require('sinon').sandbox.create(); const stubStripeTransfer = sandbox.sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null)); // after the test, or better when starting a new test sandbox.restore();
Há necessidade de outros componentes para funções como:
- Esvaziar o banco de dados (pode ser feito com uma consulta de pré-criação de hierarquia)
- Configurando-o para o estado de trabalho (sequelize-fixtures)
- Zombando de solicitações TCP para serviços de terceiros (nock)
- Usando afirmações mais ricas (chai)
- Respostas salvas de terceiros (fácil de corrigir)
Testes não tão simples
Abstração e extensibilidade são elementos-chave para construir um conjunto de testes de integração eficaz. Tudo o que tira o foco do núcleo do teste (preparação de seus dados, ação e assertiva) deve ser agrupado e abstraído em funções utilitárias.
Embora não haja um caminho certo ou errado aqui, pois tudo depende do projeto e de suas necessidades, algumas qualidades-chave ainda são comuns a qualquer bom conjunto de testes de integração.
O código a seguir mostra como testar uma API que cria uma receita e envia um email como efeito colateral.
Ele stubs o provedor de e-mail externo para que você possa testar se um e-mail teria sido enviado sem realmente enviar um. O teste também verifica se a API respondeu com o código de status apropriado.
const co = require('co'); const factory = require('factory'); const superTest = require('../utils/super_test'); const basicEnv = require('../utils/basic_test_enivornment'); const path = '/v1/admin/recipes'; basicEnv.test(`API POST ${path}`, co.wrap(function* (t, assert, sandbox) { const chef = yield factory.create('chef'); const body = { chef_id: chef.id, recipe_name: 'cake', Ingredients: ['carrot', 'chocolate', 'biscuit'] }; const stub = sandbox.stub(mockery.emailProvider, 'sendNewEmail').returnsPromise(null); const serverResponse = yield superTest.get(path, body); assert.spies(stub).called(1); assert.statusCode(serverResponse, 201); }));
O teste acima é repetível, pois sempre começa com um ambiente limpo.
Possui um processo de configuração simples, onde tudo relacionado à configuração é consolidado dentro da função basicEnv.test
.
Ele testa apenas uma ação - uma única API. E declara claramente as expectativas do teste por meio de simples declarações assert. Além disso, o teste não envolve código de terceiros por stubbing/mocking.
Comece a escrever testes de integração
Ao enviar um novo código para produção, os desenvolvedores (e todos os outros participantes do projeto) querem ter certeza de que os novos recursos funcionarão e os antigos não serão interrompidos.
Isso é muito difícil de alcançar sem testes e, se feito de maneira inadequada, pode levar à frustração, fadiga do projeto e, eventualmente, ao fracasso do projeto.
Testes de integração, combinados com testes unitários, são a primeira linha de defesa.
Usar apenas um dos dois é insuficiente e deixará muito espaço para erros descobertos. Sempre utilizar ambos tornará os novos commits robustos e proporcionará confiança e inspirará confiança em todos os participantes do projeto.