TDD para iniciantes - Aprendendo a testar sua API

*Texto de Yan Soares
Senior Software Engineer na frete.com

✅ Pré-requisitos

  1. Necessário já ter um projeto criado e inicializado com o npm ou yarn, irei utilizar o npm nesse artigo;
  2. Ter o Node.js na versão 18 na sua máquina, iniciar o TypeScript no projeto e ter Jest instalado como dependência de desenvolvimento;
  3. Conhecimento básico de services e repositories;
  4. Conhecimento básico dos princípios SOLID;
  5. Conhecimento básico de programação orientada a objetos.

🔰 Introdução

Você sabe como testar a sua aplicação? Consegue garantir que a sua função funcionará independente de qualquer fator externo? Se a resposta for não, esse artigo é para você. Iremos mostrar como fazer testes sem a necessidade de lidar com banco de dados, frameworks, nem nada do tipo. Bora lá?!

Para fazermos testes unitários temos várias bibliotecas bem famosas no mercado, nesse artigo vamos usar o Jest. Se você aprendeu ou está acostumado a fazer testes com outra biblioteca, não tem problema, pois o conceito vai ser o mesmo e iremos utilizar ao máximo os recursos da linguagem e diminuir o uso da biblioteca para simular nossos cenários.

🚀 Mão na massa!

  1. Instale o swc/core e swc/jest
npm install -D @swc/core @swc/jest

2. Configure o tsconfig.json e cole o conteúdo abaixo no arquivo

💡
Eu costumo usar bastante essa configuração em projetos pessoais, freelancers e mostro vantagens dessa configuração para meu time no trabalho.

// execute o comando no terminal
npx tsc --init

{
  "compilerOptions": {
    "outDir": "dist",
    "rootDirs": ["src", "test"],
    "target": "es2021",
    "sourceMap": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "module": "commonjs",
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"],
      "#/*": ["tests/*"]
    },
    "strict": true,
    "strictPropertyInitialization": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
  },
  "include": ["src", "tests"]
}

3. Crie o arquivo jest.config.ts ecole o conteúdo abaixo nele


