Código testável: injeção de dependência, relógio e aleatoriedade sob controle

Neste guia você vai aprender a escrever código que se deixa testar sem malabarismo: separar a regra de negócio do I/O, usar injeção de dependência, organizar o sistema em portas e adaptadores e colocar sob controle as três fontes clássicas de testes instáveis — o relógio, a aleatoriedade e os identificadores gerados (UUID). Os exemplos são curtos, em TypeScript e Python, e servem para qualquer stack.

Por que alguns códigos são difíceis de testar

Trabalho com qualidade de software desde 2010, e a maioria dos testes “flaky” que já investiguei não era culpa do framework de testes. Era culpa do código testado: uma função que lia a hora atual lá no meio, um Math.random() escondido, um cliente HTTP criado dentro do método, um singleton guardando estado entre um teste e outro.

O padrão é sempre o mesmo: a função depende de algo que ela mesma busca no mundo, e o teste não tem como trocar esse algo. Código testável é, no fundo, código em que as dependências entram pela porta da frente.

  • Dependência oculta: a função usa algo que não aparece na assinatura (hora, rede, disco, variável global).
  • Não determinismo: a mesma entrada produz saídas diferentes em execuções diferentes.
  • Estado compartilhado: um teste altera algo que outro teste lê, e a ordem de execução passa a importar.

1. Separe regra de negócio de I/O

O primeiro passo é dividir o código em duas camadas: um núcleo puro, que recebe dados e devolve decisões, e uma casca fina, que fala com banco, rede, arquivos e relógio. O núcleo é fácil de testar com testes unitários rápidos; a casca é pequena e coberta por poucos testes de integração.

Veja um exemplo típico de função misturada, em TypeScript:

// Difícil de testar: lê banco, hora e envia e-mail no mesmo lugar
async function aplicarMulta(pedidoId: string) {
  const pedido = await db.pedidos.find(pedidoId);
  const atraso = Date.now() - pedido.vencimento.getTime();
  if (atraso > 0) {
    pedido.multa = pedido.valor * 0.02;
    await db.pedidos.save(pedido);
    await email.enviar(pedido.cliente, "Multa aplicada");
  }
}

Agora a regra extraída como função pura. Ela não sabe que existe banco nem e-mail:

export function calcularMulta(valor: number, vencimento: Date, agora: Date): number {
  return agora.getTime() > vencimento.getTime() ? valor * 0.02 : 0;
}

// teste
expect(calcularMulta(100, new Date("2026-01-10"), new Date("2026-01-11"))).toBe(2);
expect(calcularMulta(100, new Date("2026-01-10"), new Date("2026-01-09"))).toBe(0);

Repare que o teste não precisa de mock nenhum. Quanto mais lógica você conseguir mover para funções assim, menos dublês de teste vai precisar.

2. Injeção de dependência sem framework

Injeção de dependência (DI) é simplesmente receber as dependências de fora, em vez de criá-las dentro. Não exige contêiner nem anotação: um parâmetro no construtor ou na função já resolve. Em Python:

class ServicoDeMulta:
    def __init__(self, repo, notificador, relogio):
        self._repo = repo
        self._notificador = notificador
        self._relogio = relogio

    def aplicar(self, pedido_id: str) -> None:
        pedido = self._repo.buscar(pedido_id)
        multa = calcular_multa(pedido.valor, pedido.vencimento, self._relogio.agora())
        if multa > 0:
            pedido.multa = multa
            self._repo.salvar(pedido)
            self._notificador.avisar(pedido.cliente, "Multa aplicada")

Em produção você monta o serviço com o repositório real; no teste, com versões em memória. A montagem de tudo acontece em um único ponto da aplicação (a chamada composition root), geralmente no main ou na inicialização do servidor.

3. Portas e adaptadores

A arquitetura de portas e adaptadores (também chamada de hexagonal) formaliza essa ideia. O núcleo define portas — interfaces que descrevem o que ele precisa — e o mundo externo fornece adaptadores que implementam essas portas.

// Portas: definidas pelo núcleo, na linguagem do domínio
export interface RepositorioPedidos {
  buscar(id: string): Promise<Pedido>;
  salvar(p: Pedido): Promise<void>;
}
export interface Notificador {
  avisar(cliente: string, msg: string): Promise<void>;
}

// Adaptador de teste: simples, em memória
export class RepositorioEmMemoria implements RepositorioPedidos {
  constructor(private dados = new Map<string, Pedido>()) {}
  async buscar(id: string) { return this.dados.get(id)!; }
  async salvar(p: Pedido) { this.dados.set(p.id, p); }
}
  • A porta fala a língua do negócio (buscar pedido), não da tecnologia (SELECT * FROM).
  • Adaptadores reais (Postgres, SMTP, fila) ficam na borda e são testados com testes de integração.
  • Fakes em memória costumam ser mais legíveis e menos frágeis que mocks que verificam cada chamada.

4. Relógio injetável

Chamar Date.now() ou datetime.now() no meio da regra é a causa mais comum de teste que passa de manhã e falha à meia-noite, no fim do mês ou na virada do horário de verão. A solução é tratar o tempo como dependência.

from datetime import datetime, timezone, timedelta
from typing import Protocol

class Relogio(Protocol):
    def agora(self) -> datetime: ...

class RelogioSistema:
    def agora(self) -> datetime:
        return datetime.now(timezone.utc)

class RelogioFixo:
    def __init__(self, instante: datetime):
        self._instante = instante
    def agora(self) -> datetime:
        return self._instante
    def avancar(self, **kwargs) -> None:
        self._instante += timedelta(**kwargs)

# teste
relogio = RelogioFixo(datetime(2026, 1, 31, 23, 59, tzinfo=timezone.utc))
relogio.avancar(minutes=2)  # testa a virada do mês sem esperar

Em TypeScript, o equivalente pode ser tão simples quanto um tipo de função:

export type Relogio = () => Date;
export const relogioSistema: Relogio = () => new Date();

const relogioFixo: Relogio = () => new Date("2026-02-28T23:59:00Z");

Dicas práticas: guarde e compare datas em UTC, converta para o fuso do usuário só na apresentação e teste explicitamente as bordas (fim de mês, ano bissexto, mudança de fuso). Para conferir valores de época durante a depuração, o conversor de timestamp ajuda bastante.

Ferramentas como os fake timers do Jest/Vitest ou bibliotecas de congelamento de tempo em Python funcionam, mas alteram o relógio global do processo. Prefira o relógio injetado no código de domínio e reserve os fake timers para casos em que você não controla o código (bibliotecas de terceiros, timers de UI).

5. Aleatoriedade com seed

Sorteios, embaralhamentos, cupons e amostragens precisam de números aleatórios, mas o teste precisa de resultados previsíveis. Injete o gerador e, quando fizer sentido, use uma seed fixa.

import random

def sortear_vencedor(participantes: list[str], rng: random.Random) -> str:
    return rng.choice(participantes)

# produção
sortear_vencedor(lista, random.Random())

# teste: mesma seed, mesmo resultado em toda execução
rng = random.Random(42)
primeiro = sortear_vencedor(["ana", "bia", "caio"], rng)
assert sortear_vencedor(["ana", "bia", "caio"], random.Random(42)) == primeiro

O Math.random() do JavaScript não aceita seed, então a saída é receber uma função geradora:

export type Aleatorio = () => number; // retorna [0, 1)

export function embaralhar<T>(itens: T[], rnd: Aleatorio): T[] {
  const copia = [...itens];
  for (let i = copia.length - 1; i > 0; i--) {
    const j = Math.floor(rnd() * (i + 1));
    [copia[i], copia[j]] = [copia[j], copia[i]];
  }
  return copia;
}

// teste: sequência controlada
const seq = [0.9, 0.1, 0.5];
let k = 0;
const rndFake: Aleatorio = () => seq[k++ % seq.length];

Ao usar seed em testes baseados em propriedades ou em geração de massa de dados, registre a seed no log quando o teste falhar. Assim qualquer pessoa reproduz o caso exato. Para montar massas rapidamente, experimente o gerador de massa de testes. Atenção: geradores com seed não servem para tokens, senhas ou qualquer coisa de segurança — ali use sempre a fonte criptográfica da plataforma.

6. UUID injetável

Identificadores gerados dentro da regra tornam as asserções vagas (“qualquer string”) e os snapshots instáveis. Trate o gerador de IDs como mais uma porta:

import { randomUUID } from "node:crypto";

export type GeradorId = () => string;
export const geradorUuid: GeradorId = () => randomUUID();

export function criarPedido(valor: number, novoId: GeradorId, relogio: Relogio) {
  return { id: novoId(), valor, criadoEm: relogio() };
}

// teste
const ids = ["pedido-1", "pedido-2"];
const geradorFake: GeradorId = () => ids.shift()!;
expect(criarPedido(50, geradorFake, relogioFixo).id).toBe("pedido-1");

Com IDs previsíveis, você consegue comparar o objeto inteiro com toEqual e os snapshots deixam de mudar a cada execução. Precisa de UUIDs reais para fixtures? Use o gerador de UUID e cole os valores no teste.

7. Evite singletons e estado global

Singletons e módulos com estado global são dependências ocultas com um agravante: sobrevivem entre testes. Um cache preenchido no teste A faz o teste B passar por acidente — ou falhar só quando roda em paralelo.

# Evite: estado global no módulo
_config = {}
def get_config():
    return _config

# Prefira: objeto criado na composição e passado adiante
class Config:
    def __init__(self, url_api: str, timeout: float):
        self.url_api = url_api
        self.timeout = timeout

def criar_app(config: Config, relogio: Relogio):
    ...
  • Crie as instâncias uma vez no composition root e passe adiante; “uma instância só” é decisão de montagem, não da classe.
  • Leia variáveis de ambiente na inicialização e transforme-as em um objeto de configuração.
  • Se precisar conviver com um singleton legado, ofereça um método de reset usado só nos testes, como passo de transição.

Checklist rápido de código testável

  • A regra de negócio roda sem banco, rede ou disco?
  • Toda dependência externa aparece na assinatura ou no construtor?
  • Hora, aleatoriedade e IDs chegam por parâmetro?
  • Os testes podem rodar em qualquer ordem e em paralelo?
  • Existe um único lugar onde as dependências reais são montadas?

Próximos passos

Escolha uma função do seu projeto que hoje depende de now() ou de um cliente criado internamente e aplique um dos passos acima — normalmente o relógio injetável é a vitória mais rápida. Depois, pratique: