Uma das coisas que eu mais quebrava a cabeça quando iniciei minha carreira era como fazer um teste de uma aplicação que envolvesse um banco de dados. Sempre achava que não era necessário testar coisas básicas como “inserts” ou até mesmo uma “select”. Buscava sempre testar apenas os “Services” do meu projeto criando “mocks” ou fazendo algo mirabolante para evitar inserir um dado no banco de dados.
Tudo isso porque sempre achei que para testar o banco, eu precisaria sempre subir um novo “database”, zerar ele, e repetir o processo sempre que fosse rodar um teste. Mas isso mudou com o tempo, e uma estratégia muito interessante para fazer esses testes é o uso das “Transactions”.
O que são Transactions?
Para quem não sabe, “Transactions” (ou transação) é uma sequência de operações executadas como uma única unidade lógica de trabalho, garantindo que ou todas as operações são realizadas com sucesso ou nenhuma delas é realizada. Essas operações podem incluir inserções, atualizações, deleções e leitura de dados. Durante a execução das “Transactions”, por padrão, sempre que ocorrer algum erro ou falha, as “Transactions” podem ser revertidas para garantir a consistência dos dados.
As “Transactions” seguem quatro propriedades fundamentais, conhecidas como “ACID”:
- Atomicity (Atomicidade)
- A transação deve ser tratada como uma unidade indivisível, ou seja, ou todas as operações são concluídas com sucesso, ou nenhuma delas é aplicado ao banco de dados
- Se ocorrer um erro no meio da transação, todas as mudanças realizadas até aquele ponto devem ser desfeitas (rollback)
- Consistency (Consistência)
- O banco de dados deve permanecer em um estado consistente antes e depois da execução da transação.
- Regras de integridade, como chaves primárias, estrangeiras e restrições, devem ser mantidas.
- Isolation (Isolamento)
- Transações concorrentes não podem interferir umas nas outras. O banco deve garantir que uma transação em andamento não veja os dados de outra transação incompleta.
- Diferentes níveis de isolamento podem ser configurados para evitar problemas como leituras sujas, leituras não repetíveis e phantom reads.
- Durability (Durabilidade)
- Depois que uma transação é confirmada (commit), suas mudanças são permanentemente salvas no banco, mesmo em caso de falha do sistema.
Ciclo de vida de uma transação
Antes de mostrar para você como usar as transações no seu código, você precisa entender o ciclo de vida de uma transação, para ter uma noção melhor do que irá acontecer no seu código ao rodar ele. Geralmente, uma transação passa pelos seguintes estados:
- Begin Transaction (Início da Transação)
- O banco de dados inicia a transação e mantém o controle de todas as operações realizadas.
- Execução das Operações
- São realizadas as operações de manipulação de dados, como INSERT, UPDATE, DELETE e SELECT.
- Commit ou Rollback
- Commit: Se todas as operações forem bem-sucedidas, a transação é confirmada e suas alterações se tornam permanentes.
- Rollback: Se houver um erro, todas as mudanças são desfeitas e o banco retorna ao estado anterior ao início da transação.
Nesse contexto, temos dois tipos de transações, a implícita e a explícita:
- Transactions implícitas: Cada comando que executamos por padrão é uma transação isolada, quando executamos um comando de INSERT em uma tabela, a inserção só é realmente salva no bd se todo comando estiver correto
- Transactions explícitas: Ocorre quando nós indicamos a partir de onde começa esse conjunto de comandos que estarão nessa transação. BEGIN TRANSACTION (POSTGRESS) START TRANSACTION(MYSQL) que, por fim, ou tudo é salvo no bd ou nada COMMIT;(SALVAR) ROLLBACK;(JOGAR TUDO “FORA”)
Como Transactions podem te ajudar a realizar testes?
Como comentei antes, podemos usar as “Transactions” para testar o nosso código, mas como isso te ajuda? Para o exemplo, vamos supor o seguinte cenário, você é um deve que segue boas práticas e está rodando sua aplicação em uma ambiente de desenvolvimento com Docker, para que não tenha nenhum perigo de quebrar ou sobrecarregar o servidor de produção. Agora, você precisa escrever os testes do seu código, mais especificamente a camada de dados, para testar se o CRUD da sua aplicação está sendo realizado com sucesso em vários cenários.
Para isso, você vai usar “Transactions”, irá escrever o seu teste realizando o inserts (ou outra operação) direto no banco, sem precisar zerar ele ou criar um novo container. A “Transaction” irá ser iniciada logo no início do seus testes, e ao final, ao invés de dar o comando para persistir os dados no banco, você simplesmente irá escrever um rollback para que os dados não sejam salvos, mas o teste executado. Vamos para um exemplo mais prático.
Neste estudo de caso estarei usando “Typescript” junto do “Jest” para escrever o teste, mas você pode escrever usando qualquer linguagem, foque em compreender o contexto.
Aqui temos um “model” de categorias, apenas para realizar um “CRUD”. O nosso modelo ficou assim:
import { Column, DataType, Table } from "sequelize-typescript";
import { Model } from "sequelize-typescript";
@Table({ tableName: 'categories' })
class Category extends Model {
@Column({
type: DataType.INTEGER,
primaryKey: true,
autoIncrement: true
})
public id: number;
@Column({
type: DataType.STRING,
unique: true
})
public name: string;
}
export default Category;
E essa é a classe que iremos testar, o “Service” responsável por fazer o “CRUD” das nossas categorias:
import { FindAndCountOptions, Transaction } from "sequelize";
import Category from "../models/category";
import { CategoryInterface, CompleteCategoryInterface } from "../interfaces/category";
import PaginatedCategoriesInterface from "../interfaces/paginatedCategories";
class CategoryService {
async create(category: CategoryInterface, transaction: Transaction | null = null) {
let createdCategory;
if (category.name == null) {
throw new Error("É preciso informar o campo 'name' para continuar")
}
if (transaction != null) {
createdCategory = await Category.create({ name: category.name }, { transaction });
} else {
createdCategory = await Category.create({ name: category.name });
}
return createdCategory;
}
async getAll(page: number, limit: number, transaction: Transaction | null = null): Promise<PaginatedCategoriesInterface> {
const offset = (page - 1) * limit;
const options: FindAndCountOptions = {
limit,
offset,
order: [['id', 'DESC']],
...(transaction ? { transaction } : {})
};
const { count, rows } = await Category.findAndCountAll(options);
return {
totalItems: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
data: rows,
};
}
async getOne(id: number, transaction: Transaction | null = null): Promise<CompleteCategoryInterface | null> {
const category = await Category.findByPk(id, {
...(transaction ? { transaction } : {})
});
return category;
}
async update(id: number, updateData: CategoryInterface, transaction: Transaction | null = null): Promise<CompleteCategoryInterface> {
const category = await Category.findByPk(id, {
...(transaction ? { transaction } : {})
});
if (category == null) throw new Error("Categoria não encontrada");
category.name = updateData.name ? updateData.name : category.name;
category.save({ ...(transaction ? { transaction } : {}) });
return category;
}
async delete(id: number, transaction: Transaction | null = null): Promise<void> {
const category = await Category.findByPk(id, {
...(transaction ? { transaction } : {})
});
if (category == null) throw new Error("Categoria não encontrada");
category.destroy({ ...(transaction ? { transaction } : {}) });
}
}
export default CategoryService;
Perceba que eu defini o atributo “transaction: Transaction | null = null” como opcional em cada método. O qual, apenas será atribuído para os testes da nossa aplicação.
Por fim, nossa classe de testes:
import { Transaction } from "sequelize";
import database from "../app/database";
import { CategoryInterface, CompleteCategoryInterface } from "../app/interfaces/category";
import CategoryService from "../app/services/category";
const { describe, expect, it, beforeAll, afterAll } = require('@jest/globals');
const service = new CategoryService();
describe('Teste de categorias', () => {
let transaction: Transaction;
let createdId: number;
beforeAll(async () => {
transaction = await database.db.transaction();
});
afterAll(async () => {
await transaction.rollback();
});
it('should create a new category', async () => {
const category: CategoryInterface = {
name: 'Categoria Teste @1213123'
};
const created = await service.create(category, transaction);
createdId = created.id;
expect(created.name).toBe(category.name);
});
it('should get all categories created', async () => {
const page: number = 1;
const limit: number = 1;
const categories = await service.getAll(page, limit, transaction);
expect(categories.data.length).toBe(1);
});
it('should not get a category if it not exists', async () => {
const id: number = Number.MAX_SAFE_INTEGER;
const category = await service.getOne(id, transaction);
expect(category).toBeNull();
});
it('should get a category if it exists', async () => {
const category = await service.getOne(createdId, transaction);
expect(category.id).toBe(createdId);
});
it('should update a category if it exists', async () => {
const updateData: CategoryInterface = { name: 'josias' };
const created = await service.create({ name: 'Categoria Inicial' }, transaction);
createdId = created.id;
const updated = await service.update(createdId, updateData, transaction);
expect(updated.name).toBe('josias');
});
it('should throw an error if the category does not exist', async () => {
const id: number = Number.MAX_SAFE_INTEGER;
const updateData: CategoryInterface = { name: 'novos dados' };
await expect(service.update(id, updateData, transaction))
.rejects
.toThrow("Categoria não encontrada");
});
it('should not delete a category if it not exists', async () => {
const id: number = Number.MAX_SAFE_INTEGER;
await expect(service.delete(id, transaction))
.rejects
.toThrow("Categoria não encontrada");
});
it('should delete a category if it exists', async () => {
await service.delete(createdId, transaction);
const category = await service.getOne(createdId, transaction);
expect(category).toBeNull();
});
});
Aqui temos os métodos “beforeAll” e “afterAll” que iniciam a nossa transaction e finalizam ela quando todos os testes forem executados. Assim, ao final do build, não teremos nenhum dado sendo persistido no banco, mas todos os testes realizados com sucesso.