Boas Práticas de Mocks com TypeScript: jest-mock-extended e Tipagem de Dependências para Times Ágeis Já leu

O Problema: Por Que Mocks São Essenciais em Testes Quando você escreve testes unitários, precisa isolar a unidade de código sob teste. Isso significa que dependências externas — serviços HTTP, bancos de dados, APIs terceirizadas — não devem ser executadas durante o teste. Se você deixar essas dependências reais rodarem, seu teste se torna frágil, lento e dependente de fatores externos que podem falhar por razões não relacionadas ao seu código. Mocks são objetos que simulam o comportamento de dependências reais. Eles permitem que você controle exatamente o que é retornado, qual exceção é lançada e quantas vezes uma função foi chamada. TypeScript torna isso ainda mais poderoso porque você pode ter mocks que respeitam a tipagem original, garantindo que seu código de teste também seja seguro em tempo de compilação. Introdução ao jest-mock-extended O é uma biblioteca que estende as capacidades nativas do Jest para criar mocks tipados e inteligentes. Enquanto o Jest oferece básico, o fornece , que

O Problema: Por Que Mocks São Essenciais em Testes

Quando você escreve testes unitários, precisa isolar a unidade de código sob teste. Isso significa que dependências externas — serviços HTTP, bancos de dados, APIs terceirizadas — não devem ser executadas durante o teste. Se você deixar essas dependências reais rodarem, seu teste se torna frágil, lento e dependente de fatores externos que podem falhar por razões não relacionadas ao seu código.

Mocks são objetos que simulam o comportamento de dependências reais. Eles permitem que você controle exatamente o que é retornado, qual exceção é lançada e quantas vezes uma função foi chamada. TypeScript torna isso ainda mais poderoso porque você pode ter mocks que respeitam a tipagem original, garantindo que seu código de teste também seja seguro em tempo de compilação.

Introdução ao jest-mock-extended

O jest-mock-extended é uma biblioteca que estende as capacidades nativas do Jest para criar mocks tipados e inteligentes. Enquanto o Jest oferece jest.fn() básico, o jest-mock-extended fornece mock<T>(), que cria um mock totalmente tipado de qualquer interface ou classe.

Instale a biblioteca com:

npm install --save-dev jest-mock-extended

A grande vantagem é que você obtém autocompletar e verificação de tipos em tempo de desenvolvimento. Você não consegue chamar um método que não existe na interface original, e o TypeScript te avisa imediatamente se tentar acessar uma propriedade inexistente.

Exemplo Básico: Criando um Mock Tipado

Imagine que você tem um serviço de autenticação:

// auth.service.ts
export interface IAuthService {
  login(email: string, password: string): Promise<string>;
  logout(): Promise<void>;
  isAuthenticated(): boolean;
}

export class AuthService implements IAuthService {
  async login(email: string, password: string): Promise<string> {
    // implementação real
    return "token123";
  }

  async logout(): Promise<void> {
    // implementação real
  }

  isAuthenticated(): boolean {
    // implementação real
    return true;
  }
}

Agora você quer testar um componente que depende desse serviço:

// user.component.ts
export class UserComponent {
  constructor(private authService: IAuthService) {}

  async handleLogin(email: string, password: string): Promise<void> {
    const token = await this.authService.login(email, password);
    if (token) {
      console.log("Login bem-sucedido");
    }
  }
}

Com jest-mock-extended, o teste fica assim:

// user.component.test.ts
import { mock } from "jest-mock-extended";
import { UserComponent } from "./user.component";
import { IAuthService } from "./auth.service";

describe("UserComponent", () => {
  it("deve realizar login com sucesso", async () => {
    // Criar um mock totalmente tipado
    const authServiceMock = mock<IAuthService>();

    // Configurar o comportamento esperado
    authServiceMock.login.mockResolvedValue("token-valido");

    // Injetar o mock no componente
    const component = new UserComponent(authServiceMock);

    // Executar a lógica
    await component.handleLogin("user@example.com", "senha123");

    // Verificar se a função foi chamada com os argumentos corretos
    expect(authServiceMock.login).toHaveBeenCalledWith(
      "user@example.com",
      "senha123"
    );
  });
});

