DEV Community

Cover image for Testes de integração para API com Typescript, mocha, chai e sinon
Matheus Santos
Matheus Santos

Posted on • Updated on

Testes de integração para API com Typescript, mocha, chai e sinon

Pular para os testes

Criando a API

A ideia é criar uma API CRUD de usuários bem simples, já que o foco aqui está nos testes. Você pode seguir o paso a passo ou simplesmente clonar o repositório do projeto.

Tecnologias utilizadas:

Typescript, POO, Express, Joi, MySQL, Prisma, NodeJs

Setup inicial

  • Inicie o repositório do projeto
npm init -y
Enter fullscreen mode Exit fullscreen mode
  • Instale todas as dependências que vão ser necessárias nesse projeto
npm install @prisma/client express joi
npm install -D typescript ts-node ts-node-dev prisma @types/node @types/express
Enter fullscreen mode Exit fullscreen mode
  • Adicione os seguintes scripts ao package.json
"scripts": {
  "start": "ts-node src/server.ts",
  "dev": "tsnd --exit-child /src/server.ts"
}
Enter fullscreen mode Exit fullscreen mode
  • Crie um tsconfig.json usando o tsc
npx tsc --init
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Iniciando o Prisma

Prisma é um ORM que lida muito bem com Typescript, tem suporte para o MySQL e um documentação muito bem elaborada. Levando isso em conta, é ele que vamos utilizar neste artigo.

Primeiro de tudo é necessário 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
Enter fullscreen mode Exit fullscreen mode
  • No schema.prisma é onde vão estar as configurações necessárias do prisma para se comunicar ao MySQL. No nosso caso adicione o seguinte código 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?
}
Enter fullscreen mode Exit fullscreen mode
  • Como o schema.prisma está num caminho diferente do padrão é necessário avisar isso ao prisma. Adicione a seguinte chave ao seu package.json
"prisma": {
  "schema": "src/prisma/schema.prisma"
}
Enter fullscreen mode Exit fullscreen mode
  • Agora é necessário inicar um container docker de MySQL (recomendado)
docker container run -d -e MYSQL_ROOT_PASSWORD=password -p 3336:3306 mysql:latest
Enter fullscreen mode Exit fullscreen mode
  • Próximo passo é criar um .env na raiz do projeto com a variável DATABASE_URL
DATABASE_URL=mysql://root:password@localhost:3336/users_api
Enter fullscreen mode Exit fullscreen mode
  • Por último, crie uma migration do prisma
npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

Construindo a API

Chegou a hora de codar a API em si. De início, como estamos usando o Typescript, crie as interfaces que serão 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> {}
Enter fullscreen mode Exit fullscreen mode

O próximo passo é criar algumas classes de erros personalizados que serão ú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;
}
Enter fullscreen mode Exit fullscreen mode
  • 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';
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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';
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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';
  }
}
Enter fullscreen mode Exit fullscreen mode
  • src/utils/errors/index.ts
export * from './BadRequest';
export * from './Conflict';
export * from './NotFound';
Enter fullscreen mode Exit fullscreen mode

Para fins didáticos, neste projeto será usado uma classe que registra logs da API (os logs são salvos no arquivo logs.txt na raiz do projeto). Copie o código dela também:

  • 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}\n`;

    fs.writeFile(Logger._filePath, sentence, { flag: 'a' });
  }
}
Enter fullscreen mode Exit fullscreen mode

Para fazer a validação dos dados das requisições, neste projeto, será ultilizado o Joi. Copie os middlewares que serão 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();
  };
}
Enter fullscreen mode Exit fullscreen mode

Partindo para a construção do user, ele vai ser dividido em 4 partes:

  • repository: interface de comunicação com o prisma;
  • service: lógica de negócio;
  • controller: middleware resposta do express, onde vai ser chamado o Logger;
  • router: estrutura dos endpoints.

Copie os códigos 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 } });
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode
  • 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Por fim, é preciso criar o express app e exportá-lo para ser acessível 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;
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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;
Enter fullscreen mode Exit fullscreen mode
  • src/server.ts
import app from './app';

const PORT = 3000;

app.listen(PORT, () => {
  console.log('Server online');
  console.log(`PORT ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Pronto, a API está feita. Agora vem a parte boa, os testes. 🧪


Testes

Teste de integração é quando testa-se vários pedaços do código de uma vez. O objetivo é testar se a interação entre eles está produzindo o resultado esperado.

Existe uma discussão sobre se faz sentido ou não mockar as operações de IO em testes de integração. Não tem nada escrito em pedra. Sendo assim, neste artigo, será mostrado como criar testes de integração mockando as operações de IO. Eu planejo criar, nas próximas semanas, um artigo onde não terá mocks mas sim um banco de dados temporário apenas para os testes.

Para este projeto o UserRepository será mockado para não haver consultas ao banco de dados (prisma) e o Logger para não haver manipulação de arquivos (fs).

Tecnologias utilizadas:

Typescript, mocha, chai, sinon

Setup inicial

  • Baixe os pacotes que serão utilizados
npm i -D mocha @types/mocha chai chai-http @types/chai sinon @types/sinon
Enter fullscreen mode Exit fullscreen mode
  • Adicione o seguinte script ao package.json
"scripts": {
  ...
  "test": "mocha --require ts-node/register __tests__/**/*.test.ts --exit"
}
Enter fullscreen mode Exit fullscreen mode
  • 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 requisição que é feita.

Exemplo: O endpoint GET /user retorna um array de usuários, sendo assim da para criar os seguintes testes:

A requisição 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 usuários?
    • o Logger foi chamado com "getAll() success"?
  • se não
    • o status http da resposta é 500 como eu esperava?
    • o body da resposta contém o objeto error?
    • objeto error contém a mensagem 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, não tem diferença) que é onde se escreve o teste em si.
  • chai
    • oferece o expect, uma ferramenta de asserção que é peça fundamental para os testes;
    • oferece matchers muito semânticos;
    • tem vários 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 função ou método.

Testando o GET /user

Traduzindo as perguntas acima para código:

  • __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 usuários 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;
  });
});
Enter fullscreen mode Exit fullscreen mode

Entendendo o teste

  • Import do app do express (app = express()) para fazer as requisições
...
import app from '../src/app';
...
Enter fullscreen mode Exit fullscreen mode
  • Aqui vai ver usado um arquivo para agrupar mocks, requests e responses
...
import * as fakeData from './fakeData';
...
Enter fullscreen mode Exit fullscreen mode

copie o fakeData

fakeData

  • __tests__/fakeData/index.ts
import { IUser } from '../../src/interfaces';

const homer: IUser = {
  id: 1,
  firstName: 'Homer',
  lastName: 'Simpson',
  email: 'homer@gmail.com',
  occupation: 'nuclear safety inspector',
};

const ragnar: IUser = {
  id: 2,
  firstName: 'Ragnar',
  lastName: 'Lodbrok',
  email: 'ragnar@gmail.com',
  occupation: 'king',
};

const eren: IUser = {
  id: 3,
  firstName: 'Eren',
  lastName: 'Yeager',
  email: 'eren.yeager@gmail.com',
  occupation: 'soldier',
};

const morty: IUser = {
  id: 4,
  firstName: 'Morty',
  lastName: 'Smith',
  email: 'msmith.125@gmail.com',
  occupation: 'student',
};

export const get = {
  mock: [homer, ragnar, eren, morty],
  response: [homer, ragnar, eren, morty],
};
Enter fullscreen mode Exit fullscreen mode

  • 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;
...
Enter fullscreen mode Exit fullscreen mode
  • 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', () => {
  ...
});
Enter fullscreen mode Exit fullscreen mode
  • 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();
});
Enter fullscreen mode Exit fullscreen mode

O que mockar o UserRepository significa? Quer dizer que vamos retirar a implementação original e fazer o método getAll() resolver (porque o método retorna uma Promise) naquilo que esperamos que ele resolva. O mesmo vale para o Logger.

Podemos fazer o método mockado resolver qualquer coisa, mas é preciso pensar bem para que o mock reflita a realidade porque, se não, os testes seriam inúteis. No caso do UserRepository.getAll(), qual valor ele poderia resolver que refletiria a realidade? Um array de usuários.

Como se mocka um método de uma classe? Usando o sinon.stub:

  • Para mockar métodos estáticos: sinon.stub(classe, 'método')
// exemplo
sinon.stub(Logger, 'save').resolves();
Enter fullscreen mode Exit fullscreen mode
  • Para mockar métodos não estáticos: sinon.stub(classe.prototype, 'método')
// exemplo
sinon.stub(UserRepository.prototype, 'getAll').resolves(fakeData.get.mock);
Enter fullscreen mode Exit fullscreen mode

  • O after executa uma callback depois do teste e é geralmente usado para fazer teardown. Aqui o teardown será reverter o que foi mockado para não ter efeitos colaterais sobre outros testes.
after(() => {
  (UserRepository.prototype.getAll as sinon.SinonStub).restore();
  (Logger.save as sinon.SinonStub).restore();
});
Enter fullscreen mode Exit fullscreen mode

Como se reverte o que foi mockado? Usando o restore() do sinon. Como estamos usando Typescript é necessário fazer a conversão do método mockado para sinon.SinonStub porque o Typescript, por padrão, não o entende como um stub.

  • Para métodos estáticos: (classe.método as sinon.SinonStub).restore()
// exemplo
(Logger.save as sinon.SinonStub).restore();
Enter fullscreen mode Exit fullscreen mode
  • Para métodos não estáticos: (classe.prototype.método as sinon.SinonStub).restore()
// exemplo
(UserRepository.prototype.getAll as sinon.SinonStub).restore();
Enter fullscreen mode Exit fullscreen mode

  • O it é onde está o teste em si. Como questionado aqui, o que o teste está fazendo, é:
it('deve retornar um array de usuários e enviar status 200', async () => {
  // faz a requisição 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;
});
Enter fullscreen mode Exit fullscreen mode

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 requisição 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;
  });
});
Enter fullscreen mode Exit fullscreen mode

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 usuários 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;
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Rode os testes com o npm test.

Testando PUT /user

Testar o método PUT é um pouco mais complexo porque ele usa 2 métodos do repository e envia um body na requisição.

  • __tests__/fakeData/index.ts
...
const ragnar: IUser = {
  id: 2,
  firstName: 'Ragnar',
  lastName: 'Lodbrok',
  email: 'ragnar@gmail.com',
  occupation: 'king',
};

const updatedRagnar: IUser = {
  id: 2,
  firstName: 'Bjorn',
  lastName: 'Ironside',
  email: 'ragnar@gmail.com',
  occupation: 'king',
};
...

export const put = {
  getByIdMock: ragnar, // mock do UserRepository.getById()
  mock: updatedRagnar, // mock do UserRepository.update()
  request: {   // body da requisição
    firstName: updatedRagnar.firstName,
    lastName: updatedRagnar.lastName,
  },
  response: updatedRagnar, // body da resposta
};
...
Enter fullscreen mode Exit fullscreen mode
  • __tests__/user.test.ts
...
describe('PUT /user/:id', () => {
  describe('caso o usuário exista', () => {
    before(() => {
      // mock do UserRepository.update
      sinon.stub(UserRepository.prototype, 'update').resolves(fakeData.put.mock);

      // mock do UserRepository.getById
      sinon.stub(UserRepository.prototype, 'getById').resolves(fakeData.put.getByIdMock);

      // mock do Logger
      sinon.stub(Logger, 'save').resolves();
    });

    after(() => {
      // reverte os mocks
      (UserRepository.prototype.update as sinon.SinonStub).restore();
      (UserRepository.prototype.getById as sinon.SinonStub).restore();
      (Logger.save as sinon.SinonStub).restore();
    });

    it('deve retornar o usuário atualziado e enviar status 200', async () => {
      // id = 2 -> ragnar
      // faz a requisição, o método 'send' serve para enviar um body na requisição
      const { status, body } = await chai.request(app).put('/user/2 ').send(fakeData.put.request);

      // testa se o status http é 200
      expect(status).to.be.equal(200);

      // testa se o body da resposta é um objeto
      expect(body).to.be.an('object');

      // testa se o body da resposta é estritamente igual ao que se espera
      expect(body).to.be.deep.equal(fakeData.put.response);

      // testa se Logger.save foi chamado como: Logger.save('update() success')
      expect((Logger.save as sinon.SinonStub).calledWith('update() success')).to.be.true;
    });
  });

  describe('caso o usuário não exista', () => {
    before(() => {
      // mock do UserRepository.update
      sinon.stub(UserRepository.prototype, 'update').resolves(fakeData.put.mock);

      // mock do UserRepository.getById, null indica que o id não está cadastrado
      sinon.stub(UserRepository.prototype, 'getById').resolves(null);

      // mock do Logger
      sinon.stub(Logger, 'save').resolves();
    });

    after(() => {
      // reverte os mocks
      (UserRepository.prototype.update as sinon.SinonStub).restore();
      (UserRepository.prototype.getById as sinon.SinonStub).restore();
      (Logger.save as sinon.SinonStub).restore();
    });

    it('deve retornar a mensagem do erro e enviar status 404', async () => {
      // id = 9999
      // faz a requisição, o método 'send' serve para enviar um body na requisição
      const { status, body } = await chai.request(app).put('/user/9999').send(fakeData.put.mock);

      // testa se o status http é 404
      expect(status).to.be.equal(404);

      // testa se o body da resposta tem a propriedade error
      expect(body).to.have.property('error');

      // testa se error.message é igual ao que se espera
      expect(body.error.message).to.be.equal('user not found');

      // testa se Logger.save foi chamado como: Logger.save('update() fail')
      expect((Logger.save as sinon.SinonStub).calledWith('update() fail')).to.be.true;
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Para ver todos os testes criados para o PUT /user dê uma olhada no repositório.

Próximos passos

Tente criar testes para as outras rotas da API. Algumas dicas que podem ajudar:

  • antes de começar 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 POST /user o service chama 2 métodos do UserRepository, então pense nisso quando for criar os mocks;
  • use e alimente o fakeData com mais objetos;
  • dê uma olhada nos recursos adicionais;
  • você sempre pode consultar o repositório do projeto.

Referências

Considerações finais

Espero que tenham gostado do artigo. Qualquer dúvida é só perguntar aqui em baixo e eu tentarei ao máximo responder!

Github: @matheusg18
Linkedin: @matheusg18

Top comments (3)

Collapse
 
dashart profile image
Jonatas Queiroz Lima • Edited

Excelente post, bem explicado! Me ajudou a consultar alguns detalhes durante o teste da API que estava fazendo XD

Collapse
 
israeljs profile image
Israel Jerônimo

Parabéns cara! Muito bem escrito, vai me ajudar muito!

Collapse
 
ggiacomini2012 profile image
Guilherme Giacomini Teixeira

Love it!