An Interest In:
Web News this Week
- April 27, 2024
- April 26, 2024
- April 25, 2024
- April 24, 2024
- April 23, 2024
- April 22, 2024
- April 21, 2024
Testes de integrao para API com Typescript, mocha, chai e sinon
Pular para os testes
Criando a API
A ideia criar uma API CRUD
de usurios bem simples j que o foco aqui est nos testes. Voc pode seguir o paso a passo ou simplesmente clonar o repositrio do projeto: git clone [email protected]:matheusg18/integration-test-api.git
.
Tecnologias utilizadas:
Typescript, POO, Express, Joi, MySQL, Prisma, NodeJs
Setup inicial
- Inicie o repositrio do projeto
npm init -y
- Instale todas as dependncias que vo ser necessrias nesse projeto
npm install @prisma/client express joinpm install -D typescript ts-node ts-node-dev prisma @types/node @types/express
- Adicione os seguintes scripts ao
package.json
"scripts": { "start": "ts-node src/server.ts", "dev": "tsnd --exit-child /src/server.ts"}
- Crie um
tsconfig.json
usando otsc
npx tsc --init
Exemplo do package.json
{ "name": "integration-test-api", "version": "1.0.0", "description": "", "main": "server.ts", "scripts": { "start": "ts-node src/server.ts", "dev": "tsnd --exit-child /src/server.ts" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@types/express": "^4.17.13", "@types/node": "^17.0.23", "prisma": "^3.12.0", "ts-node": "^10.7.0", "ts-node-dev": "^1.1.8", "typescript": "^4.6.3" }, "dependencies": { "@prisma/client": "^3.12.0", "express": "^4.17.3", "joi": "^17.6.0" }}
Iniciando o Prisma
Prisma um ORM que lida muito bem com Typescript, tem suporte para o MySQL e um documentao muito bem elaborada. Levando isso em conta, ele que vamos utilizar neste artigo.
Primeiro de tudo necessrio criar o schema do prisma. Para isso siga os passos abaixo:
- Crie a pasta
src/
, dentro dela crie outra pastaprisma/
e dentro desta crie o arquivoschema.prisma
src prisma schema.prisma
- No
schema.prisma
onde vo estar as configuraes necessrias do prisma para se comunicar ao MySQL. No nosso caso adicione o seguinte cdigo ao arquivo
generator client { provider = "prisma-client-js"}datasource db { provider = "mysql" url = env("DATABASE_URL")}model user { id Int @id @default(autoincrement()) firstName String lastName String email String @unique occupation String?}
- Como o
schema.prisma
est num caminho diferente do padro necessrio avisar isso ao prisma. Adicione a seguinte chave ao seupackage.json
"prisma": { "schema": "src/prisma/schema.prisma"}
- Agora necessrio inicar um container docker de MySQL (recomendado)
docker container run -d -e MYSQL_ROOT_PASSWORD=password -p 3336:3306 mysql:latest
- Prximo passo criar um
.env
na raiz do projeto com a varivelDATABASE_URL
DATABASE_URL=mysql://root:password@localhost:3336/users_api
- Por ltimo, crie uma migration do prisma
npx prisma migrate dev --name init
Construindo a API
Chegou a hora de codar a API em si. De incio, como estamos usando o Typescript, crie as interfaces que sero usadas no projeto:
src/interfaces/index.ts
export interface IUser extends IUserCreateRequest { id: number;}export interface IUserCreateRequest { firstName: string; lastName: string; email: string; occupation: string | undefined | null;}export interface IUserUpdateRequest extends Partial<IUserCreateRequest> {}
O prximo passo criar algumas classes de erros personalizados que sero teis quando algum erro esperado acontecer:
src/utils/errors/HttpError.ts
export default abstract class HttpError extends Error { public abstract httpCode: number; public abstract name: string;}
src/utils/errors/BadRequest.ts
import HttpError from './HttpError';export class BadRequest extends HttpError { public httpCode: number; public name: string; constructor(message: string, httpCode = 400) { super(message); this.httpCode = httpCode; this.name = 'BadRequest'; }}
src/utils/errors/Conflict.ts
import HttpError from './HttpError';export class Conflict extends HttpError { public httpCode: number; public name: string; constructor(message: string, httpCode = 409) { super(message); this.httpCode = httpCode; this.name = 'Conflict'; }}
src/utils/errors/NotFound.ts
import HttpError from './HttpError';export class NotFound extends HttpError { public httpCode: number; public name: string; constructor(message: string, httpCode = 404) { super(message); this.httpCode = httpCode; this.name = 'NotFound'; }}
src/utils/errors/index.ts
export * from './BadRequest';export * from './Conflict';export * from './NotFound';
Para fins didticos, neste projeto ser usado uma classe que registra logs da API
(os logs so salvos no arquivo logs.txt
na raiz do projeto). Copie o cdigo dela tambm:
src/utils/logger.ts
import fs from 'fs/promises';import path from 'path';export default class Logger { private static _filePath = path.resolve(__dirname, '../../logs.txt'); public static async save(info: string): Promise<void> { const sentence = `${new Date().toLocaleString('pt-BR')}: ${info}
`; fs.writeFile(Logger._filePath, sentence, { flag: 'a' }); }}
Para fazer a validao dos dados das requisies, neste projeto, ser ultilizado o Joi. Copie os middlewares que sero usados:
src/middlewares/index.ts
import { ErrorRequestHandler, RequestHandler } from 'express';import joi from 'joi';import { IUserCreateRequest, IUserUpdateRequest } from '../interfaces';import { BadRequest } from '../utils/errors/BadRequest';import HttpError from '../utils/errors/HttpError';export default class Middlewares { private static _createSchema = joi.object({ firstName: joi.string().required(), lastName: joi.string().required(), email: joi.string().email().required(), occupation: joi.string().required(), }); private static _updateSchema = joi.object({ firstName: joi.string(), lastName: joi.string(), email: joi.string().email(), occupation: joi.string(), }); public static error: ErrorRequestHandler = (err, _req, res, _next) => { if (err instanceof HttpError) { const { httpCode, message } = err; return res.status(httpCode).json({ error: { message } }); } return res.status(500).json({ error: { message: err.message } }); }; public static createValidation: RequestHandler = (req, _res, next) => { const { email, firstName, lastName, occupation } = req.body as IUserCreateRequest; const { error } = Middlewares._createSchema.validate({ email, firstName, lastName, occupation, }); if (error) return next(new BadRequest(error.message)); next(); }; public static updateValidation: RequestHandler = (req, _res, next) => { const { email, firstName, lastName, occupation } = req.body as IUserUpdateRequest; const { error } = Middlewares._updateSchema.validate({ email, firstName, lastName, occupation, }); if (error) return next(new BadRequest(error.message)); next(); };}
Partindo para a construo do user
, ele vai ser dividido em 4 partes:
repository
: interface de comunicao com o prisma;service
: lgica de negcio;controller
: middleware resposta do express, onde vai ser chamado ologger
;router
: estrutura dos endpoints.
Copie os cdigos abaixo:
src/user/repository.ts
import { PrismaClient } from '@prisma/client';import { IUser, IUserCreateRequest, IUserUpdateRequest } from '../interfaces';export default class UserRepository { private _prisma: PrismaClient; constructor(prisma = new PrismaClient()) { this._prisma = prisma; } public async getAll(): Promise<IUser[]> { return this._prisma.user.findMany(); } public async getById(id: number): Promise<IUser | null> { return this._prisma.user.findUnique({ where: { id } }); } public async getByEmail(email: string): Promise<IUser | null> { return this._prisma.user.findUnique({ where: { email } }); } public async create(newUser: IUserCreateRequest): Promise<IUser> { return this._prisma.user.create({ data: newUser }); } public async update(id: number, payload: IUserUpdateRequest): Promise<IUser> { return this._prisma.user.update({ where: { id }, data: payload }); } public async delete(id: number): Promise<IUser> { return this._prisma.user.delete({ where: { id } }); }}
src/user/service.ts
import { IUser, IUserCreateRequest, IUserUpdateRequest } from '../interfaces';import { Conflict, NotFound } from '../utils/errors';import UserRepository from './repository';export default class UserService { private _repository: UserRepository; constructor(repository = new UserRepository()) { this._repository = repository; } public async getAll(): Promise<IUser[]> { return this._repository.getAll(); } public async getById(id: number): Promise<IUser> { const user = await this._repository.getById(id); if (!user) throw new NotFound('user not found'); return user; } public async create(newUser: IUserCreateRequest): Promise<IUser> { const userExists = await this._repository.getByEmail(newUser.email); if (userExists) throw new Conflict('user already exists'); return this._repository.create(newUser); } public async update(id: number, payload: IUserUpdateRequest): Promise<IUser> { const user = await this._repository.getById(id); if (!user) throw new NotFound('user not found'); return this._repository.update(id, payload); } public async delete(id: number): Promise<IUser> { const user = await this._repository.getById(id); if (!user) throw new NotFound('user not found'); return this._repository.delete(id); }}
src/user/controller.ts
import { RequestHandler } from 'express';import { IUser, IUserCreateRequest, IUserUpdateRequest } from '../interfaces';import { BadRequest } from '../utils/errors/BadRequest';import Logger from '../utils/logger';import UserService from './service';export default class UserController { private _service: UserService; constructor(service = new UserService()) { this._service = service; } public getAll: RequestHandler = async (_req, res, next) => { try { const allUsers = await this._service.getAll(); res.status(200).json(allUsers); Logger.save('getAll() success'); } catch (error) { Logger.save('getAll() fail'); next(error); } }; public getById: RequestHandler = async (req, res, next) => { const id = parseInt(req.params.id, 10); if (isNaN(id)) return next(new BadRequest('invalid id')); try { const user = await this._service.getById(id); res.status(200).json(user); Logger.save('getById() success'); } catch (error) { Logger.save('getById() fail'); next(error); } }; public create: RequestHandler = async (req, res, next) => { const { email, firstName, lastName, occupation } = req.body as IUserCreateRequest; try { const newUser = await this._service.create({ email, firstName, lastName, occupation }); res.status(201).json(newUser); Logger.save('create() success'); } catch (error) { Logger.save('create() fail'); next(error); } }; public update: RequestHandler = async (req, res, next) => { const id = parseInt(req.params.id, 10); if (isNaN(id)) return next(new BadRequest('invalid id')); const { email, firstName, lastName, occupation } = req.body as IUserUpdateRequest; try { const updatedUser = await this._service.update(id, { email, firstName, lastName, occupation, }); res.status(200).json(updatedUser); Logger.save('update() success'); } catch (error) { Logger.save('update() fail'); next(error); } }; public delete: RequestHandler = async (req, res, next) => { const id = parseInt(req.params.id, 10); if (isNaN(id)) return next(new BadRequest('invalid id')); try { await this._service.delete(id); res.status(204).end(); Logger.save('delete() success'); } catch (error) { Logger.save('delete() fail'); next(error); } };}
src/user/router.ts
import { Router } from 'express';import Middlewares from '../middlewares';import UserController from './controller';export default class UserRouter { private _router: Router; private _controller: UserController; constructor(router = Router(), controller = new UserController()) { this._router = router; this._controller = controller; this._router.get('/', this._controller.getAll); this._router.get('/:id', this._controller.getById); this._router.post('/', Middlewares.createValidation, this._controller.create); this._router.put('/:id', Middlewares.updateValidation, this._controller.update); this._router.delete('/:id', this._controller.delete); } get router() { return this._router; }}
Por fim, preciso criar o express app
e export-lo para ser acessvel aos testes.
Obs.: Como instanciar um UserRouter
muito verboso isso vai ser feito por uma classe especial, a Factory
.
src/factory.ts
import { PrismaClient } from '@prisma/client';import { Router } from 'express';import UserController from './user/controller';import UserRepository from './user/repository';import UserRouter from './user/router';import UserService from './user/service';export default class Factory { private static _prisma = new PrismaClient(); public static get userRouter() { const userRepository = new UserRepository(Factory._prisma); const userService = new UserService(userRepository); const userController = new UserController(userService); const userRouter = new UserRouter(Router(), userController); return userRouter.router; }}
src/app.ts
import express from 'express';import Factory from './factory';import Middlewares from './middlewares';const app = express();app.use(express.json());app.use('/user', Factory.userRouter);app.use(Middlewares.error);export default app;
src/server.ts
import app from './app';const PORT = 3000;app.listen(PORT, () => { console.log('Server online'); console.log(`PORT ${PORT}`);});
Pronto, a API
est feita. Agora vem a parte boa, os testes.
Testes
Teste de integrao quando testa-se vrios pedaos do cdigo de uma vez. O objetivo testar se a interao entre eles est produzindo o resultado esperado.
Existe uma discusso sobre se faz sentido ou no mockar as operaes de IO
em testes de integrao. No tem nada escrito em pedra. Sendo assim, neste artigo, ser mostrado como criar testes de integrao mockando as operaes de IO
. Eu planejo criar, nas prximas semanas, um artigo onde no ter mocks mas sim um banco de dados temporrio apenas para os testes.
Para este projeto o repository
vai ser mockado para no haver consultas ao banco de dados (prisma) e o logger
para no haver manipulao de arquivos (fs).
Tecnologias utilizadas:
Typescript, mocha, chai, sinon
Setup inicial
- Baixe os pacotes que sero utilizados
npm i -D mocha @types/mocha chai chai-http @types/chai sinon @types/sinon
- Adicione o seguinte script ao
package.json
"scripts": { ... "test": "mocha --require ts-node/register __tests__/**/*.test.ts --exit"}
- Crie a pasta
__tests__
na raiz do projeto
Contexto
Antes, vale a pena pensar:
"O que que eu vou testar?"
O que vai ser testado a resposta da API
dependo da requisio que feita.
Exemplo: O endpoint GET /user
retorna um array de usurios, sendo assim da para criar os seguintes testes:
A requisio deu certo?
- se sim
- o status http da resposta
200
como eu esperava? - o body da resposta um array?
- o body da resposta um array de usurios?
- o logger foi chamado com
"getAll() success"
?
- o status http da resposta
- se no
- o status http da resposta
500
como eu esperava? - o body da resposta contm o objeto
error
? - objeto
error
contm a menssagem que eu esperava? - o logger foi chamado com
"getAll() fail"
?
- o status http da resposta
"Como que eu vou testar isso?"
Usando as ferramentas que o mocha
, chai
e sinon
nos oferece:
- mocha
- o
test runner
, ou seja, a ferramenta que executa os testes em si com comandos de terminal; - oferece o
describe
que cria um contexto de testes, agrupa testes e agrupa at mesmo outrosdescribe
; - oferece o
it
(outest
, no tem diferena) que onde se escreve o teste em si.
- o
- chai
- oferece o
expect
, uma ferramenta de assero que pea fundamental para os testes; - oferece matchers muito semnticos;
- tem vrios plugins que adicionam ferramentas muito teis e um deles vai ser usado aqui, o
chai-http
.
- oferece o
- sinon
- oferece o
sinon.stub
, ferramenta de mockar, simular o comportamento de uma funo ou mtodo.
- oferece o
Testando o GET /user
Traduzindo as perguntas acima para cdigo:
__tests__/user.test.ts
import chai from 'chai';import sinon from 'sinon';import chaiHttp from 'chai-http';import UserRepository from '../src/user/repository';import app from '../src/app';import * as fakeData from './fakeData';import Logger from '../src/utils/logger';chai.use(chaiHttp);const { expect } = chai;describe('em caso de sucesso', () => { before(() => { sinon.stub(UserRepository.prototype, 'getAll').resolves(fakeData.get.mock); sinon.stub(Logger, 'save').resolves(); }); after(() => { (UserRepository.prototype.getAll as sinon.SinonStub).restore(); (Logger.save as sinon.SinonStub).restore(); }); it('deve retornar um array de usurios e enviar status 200', async () => { const { status, body } = await chai.request(app).get('/user'); expect(status).to.be.equal(200); expect(body).to.be.an('array'); expect(body).to.be.deep.equal(fakeData.get.response); expect((Logger.save as sinon.SinonStub).calledWith('getAll() success')).to.be.true; });});
Entendendo o teste
- Import do
app
do express (app = express()
) para fazer as requisies
...import app from '../src/app';...
- Aqui vai ver usado um arquivo para agrupar mocks, requests e responses
...import * as fakeData from './fakeData';...
copie o fakeData
fakeData
__tests__/fakeData/index.ts
import { IUser } from '../../src/interfaces';const homer: IUser = { id: 1, firstName: 'Homer', lastName: 'Simpson', email: '[email protected]', occupation: 'nuclear safety inspector',};const ragnar: IUser = { id: 2, firstName: 'Ragnar', lastName: 'Lodbrok', email: '[email protected]', occupation: 'king',};const eren: IUser = { id: 3, firstName: 'Eren', lastName: 'Yeager', email: '[email protected]', occupation: 'soldier',};const morty: IUser = { id: 4, firstName: 'Morty', lastName: 'Smith', email: '[email protected]', occupation: 'student',};export const get = { mock: [homer, ragnar, eren, morty], response: [homer, ragnar, eren, morty],};
- Insere o plugin
chai-http
para ser usado pelo chai e pega oexpect
, que basicamente o que usado
...chai.use(chaiHttp);const { expect } = chai;...
- Aqui o
describe
est sendo usado para criar um contexto de testes, ou seja, para agrupar obefore
,after
eit
de forma isolada
describe('em caso de sucesso', () => { ...});
- O
before
executa uma callback antes do teste e geralmente usado para fazer setup. Aqui o setup ser mockar oUserRepository
e oLogger
.
before(() => { sinon.stub(UserRepository.prototype, 'getAll').resolves(fakeData.get.mock); sinon.stub(Logger, 'save').resolves();});
O que mockar o UserRepository
significa? Quer dizer que vamos retirar a implementao original e fazer o mtodo getAll()
resolver (porque o mtodo retorna uma Promise
) naquilo que esperamos que ele resolva. O mesmo vale para o Logger
.
Podemos fazer o mtodo mockado resolver qualquer coisa, mas preciso pensar bem para que o mock reflita a realidade porque, se no, os testes seriam inteis. No caso do UserRepository.getAll()
, qual valor ele poderia resolver que refletiria a realidade? Um array de usurios.
Como se mocka um mtodo de uma classe? Usando o sinon.stub(obj, 'method')
:
- Para mockar mtodos estticos:
sinon.stub(classe, 'mtodo')
// exemplosinon.stub(Logger, 'save').resolves();
- Para mockar mtodos no estticos:
sinon.stub(classe.prototype, 'mtodo')
// exemplosinon.stub(UserRepository.prototype, 'getAll').resolves(fakeData.get.mock);
- O
after
executa uma callback depois do teste e geralmente usado para fazer teardown. Aqui o teardown ser reverter o que foi mockado para no ter efeitos colaterais sobre outros testes.
after(() => { (UserRepository.prototype.getAll as sinon.SinonStub).restore(); (Logger.save as sinon.SinonStub).restore();});
Como se reverte o que foi mockado? Usando o restore()
do sinon. Como estamos usando Typescript necessrio fazer a converso do mtodo mockado para sinon.SinonStub
porque o Typescript, por padro, no o entende como um stub.
- Para mtodos estticos:
(classe.mtodo as sinon.SinonStub).restore()
// exemplo(Logger.save as sinon.SinonStub).restore();
- Para mtodos no estticos:
(classe.prototype.mtodo as sinon.SinonStub).restore()
// exemplo(UserRepository.prototype.getAll as sinon.SinonStub).restore();
- O
it
onde est o teste em si. Como questionado aqui, o que o teste est fazendo, :
it('deve retornar um array de usurios e enviar status 200', async () => { // faz a requisio para a rota GET /user const { status, body } = await chai.request(app).get('/user'); // testa se o status igual a 200 expect(status).to.be.equal(200); // testa se o body da resposta um array expect(body).to.be.an('array'); // testa se o body da resposta estritamente igual ao esperado expect(body).to.be.deep.equal(fakeData.get.response); // testa se Logger.save foi chamado como: Logger.save('getAll() success') expect((Logger.save as sinon.SinonStub).calledWith('getAll() success')).to.be.true;});
D uma olhada no fakeData
O teste do caso de sucesso est feito, mas e no caso onde acontece um erro?
describe('em caso de erro no banco de dados', () => { before(() => { // mocka o UserRepository para que lance um erro sinon.stub(UserRepository.prototype, 'getAll').throws(new Error('db error')); // mocka o Logger sinon.stub(Logger, 'save').resolves(); }); after(() => { // restaura o UserRepository (UserRepository.prototype.getAll as sinon.SinonStub).restore(); // restaura o Logger (Logger.save as sinon.SinonStub).restore(); }); it('deve retornar a mensagem do erro e enviar status 500', async () => { // faz a requisio para a rota GET /user const { status, body } = await chai.request(app).get('/user'); // testa se o status igual a 500 expect(status).to.be.equal(500); // testa se o body da resposta tem propriedade error expect(body).to.have.property('error'); //testa se no body da resposta error.message o que se espera expect(body.error.message).to.be.equal('db error'); // testa se Logger.save foi chamado como: Logger.save('getAll() fail') expect((Logger.save as sinon.SinonStub).calledWith('getAll() fail')).to.be.true; });});
Por fim, vamos agrupar o teste do caso de sucesso e do caso de falha num describe:
describe('GET /user', () => { describe('em caso de sucesso', () => { before(() => { sinon.stub(UserRepository.prototype, 'getAll').resolves(fakeData.get.mock); sinon.stub(Logger, 'save').resolves(); }); after(() => { (UserRepository.prototype.getAll as sinon.SinonStub).restore(); (Logger.save as sinon.SinonStub).restore(); }); it('deve retornar um array de usurios e enviar status 200', async () => { const { status, body } = await chai.request(app).get('/user'); expect(status).to.be.equal(200); expect(body).to.be.an('array'); expect(body).to.be.deep.equal(fakeData.get.response); expect((Logger.save as sinon.SinonStub).calledWith('getAll() success')).to.be.true; }); }); describe('em caso de erro no banco de dados', () => { before(() => { sinon.stub(UserRepository.prototype, 'getAll').throws(new Error('db error')); sinon.stub(Logger, 'save').resolves(); }); after(() => { (UserRepository.prototype.getAll as sinon.SinonStub).restore(); (Logger.save as sinon.SinonStub).restore(); }); it('deve retornar a mensagem do erro e enviar status 500', async () => { const { status, body } = await chai.request(app).get('/user'); expect(status).to.be.equal(500); expect(body).to.have.property('error'); expect(body.error.message).to.be.equal('db error'); expect((Logger.save as sinon.SinonStub).calledWith('getAll() fail')).to.be.true; }); });});
Rode os testes com o npm test
.
Prximos passos
Tente criar testes para as outras rotas da API
. Aqui vai algumas dicas que podem ajudar:
- antes de comear a escrever o teste pense no que voc vai estar testando, como foi feito aqui;
- enquanto estiver escrevendo os testes, deixe o container MySQL do docker inativo para evitar falso-positivo;
- em
GET /user/:id
o service chama 2 mtodos doUserRepository
, ento pense nisso quando for criar os mocks; - use e alimente o
fakeData
com mais objetos; - d ums olhada nos recursos adicionais
- voc sempre pode consultar o repositrio do projeto.
Referncias
Consideraes finais
Espero que tenham gostado do artigo. Qualquer dvida s perguntar aqui em baixo e eu tentarei ao mximo responder!
Github: @matheusg18
Linkedin: @matheusg18
Original Link: https://dev.to/matheusg18/testes-de-integracao-para-api-com-typescript-mocha-chai-e-sinon-3np9
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To