Leodario.com

Leodario.com – Tudo sobre Tecnologia

Aula 71 – API JavaScript, Node.js e Express – Testing Fundamentals – Unit vs Integration vs E2E

Imagem destacada da aula de API

Introdução (3 min)

Prezados engenheiros e arquitetos de software, sejam bem-vindos à Aula 71! Hoje mergulharemos em um pilar essencial para a construção de sistemas resilientes: as diferentes facetas dos testes automatizados. Para começar, imagine que estamos construindo uma ponte colossal, daquelas que atravessam um grande rio.

Como garantir que essa ponte será segura e duradoura? Não podemos simplesmente construí-la e esperar que funcione. Precisamos testar cada parte! Primeiro, testamos a resistência de cada viga de aço individualmente em laboratório – isso seria o equivalente a um teste de unidade. Em seguida, verificamos como as vigas se conectam umas às outras, se os encaixes suportam o peso e o estresse da união – isso se assemelha a um teste de integração. Finalmente, depois que a ponte está completa, colocamos caminhões pesados para trafegar por ela, simulamos diferentes condições climáticas, e observamos se o fluxo de tráfego é contínuo e seguro de ponta a ponta – essa é a essência de um teste End-to-End (E2E).

No universo das APIs modernas, essa analogia é primordial. APIs são as “pontes” que conectam diferentes sistemas. Sem testes rigorosos, nossas APIs podem falhar silenciosamente, causando interrupções, perda de dados e impactando negativamente a experiência do usuário. Para APIs que servem milhões de usuários, a confiabilidade é valiosa.

Nesta aula avançada, você irá praticar a escrita de testes de unidade, integração e E2E, compreendendo suas distinções e aprimorando sua habilidade de garantir a qualidade do seu código. Nosso contexto principal será o ecossistema Node.js e Express, que é amplamente empregado na criação de APIs escaláveis e de alto desempenho. Ao final, você terá as ferramentas para desenvolver sistemas com uma base de testes sólida e abrangente.

Conceito Fundamental (7 min)

A pirâmide de testes é um conceito fundamental no desenvolvimento de software, que nos orienta a priorizar diferentes tipos de testes. Na base, temos muitos testes de unidade, seguidos por menos testes de integração, e no topo, o menor número de testes E2E. Isso reflete o custo, a velocidade e a abrangência de cada tipo.

Vamos explorar cada um em detalhes:

Testes de Unidade (Unit Tests)

Os testes de unidade focam em verificar as menores partes isoláveis de um sistema, as “unidades” de código. Uma unidade pode ser uma função específica, um método de uma classe ou um módulo pequeno. O propósito é assegurar que cada componente individual opere corretamente, de forma independente.

    • Explicação Detalhada: Um teste de unidade isola a peça de código sob avaliação, “mockando” (simulando) ou “stubando” (substituindo) quaisquer dependências externas (como bancos de dados, outras APIs ou sistemas de arquivos). Isso garante que o teste não seja influenciado por falhas em outras partes do sistema.
    • Terminologia Correta: Utilizamos test runners como Jest ou Mocha para executar os testes e assertion libraries como expect (integrado ao Jest) para verificar os resultados. Mocks e Stubs são objetos simulados que imitam o comportamento de dependências reais, garantindo o isolamento.
    • Casos de Uso Reais: Validar funções de cálculo, formatadores de dados, algoritmos de ordenação ou qualquer lógica de negócios contida em uma única função. Por exemplo, uma função que calcula o preço final de um produto com descontos.
    • Como se integra: São os testes mais rápidos, executados frequentemente durante o desenvolvimento e como parte do processo de CI/CD (Integração Contínua/Entrega Contínua).
    • Vantagens: Extremamente rápidos, fornecem feedback imediato, facilitam a depuração ao isolar o problema e documentam o comportamento esperado do código.
    • Desvantagens: Não detectam problemas de integração entre componentes ou falhas de sistema completo.

Testes de Integração (Integration Tests)

Os testes de integração avaliam a interação e comunicação entre diferentes unidades ou módulos de um sistema. Eles verificam se os componentes, quando combinados, funcionam em conjunto conforme o esperado.

    • Explicação Detalhada: Diferente dos testes de unidade, aqui permitimos que alguns componentes reais interajam. Por exemplo, testar como um controlador de API se comunica com um serviço de negócios, ou como um serviço de negócios interage com o banco de dados. Ainda podemos mockar ou stubar dependências mais externas.
    • Terminologia Correta: Frequentemente usamos o mesmo test runner (Jest) e bibliotecas como Supertest para simular requisições HTTP a uma API real, verificando as respostas.
    • Casos de Uso Reais: Verificar se um endpoint de API REST processa uma requisição corretamente, chama o serviço adequado e armazena os dados no banco de dados. Testar a comunicação entre microsserviços.
    • Como se integra: São mais lentos que os testes de unidade, mas ainda significativamente mais rápidos que os E2E. São executados em pipelines de CI/CD após os testes de unidade.
    • Vantagens: Revelam problemas de interfaces, contratos de dados e comunicação entre módulos. Oferecem maior confiança de que as partes do sistema operam em harmonia.
    • Desvantagens: Mais lentos e complexos de escrever e manter do que os testes de unidade. A depuração pode ser mais desafiadora, pois envolve múltiplos componentes.

Testes End-to-End (E2E Tests)

Os testes E2E simulam o fluxo completo de um usuário final através da aplicação, desde a interface do usuário (se houver) até o banco de dados e quaisquer sistemas externos. Eles verificam a funcionalidade do sistema como um todo, do início ao fim.

    • Explicação Detalhada: Para uma API, um teste E2E pode envolver a realização de várias chamadas HTTP em sequência para simular um fluxo de usuário (ex: criar conta, fazer login, buscar dados do perfil, atualizar perfil). Eles testam a aplicação implantada, acessível via rede, interagindo com todos os componentes reais, incluindo o banco de dados e serviços externos.
    • Terminologia Correta: Para APIs, bibliotecas como Supertest ainda podem ser usadas para orquestrar múltiplas chamadas. Em cenários com UI, ferramentas como Cypress, Playwright ou Selenium simulam interações do navegador.
    • Casos de Uso Reais: Testar o fluxo completo de um pedido em um e-commerce (adicionar ao carrinho, finalizar compra, receber confirmação). Para uma API, seria criar um usuário, autenticar, postar um item, e depois buscar esse item, verificando cada etapa.
    • Como se integra: São os testes mais lentos e caros de executar. Geralmente são executados com menos frequência (por exemplo, antes de um deploy para produção ou em ambientes de staging).
    • Vantagens: Oferecem a maior confiança, pois validam a experiência real do usuário e a funcionalidade do sistema completo. Capturam problemas que outros testes podem perder (configuração de ambiente, problemas de rede, etc.).
    • Desvantagens: São lentos, frágeis (podem quebrar por pequenas mudanças na UI ou na API), caros de desenvolver e manter. A depuração é a mais complexa.

Em resumo, a combinação inteligente desses três tipos de testes nos habilita a construir APIs robustas e confiáveis, minimizando riscos e maximizando a qualidade do software.

Implementação Prática (10 min)

Agora, vamos à parte prática para solidificar esses conceitos. Iremos desenvolver uma API Express simples e, em seguida, implementar testes de unidade, integração e E2E para ela usando Jest e Supertest.

Para garantir a compatibilidade com ambientes como o HostGator Plano M, focaremos em uma estrutura de projeto que utiliza variáveis de ambiente de forma robusta e scripts NPM para gerenciar os diferentes modos de execução.

Primeiro, crie uma nova pasta para o projeto e inicialize-o:

mkdir api-test-demo
cd api-test-demo
npm init -y
npm install express jest supertest cross-env
npm install --save-dev @types/express @types/jest @types/supertest # Para quem usa TypeScript, essencial. Para JS puro, opcional.

Estrutura de Pastas:

api-test-demo/
├── src/
│   ├── models/
│   │   └── UserModel.js
│   ├── services/
│   │   └── UserService.js
│   ├── controllers/
│   │   └── UserController.js
│   ├── routes/
│   │   └── UserRoutes.js
│   └── app.js
├── tests/
│   ├── unit/
│   │   └── userService.test.js
│   ├── integration/
│   │   └── userController.test.js
│   └── e2e/
│       └── userFlow.test.js
├── .env.test
├── .env.development
├── jest.config.js
└── package.json

Arquivos de Configuração e Ambiente:

Crie os arquivos .env.test e .env.development:

.env.test

NODE_ENV=test
PORT=3001
DB_CONNECTION_STRING=mongodb://localhost:27017/test_db_api_test_demo # Banco de dados exclusivo para testes
LOG_LEVEL=warn

.env.development

NODE_ENV=development
PORT=3000
DB_CONNECTION_STRING=mongodb://localhost:27017/dev_db_api_test_demo # Banco de dados para desenvolvimento
LOG_LEVEL=info

jest.config.js:
Este arquivo habilita o Jest a encontrar seus testes e usar configurações específicas.

// jest.config.js
module.exports = {
  // Define os padrões para encontrar arquivos de teste
  testMatch: [
    "/tests//.test.js"
  ],
  // Garante que os testes rodem em um ambiente Node.js
  testEnvironment: "node",
  // Ignora o diretório node_modules e outros padrões
  testPathIgnorePatterns: [
    "/node_modules/"
  ],
  // Executa um script antes de todos os testes
  setupFiles: ["dotenv/config"],
  // Fornece um mapeamento para aliases de módulos, se necessário
  moduleNameMapper: {
    "^@src/(.)$": "/src/$1"
  }
};

package.json (adicione os scripts de teste):

{
  "name": "api-test-demo",
  "version": "1.0.0",
  "description": "Demonstracao de testes de unidade, integracao e E2E para APIs.",
  "main": "src/app.js",
  "scripts": {
    "start": "cross-env NODE_ENV=development node src/app.js",
    "test": "cross-env NODE_ENV=test jest --detectOpenHandles --forceExit",
    "test:unit": "cross-env NODE_ENV=test jest tests/unit --detectOpenHandles --forceExit",
    "test:integration": "cross-env NODE_ENV=test jest tests/integration --detectOpenHandles --forceExit",
    "test:e2e": "cross-env NODE_ENV=test jest tests/e2e --detectOpenHandles --forceExit"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "cross-env": "^7.0.3",
    "jest": "^29.7.0",
    "supertest": "^6.3.3"
  }
}

Nota sobre HostGator Plano M: A utilização de cross-env é valiosa para definir variáveis de ambiente de forma cross-platform, essencial para garantir que seus scripts de teste e start funcionem tanto em seu ambiente local quanto em servidores Linux como o da HostGator. As variáveis .env são lidas pelo dotenv (que o jest.config.js já inclui, e você pode usar no app.js também), viabilizando configurações diferentes por ambiente. No HostGator, você as configuraria diretamente no painel de controle ou via htaccess para variáveis de ambiente do Node.js, mas o princípio de ter ambientes distintos é o mesmo.

Implementação da API (src/)

Para simplificar e focar nos testes, simularemos um “banco de dados” em memória.

src/models/UserModel.js
Representa a “estrutura” de um usuário.

// src/models/UserModel.js
class User {
    constructor(id, name, email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }
}

// Simula um "repositório" em memória para armazenamento. // Em um cenário real, isso seria uma interface para um banco de dados (MongoDB, PostgreSQL, etc.). const users = []; let nextId = 1;

const save = (user) => { // Em um ambiente real, você faria validação aqui antes de salvar if (!user.name || !user.email) { throw new Error("Nome e email são obrigatórios."); // Exemplo de erro básico } const newUser = new User(nextId++, user.name, user.email); users.push(newUser); return newUser; };

const findById = (id) => { return users.find(u => u.id === parseInt(id)); };

const findAll = () => { return users; };

const clearAll = () => { users.length = 0; // Limpa o array nextId = 1; // Reseta o contador de ID };

module.exports = { save, findById, findAll, clearAll, _getUsers: () => users // Apenas para fins de teste avançado, normalmente não exposto };

src/services/UserService.js
Contém a lógica de negócios para usuários.

// src/services/UserService.js
const UserModel = require('../models/UserModel');

class UserService { /* Gera um log profissional. Em um cenário real, usaria uma biblioteca como Winston ou Pino. @param {string} level - Nível do log (info, warn, error). @param {string} message - Mensagem a ser logada. / static log(level, message) { if (process.env.LOG_LEVEL === 'info' || process.env.LOG_LEVEL === 'warn' || process.env.LOG_LEVEL === 'error') { // Simples console.log para fins didáticos, em produção use um logger robusto. console.log([${level.toUpperCase()}] ${new Date().toISOString()} - ${message}); } }

/ Cria um novo usuário. @param {object} userData - Dados do usuário (name, email). @returns {object} O usuário criado. @throws {Error} Se os dados forem inválidos. / static createUser(userData) { UserService.log('info', Tentando criar usuário: ${JSON.stringify(userData)}); // Validação de entrada robusta if (!userData || typeof userData.name !== 'string' || userData.name.trim() === '' || typeof userData.email !== 'string' || !userData.email.includes('@')) { UserService.log('warn', Falha na validação de dados para criar usuário.); throw new Error('Dados de usuário inválidos: nome e email são obrigatórios e devem ser válidos.'); }

try { const newUser = UserModel.save(userData); UserService.log('info', Usuário criado com sucesso: ${newUser.id}); return newUser; } catch (error) { UserService.log('error', Erro ao salvar usuário no modelo: ${error.message}); throw new Error(Falha ao criar usuário: ${error.message}); // Encapsula o erro } }

/* Busca um usuário pelo ID. @param {string} id - O ID do usuário. @returns {object|null} O usuário encontrado ou null. / static getUserById(id) { UserService.log('info', Buscando usuário pelo ID: ${id}); const user = UserModel.findById(id); if (!user) { UserService.log('info', Usuário com ID ${id} não encontrado.); } return user; }

/ Retorna todos os usuários. @returns {array} Lista de todos os usuários. / static getAllUsers() { UserService.log('info', Buscando todos os usuários.); return UserModel.findAll(); } }

module.exports = UserService;

src/controllers/UserController.js
Gerencia as requisições HTTP e as respostas.

// src/controllers/UserController.js
const UserService = require('../services/UserService');

class UserController { /* Rota para criar um novo usuário. @param {object} req - Objeto de requisição Express. @param {object} res - Objeto de resposta Express. / static async createUser(req, res) { try { const newUser = UserService.createUser(req.body); res.status(201).json(newUser); } catch (error) { // Error handling extraordinário: captura diferentes tipos de erros // e retorna respostas HTTP apropriadas. if (error.message.includes('Dados de usuário inválidos')) { return res.status(400).json({ message: error.message }); } res.status(500).json({ message: 'Erro interno do servidor ao criar usuário.', error: error.message }); } }

/ Rota para obter um usuário pelo ID. @param {object} req - Objeto de requisição Express. @param {object} res - Objeto de resposta Express. / static async getUserById(req, res) { const { id } = req.params; try { const user = UserService.getUserById(id); if (user) { return res.status(200).json(user); } res.status(404).json({ message: Usuário com ID ${id} não encontrado. }); } catch (error) { res.status(500).json({ message: 'Erro interno do servidor ao buscar usuário.', error: error.message }); } }

/ Rota para obter todos os usuários. @param {object} req - Objeto de requisição Express. @param {object} res - Objeto de resposta Express. / static async getAllUsers(req, res) { try { const users = UserService.getAllUsers(); res.status(200).json(users); } catch (error) { res.status(500).json({ message: 'Erro interno do servidor ao buscar usuários.', error: error.message }); } } }

module.exports = UserController;

src/routes/UserRoutes.js
Define as rotas da API.

// src/routes/UserRoutes.js
const express = require('express');
const UserController = require('../controllers/UserController');
const router = express.Router();

router.post('/users', UserController.createUser); router.get('/users', UserController.getAllUsers); router.get('/users/:id', UserController.getUserById);

module.exports = router;

src/app.js
O ponto de entrada da aplicação Express.

// src/app.js
require('dotenv').config({ path: .env.${process.env.NODE_ENV || 'development'} });
const express = require('express');
const userRoutes = require('./routes/UserRoutes');
const UserService = require('./services/UserService'); // Para logging

const app = express(); const PORT = process.env.PORT || 3000;

app.use(express.json()); // Habilita o parsing de JSON no corpo das requisições

// Rotas da API app.use('/api', userRoutes);

// Middleware de tratamento de erros global (melhores práticas enterprise) app.use((err, req, res, next) => { UserService.log('error', Erro global: ${err.message}, Stack: ${err.stack}); res.status(500).json({ message: 'Ocorreu um erro inesperado no servidor.' }); });

// Apenas escuta a porta se não estiver em ambiente de teste if (process.env.NODE_ENV !== 'test') { app.listen(PORT, () => { UserService.log('info', Servidor Express operando na porta ${PORT} em modo ${process.env.NODE_ENV}); }); } else { UserService.log('info', Aplicação em modo de teste, não escutando diretamente na porta.); }

module.exports = app; // Exporta para que os testes possam iniciar e parar o servidor

Implementação dos Testes (tests/)

Teste de Unidade: tests/unit/userService.test.js

Foca na lógica interna do UserService, mockando a dependência UserModel.

// tests/unit/userService.test.js
const UserService = require('../../src/services/UserService');
const UserModel = require('../../src/models/UserModel'); // Importa para mockar

// Mocka completamente o módulo UserModel para isolar o UserService jest.mock('../../src/models/UserModel', () => ({ // Retorna um objeto que simula o comportamento do UserModel real save: jest.fn((userData) => ({ id: 1, ...userData })), // Retorna um usuário mockado com ID findById: jest.fn((id) => { if (id === 1) return { id: 1, name: 'Mock User', email: '[email protected]' }; return null; }), findAll: jest.fn(() => [ { id: 1, name: 'Mock User 1', email: '[email protected]' }, { id: 2, name: 'Mock User 2', email: '[email protected]' } ]), clearAll: jest.fn() // Um mock para a função de limpeza, se ela for chamada. }));

describe('UserService - Testes de Unidade', () => { // Antes de cada teste neste bloco, reseta os mocks para garantir isolamento beforeEach(() => { UserModel.save.mockClear(); UserModel.findById.mockClear(); UserModel.findAll.mockClear(); });

// Teste para criação bem-sucedida de usuário it('deve criar um usuário com dados válidos', () => { const userData = { name: 'João Silva', email: '[email protected]' }; // O mock de UserModel.save já está configurado para retornar um objeto com id const newUser = UserService.createUser(userData);

expect(newUser).toHaveProperty('id', 1); // Verifica se o ID do mock foi retornado expect(newUser.name).toBe('João Silva'); expect(newUser.email).toBe('[email protected]'); expect(UserModel.save).toHaveBeenCalledTimes(1); // Garante que o método save foi chamado expect(UserModel.save).toHaveBeenCalledWith(userData); // Garante que foi chamado com os dados corretos });

// Teste para validação de dados inválidos na criação it('deve lançar um erro se os dados do usuário forem inválidos', () => { const invalidUserData = { name: '', email: 'invalid-email' }; // Dados inválidos expect(() => UserService.createUser(invalidUserData)).toThrow('Dados de usuário inválidos: nome e email são obrigatórios e devem ser válidos.'); expect(UserModel.save).not.toHaveBeenCalled(); // Garante que UserModel.save não foi chamado });

// Teste para buscar usuário por ID existente it('deve retornar um usuário se o ID existir', () => { const user = UserService.getUserById(1); expect(user).toEqual({ id: 1, name: 'Mock User', email: '[email protected]' }); expect(UserModel.findById).toHaveBeenCalledWith(1); });

// Teste para buscar usuário por ID inexistente it('deve retornar null se o ID não existir', () => { const user = UserService.getUserById(99); expect(user).toBeNull(); expect(UserModel.findById).toHaveBeenCalledWith(99); });

// Teste para buscar todos os usuários it('deve retornar uma lista de todos os usuários', () => { const users = UserService.getAllUsers(); expect(users).toHaveLength(2); expect(users[0].name).toBe('Mock User 1'); expect(UserModel.findAll).toHaveBeenCalledTimes(1); }); });

Teste de Integração: tests/integration/userController.test.js

Testa a camada do UserController e sua comunicação com o UserService (que ainda usa o UserModel real, mas em memória).

// tests/integration/userController.test.js
const request = require('supertest');
const app = require('../../src/app'); // Importa a aplicação Express
const UserModel = require('../../src/models/UserModel'); // Importa o modelo para limpar o "banco"

describe('UserController - Testes de Integração', () => { // Antes de cada teste, limpa o "banco de dados" em memória para garantir um estado limpo beforeEach(() => { UserModel.clearAll(); });

// Teste para a rota POST /api/users - criação bem-sucedida it('POST /api/users deve criar um novo usuário e retornar status 201', async () => { const userData = { name: 'Alice', email: '[email protected]' }; const response = await request(app) .post('/api/users') // Faz uma requisição POST para a rota .send(userData) // Envia os dados no corpo da requisição .expect(201); // Espera um status HTTP 201 (Created)

expect(response.body).toHaveProperty('id'); expect(response.body.name).toBe(userData.name); expect(response.body.email).toBe(userData.email);

// Opcional: Verifica se o usuário foi realmente salvo no "banco de dados" const savedUser = UserModel.findById(response.body.id); expect(savedUser).not.toBeNull(); expect(savedUser.name).toBe(userData.name); });

// Teste para a rota POST /api/users - dados inválidos it('POST /api/users deve retornar status 400 se os dados do usuário forem inválidos', async () => { const invalidUserData = { name: '', email: 'notanemail' }; const response = await request(app) .post('/api/users') .send(invalidUserData) .expect(400); // Espera um status HTTP 400 (Bad Request)

expect(response.body).toHaveProperty('message', 'Dados de usuário inválidos: nome e email são obrigatórios e devem ser válidos.'); });

// Teste para a rota GET /api/users/:id - busca bem-sucedida it('GET /api/users/:id deve retornar um usuário existente', async () => { const user = UserModel.save({ name: 'Bob', email: '[email protected]' }); // Prepara o "banco" const response = await request(app) .get(/api/users/${user.id}) .expect(200); // Espera um status HTTP 200 (OK)

expect(response.body).toEqual(user); });

// Teste para a rota GET /api/users/:id - usuário não encontrado it('GET /api/users/:id deve retornar status 404 se o usuário não for encontrado', async () => { await request(app) .get('/api/users/999') // ID que não existe .expect(404); // Espera um status HTTP 404 (Not Found) });

// Teste para a rota GET /api/users - obter todos os usuários it('GET /api/users deve retornar uma lista de todos os usuários', async () => { UserModel.save({ name: 'Charlie', email: '[email protected]' }); UserModel.save({ name: 'Diana', email: '[email protected]' });

const response = await request(app) .get('/api/users') .expect(200);

expect(response.body).toHaveLength(2); expect(response.body[0].name).toBe('Charlie'); }); });

Teste End-to-End (API-focused): tests/e2e/userFlow.test.js

Simula um fluxo completo de usuário através de múltiplas chamadas à API, verificando o estado da aplicação.

// tests/e2e/userFlow.test.js
const request = require('supertest');
const app = require('../../src/app');
const UserModel = require('../../src/models/UserModel'); // Para limpar o "banco"

describe('Fluxo de Usuário - Testes E2E (API-focused)', () => { // Antes de cada teste, garantimos um ambiente limpo beforeEach(() => { UserModel.clearAll(); });

// Teste de fluxo completo: criar -> buscar individualmente -> buscar todos it('deve permitir criar um usuário, buscar por ID e depois listá-lo', async () => { // 1. Criar um novo usuário const userData = { name: 'Ester', email: '[email protected]' }; const createResponse = await request(app) .post('/api/users') .send(userData) .expect(201);

const createdUser = createResponse.body; expect(createdUser).toHaveProperty('id'); expect(createdUser.name).toBe(userData.name); expect(createdUser.email).toBe(userData.email);

// 2. Buscar o usuário criado pelo ID const getByIdResponse = await request(app) .get(/api/users/${createdUser.id}) .expect(200);

expect(getByIdResponse.body).toEqual(createdUser);

// 3. Listar todos os usuários e verificar se o usuário criado está presente const getAllResponse = await request(app) .get('/api/users') .expect(200);

expect(getAllResponse.body).toHaveLength(1); expect(getAllResponse.body[0]).toEqual(createdUser); });

// Outro fluxo: tentar buscar um usuário antes de criá-lo it('não deve encontrar um usuário que não foi criado ainda', async () => { // Tentar buscar um usuário com ID hipotético await request(app) .get('/api/users/123') // Assumindo que 123 não existirá .expect(404);

// Depois, criar um usuário const userData = { name: 'Fernando', email: '[email protected]' }; await request(app) .post('/api/users') .send(userData) .expect(201);

// Tentar buscar novamente o ID inexistente await request(app) .get('/api/users/123') .expect(404);

// E buscar o usuário que foi criado const getAllResponse = await request(app) .get('/api/users') .expect(200); expect(getAllResponse.body).toHaveLength(1); expect(getAllResponse.body[0].name).toBe('Fernando'); }); });

Para executar os testes:


Executa todos os testes (unidade, integração, E2E)

📚 Informações da Aula

Curso: API Completo - Node.js & Express

Tempo estimado: 25 minutos

Pré-requisitos: JavaScript básico

npm test

Executa apenas os testes de unidade

npm run test:unit

Executa apenas os testes de integração

npm run test:integration

Executa apenas os testes E2E

npm run test:e2e

Este setup oferece uma base robusta para o desenvolvimento de APIs testáveis, seguindo padrões enterprise e facilitando a manutenção e escalabilidade do seu projeto. Lembre-se que o uso de um banco de dados em memória simplifica os testes aqui, mas em cenários reais, os testes de integração e E2E podem interagir com um banco de dados de teste real, que deve ser limpo e populado antes de cada suite de testes.

Exercício Hands-On (5 min)

Parabéns por chegar até aqui! Agora é a sua vez de aplicar os conhecimentos.

Desafio Prático:

Crie um novo endpoint na API para excluir um usuário por ID. Em seguida, desenvolva os testes apropriados para essa nova funcionalidade:

  • Um teste de unidade para a função de exclusão no UserService (você precisará adicioná-la e mockar o UserModel).
  • Um teste de integração para a rota DELETE /api/users/:id no UserController.
  • Um teste E2E que simule o fluxo de criar um usuário, excluí-lo, e depois tentar buscá-lo para confirmar a exclusão.

Solução Detalhada Passo a Passo:

  • Atualize src/models/UserModel.js:

Adicione uma função removeById:

    // ... (restante do código)

const removeById = (id) => { const initialLength = users.length; const userIndex = users.findIndex(u => u.id === parseInt(id)); if (userIndex > -1) { users.splice(userIndex, 1); return true; // Indica que o usuário foi removido } return false; // Indica que o usuário não foi encontrado };

module.exports = { save, findById, findAll, clearAll, removeById, // Adicionado _getUsers: () => users };

  • Atualize src/services/UserService.js:

Adicione um método deleteUser no UserService:

    // ... (restante do código)

class UserService { // ... (métodos existentes)

/ Exclui um usuário pelo ID. @param {string} id - O ID do usuário. @returns {boolean} True se o usuário foi excluído, false caso contrário. / static deleteUser(id) { UserService.log('info', Tentando excluir usuário com ID: ${id}); const wasRemoved = UserModel.removeById(id); if (wasRemoved) { UserService.log('info', Usuário com ID ${id} excluído com sucesso.); } else { UserService.log('warn', Usuário com ID ${id} não encontrado para exclusão.); } return wasRemoved; } } // ... (exportação)

  • Atualize src/controllers/UserController.js:

Adicione um método deleteUser no UserController:

    // ... (restante do código)

class UserController { // ... (métodos existentes)

/ Rota para excluir um usuário pelo ID. @param {object} req - Objeto de requisição Express. @param {object} res - Objeto de resposta Express. / static async deleteUser(req, res) { const { id } = req.params; try { const wasDeleted = UserService.deleteUser(id); if (wasDeleted) { return res.status(204).send(); // 204 No Content para exclusão bem-sucedida } res.status(404).json({ message: Usuário com ID ${id} não encontrado para exclusão. }); } catch (error) { res.status(500).json({ message: 'Erro interno do servidor ao excluir usuário.', error: error.message }); } } } // ... (exportação)

  • Atualize src/routes/UserRoutes.js:

Adicione a nova rota DELETE:

    // ... (restante do código)

router.post('/users', UserController.createUser); router.get('/users', UserController.getAllUsers); router.get('/users/:id', UserController.getUserById); router.delete('/users/:id', UserController.deleteUser); // Nova rota

module.exports = router;

  • Crie tests/unit/userServiceDelete.test.js (ou adicione ao userService.test.js):
    // tests/unit/userServiceDelete.test.js
    const UserService = require('../../src/services/UserService');
    const UserModel = require('../../src/models/UserModel');

// Mantenha o mock do UserModel como no exemplo anterior, mas adicione removeById jest.mock('../../src/models/UserModel', () => ({ save: jest.fn((userData) => ({ id: 1, ...userData })), findById: jest.fn((id) => { if (id === 1) return { id: 1, name: 'Mock User', email: '[email protected]' }; return null; }), findAll: jest.fn(() => [/ ... */]), clearAll: jest.fn(), removeById: jest.fn((id) => id === 1) // Mock para retornar true se ID for 1, false caso contrário }));

describe('UserService - Testes de Unidade para Exclusão', () => { beforeEach(() => { UserModel.removeById.mockClear(); });

it('deve excluir um usuário existente', () => { const result = UserService.deleteUser(1); expect(result).toBe(true); expect(UserModel.removeById).toHaveBeenCalledWith(1); });

it('deve retornar false se o usuário não for encontrado para exclusão', () => { const result = UserService.deleteUser(99); expect(result).toBe(false); expect(UserModel.removeById).toHaveBeenCalledWith(99); }); });

  • Atualize tests/integration/userController.test.js:

Adicione testes para a rota DELETE:

    // ... (restante do código)

describe('UserController - Testes de Integração', () => { beforeEach(() => { UserModel.clearAll(); });

// ... (testes existentes)

// Teste para a rota DELETE /api/users/:id - exclusão bem-sucedida it('DELETE /api/users/:id deve excluir um usuário e retornar status 204', async () => { const user = UserModel.save({ name: 'Frank', email: '[email protected]' }); // Prepara o "banco" await request(app) .delete(/api/users/${user.id}) .expect(204); // Espera um status HTTP 204 (No Content)

// Opcional: Verifica se o usuário foi realmente removido const removedUser = UserModel.findById(user.id); expect(removedUser).toBeUndefined(); // Ou null, dependendo da sua implementação de findById após exclusão });

// Teste para a rota DELETE /api/users/:id - usuário não encontrado it('DELETE /api/users/:id deve retornar status 404 se o usuário não for encontrado para exclusão', async () => { await request(app) .delete('/api/users/999') // ID que não existe .expect(404); }); });

  • Atualize tests/e2e/userFlow.test.js:

Adicione um novo teste E2E para o fluxo de exclusão:

    // ... (restante do código)

describe('Fluxo de Usuário - Testes E2E (API-focused)', () => { beforeEach(() => { UserModel.clearAll(); });

// ... (testes existentes)

// Novo teste E2E: Criar -> Excluir -> Verificar exclusão it('deve permitir criar um usuário, excluí-lo e confirmar que ele não existe mais', async () => { // 1. Criar um novo usuário const userData = { name: 'Grace', email: '[email protected]' }; const createResponse = await request(app) .post('/api/users') .send(userData) .expect(201);

const createdUser = createResponse.body; expect(createdUser).toHaveProperty('id');

// 2. Excluir o usuário criado await request(app) .delete(/api/users/${createdUser.id}) .expect(204); // Espera 204 No Content

// 3. Tentar buscar o usuário excluído por ID e esperar 404 await request(app) .get(/api/users/${createdUser.id}) .expect(404);

// 4. Listar todos os usuários e verificar que não há nenhum const getAllResponse = await request(app) .get('/api/users') .expect(200);

expect(getAllResponse.body).toHaveLength(0); // Lista deve estar vazia }); });

Como Testar e Validar o Resultado:

Execute os testes com o comando:

npm test

Você deve ver todos os testes passando, incluindo os novos que você elaborou.

Troubleshooting dos Erros Mais Comuns:

  • “Error: connect ECONNREFUSED 127.0.0.1:3000”: O servidor Express não está rodando ou está em uma porta diferente. Certifique-se de que o app.listen está comentado no app.js quando NODE_ENV é test, e que supertest está configurado para o app exportado.
  • “Expected status 200, got 404”: Verifique se a rota está definida corretamente no UserRoutes.js e se os parâmetros estão sendo passados adequadamente.
  • Mocks incompletos ou incorretos: Se os testes de unidade falharem ao interagir com dependências, revise o jest.mock para garantir que todos os métodos necessários da dependência estão sendo simulados.
  • Estado inconsistente entre testes: Se os testes falham intermitentemente, verifique se beforeEach está limpando o “banco de dados” ou resetando o estado da aplicação antes de cada teste. Isso é fundamental para testes de integração e E2E.

Próximos Passos Sugeridos:

  • Integração com um Banco de Dados Real de Teste: Explore bibliotecas como mongodb-memory-server para Jest, que permite rodar um MongoDB em memória para testes, ou configure um banco de dados PostgreSQL/MySQL de teste que é resetado a cada suite.
  • CI/CD: Integre seus testes em um pipeline de Integração Contínua (ex: GitHub Actions, GitLab CI, Jenkins) para que eles rodem automaticamente a cada commit.
  • Testes de Performance: Além dos testes de funcionalidade, explore ferramentas como k6 ou Artillery para simular cargas de usuários e verificar o desempenho da sua API.
  • Testes de Segurança: Considere ferramentas como OWASP ZAP para identificar vulnerabilidades de segurança na sua API.

Ao dominar essas técnicas, você estará preparado para construir APIs de nível enterprise, robustas, seguras e de fácil manutenção. Continue praticando e explorando!

🚀 Pronto para a próxima aula?

Continue sua jornada no desenvolvimento de APIs e domine Node.js & Express!

📚 Ver todas as aulas