Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
April 14, 2022 05:18 pm GMT

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 o tsc
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 pasta prisma/ e dentro desta crie o arquivo schema.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 seu package.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 varivel DATABASE_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 o logger;
  • 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"?
  • 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"?

"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 outros describe;
    • oferece o it (ou test, no tem diferena) que onde se escreve o teste em si.
  • 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.
  • sinon
    • oferece o sinon.stub, ferramenta de mockar, simular o comportamento de uma funo ou mtodo.

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 o expect, 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 o before, after e it 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 o UserRepository e o Logger.
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;});

Mais sobre chai matchers

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 do UserRepository, 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

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To