TypeScript

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

11 min de leitura

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

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

<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 &#039;../types/User&#039;;

export class UserFixture {

static createUser(overrides?: Partial&lt;User&gt;): User {

const defaultUser: User = {

id: Math.floor(Math.random() * 10000),

email: &#039;user@example.com&#039;,

name: &#039;John Doe&#039;,

createdAt: new Date(),

isActive: true,

};

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

}

static createMultipleUsers(count: number, overrides?: Partial&lt;User&gt;): User[] {

return Array.from({ length: count }, (_, index) =&gt;

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 &#039;typeorm&#039;;

import { User } from &#039;../entities/User&#039;;

export async function setupTestDatabase(): Promise&lt;Connection&gt; {

const connection = await createConnection({

type: &#039;sqlite&#039;,

database: &#039;:memory:&#039;,

entities: [User],

synchronize: true,

logging: false,

});

return connection;

}

export async function teardownTestDatabase(): Promise&lt;void&gt; {

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 &#039;@jest/globals&#039;;

import { expect } from &#039;expect&#039;;

import { getRepository, Connection } from &#039;typeorm&#039;;

import { UserService } from &#039;./UserService&#039;;

import { User } from &#039;../entities/User&#039;;

import { UserFixture } from &#039;../fixtures/UserFixture&#039;;

import {

setupTestDatabase,

teardownTestDatabase,

} from &#039;../database/connection.test&#039;;

describe(&#039;UserService Integration Tests&#039;, () =&gt; {

let connection: Connection;

let userService: UserService;

let userRepository: any;

beforeAll(async () =&gt; {

connection = await setupTestDatabase();

userRepository = getRepository(User);

userService = new UserService(userRepository);

});

afterAll(async () =&gt; {

await teardownTestDatabase();

});

beforeEach(async () =&gt; {

// Limpa dados antes de cada teste

await userRepository.delete({});

});

it(&#039;should create a new user and retrieve it&#039;, async () =&gt; {

// Arrange: preparar dados

const userData = UserFixture.createUser({

email: &#039;john@example.com&#039;,

name: &#039;John Smith&#039;,

});

// Act: executar ação

const createdUser = await userService.create(userData);

// Assert: verificar resultado

expect(createdUser).toBeDefined();

expect(createdUser.email).toBe(&#039;john@example.com&#039;);

// Verificar persistência

const retrieved = await userService.findById(createdUser.id);

expect(retrieved).toBeDefined();

expect(retrieved!.name).toBe(&#039;John Smith&#039;);

});

it(&#039;should update an existing user&#039;, async () =&gt; {

const user = await userService.create(UserFixture.createUser());

const updated = await userService.update(user.id, {

name: &#039;Updated Name&#039;,

});

expect(updated.name).toBe(&#039;Updated Name&#039;);

const retrieved = await userService.findById(user.id);

expect(retrieved!.name).toBe(&#039;Updated Name&#039;);

});

it(&#039;should find users by active status&#039;, async () =&gt; {

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) =&gt; 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 &quot;magic strings&quot; 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 &#039;typeorm&#039;;

import { User } from &#039;../entities/User&#039;;

export class UserService {

constructor(private userRepository: Repository&lt;User&gt;) {}

async create(user: Omit&lt;User, &#039;id&#039;&gt;): Promise&lt;User&gt; {

const newUser = this.userRepository.create(user);

return this.userRepository.save(newUser);

}

async findById(id: number): Promise&lt;User | undefined&gt; {

return this.userRepository.findOne(id);

}

async update(

id: number,

updates: Partial&lt;User&gt;

): Promise&lt;User&gt; {

await this.userRepository.update(id, updates);

return this.userRepository.findOne(id) as Promise&lt;User&gt;;

}

async findByActive(isActive: boolean): Promise&lt;User[]&gt; {

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 &#039;typeorm&#039;;

export class TestTransaction {

constructor(private connection: Connection) {}

async run&lt;T&gt;(callback: () =&gt; Promise&lt;T&gt;): Promise&lt;T&gt; {

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 () =&gt; {

testTransaction = new TestTransaction(connection);

});

it(&#039;should handle concurrent user creation&#039;, async () =&gt; {

await testTransaction.run(async () =&gt; {

const user1 = UserFixture.createUser({ email: &#039;user1@test.com&#039; });

const user2 = UserFixture.createUser({ email: &#039;user2@test.com&#039; });

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 &#039;../types/Post&#039;;

export class PostFixture {

static createPost(

userId: number,

overrides?: Partial&lt;Post&gt;

): Post {

const defaultPost: Post = {

id: Math.floor(Math.random() * 10000),

title: &#039;Sample Post&#039;,

content: &#039;This is a sample post content.&#039;,

userId,

createdAt: new Date(),

};

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

}

static createPostsForUser(

userId: number,

count: number,

overrides?: Partial&lt;Post&gt;

): Post[] {

return Array.from({ length: count }, (_, index) =&gt;

this.createPost(userId, {

id: index + 1,

title: Post ${index + 1},

...overrides,

})

);

}

}

// Teste com relacionamentos

it(&#039;should retrieve all posts for a user&#039;, async () =&gt; {

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&lt;T&gt; {

protected abstract getDefaults(): T;

create(overrides?: Partial&lt;T&gt;): T {

return { ...this.getDefaults(), ...overrides };

}

createMultiple(count: number, overrides?: Partial&lt;T&gt;): T[] {

return Array.from({ length: count }, () =&gt; this.create(overrides));

}

}

// src/fixtures/UserFixture.ts (refatorado)

import { BaseFixture } from &#039;./BaseFixture&#039;;

import { User } from &#039;../types/User&#039;;

export class UserFixture extends BaseFixture&lt;User&gt; {

protected getDefaults(): User {

return {

id: Math.floor(Math.random() * 10000),

email: &#039;user@example.com&#039;,

name: &#039;John Doe&#039;,

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>&lt;!-- FIM --&gt;</p>

Comentários

Mais em TypeScript

Guia Completo de Recursive Types em TypeScript: Árvores, JSON e Estruturas Profundas
Guia Completo de Recursive Types em TypeScript: Árvores, JSON e Estruturas Profundas

Entendendo Tipos Recursivos em TypeScript Um tipo recursivo é aquele que faz...

Guia Completo de Tipos Primitivos, Literais e Type Inference em TypeScript
Guia Completo de Tipos Primitivos, Literais e Type Inference em TypeScript

Tipos Primitivos em TypeScript Os tipos primitivos são a base de qualquer pro...

TypeScript Compiler API: Parsear, Transformar e Gerar Código na Prática
TypeScript Compiler API: Parsear, Transformar e Gerar Código na Prática

Introdução à TypeScript Compiler API A TypeScript Compiler API é um conjunto...