Testes de Integração com TypeScript: Banco Real e Fixtures Tipadas: Do Básico ao Avançado Já leu

O que são Testes de Integração e Por Que Importam Testes de integração validam o comportamento de múltiplos componentes trabalhando juntos em cenários próximos à realidade. Diferente dos testes unitários, que isolam uma função específica, os testes de integração exercitam o fluxo completo: requisição HTTP → validação de entrada → interação com banco de dados → resposta formatada. Em uma aplicação TypeScript moderna, você típicamente testa controladores, serviços e a camada de persistência operando em harmonia. A importância reside em capturar bugs que não emergem quando testamos componentes isoladamente. Um teste unitário pode passar para uma função que formata datas, mas o teste de integração revela que aquela função quebra quando o banco retorna um tipo inesperado. Além disso, testes de integração servem como documentação viva do comportamento esperado do sistema, mostrando como as partes se encaixam. Estruturando Fixtures Tipadas com TypeScript Entendendo Fixtures e Seu Papel Uma fixture é um conjunto de dados predefinido usado para preparar o ambiente

O que são Testes de Integração e Por Que Importam

Testes de integração validam o comportamento de múltiplos componentes trabalhando juntos em cenários próximos à realidade. Diferente dos testes unitários, que isolam uma função específica, os testes de integração exercitam o fluxo completo: requisição HTTP → validação de entrada → interação com banco de dados → resposta formatada. Em uma aplicação TypeScript moderna, você típicamente testa controladores, serviços e a camada de persistência operando em harmonia.

A importância reside em capturar bugs que não emergem quando testamos componentes isoladamente. Um teste unitário pode passar para uma função que formata datas, mas o teste de integração revela que aquela função quebra quando o banco retorna um tipo inesperado. Além disso, testes de integração servem como documentação viva do comportamento esperado do sistema, mostrando como as partes se encaixam.

Estruturando Fixtures Tipadas com TypeScript

Entendendo Fixtures e Seu Papel

Uma fixture é um conjunto de dados predefinido usado para preparar o ambiente de teste. Em testes de integração com banco de dados real, você precisa de dados consistentes e previsíveis. Sem fixtures bem estruturadas, você gasta tempo criando dados manualmente em cada teste, duplica lógica de setup e torna os testes frágeis.

Fixtures tipadas no TypeScript significam que seus dados de teste têm tipos explícitos, aproveitando todo o poder do sistema de tipos. Isso evita erros silenciosos onde você atribui um valor incompatível à fixture, detectando o problema em tempo de compilação, não durante a execução.

Criando uma Factory de Fixtures Tipada

Vamos construir um exemplo prático. Imagine uma aplicação de gerenciamento de usuários:

// src/types/User.ts
export interface User {
  id: number;
  email: string;
  name: string;
  createdAt: Date;
  isActive: boolean;
}

// src/fixtures/UserFixture.ts
import { User } from '../types/User';

export class UserFixture {
  static createUser(overrides?: Partial<User>): User {
    const defaultUser: User = {
      id: Math.floor(Math.random() * 10000),
      email: 'user@example.com',
      name: 'John Doe',
      createdAt: new Date(),
      isActive: true,
    };

    return { ...defaultUser, ...overrides };
  }

  static createMultipleUsers(count: number, overrides?: Partial<User>): User[] {
    return Array.from({ length: count }, (_, index) =>
      this.createUser({
        id: index + 1,
        email: `user${index + 1}@example.com`,
        ...overrides,
      })
    );
  }
}

Este padrão (conhecido como Object Mother ou Builder Pattern) oferece valor real: valores sensatos por padrão, facilidade em sobrescrever apenas o que importa para seu teste, e reutilização sem duplicação.

Testes de Integração com Banco de Dados Real

Configurando o Ambiente de Teste

Para testes de integração reais, você precisa de um banco de dados isolado. A prática mais comum é usar um banco em memória (SQLite) ou um container Docker com PostgreSQL/MySQL. Aqui usaremos TypeORM com SQLite para simplicidade:

