Utilizando Testcontainers para Testes de Integração com NestJS e Prisma ORM
Introdução
Neste guia, exploraremos como realizar testes de integração em nossa aplicação usando uma combinação poderosa de tecnologias: NestJs junto com o Prisma ORM, e aproveitando os recursos do Testcontainers.
O foco deste post é mostrar como integrar o Testcontainers em aplicações NestJS com Prisma, proporcionando testes robustos e eficientes. Embora não abordemos a criação e configuração inicial do projeto, irei explicar brevemente a estrutura básica que adotaremos.
Nosso projeto será composto por apenas três endpoints: criar usuário, atualizar e listar por ID.
Vamos utilizar constratos, já fiz um post sobre isso também, e postgres para banco de dados.
O que é Testcontainers?
Antes de mergulharmos nos testes de integração, é importante compreender o papel do Testcontainers. Esses testes são cruciais para garantir o funcionamento fluido de uma aplicação, pois abrangem o teste de todo o fluxo de funcionalidades. Embora os testes unitários tenham seu lugar, os testes de integração oferecem uma cobertura mais abrangente.
No entanto, os testes de integração podem ser complexos, pois exigem a execução de vários componentes essenciais antes de iniciar os testes. É aqui que entra o Testcontainers.
O Testcontainers facilita a execução dos componentes necessários para a aplicação, como um banco de dados. Por exemplo, se precisarmos de um banco de dados Postgres, o Testcontainers inicia uma instância temporária do Postgres para nós, executa os testes e, em seguida, limpa o ambiente, excluindo o container.
Como vamos utilizar?
Vamos utilizar o jest para realizar os testes e o supertest para fazer nossas chamadas http, a ideia seria:
No inicio de todos os teste:
- Iniciar o Testcontainers juntamente com postgres
- Iniciar o prisma
- Criar uma instância da nossa aplicação
A cada caso de teste:
- Resetamos o banco e garantimos isolamento dos testes
- Rodamos as migrations
No fim de todos os teste:
- Desconectamos o prisma do banco
- Finalizamos o postgres
- Paramos o container
Poderíamos fazer todas as etapas acima a cada caso de teste, se tivermos 10 casos de teste, vamos levantar 10 containers, porém ficaria mais lento a cada teste subir um novo container com postgres, dessa forma vamos levantar apenas 1 container, fica mais rápido apenas limpar o banco e rodar as migrations novamente, mas cada cenário pede uma abordagem, em nosso exemplo esse cenários serve muito bem, mas caso seja melhor para o seu caso, você pode fazer exatamente como as docs do Testcontainers fazem.
Configurando o Jest
Antes de iniciar com testecotainers precisamos ajustar algumas coisas do jest, por padrão o nestJS criar alguns arquivos para teste e2e, vamos precisar ajustar.
Vamos renomear o arquivo chamado jest-e2e.json
que fica na pasta test para jest-integration.json
e vamos alterar o testRegex
para buscar todos os arquivos com .integration.spec.ts
no nome, você pode chamar o arquivo da forma que deseja, vai ficar asssim:
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".integration.spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
Vamos criar também um novo script para rodas os testes no package.json
:
"test:integration": "jest --config ./test/jest-integration.json"
Com isso temos o script pronto, basta rodas nom run test:integration
ou yarn test:integration
Instalando os pacotes
Vamos precisar de alguns pacotes, são eles:
O pacote do testcontainers
npm install testcontainers --save-dev
O pacote do postgres para o Testcontainers:
npm install @testcontainers/postgresql --save-dev
O pacote do drive do pg (postgres)
npm install pg
O pacote do super test
npm install supertest --save-dev
Com isso temos todos os pacotes necessários.
Iniciado os testes
Vamos renomear o arquivo app.e2e-spec.ts
criado pelo nestJS para user.integration.spec.ts
, como vamos iniciar apenas 1 instância do postgres via container para todos os testes, precisamos deixar acessível de forma global, por isso vamos iniciar assim:
let container: StartedPostgreSqlContainer; // importamos do pacote testcontainers/postgresql
let prismaClient: PrismaClient; // importamos do Prisma Client
let app: INestApplication; // importamos nestjs common
let urlConnection: string; // a url do banco que sera criada pelo testcontainers
let client: Client; // importamos do pacote pg
Isso vai nos permitir acessar fora do beforeAll
que vamos criar.
Vamos iniciar tudo dentro de um beforeAll
do jest, vamos primeiro estar o PostgreSqlContainer
depois vamos criar um Client
do postgres com o pacote pg, conectamos o prisma ao banco e por fim iniciamos o nestJS, também pegamos a url de conexão com o postgres e atribuímos a variável DATABASE_URL
que é usada por padrão pelo prisma.
vai ficar assim:
beforeAll(async () => {
container = await new PostgreSqlContainer().start();
client = new Client({
host: container.getHost(),
port: container.getPort(),
user: container.getUsername(),
password: container.getPassword(),
database: container.getDatabase(),
});
await client.connect();
process.env.DATABASE_URL = container.getConnectionUri();
urlConnection = container.getConnectionUri();
// create a new instance of PrismaClient with the connection string
prismaClient = new PrismaClient({
datasources: {
db: {
url: urlConnection,
},
},
});
// start the nestjs application
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe());
await app.init();
});
Agora vamos fazer nosso afterAll
que vai ser executado ao terminar todos os testes, basicamente finalizamos tudo que iniciamos no beforeAll
afterAll(async () => {
await prismaClient.$disconnect();
await client.end();
await container.stop();
});
Com isso temos quase tudo pronto para criar os casos de teste, mas precisamos garantir que a cada caso de teste, o que foi gerado por um caso não afete o outro, para isso vamos fazer um beforeEach
que antes de cada caso de teste vai resetar o banco e rodas as migrations, garantindo que cada caso de teste tenho o banco zerado.
beforeEach(async () => {
// drop schema and create a new one
execSync(`npx prisma migrate reset --force`, {
env: {
...process.env,
DATABASE_URL: urlConnection,
},
});
execSync(`npx prisma migrate deploy`, {
env: {
...process.env,
DATABASE_URL: urlConnection,
},
});
});
Usamos o execSync
para executar comandos no shell
, resetando o banco com o comando do prisma cli, mas é preciso usar o --force
para forçar o reset, sem isso o prisma pede uma confirmação manual.
Depois de resetar, rodamos as migrations, passando a url de conexão que colocamos no urlConnection
no nosso beforeAll
. Também vamos criar um afterEach
para limpar os mocks do jest e garantir que nenhum mock que possa ser criado influencie os demais testes:
afterEach(() => {
jest.restoreAllMocks();
});
Com isso temos as configurações de teste prontas, agora é só escrever os testes.
Criando os casos de teste
Vamos usar o super test para chamar nosso endpoint de criação de usuários:
describe('[/user] POST', () => {
it('should create a user', async () => {
const userData: UserDto = {
email: 'john.doe@email.com',
first_name: 'John',
last_name: 'Doe',
password: 'password@1234',
};
await request(app.getHttpServer()).post('/user').send(userData).expect(201);
const userDb = await prismaClient.user.findUnique({
where: {
email: userData.email,
},
});
expect(userDb).toBeTruthy();
expect(userDb.email).toBe(userData.email);
expect(userDb.first_name).toBe(userData.first_name);
expect(userDb.last_name).toBe(userData.last_name);
expect(userDb.password).not.toBe(userData.password);
expect(userDb.created_at).toBeTruthy();
});
});
Criamos um usuário usando nosso endpoint [POST] /user
e depois buscamos no banco para garantir que o usuário foi salvo, rodando o teste com:
npm run test:integration
Vamos ver que o teste passou:
Vamos fazer todos os testes, o foco não é mostrar cada teste, veja como ficou todos:
describe('[/user] POST', () => {
it('should create a user', async () => {
const userData: UserDto = {
email: 'john.doe@email.com',
first_name: 'John',
last_name: 'Doe',
password: 'password@1234',
};
await request(app.getHttpServer()).post('/user').send(userData).expect(201);
const userDb = await prismaClient.user.findUnique({
where: {
email: userData.email,
},
});
expect(userDb).toBeTruthy();
expect(userDb.email).toBe(userData.email);
expect(userDb.first_name).toBe(userData.first_name);
expect(userDb.last_name).toBe(userData.last_name);
expect(userDb.password).not.toBe(userData.password);
expect(userDb.created_at).toBeTruthy();
});
it('should not create a user with invalid data', async () => {
const userData: UserDto = {
email: 'john.doe@email.com',
first_name: 'John',
last_name: 'Doe',
password: 'password@1234',
};
await prismaClient.user.create({
data: userData,
});
const response = await request(app.getHttpServer())
.post('/user')
.send(userData)
.expect(400);
expect(response.body).toEqual({
statusCode: 400,
message: 'user already exists',
error: 'Bad Request',
});
});
it('should be able to update user', async () => {
const userData: UserDto = {
email: 'john.doe@email.com',
first_name: 'John',
last_name: 'Doe',
password: 'password@1234',
};
const userdb = await prismaClient.user.create({
data: userData,
});
await request(app.getHttpServer())
.patch(`/user/${userdb.id}`)
.send({
first_name: 'John Doe Updated',
})
.expect(200);
const userUpdated = await prismaClient.user.findUnique({
where: {
id: userdb.id,
},
});
expect(userUpdated.first_name).toBe('John Doe Updated');
});
it("should not be able to update user that doesn't exist", async () => {
const response = await request(app.getHttpServer())
.patch(`/user/${randomUUID()}`)
.send({
first_name: 'John Doe Updated',
})
.expect(404);
expect(response.body).toEqual({
statusCode: 404,
message: 'user not found',
error: 'Not Found',
});
});
it('should not be able to update user with email already in use', async () => {
const userData: UserDto = {
email: 'john.doe@email.com',
first_name: 'John',
last_name: 'Doe',
password: 'password@1234',
};
const userDb = await prismaClient.user.create({
data: userData,
});
const response = await request(app.getHttpServer())
.patch(`/user/${userDb.id}`)
.send({
email: userData.email,
})
.expect(400);
expect(response.body).toEqual({
statusCode: 400,
message: 'email already exists',
error: 'Bad Request',
});
});
});
Com isso temos nossa api com quase 100% de cobertura em teste de integração, garantindo seu funcionamento conforme o esperado e nos ajudando em futuras alterações.
Containers Genéricos
Você deve ter percebido que usamos o PostgreSqlContainer
mas o testcontainers não oferece um container pronto para tudo, por isso temos os GenericContainer
, com eles podemos passar qualquer imagem, por exemplo, se usarmos o postgis que é uma extensão do postgres para trabalhar com dados geoespaciais, o PostgreSqlContainer
não resolveria, então usaríamos o GenericContainer
:
Ficaria desta forma:
beforeAll(async () => {
container = await new GenericContainer('postgis/postgis:16-3.4:alpine')
.withEnvironment({
POSTGRES_HOST: dbHost,
POSTGRES_PASSWORD: dbPass,
POSTGRES_USER: dbUser,
POSTGRES_DB: dbName,
})
.withExposedPorts(dbPort)
.start();
const mappedPort = container.getMappedPort(dbPort);
urlConnection = `postgresql://${dbUser}:${dbPass}@${dbHost}:${mappedPort}/${dbName}`;
process.env.DATABASE_URL = urlConnection;
//.. resto do codigo
});
Bem semelhante ao que fizemos, você pode criar GenericContainer
de qualquer imagem, como elastic search, mongo, mysql.
Considerações finais
Bom, este foi um pequeno post mostrando de forma simples como usar o poderoso Testcontainers com nestjs e prisma orm, essa foi uma maneira simples e rápida que encontrei de trabalhar com ele, existem diversas formas e abordagens, essa forma que encontrei deixaram meus testes um pouco mais rápidos e simples, os testes de integração tendem a ser mais demorados de executar e utilizando container isso pode aumentar ainda mais o tempo, apenas 5 casos de testes levaram cerca de 12 segundos, imagine centenas de testes.
Link do repositório
repositório do projeto
Se inscreva e receba um aviso sobre novos posts, participar