O mock<IAuthService>() já sabe que login existe, que retorna uma Promise, e que mockResolvedValue é o método correto para um método assíncrono. Se você tentasse chamar um método inexistente, TypeScript reclamaria ainda na escrita do código.

Tipagem de Dependências e Padrões de Injeção

Tipar corretamente suas dependências é o alicerce para criar mocks eficientes. Existem diferentes abordagens, cada uma com seus trade-offs.

Interfaces vs Classes: Qual Usar?

Use interfaces quando a dependência é um contrato (o "quê" ela faz). Use classes abstratas quando você precisa de lógica compartilhada. Para testes, interfaces são preferíveis porque são mais leves e não carregam implementação.

// ❌ Evite: Depender de implementação concreta
export class OrderService {
  constructor(private database: PostgresDatabase) {}
}

// ✅ Prefira: Depender de abstração
export interface IDatabase {
  query(sql: string): Promise<any[]>;
  insert(table: string, data: object): Promise<void>;
}

export class OrderService {
  constructor(private database: IDatabase) {}
}

Por que isso importa? Porque quando você precisa testar OrderService, pode passar um mock que implementa IDatabase sem se preocupar com conexões reais com Postgres.

Constructor Injection vs Property Injection

Constructor Injection é o padrão recomendado. As dependências são explícitas, imutáveis após a construção e fáceis de testar:

// ✅ Constructor Injection
export class EmailService {
  constructor(
    private smtpClient: ISmtpClient,
    private logger: ILogger
  ) {}

  async sendEmail(to: string, subject: string, body: string): Promise<void> {
    this.logger.info(`Enviando email para ${to}`);
    await this.smtpClient.send({ to, subject, body });
  }
}

// Teste
describe("EmailService", () => {
  it("deve enviar email e registrar no log", async () => {
    const smtpMock = mock<ISmtpClient>();
    const loggerMock = mock<ILogger>();

    const service = new EmailService(smtpMock, loggerMock);
    await service.sendEmail("user@example.com", "Olá", "Bem-vindo!");

    expect(smtpMock.send).toHaveBeenCalled();
    expect(loggerMock.info).toHaveBeenCalled();
  });
});

Isso é muito mais testável do que injetar dependências via setters ou diretamente em propriedades públicas.

Exemplo Real: Serviço de Pagamento com Múltiplas Dependências

Aqui está um exemplo mais próximo da realidade, onde você tem várias dependências e precisa fazer testes mais sofisticados:

// payment.interfaces.ts
export interface IPaymentGateway {
  charge(amount: number, token: string): Promise<{ transactionId: string }>;
}

export interface INotificationService {
  sendConfirmation(email: string, transactionId: string): Promise<void>;
}

export interface IOrderRepository {
  updateOrderStatus(orderId: string, status: string): Promise<void>;
}

// payment.service.ts
export class PaymentService {
  constructor(
    private gateway: IPaymentGateway,
    private notifier: INotificationService,
    private orderRepo: IOrderRepository
  ) {}

  async processPayment(
    orderId: string,
    amount: number,
    token: string,
    email: string
  ): Promise<void> {
    const result = await this.gateway.charge(amount, token);

    await this.orderRepo.updateOrderStatus(
      orderId,
      "payment_confirmed"
    );

    await this.notifier.sendConfirmation(email, result.transactionId);
  }
}

// payment.service.test.ts
import { mock } from "jest-mock-extended";
import { PaymentService } from "./payment.service";
import {
  IPaymentGateway,
  INotificationService,
  IOrderRepository,
} from "./payment.interfaces";

describe("PaymentService", () => {
  it("deve processar pagamento, atualizar pedido e notificar cliente", async () => {
    const gatewayMock = mock<IPaymentGateway>();
    const notifierMock = mock<INotificationService>();
    const repositoryMock = mock<IOrderRepository>();

    // Configurar os retornos esperados
    gatewayMock.charge.mockResolvedValue({
      transactionId: "txn-12345",
    });
    notifierMock.sendConfirmation.mockResolvedValue(undefined);
    repositoryMock.updateOrderStatus.mockResolvedValue(undefined);

    const service = new PaymentService(
      gatewayMock,
      notifierMock,
      repositoryMock
    );

    // Executar
    await service.processPayment(
      "order-456",
      99.99,
      "tok_visa",
      "customer@example.com"
    );

    // Verificar as chamadas e a ordem
    expect(gatewayMock.charge).toHaveBeenCalledWith(99.99, "tok_visa");
    expect(repositoryMock.updateOrderStatus).toHaveBeenCalledWith(
      "order-456",
      "payment_confirmed"
    );
    expect(notifierMock.sendConfirmation).toHaveBeenCalledWith(
      "customer@example.com",
      "txn-12345"
    );
  });

  it("deve lançar erro se o gateway falhar", async () => {
    const gatewayMock = mock<IPaymentGateway>();
    const notifierMock = mock<INotificationService>();
    const repositoryMock = mock<IOrderRepository>();

    // Simular uma falha no gateway
    gatewayMock.charge.mockRejectedValue(
      new Error("Cartão recusado")
    );

    const service = new PaymentService(
      gatewayMock,
      notifierMock,
      repositoryMock
    );

    // Esperar por uma exceção
    await expect(
      service.processPayment(
        "order-456",
        99.99,
        "tok_invalid",
        "customer@example.com"
      )
    ).rejects.toThrow("Cartão recusado");

    // Verificar que repositório e notificador NÃO foram chamados
    expect(repositoryMock.updateOrderStatus).not.toHaveBeenCalled();
    expect(notifierMock.sendConfirmation).not.toHaveBeenCalled();
  });
});

Técnicas Avançadas de Mock com jest-mock-extended

Agora que você entende o básico, vamos explorar funcionalidades mais sofisticadas que tornam seus testes ainda mais robustos.

Mockando Métodos Específicos com Comportamentos Diferentes

Às vezes você quer que diferentes chamadas ao mesmo método retornem valores diferentes:

interface IUserRepository {
  findById(id: string): Promise<User | null>;
}

describe("UserService", () => {
  it("deve lidar com usuários existentes e inexistentes", async () => {
    const repoMock = mock<IUserRepository>();

    // Primeira chamada retorna um usuário, segunda retorna null
    repoMock.findById
      .mockResolvedValueOnce({ id: "1", name: "Alice" })
      .mockResolvedValueOnce(null);

    const service = new UserService(repoMock);

    const user1 = await service.findUser("1");
    const user2 = await service.findUser("2");

    expect(user1?.name).toBe("Alice");
    expect(user2).toBeNull();
  });
});

Usando mockImplementation para Lógica Customizada

Quando o comportamento é complexo demais para um simples mockResolvedValue, use mockImplementation:

interface ICalculator {
  calculate(expression: string): number;
}

it("deve validar expressões matemáticas", () => {
  const calcMock = mock<ICalculator>();

  calcMock.calculate.mockImplementation((expr: string) => {
    if (expr === "2+2") return 4;
    if (expr === "10-5") return 5;
    throw new Error("Expressão inválida");
  });

  expect(calcMock.calculate("2+2")).toBe(4);
  expect(() => calcMock.calculate("invalid")).toThrow();
});

Capturando Argumentos com mockCalls

Às vezes você precisa verificar não apenas se uma função foi chamada, mas com quais argumentos exatos:

interface ILogger {
  log(level: string, message: string, data?: any): void;
}

it("deve registrar erros com dados contextuais", () => {
  const loggerMock = mock<ILogger>();

  const service = new Service(loggerMock);
  service.doSomethingThatLogs();

  // Verificar a última chamada
  const lastCall = loggerMock.log.mock.calls[loggerMock.log.mock.calls.length - 1];
  expect(lastCall[0]).toBe("error");
  expect(lastCall[1]).toContain("falha");
  expect(lastCall[2]).toEqual({ code: 500 });
});

Mockando Propriedades, Não Apenas Métodos

Interfaces também podem ter propriedades. jest-mock-extended permite mockear isso também:

interface IConfig {
  apiUrl: string;
  timeout: number;
  retries: number;
}

it("deve usar configurações do mock", () => {
  const configMock = mock<IConfig>({
    apiUrl: "https://test-api.example.com",
    timeout: 5000,
    retries: 2,
  });

  const client = new ApiClient(configMock);

  expect(client.getApiUrl()).toBe("https://test-api.example.com");
  expect(client.getTimeout()).toBe(5000);
});

Mockando Métodos em Cadeia (Fluent API)

Se seu código usa uma API fluente (método retorna this), você pode mockear isso:

interface IQueryBuilder {
  select(fields: string[]): IQueryBuilder;
  where(condition: string): IQueryBuilder;
  orderBy(field: string): IQueryBuilder;
  build(): string;
}

it("deve construir query com métodos encadeados", () => {
  const queryMock = mock<IQueryBuilder>();

  // Configurar encadeamento
  queryMock.select.mockReturnValue(queryMock);
  queryMock.where.mockReturnValue(queryMock);
  queryMock.orderBy.mockReturnValue(queryMock);
  queryMock.build.mockReturnValue("SELECT * FROM users WHERE id = 1 ORDER BY name");

  const query = queryMock
    .select(["id", "name"])
    .where("id = 1")
    .orderBy("name")
    .build();

  expect(query).toContain("SELECT");
  expect(queryMock.select).toHaveBeenCalled();
  expect(queryMock.where).toHaveBeenCalled();
});

Integração com Padrões de Arquitetura

Até agora vimos exemplos simples. Em aplicações reais, você usa injeção de dependência em escala, frequentemente com containers de IoC.

Usando Mocks com Decorators (NestJS Example)

Se você usa NestJS, pode integrar mocks perfeitamente com os providers:

// user.service.ts
import { Injectable } from "@nestjs/common";

export interface IUserRepository {
  findById(id: string): Promise<any>;
}

@Injectable()
export class UserService {
  constructor(private userRepo: IUserRepository) {}

  async getUser(id: string) {
    return this.userRepo.findById(id);
  }
}

// user.service.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { mock } from "jest-mock-extended";
import { UserService } from "./user.service";
import { IUserRepository } from "./user.repository.interface";

describe("UserService", () => {
  let service: UserService;
  let userRepoMock: jest.Mocked<IUserRepository>;

  beforeEach(async () => {
    userRepoMock = mock<IUserRepository>();

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: "IUserRepository",
          useValue: userRepoMock,
        },
      ],
    }).compile();

    service = module.get<UserService>(UserService);
  });

  it("deve buscar usuário pelo ID", async () => {
    userRepoMock.findById.mockResolvedValue({ id: "1", name: "Bob" });

    const result = await service.getUser("1");

    expect(result.name).toBe("Bob");
    expect(userRepoMock.findById).toHaveBeenCalledWith("1");
  });
});

Testando Comportamento com Estados Múltiplos

Em sistemas mais complexos, uma dependência pode passar por múltiplos estados. Você pode simular isso:

interface IAuthenticator {
  authenticate(credentials: any): Promise<boolean>;
  getUser(): { id: string; role: string } | null;
  logout(): void;
}

describe("ProtectedResource", () => {
  it("deve negar acesso antes da autenticação e permitir depois", async () => {
    const authMock = mock<IAuthenticator>();

    // Simular estado não autenticado
    authMock.getUser.mockReturnValue(null);
    let resource = new ProtectedResource(authMock);
    expect(() => resource.getData()).toThrow("Não autenticado");

    // Simular autenticação bem-sucedida
    authMock.authenticate.mockResolvedValue(true);
    authMock.getUser.mockReturnValue({ id: "123", role: "admin" });

    await authMock.authenticate({});
    expect(resource.getData()).toEqual({ sensitive: "data" });
  });
});

Conclusão

Você aprendeu três pilares fundamentais para dominar testes em TypeScript:

  1. jest-mock-extended fornece segurança de tipos: Diferente de jest.fn() básico, mock<T>() integra-se perfeitamente com TypeScript, oferecendo autocompletar e detecção de erros em tempo de compilação. Isso não é apenas conforto — é precisão.

  2. Tipar dependências com interfaces é o padrão: Depender de abstrações (interfaces) em vez de implementações concretas torna seu código testável por design. Constructor injection é o padrão ouro. Quando suas dependências são bem tipadas, criar mocks delas torna-se uma tarefa mecânica e segura.

  3. Mocks sofisticados simulam comportamentos realistas: Você vai além de simples mockResolvedValue. Use mockImplementation para lógica complexa, mockRejectedValue para simular erros, e combine múltiplos mocks para testar fluxos inteiros. Seus testes passam a ser documentação viva do comportamento esperado.

Referências


Artigos relacionados