// src/database/connection.test.ts
import { createConnection, getConnection, Connection } from 'typeorm';
import { User } from '../entities/User';

export async function setupTestDatabase(): Promise<Connection> {
  const connection = await createConnection({
    type: 'sqlite',
    database: ':memory:',
    entities: [User],
    synchronize: true,
    logging: false,
  });
  return connection;
}

export async function teardownTestDatabase(): Promise<void> {
  const connection = getConnection();
  await connection.dropDatabase();
  await connection.close();
}

A estratégia aqui é clara: cada suite de testes obtém um banco virgem, executando as migrations automaticamente (synchronize: true). Após os testes, descartamos tudo. Isso garante isolamento entre testes — nenhum teste afeta outro.

Escrevendo um Teste de Integração Funcional

// src/services/UserService.test.ts
import { describe, it, beforeAll, afterAll, beforeEach } from '@jest/globals';
import { expect } from 'expect';
import { getRepository, Connection } from 'typeorm';
import { UserService } from './UserService';
import { User } from '../entities/User';
import { UserFixture } from '../fixtures/UserFixture';
import {
  setupTestDatabase,
  teardownTestDatabase,
} from '../database/connection.test';

describe('UserService Integration Tests', () => {
  let connection: Connection;
  let userService: UserService;
  let userRepository: any;

  beforeAll(async () => {
    connection = await setupTestDatabase();
    userRepository = getRepository(User);
    userService = new UserService(userRepository);
  });

  afterAll(async () => {
    await teardownTestDatabase();
  });

  beforeEach(async () => {
    // Limpa dados antes de cada teste
    await userRepository.delete({});
  });

  it('should create a new user and retrieve it', async () => {
    // Arrange: preparar dados
    const userData = UserFixture.createUser({
      email: 'john@example.com',
      name: 'John Smith',
    });

    // Act: executar ação
    const createdUser = await userService.create(userData);

    // Assert: verificar resultado
    expect(createdUser).toBeDefined();
    expect(createdUser.email).toBe('john@example.com');

    // Verificar persistência
    const retrieved = await userService.findById(createdUser.id);
    expect(retrieved).toBeDefined();
    expect(retrieved!.name).toBe('John Smith');
  });

  it('should update an existing user', async () => {
    const user = await userService.create(UserFixture.createUser());

    const updated = await userService.update(user.id, {
      name: 'Updated Name',
    });

    expect(updated.name).toBe('Updated Name');

    const retrieved = await userService.findById(user.id);
    expect(retrieved!.name).toBe('Updated Name');
  });

  it('should find users by active status', async () => {
    await userService.create(UserFixture.createUser({ isActive: true }));
    await userService.create(UserFixture.createUser({ isActive: true }));
    await userService.create(UserFixture.createUser({ isActive: false }));

    const activeUsers = await userService.findByActive(true);

    expect(activeUsers).toHaveLength(2);
    expect(activeUsers.every((u) => u.isActive)).toBe(true);
  });
});

Neste teste, cada caso segue o padrão AAA (Arrange, Act, Assert). Usamos fixtures para evitar "magic strings" e mantemos dados visíveis e intencionais. O beforeEach garante que cada teste começa limpo.

Implementando o UserService

Para completude, aqui está uma implementação simples:

// src/services/UserService.ts
import { Repository } from 'typeorm';
import { User } from '../entities/User';

export class UserService {
  constructor(private userRepository: Repository<User>) {}

  async create(user: Omit<User, 'id'>): Promise<User> {
    const newUser = this.userRepository.create(user);
    return this.userRepository.save(newUser);
  }

  async findById(id: number): Promise<User | undefined> {
    return this.userRepository.findOne(id);
  }

  async update(
    id: number,
    updates: Partial<User>
  ): Promise<User> {
    await this.userRepository.update(id, updates);
    return this.userRepository.findOne(id) as Promise<User>;
  }

  async findByActive(isActive: boolean): Promise<User[]> {
    return this.userRepository.find({ where: { isActive } });
  }
}

Boas Práticas e Padrões Avançados

Cleanup e Transações em Testes

Para testes verdadeiramente isolados, você pode usar transações. Cada teste roda em uma transação que faz rollback ao final, em vez de deletar dados manualmente:

// src/database/test-transaction.ts
import { Connection } from 'typeorm';

export class TestTransaction {
  constructor(private connection: Connection) {}

  async run<T>(callback: () => Promise<T>): Promise<T> {
    const queryRunner = this.connection.createQueryRunner();
    await queryRunner.startTransaction();

    try {
      const result = await callback();
      await queryRunner.rollbackTransaction();
      return result;
    } finally {
      await queryRunner.release();
    }
  }
}

// Uso no teste
beforeEach(async () => {
  testTransaction = new TestTransaction(connection);
});

it('should handle concurrent user creation', async () => {
  await testTransaction.run(async () => {
    const user1 = UserFixture.createUser({ email: 'user1@test.com' });
    const user2 = UserFixture.createUser({ email: 'user2@test.com' });

    const results = await Promise.all([
      userService.create(user1),
      userService.create(user2),
    ]);

    expect(results).toHaveLength(2);
    // Transação faz rollback após teste
  });
});

Fixtures com Dados Relacionados

Em aplicações reais, você precisa de relacionamentos. Estenda suas factories:

// src/types/Post.ts
export interface Post {
  id: number;
  title: string;
  content: string;
  userId: number;
  createdAt: Date;
}

// src/fixtures/PostFixture.ts
import { Post } from '../types/Post';

export class PostFixture {
  static createPost(
    userId: number,
    overrides?: Partial<Post>
  ): Post {
    const defaultPost: Post = {
      id: Math.floor(Math.random() * 10000),
      title: 'Sample Post',
      content: 'This is a sample post content.',
      userId,
      createdAt: new Date(),
    };

    return { ...defaultPost, ...overrides };
  }

  static createPostsForUser(
    userId: number,
    count: number,
    overrides?: Partial<Post>
  ): Post[] {
    return Array.from({ length: count }, (_, index) =>
      this.createPost(userId, {
        id: index + 1,
        title: `Post ${index + 1}`,
        ...overrides,
      })
    );
  }
}

// Teste com relacionamentos
it('should retrieve all posts for a user', async () => {
  const user = await userService.create(UserFixture.createUser());
  const posts = PostFixture.createPostsForUser(user.id, 3);

  for (const post of posts) {
    await postService.create(post);
  }

  const userPosts = await postService.findByUserId(user.id);
  expect(userPosts).toHaveLength(3);
});

Validação de Tipos em Fixtures

Aproveite o TypeScript para validar suas fixtures em tempo de compilação:

// src/fixtures/BaseFixture.ts
export abstract class BaseFixture<T> {
  protected abstract getDefaults(): T;

  create(overrides?: Partial<T>): T {
    return { ...this.getDefaults(), ...overrides };
  }

  createMultiple(count: number, overrides?: Partial<T>): T[] {
    return Array.from({ length: count }, () => this.create(overrides));
  }
}

// src/fixtures/UserFixture.ts (refatorado)
import { BaseFixture } from './BaseFixture';
import { User } from '../types/User';

export class UserFixture extends BaseFixture<User> {
  protected getDefaults(): User {
    return {
      id: Math.floor(Math.random() * 10000),
      email: 'user@example.com',
      name: 'John Doe',
      createdAt: new Date(),
      isActive: true,
    };
  }
}

Conclusão

Aprendemos três pilares fundamentais para dominar testes de integração em TypeScript. Primeiro, fixtures tipadas não são luxo — são essenciais para evitar repetição e capturar erros em tempo de compilação. Segundo, testar contra um banco real (mesmo que em memória) é o único jeito de validar verdadeiramente como seus componentes interagem com a persistência. Terceiro, padrões como transações de rollback e factories bem estruturadas transformam testes frágeis em suites confiáveis e mantíveis que crescem com sua aplicação.

Referências


Artigos relacionados