Teste que quebra na virada do mês: injete o relógio e teste a fronteira
Tire o relógio real dos seus testes, fixe o instante que importa e cubra dia 31, 29 de fevereiro e meia-noite antes que o CI descubra por você.
Dia 31 de janeiro, 2h da manhã, o build agendado fica vermelho. O teste "vencimento cai no mês seguinte" passou o mês inteiro e hoje diz que a próxima cobrança é 3 de março. Ninguém mexeu no código desde a semana passada. Mexeu o calendário. A resposta curta: o código não pode perguntar a hora direto ao sistema. Ele recebe um relógio como dependência, o teste entrega um relógio parado num instante escolhido e você escreve casos explícitos para as fronteiras: 23h59, dia 31, 29 de fevereiro e o fuso do servidor de CI.
Com isso, o teste deixa de ser uma loteria que depende do dia em que roda e passa a documentar a regra. O bug do dia 31 aparece numa terça qualquer, na sua máquina, e não numa madrugada de fechamento.
Por que o teste passa hoje e falha amanhã
Quando a função chama new Date(), DateTime.Now ou time.time() por dentro, ela tem uma entrada escondida. O teste controla os parâmetros, mas não controla essa entrada. Em 28 dos 31 dias ela não muda o resultado, e por isso o teste parece estável.
As datas têm pontos onde a aritmética muda de comportamento. O mês tem 28, 29, 30 ou 31 dias. O ano bissexto acrescenta um dia que só aparece a cada quatro anos. A meia-noite muda o "hoje". E o fuso do servidor decide em que dia você está: às 22h de Brasília, um runner de CI em UTC já está no dia seguinte, porque lá são 1h da manhã. Por isso tantos testes de data quebram justamente de madrugada ou no fim do mês.
Veja o caso da cobrança mensal. O código original fazia isto:
function proximoVencimento(data) {
const proxima = new Date(data);
proxima.setMonth(proxima.getMonth() + 1);
return proxima;
}
Para 15 de janeiro, devolve 15 de fevereiro. Para 31 de janeiro de 2026, o JavaScript tenta montar 31 de fevereiro, que não existe, e transborda para 3 de março. O teste que usava a data de hoje só pegava isso se o CI rodasse num dia 29, 30 ou 31. Com o relógio real, o bug é intermitente. Com o relógio fixo, ele é determinístico.
Como tirar o relógio de dentro do código
A técnica tem nome antigo, clock wrapper: em vez de ler a hora do sistema em qualquer lugar, o código pede a hora a um objeto que você controla. Em produção, esse objeto devolve a hora real. No teste, devolve o instante que o teste escolheu.
- Procure no código chamadas diretas ao relógio:
new Date()sem argumento,Date.now(),DateTime.Now,LocalDate.now(),datetime.now(). - Crie uma dependência pequena, com um único método que devolve o instante atual.
- Passe essa dependência pelo construtor ou pelo parâmetro da função, com a hora real como padrão de produção.
- Nos testes, crie um relógio fixo e, quando precisar, um que avança só quando o teste mandar.
- Escreva um caso por fronteira, com a data no nome do teste.
- Fixe o fuso do processo de teste e escreva pelo menos um caso que prove que o fuso não muda o resultado.
O relógio injetável em JavaScript
No nosso exemplo, um serviço de assinaturas calcula o próximo vencimento a partir de "agora". Primeiro, a correção da regra: se o mês seguinte não tem o dia, o vencimento vai para o último dia dele.
// assinatura.js
const relogioDoSistema = { agora: () => new Date() };
function proximoVencimento(data) {
const ano = data.getFullYear();
const mes = data.getMonth();
const dia = data.getDate();
const ultimoDiaDoProximo = new Date(ano, mes + 2, 0).getDate();
return new Date(ano, mes + 1, Math.min(dia, ultimoDiaDoProximo));
}
function criarAssinatura(relogio = relogioDoSistema) {
const inicio = relogio.agora();
return { inicio, vencimento: proximoVencimento(inicio) };
}
module.exports = { proximoVencimento, criarAssinatura };
O truque de new Date(ano, mes + 2, 0) é que o dia zero de um mês é o último dia do mês anterior. Para 31 de janeiro, ele calcula o último dia de fevereiro, 28, e o vencimento fica em 28 de fevereiro. Repare que criarAssinatura nunca chama new Date() sem argumento: ela pergunta ao relógio que recebeu.
Os testes das fronteiras
Agora os testes, com o executor nativo do Node (node --test). Cada caso fixa um instante e diz no nome qual fronteira está cobrindo.
// assinatura.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const { criarAssinatura } = require('./assinatura');
function relogioFixo(ano, mes, dia, hora = 12, minuto = 0) {
const instante = new Date(ano, mes - 1, dia, hora, minuto);
return { agora: () => new Date(instante) };
}
function dataCurta(d) {
return [d.getFullYear(), d.getMonth() + 1, d.getDate()].join('-');
}
test('15 de janeiro vence em 15 de fevereiro', () => {
const a = criarAssinatura(relogioFixo(2026, 1, 15));
assert.equal(dataCurta(a.vencimento), '2026-2-15');
});
test('31 de janeiro vence no último dia de fevereiro', () => {
const a = criarAssinatura(relogioFixo(2026, 1, 31));
assert.equal(dataCurta(a.vencimento), '2026-2-28');
});
test('31 de janeiro de ano bissexto vence em 29 de fevereiro', () => {
const a = criarAssinatura(relogioFixo(2028, 1, 31));
assert.equal(dataCurta(a.vencimento), '2028-2-29');
});
test('31 de dezembro às 23h59 vence em 31 de janeiro do ano seguinte', () => {
const a = criarAssinatura(relogioFixo(2026, 12, 31, 23, 59));
assert.equal(dataCurta(a.vencimento), '2027-1-31');
});
Esses quatro testes passam ou falham do mesmo jeito em qualquer dia do ano. O de 31 de janeiro teria pegado o bug original na primeira execução, numa tarde normal.
Quando você não pode mudar a assinatura da função
Em código legado, nem sempre dá para injetar o relógio de imediato. Os executores de teste populares oferecem relógio falso global: no Jest e no Vitest, useFakeTimers() combinado com setSystemTime() faz new Date() devolver o instante que você fixou. Em Python, a biblioteca freezegun faz o mesmo papel. No .NET 8 em diante, a abstração TimeProvider já vem na biblioteca padrão e existe um provedor falso para testes no pacote Microsoft.Extensions.TimeProvider.Testing.
Use o relógio falso global como ponte, não como destino. Ele resolve o teste de hoje, mas a dependência escondida continua lá, e o próximo teste que esquecer de congelar a hora volta a depender do calendário.
O fuso do CI é uma entrada do teste
Sua máquina está em America/Sao_Paulo. O runner de CI quase sempre está em UTC. Um teste que monta a data com new Date('2026-01-31') (só a data, sem hora) recebe meia-noite em UTC, que em Brasília ainda é 30 de janeiro às 21h. O mesmo teste dá resultados diferentes nos dois ambientes.
Duas medidas resolvem. Primeiro, fixe o fuso do processo de teste, por exemplo com TZ=America/Sao_Paulo node --test no script do CI, para todo mundo rodar no mesmo fuso. Segundo, decida o que a regra de negócio usa: se o vencimento é "dia do calendário de quem contratou", o código precisa receber esse fuso explicitamente. O post sobre gravar o instante em UTC e mostrar no fuso de quem lê trata a parte de armazenamento, e o de data e fuso no PHP mostra o mesmo problema do lado do servidor.
{
"scripts": {
"test": "TZ=America/Sao_Paulo node --test"
}
}
Esse formato de variável na frente do comando funciona em shells Unix, que é o que os runners Linux usam. No Windows, defina a variável no próprio passo do CI ou use uma ferramenta como cross-env.
Erros comuns
- Calcular o valor esperado com o relógio real dentro do teste. Se o teste faz a mesma conta que o código para descobrir o esperado, ele repete o bug e passa junto com ele.
- Esperar o tempo passar com
sleepousetTimeout. O teste fica lento e ainda assim instável, porque a máquina do CI pode estar ocupada e atrasar a execução. - Esquecer de restaurar o relógio falso global. O teste seguinte herda o instante congelado e falha de um jeito que ninguém consegue explicar olhando só para ele.
- Testar só o meio do mês. Dia 15 nunca revela transbordo; os bugs moram no dia 31, em fevereiro e na virada do ano.
- Montar a data a partir de texto sem hora nem fuso. O mesmo literal vira dias diferentes na sua máquina e no CI.
- Confundir com o problema do relógio do servidor. Relógio de máquina desajustado é outro assunto, tratado em token recusado por relógio torto; aqui o problema é o teste depender do calendário.
Checklist
- Nenhuma regra de negócio chama o relógio do sistema diretamente; todas recebem um relógio.
- Existe um relógio fixo reutilizável na pasta de testes.
- Há casos explícitos para dia 31, 29 de fevereiro, 31 de dezembro e 23h59.
- O fuso do processo de teste está fixado no script do CI.
- Pelo menos um teste prova o comportamento em outro fuso, se a regra depender dele.
- Nenhum teste usa espera real para simular passagem de tempo.
- Relógios falsos globais são restaurados depois de cada teste.
Escolha a regra de prazo mais importante do seu sistema, a de "expira em 30 dias" ou a de "vence no mês que vem", e rode o teste dela com o relógio parado em 31 de janeiro às 23h59: o resultado é o que você escreveria no contrato?
Referência: https://martinfowler.com/bliki/ClockWrapper.html