export default {
  coverageDirectory: 'coverage',
  coverageProvider: 'babel',
  collectCoverageFrom: [
    '<rootDir>/src/**/*.ts'
  ],
  moduleNameMapper: {
    '^#/(.*)$': '<rootDir>/test/$1',
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  testMatch: ['**/*.spec.ts'],
  roots: ['<rootDir>/src', '<rootDir>/test'],
  transform: { '.ts$': '@swc/jest' },
  clearMocks: true,
};

4. Criando arquivo do model de user: src/models/user.ts


import { randomUUID } from 'node:crypto';

export type UserDTO = {
  name: string;
  email: string;
}

export class User {
  public id: string;
  public name: string;
  public email: string;

  constructor (params: UserDTO, id?: string) {
		this.id = id ?? randomUUID();
    this.name = params.name;
    this.email = params.email;
  }
}

5. Vamos criar nossa interface de usuário: src/repositories/interfaces/user.ts


import { User, UserDTO } from "@/models/user";

export interface UsersRepository {
  create: (params: UserDTO) => Promise<void>
  findByEmail: (email: string) => Promise<User | undefined>
}

6. Criando arquivo com o service responsável por criar o usuário: src/services/create-user-service.ts

import { UserDTO } from "@/models/user";
import { UsersRepository } from "@/repositories/interfaces/user";

export class CreateUserService {
  constructor (private readonly usersRepository: UsersRepository) {}

  public async execute (user: UserDTO): Promise<void> {}
}

Nesse ponto as coisas começam a ficar mais interessantes, pois aqui estamos aplicando dois princípios importantes do SOLID:

  • Single Responsibility Principle (SRP)
  • Dependency Inversion Principle (DIP)
💡
Se você não conhece todos os princípios do SOLID, indico que se aprofunde neles quando pude. Clique aqui para acessar uma das minhas recomendações de leitura.

7. Criando o arquivo de testes do nosso service de criação do usuário

import { CreateUserService } from "@/services/create-user-service";
import { User, UserDTO } from "@/models/user";

import { InMemoryUsersRepository } from "#/repositories/in-memory-users-repository";

interface Subject {
  usersRepository: InMemoryUsersRepository;
  sut: CreateUserService;
}

const createSubject = (user: User[] = []): Subject => {
  const usersRepository = new InMemoryUsersRepository(user)
  const sut = new CreateUserService(usersRepository)

  return {
    usersRepository,
    sut
  }
}

describe("CreateUserService", () => {
  it("should be able to create new user", async () => {
    const { sut, usersRepository } = createSubject()
    const userData: UserDTO = {
      name: 'john doe',
      email: 'johndoe@mail.com'
    }

    await sut.execute(userData)

    expect(usersRepository.create).toHaveBeenNthCalledWith(1, userData)
  })
})

Um dos pontos que eu mencionei no início desse artigo foi de usar ao máximo os recursos da linguagem nos nossos testes e diminuir também a dependência do framework. O ponto aqui está no nosso createSubject, essa função é responsável por gerar as instâncias das nossas classes que serão usadas nesse teste. Este exemplo é bem simples e só temos uma que é o nosso InMemoryUsersRepository, você pode conferir o código dele logo abaixo.

Nesse pequeno teste temos 2 conceitos aplicados:

  • Triple A (arrange, act, assert):

arrange é basicamente o setup do nosso teste, ou seja, tudo que precisamos pra fazer o teste passar;

act é a chamada da nossa função, ou seja, o await sut.execute(userData);

assert é cada expect que fizemos, não diria que há um limite de expects, costumo fazer o que é necessário pra cada caso.

SUT: é um acrônimo para system under test, ou sistema sob teste, ele é a nossa classe que está sendo testada e, neste caso é o service, mas também poderia ser um controller, uma chamada de API externa, middleware, qualquer coisa que se faça necessário um teste.

8. Código do repositório em memória

import { User, UserDTO } from "@/models/user";
import { UsersRepository } from "@/repositories/interfaces/user"

export class InMemoryUsersRepository implements UsersRepository {
  constructor (private readonly users: User[] = []) {}

  public async create(user: UserDTO): Promise<void> {}
}

9. Continuando nosso teste se rodarmos o primeiro it ele não vai passar por dois motivos: o primeiro, que você já deve ter imaginado é que nosso service (ainda) não possui nenhuma implementação, o outro ponto é que não estamos gerando um mock da nossa função create do repositório em memória que é o responsável por executar a ação de persistir a informação, seja num array em memória ou mesmo num banco de dados em produção como MySQL, PostgresSQL, SQL Server, etc.

10. Vamos atualizar os códigos para ver nosso primeiro teste funcionando

import { UserDTO } from "@/models/user";
import { CreateUser } from "@/repositories/interfaces/user";

export class CreateUserService {
  constructor (private readonly usersRepository: CreateUser) {}

  public async execute (user: UserDTO): Promise<void> {
    await this.usersRepository.create(user)
  }
}
const createSubject = (user: User[] = []): Subject => {
  const usersRepository = new InMemoryUsersRepository(user)
  const sut = new CreateUserService(usersRepository)

  usersRepository.create = jest.fn()

  return {
    usersRepository,
    sut
  }
}

Depois de trabalhar com TDD diariamente nos últimos anos, tenho dado algumas dicas valiosas para devs não se perderem nos testes, dá uma olhada.

  • Vai acontecer de você copiar um arquivo inteiro de teste e só adaptar, não somos obrigados a decorar nada, certo? Uma dica muito útil é você substituir a classe que você vai testar e com a ajuda do TypeScript e do seu editor favorito (o meu é o vscode), eles vão te guiando em tudo, das dependências corretas até o tipo de informação que a função que você quer testar recebe;
  • NUNCA crie mocks da função que você quer testar, caso você crie por engano o próprio jest vai te avisar do erro; SEMPRE crie um mock da função que você quer testar a chamada dela usando o expect, novamente, se der algum erro o jest vai te avisar que a função precisa ser um mock ou spy obrigatoriamente;

A respeito de mocks, retirei esse trecho traduzido livremente do site da pluralsight:

Existem várias maneiras de criar funções fictícias. O método jest.fn nos permite criar uma nova função simulada diretamente. Se você está mockando de um método de objeto, pode usar jest.spyOn. E se você quiser mockar de um módulo inteiro, pode usar jest.mock.”

Continuando… E se a gente quiser validar um usuário pelo e-mail dele porque nosso sistema não vai permitir usuários com e-mail duplicados? Vem comigo que eu te mostro!

11. Atualizando nosso teste antes de seguir, vamos lembrar da máxima do TDD: primeiro fazemos o teste, rodamos e vemos quebrar, implementamos o mínimo pra passar e refatoramos se necessário. Aqui vou colocar apenas mais um it. Outro ponto importante, como estamos lançando uma exceção no nosso service, essa é apenas uma das várias formas que temos para testar chamadas que vão lançar exceções.

it("should not be able to create new user", async () => {
  const { sut, usersRepository } = createSubject([userModel])

  await expect(sut.execute(userData)).rejects.toBeInstanceOf(Error)

  expect(usersRepository.create).not.toHaveBeenCalled()
})

Agora atualizando a função execute do service

public async execute (user: UserDTO): Promise<void> {
  const usersExists = await this.usersRepository.findByEmail(user.email);
  if (usersExists) throw new Error('Usuário já existe.');

  await this.usersRepository.create(user);
}

Atualizando nosso repositório em memória

import { randomUUID } from "node:crypto";

import { User, UserDTO } from "@/models/user";
import { UsersRepository } from "@/repositories/interfaces/user"

export class InMemoryUsersRepository implements UsersRepository {
  constructor (private readonly users: User[] = []) {}

  public async create(user: UserDTO): Promise<void> {
    this.users.push({
      id: randomUUID(),
      ...user
    })
  }

  public async findByEmail (email: string): Promise<User | undefined> {
    const user = this.users.find((user) => user.email === email);
    return user ? user : undefined;
  }
}

Bom pessoal, é isso!

Foi um exemplo de teste super simples mesmo, aproveitei para focar mais nos conceitos do TDD, dessa forma a gente consegue garantir que nosso fluxo funciona independente de qualquer mecanismo de persistência de banco de dados.

Se você quiser aprofundar seus conhecimentos em TDD, dá uma olhada nesse conteúdo disponível no canal do YouTube da Rocketseat: