<h2>O que são Testes de Integração e Por Que Importam</h2>
<p>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.</p>
<p>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.</p>
<h2>Estruturando Fixtures Tipadas com TypeScript</h2>
<h3>Entendendo Fixtures e Seu Papel</h3>
<p>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.</p>
<p>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.</p>
<h3>Criando uma Factory de Fixtures Tipada</h3>
<p>Vamos construir um exemplo prático. Imagine uma aplicação de gerenciamento de usuários:</p>
<pre><code class="language-typescript">// 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,
})
);
}
}</code></pre>
<p>Este padrão (conhecido como <strong>Object Mother</strong> ou <strong>Builder Pattern</strong>) oferece valor real: valores sensatos por padrão, facilidade em sobrescrever apenas o que importa para seu teste, e reutilização sem duplicação.</p>
<h2>Testes de Integração com Banco de Dados Real</h2>
<h3>Configurando o Ambiente de Teste</h3>
<p>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:</p>
<pre><code class="language-typescript">// 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();
}</code></pre>
<p>A estratégia aqui é clara: cada suite de testes obtém um banco virgem, executando as migrations automaticamente (<code>synchronize: true</code>). Após os testes, descartamos tudo. Isso garante isolamento entre testes — nenhum teste afeta outro.</p>
<h3>Escrevendo um Teste de Integração Funcional</h3>
<pre><code class="language-typescript">// 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);
});
});</code></pre>
<p>Neste teste, cada caso segue o padrão <strong>AAA</strong> (Arrange, Act, Assert). Usamos fixtures para evitar "magic strings" e mantemos dados visíveis e intencionais. O <code>beforeEach</code> garante que cada teste começa limpo.</p>
<h3>Implementando o UserService</h3>
<p>Para completude, aqui está uma implementação simples:</p>
<pre><code class="language-typescript">// 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 } });
}
}</code></pre>
<h2>Boas Práticas e Padrões Avançados</h2>
<h3>Cleanup e Transações em Testes</h3>
<p>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:</p>
<pre><code class="language-typescript">// 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
});
});</code></pre>
<h3>Fixtures com Dados Relacionados</h3>
<p>Em aplicações reais, você precisa de relacionamentos. Estenda suas factories:</p>
<pre><code class="language-typescript">// 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);
});</code></pre>
<h3>Validação de Tipos em Fixtures</h3>
<p>Aproveite o TypeScript para validar suas fixtures em tempo de compilação:</p>
<pre><code class="language-typescript">// 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,
};
}
}</code></pre>
<h2>Conclusão</h2>
<p>Aprendemos três pilares fundamentais para dominar testes de integração em TypeScript. <strong>Primeiro</strong>, fixtures tipadas não são luxo — são essenciais para evitar repetição e capturar erros em tempo de compilação. <strong>Segundo</strong>, testar contra um banco real (mesmo que em memória) é o único jeito de validar verdadeiramente como seus componentes interagem com a persistência. <strong>Terceiro</strong>, 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.</p>
<h2>Referências</h2>
<ul>
<li><a href="https://typeorm.io/#testing" target="_blank" rel="noopener noreferrer">TypeORM Testing Documentation</a></li>
<li><a href="https://jestjs.io/docs/getting-started" target="_blank" rel="noopener noreferrer">Jest Getting Started</a></li>
<li><a href="https://testing-library.com/docs/queries/about" target="_blank" rel="noopener noreferrer">Testing Library - Integration Testing Best Practices</a></li>
<li><a href="https://martinfowler.com/bliki/ObjectMother.html" target="_blank" rel="noopener noreferrer">Patterns of Enterprise Application Architecture - Martin Fowler (Object Mother Pattern)</a></li>
<li><a href="https://www.typescriptlang.org/docs/handbook/2/types-from-types.html" target="_blank" rel="noopener noreferrer">TypeScript Handbook - Advanced Types</a></li>
</ul>
<p><!-- FIM --></p>