Supertest: testando rotas HTTP

1. Introdução ao Supertest e Testes de API

Supertest é uma biblioteca de teste para Node.js que permite testar rotas HTTP de forma simples e intuitiva. Ela se integra perfeitamente com frameworks como Express e Koa, permitindo que você faça requisições HTTP programáticas sem precisar iniciar um servidor real.

Por que usar Supertest com Node.js + Express? A principal vantagem é poder testar suas rotas HTTP em isolamento, garantindo que cada endpoint funcione corretamente antes de integrar com outros componentes. Isso difere de testes unitários (que testam funções isoladas) e testes de integração (que testam múltiplos componentes juntos). Os testes de API com Supertest focam especificamente nas respostas HTTP.

Para instalar:

npm install supertest --save-dev

Recomendamos usar junto com Jest, o framework de testes mais popular para Node.js:

npm install jest --save-dev

2. Configurando o Ambiente de Teste

Vamos criar uma aplicação Express mínima. O segredo é separar a criação do app do app.listen:

// app.js
const express = require('express');
const app = express();

app.use(express.json());

app.get('/', (req, res) => {
  res.status(200).json({ message: 'Hello World' });
});

module.exports = app;
// server.js
const app = require('./app');
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Estrutura de pastas recomendada:

projeto/
├── src/
│   ├── app.js
│   ├── routes/
│   └── controllers/
├── tests/
│   ├── routes/
│   └── helpers/
├── package.json
└── jest.config.js

3. Testando Rotas GET

Vamos testar uma rota GET simples:

// tests/routes/get.test.js
const request = require('supertest');
const app = require('../../src/app');

describe('GET /', () => {
  it('deve retornar status 200 e mensagem Hello World', async () => {
    const response = await request(app).get('/');

    expect(response.status).toBe(200);
    expect(response.headers['content-type']).toMatch(/json/);
    expect(response.body).toEqual({ message: 'Hello World' });
  });
});

Testando parâmetros de rota e query strings:

// tests/routes/users.test.js
describe('GET /users/:id', () => {
  it('deve retornar usuário por ID', async () => {
    const response = await request(app).get('/users/123');
    expect(response.status).toBe(200);
    expect(response.body.id).toBe('123');
  });

  it('deve filtrar por query string', async () => {
    const response = await request(app)
      .get('/users')
      .query({ role: 'admin', active: true });

    expect(response.status).toBe(200);
    expect(response.body.length).toBeGreaterThan(0);
  });
});

4. Testando Rotas POST, PUT e DELETE

Testando criação de recursos:

// tests/routes/post.test.js
describe('POST /users', () => {
  it('deve criar um novo usuário', async () => {
    const newUser = {
      name: 'João Silva',
      email: 'joao@email.com',
      age: 30
    };

    const response = await request(app)
      .post('/users')
      .send(newUser)
      .set('Content-Type', 'application/json');

    expect(response.status).toBe(201);
    expect(response.body).toHaveProperty('id');
    expect(response.body.name).toBe('João Silva');
  });

  it('deve retornar 400 para dados inválidos', async () => {
    const invalidUser = { name: '' };

    const response = await request(app)
      .post('/users')
      .send(invalidUser);

    expect(response.status).toBe(400);
    expect(response.body.error).toMatch(/nome é obrigatório/i);
  });
});

Testando atualização e exclusão:

describe('PUT /users/:id', () => {
  it('deve atualizar usuário existente', async () => {
    const response = await request(app)
      .put('/users/123')
      .send({ name: 'João Atualizado' });

    expect(response.status).toBe(200);
    expect(response.body.name).toBe('João Atualizado');
  });
});

describe('DELETE /users/:id', () => {
  it('deve excluir usuário', async () => {
    const response = await request(app).delete('/users/123');
    expect(response.status).toBe(204);
  });
});

5. Testando Autenticação e Headers Personalizados

Simulando autenticação com JWT:

// tests/routes/auth.test.js
describe('GET /protected', () => {
  it('deve acessar rota protegida com token válido', async () => {
    const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';

    const response = await request(app)
      .get('/protected')
      .set('Authorization', `Bearer ${token}`);

    expect(response.status).toBe(200);
  });

  it('deve retornar 401 sem token', async () => {
    const response = await request(app).get('/protected');
    expect(response.status).toBe(401);
  });

  it('deve retornar 403 para token inválido', async () => {
    const response = await request(app)
      .get('/protected')
      .set('Authorization', 'Bearer token_invalido');

    expect(response.status).toBe(403);
  });
});

Testando cookies e headers customizados:

describe('POST /login', () => {
  it('deve definir cookie de sessão', async () => {
    const response = await request(app)
      .post('/login')
      .send({ username: 'admin', password: '123' });

    expect(response.headers['set-cookie']).toBeDefined();
    expect(response.headers['x-request-id']).toMatch(/^[a-f0-9-]+$/);
  });
});

6. Testando Upload de Arquivos e Multipart

// tests/routes/upload.test.js
const path = require('path');

describe('POST /upload', () => {
  it('deve fazer upload de arquivo', async () => {
    const filePath = path.join(__dirname, '../fixtures/test.txt');

    const response = await request(app)
      .post('/upload')
      .attach('file', filePath);

    expect(response.status).toBe(200);
    expect(response.body.filename).toBe('test.txt');
    expect(response.body.size).toBeGreaterThan(0);
  });

  it('deve rejeitar arquivos muito grandes', async () => {
    const largeFile = Buffer.alloc(10 * 1024 * 1024); // 10MB

    const response = await request(app)
      .post('/upload')
      .attach('file', largeFile, 'large.txt');

    expect(response.status).toBe(413);
  });

  it('deve rejeitar formatos inválidos', async () => {
    const response = await request(app)
      .post('/upload')
      .attach('file', Buffer.from('fake'), 'file.exe');

    expect(response.status).toBe(400);
    expect(response.body.error).toMatch(/formato não permitido/i);
  });
});

7. Boas Práticas e Integração com Jest

Usando hooks para limpeza de dados:

// tests/routes/integration.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../../src/database');

describe('CRUD de usuários', () => {
  beforeEach(async () => {
    await db.clearUsers();
    await db.seedTestData();
  });

  afterEach(async () => {
    await db.clearUsers();
  });

  it('deve listar todos os usuários', async () => {
    const response = await request(app).get('/users');
    expect(response.status).toBe(200);
    expect(response.body.length).toBe(3); // dados seed
  });

  it('deve criar e depois listar', async () => {
    await request(app)
      .post('/users')
      .send({ name: 'Novo Usuário' });

    const response = await request(app).get('/users');
    expect(response.body.length).toBe(4);
  });
});

Mockando dependências externas:

jest.mock('../../src/services/email');
const emailService = require('../../src/services/email');

describe('POST /register', () => {
  it('deve enviar email de boas-vindas', async () => {
    emailService.sendWelcome.mockResolvedValue(true);

    await request(app)
      .post('/register')
      .send({ email: 'teste@email.com' });

    expect(emailService.sendWelcome).toHaveBeenCalledWith('teste@email.com');
  });
});

8. Debugando e Otimizando Testes com Supertest

Lidando com timeouts:

describe('GET /slow-endpoint', () => {
  jest.setTimeout(10000); // 10 segundos

  it('deve processar requisição lenta', async () => {
    const response = await request(app)
      .get('/slow-endpoint')
      .timeout(5000); // timeout específico da requisição

    expect(response.status).toBe(200);
  });
});

Debugging com logs condicionais:

it('deve retornar dados corretos', async () => {
  const response = await request(app).get('/complex-endpoint');

  if (process.env.DEBUG_TESTS) {
    console.log('Response body:', JSON.stringify(response.body, null, 2));
    console.log('Headers:', response.headers);
  }

  expect(response.status).toBe(200);
});

Dicas para manter testes rápidos:
- Use testes paralelos com --runInBand quando necessário
- Evite fazer chamadas reais a APIs externas
- Utilize bancos de dados em memória (SQLite, MongoDB Memory Server)
- Mantenha os testes focados em uma única responsabilidade
- Use beforeAll para configurações pesadas (criar tabelas, popular dados)

Referências