Testes no Node.js com Jest

1. Introdução ao Jest e Configuração Inicial

Jest é um framework de testes desenvolvido pelo Facebook, amplamente utilizado no ecossistema JavaScript e React. Sua popularidade vem da facilidade de configuração, velocidade de execução e recursos integrados como mocking, cobertura de código e suporte nativo a async/await.

No contexto Node.js + React, Jest se destaca por funcionar perfeitamente tanto para testes de backend (APIs, serviços, lógica de negócios) quanto para testes de frontend (componentes React, hooks, utilitários).

Instalação e Configuração Básica

npm install --save-dev jest

Crie o arquivo jest.config.js na raiz do projeto:

module.exports = {
  testEnvironment: 'node',
  testMatch: ['**/__tests__/**/*.test.js'],
  clearMocks: true,
  collectCoverageFrom: ['src/**/*.js', '!src/**/*.test.js']
};

Adicione os scripts no package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Estrutura de Pastas Recomendada

src/
  __tests__/
    unit/
      math.test.js
    integration/
      api.test.js
  utils/
    math.js
  routes/
    users.js

2. Escrevendo Testes Unitários com Jest

A sintaxe fundamental do Jest é composta por describe, it/test e expect. Os matchers são funções que permitem verificar valores de diferentes formas.

// src/utils/math.js
function sum(a, b) {
  return a + b;
}

function divide(a, b) {
  if (b === 0) throw new Error('Division by zero');
  return a / b;
}

async function fetchData() {
  return Promise.resolve({ id: 1, name: 'John' });
}

module.exports = { sum, divide, fetchData };
// src/__tests__/unit/math.test.js
const { sum, divide, fetchData } = require('../utils/math');

describe('Math utilities', () => {
  describe('sum', () => {
    it('should add two positive numbers correctly', () => {
      expect(sum(2, 3)).toBe(5);
    });

    it('should handle negative numbers', () => {
      expect(sum(-1, 5)).toBe(4);
    });
  });

  describe('divide', () => {
    it('should divide two numbers correctly', () => {
      expect(divide(10, 2)).toBe(5);
    });

    it('should throw error when dividing by zero', () => {
      expect(() => divide(10, 0)).toThrow('Division by zero');
    });
  });

  describe('fetchData', () => {
    it('should return user data asynchronously', async () => {
      const data = await fetchData();
      expect(data).toEqual({ id: 1, name: 'John' });
    });
  });
});

Matchers essenciais:
- toBe - comparação estrita (===)
- toEqual - comparação profunda de objetos
- toContain - verifica se array contém elemento
- toThrow - verifica se função lança exceção

3. Mocking e Isolamento de Dependências

Mocks são essenciais para isolar a unidade sendo testada de suas dependências externas como bancos de dados, APIs e serviços.

// src/services/userService.js
const database = require('./database');

async function getUser(id) {
  const user = await database.findUser(id);
  if (!user) throw new Error('User not found');
  return user;
}

module.exports = { getUser };
// src/__tests__/unit/userService.test.js
const { getUser } = require('../services/userService');
const database = require('../services/database');

jest.mock('../services/database');

describe('User Service', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should return user when found', async () => {
    const mockUser = { id: 1, name: 'Alice' };
    database.findUser.mockResolvedValue(mockUser);

    const user = await getUser(1);
    expect(user).toEqual(mockUser);
    expect(database.findUser).toHaveBeenCalledWith(1);
  });

  it('should throw error when user not found', async () => {
    database.findUser.mockResolvedValue(null);

    await expect(getUser(999)).rejects.toThrow('User not found');
  });
});

Usando jest.spyOn() para espiar métodos existentes:

const logger = require('../utils/logger');

test('should log error message', () => {
  const spy = jest.spyOn(logger, 'error').mockImplementation(() => {});

  // código que chama logger.error

  expect(spy).toHaveBeenCalledWith('Erro crítico');
  spy.mockRestore();
});

4. Testes em Rotas HTTP com Supertest

Supertest permite testar servidores HTTP sem precisar iniciá-los em uma porta real.

npm install --save-dev supertest
// src/routes/users.js
const express = require('express');
const router = express.Router();

router.get('/users/:id', async (req, res) => {
  try {
    const user = await getUser(req.params.id);
    res.json(user);
  } catch (error) {
    res.status(404).json({ error: error.message });
  }
});

router.post('/users', async (req, res) => {
  const newUser = await createUser(req.body);
  res.status(201).json(newUser);
});

module.exports = router;
// src/__tests__/integration/users.test.js
const request = require('supertest');
const app = require('../../app');

jest.mock('../../services/database');

describe('Users API', () => {
  describe('GET /users/:id', () => {
    it('should return user when exists', async () => {
      database.findUser.mockResolvedValue({ id: 1, name: 'Alice' });

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

      expect(response.body).toEqual({ id: 1, name: 'Alice' });
    });

    it('should return 404 when user not found', async () => {
      database.findUser.mockResolvedValue(null);

      const response = await request(app)
        .get('/users/999')
        .expect(404);

      expect(response.body).toEqual({ error: 'User not found' });
    });
  });

  describe('POST /users', () => {
    it('should create user and return 201', async () => {
      const newUser = { name: 'Bob', email: 'bob@test.com' };
      database.createUser.mockResolvedValue({ id: 2, ...newUser });

      const response = await request(app)
        .post('/users')
        .send(newUser)
        .expect(201);

      expect(response.body).toHaveProperty('id');
      expect(response.body.name).toBe('Bob');
    });
  });
});

5. Testes com Banco de Dados e Operações Assíncronas

Para testar operações reais de banco de dados, use bancos em memória como SQLite ou MongoDB Memory Server.

npm install --save-dev mongodb-memory-server
// src/__tests__/integration/database.test.js
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');

let mongoServer;

beforeAll(async () => {
  mongoServer = await MongoMemoryServer.create();
  await mongoose.connect(mongoServer.getUri());
});

afterAll(async () => {
  await mongoose.disconnect();
  await mongoServer.stop();
});

beforeEach(async () => {
  await User.deleteMany({});
});

describe('User Model', () => {
  it('should create and save user successfully', async () => {
    const user = new User({ name: 'Alice', email: 'alice@test.com' });
    const savedUser = await user.save();

    expect(savedUser._id).toBeDefined();
    expect(savedUser.name).toBe('Alice');
  });
});

Usando timers falsos para testar operações com tempo:

jest.useFakeTimers();

it('should call callback after 1 second', () => {
  const callback = jest.fn();
  setTimeout(callback, 1000);

  jest.advanceTimersByTime(1000);
  expect(callback).toHaveBeenCalled();
});

6. Cobertura de Código e Relatórios

Ative o relatório de cobertura com a flag --coverage:

jest --coverage

Configure thresholds no jest.config.js:

module.exports = {
  collectCoverage: true,
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  coverageReporters: ['html', 'lcov', 'text']
};

Isso garante que o pipeline falhe se a cobertura ficar abaixo de 80%.

7. Testes em Componentes React (com Jest e Testing Library)

npm install --save-dev @testing-library/react @testing-library/jest-dom
// src/components/Counter.jsx
import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p data-testid="count">Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

export default Counter;
// src/__tests__/components/Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from '../../components/Counter';

describe('Counter Component', () => {
  it('should render initial count as 0', () => {
    render(<Counter />);
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 0');
  });

  it('should increment count when button is clicked', () => {
    render(<Counter />);
    const button = screen.getByText('Increment');
    fireEvent.click(button);
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 1');
  });
});

Testando hooks customizados com renderHook:

import { renderHook, act } from '@testing-library/react';
import useCounter from '../../hooks/useCounter';

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

8. Boas Práticas e Padrões Avançados

Padrão AAA (Arrange, Act, Assert)

test('should calculate discount correctly', () => {
  // Arrange
  const price = 100;
  const discount = 0.1;

  // Act
  const finalPrice = calculateDiscount(price, discount);

  // Assert
  expect(finalPrice).toBe(90);
});

Testes Parametrizados

describe.each([
  [1, 1, 2],
  [-1, 1, 0],
  [0, 0, 0],
  [2.5, 3.5, 6]
])('sum(%i, %i)', (a, b, expected) => {
  test(`returns ${expected}`, () => {
    expect(sum(a, b)).toBe(expected);
  });
});

Snapshot Testing

test('should match snapshot', () => {
  const tree = renderer.create(<Component />).toJSON();
  expect(tree).toMatchSnapshot();
});

Para atualizar snapshots: jest --updateSnapshot

Referências