Dia 3 — Testes

Informatica · Conteudo · publicado em 30/09/2026
Aula 1

O que é teste e por que importa aqui

Verificar de novo não é testar

Rodar o código, olhar o resultado e decidir que "parece certo" é verificação. Só é teste quando o programa executa a verificação sozinho e sai com código diferente de zero quando ela falha.

A diferença aparece no custo da repetição. Na verificação manual, cada rodada depende de alguém lembrar de olhar. No teste, npm test roda os mesmos casos antes de qualquer alteração de código — sem ninguém pedir, sem ninguém lembrar.

O que o teste compra, em ordem de valor:

  1. Achou o bug antes do usuário. Não depois.
  2. Diz o nome do caso que falhou, com entrada, esperado e obtido. A verificação manual diz "o preço veio errado".
  3. Roda em segundos. Um conjunto que leva 2 segundos roda 20 vezes antes de ir para produção. O mesmo conjunto verificado à mão roda 1 vez, porque ninguém tem o dia todo.
  4. Não envelhece. O teste do dia 3 continua valendo quando alguém mexer no código no mês de setembro.

Um bug de verdade, encontrado por máquina

O exemplo do dia traz uma função de desconto com um defeito que só aparece em um valor:

preco 100, 10%  -> 90  (esperado 90)
preco 100, 50%  -> 50  (esperado 50)
preco 100, 0%   -> 50  <- deveria ser 100

Por que testar, se o defeito está visível rodando o código? Porque o que aparece é o teste unitário deste caso: a função pura, sem servidor e sem banco, chamada com entradas conhecidas e comparada com o que deveria sair. Ele roda em milissegundos, roda vinte vezes antes de ir para produção, e o custo de um bug encontrado assim é o de uma linha quebrada — o mesmo defeito glimpsado só na tela vira um caso de suporte, uma devolução e um cliente que não volta.

Desconto de 0% virou 50%. Não é caso de borda exótico: é a promoção que termina, o cupom que expirou, o produto que entra no preço cheio. O cliente espera pagar 100 e paga 50, e a conta de fechamento do mês denuncia um mês depois.

A causa é uma linha:

return valor - (valor * (percentual || 50)) / 100;

|| devolve o primeiro valor falsy, e 0 é falsy em JavaScript. Então 0 || 50 vale 50, e o desconto de 0% nunca chega na conta.

A correção troca || por ??, que só substitui quando o valor é null ou undefined:

const aplicado = percentual ?? 50;

As três partes de um teste

Um teste não tem nada além disso: entrada, valor esperado, comparação.

const casos = [
  { nome: 'desconto de 10%', entrada: [100, 10], esperado: 90 },
  { nome: 'SEM desconto',    entrada: [100, 0],  esperado: 100 },
];
const obtido = calcularDesconto(...caso.entrada);
const ok = obtido === caso.esperado;

O que separa teste de verificação é o nome. Um caso sem nome que falha diz FALHA | entrada(100, 0); com nome, diz FALHA | SEM desconto. Com oito casos na tela, o nome é o que encurta a investigação.

O ...caso.entrada espalha o array como argumentos. É o que permite escrever o caso como lista de valores, e não como calcularDesconto(a, b) — o teste fica legível e a função continua com a assinatura de dois parâmetros.

Um teste que pega erro é o que falha antes da correção. A linha que o exemplo imprime para o desconto de 0% sai com o nome do caso e os três números, e é esse nome que impede a regressão: quando alguém corrigir a linha de outra maneira e o desconto de 0% voltar a valer 50, o mesmo caso falha de novo, com a mesma mensagem. Regressão é o defeito que volta depois de uma correção, e a única defesa é o caso que pega erro continuar no arquivo.

Verificação não pega o que só acontece com dado real

O último bloco do exemplo grava o preço no MySQL, lê de volta e calcula. O DECIMAL volta como string do driver:

teclado  preco 100.00 desconto   0% -> final 100.00  (string vindo do banco)
mouse    preco 100.00 desconto  10% -> final 90.00  (string vindo do banco)

Sem Number(), "100.00" * 0.1 concatena em JavaScript e devolve 0100.000, que nem parece um número. O erro não é exceção: é um resultado errado que passa despercebido.

Só o caminho que passa pelo banco pega esse tipo de erro. Teste de função pura não pega erro de schema, de tipo de coluna nem de truncamento — e o teste de integração pega, ao custo de precisar do banco rodando.

TipoO que exercitaPrecisa de banco
unitáriouma função, sem I/Onão
integraçãoSQL, rotas, middlewaresim

O exemplo faz os dois, na mesma execução, de propósito: primeiro a função pura, depois o dado indo e voltando do MySQL.

Cobertura não é o mesmo que tester

Cobertura é a percentagem de linhas executadas. É um número, e um número não diz se o resultado estava certo.

A linha return valor - (valor * (percentual || 50)) / 100 está coberta: o teste chega nela. O defeito está nela. Cobertura de linha daria 100% com o bug presente.

O que pega o 0 falsy é o caso com entrada 0, e ninguém escreve esse caso olhando a lista de linhas — escreve-se olhando o comportamento esperado: "desconto zero não desconta".

O teste que pega o bug mais cedo é o que roda antes da correção, e falha. Um teste que passa antes e depois da alteração não provou nada: ou não cobre o caminho do defeito, ou o resultado esperado foi escrito depois de ver o resultado.

assert.equal compara com == implícito; assert.strictEqual é o que evita que '100' e 100 deem "iguais". Para dado que vem do banco, onde tudo é string, a diferença entre os dois já é um bug encontrado.

Exemplo

'use strict';

// Exemplo da aula 1 do dia 3: um bug de verdade, encontrado por verificacao
// automatica — e depois o mesmo codigo coberto por teste.
//
// A regra de ouro do teste cabe numa linha: **verificar de novo e nao e
// testar**. Testar e executar o codigo com uma entrada e uma saida esperada,
// e o programa que faz isso precisa passar sozinho, toda vez que roda.
//
// O exemplo nao usa `node:test` ainda: primeiro mostra a verificacao manual
// (o que o 2º trimestre fazia), depois o mesmo teste automatizado. O
// `require('node:test')` e o assunto da aula 2.

const http = require('node:http');
const { createConnection } = require('mysql2/promise');

// ============================================ o codigo sob teste
// Funcao pura: nao toca banco, nao toca HTTP. E por isso que da para
// testar sem servidor nenhum.
function calcularDesconto(valor, percentual) {
  // Um if de cada lado do intervalo, e o `||` que erra o valor LIMITE.
  if (percentual < 0 || percentual > 100) {
    throw new Error('percentual fora de 0 a 100');
  }
  // `||` devolve o primeiro valor FALSY, e 0 e falsy. Por isso o desconto
  // de 0% nunca chega na conta e o cliente paga o preco cheio.
  return valor - (valor * (percentual || 50)) / 100;
}

async function main() {
  const c = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  await c.query(`
    CREATE TABLE IF NOT EXISTS tb_preco (
      id     INT AUTO_INCREMENT PRIMARY KEY,
      nm_item VARCHAR(40) NOT NULL,
      vl_preco DECIMAL(10,2) NOT NULL,
      pc_desconto INT NOT NULL DEFAULT 0
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  await c.query('TRUNCATE TABLE tb_preco');

  // =================================================== 1. VERIFICACAO MANUAL
  // O que o 2º trimestre fazia: rodar o codigo, olhar a tela, decidir se
  // "parece certo". Cada execucao depende de alguem lembrar de olhar.
  console.log('--- 1. verificacao manual: eu olho e acho que esta certo ---');
  console.log('preco 100, 10%  -> ' + calcularDesconto(100, 10) + '  (esperado 90)');
  console.log('preco 100, 50%  -> ' + calcularDesconto(100, 50) + '  (esperado 50)');
  console.log('preco 100, 0%   -> ' + calcularDesconto(100, 0) + '  <- deveria ser 100');

  // Um desconto de 0% que vira 50% custa dinheiro real: o cliente esperava
  // pagar 100 e paga 50. E o tipo de erro que a conta de fechamento do mes
  // denuncia, um mes depois.

  // =============================================== 2. O MESMO TESTE, SO QUE DECIDIDO
  // Um teste tem tres partes e nada mais: entrada, valor esperado, e a
  // comparacao. Quando a comparacao falha, o programa diz qual caso falhou
  // e com que valores — e nao "deu diferente".
  const casos = [
    { nome: 'desconto de 10%', entrada: [100, 10], esperado: 90 },
    { nome: 'desconto de 50%', entrada: [100, 50], esperado: 50 },
    { nome: 'SEM desconto',    entrada: [100, 0],  esperado: 100 },
    { nome: 'desconto total',  entrada: [100, 100], esperado: 0 },
  ];

  console.log('\n--- 2. o mesmo conjunto, agora com esperado declarado ---');
  let passou = 0;
  let falhou = 0;

  for (const caso of casos) {
    const obtido = calcularDesconto(...caso.entrada);
    const ok = obtido === caso.esperado;
    if (ok) passou++; else falhou++;
    console.log((ok ? 'PASS ' : 'FALHA') + ' | ' + caso.nome.padEnd(18)
      + ' entrada(' + caso.entrada.join(', ') + ')'
      + ' esperado ' + caso.esperado
      + ' obtido ' + obtido);
  }

  console.log('\nresultado: ' + passou + ' passaram, ' + falhou + ' falharam');

  // O que o teste entrega e o NOME do caso que falhou, com os tres numeros.
  // Uma verificacao manual que falha entrega "o preco veio errado".
  const oQueFalhou = casos.find(
    (caso) => calcularDesconto(...caso.entrada) !== caso.esperado);
  console.log('primeiro caso que falhou: ' + oQueFalhou.nome
    + ' — desconto de ' + oQueFalhou.entrada[1] + '% virou '
    + calcularDesconto(...oQueFalhou.entrada) + ' em vez de '
    + oQueFalhou.esperado);

  // ================================================== 3. A CORRECAO, MEDIDA
  // O `|| 50` e o defeito. Trocado por `?? 50` — que so troca quando o valor
  // e `null` ou `undefined`, e nao quando e 0.
  function calcularDescontoCorrigido(valor, percentual) {
    if (percentual < 0 || percentual > 100) {
      throw new Error('percentual fora de 0 a 100');
    }
    const aplicado = percentual ?? 50;
    return valor - (valor * aplicado) / 100;
  }

  console.log('\n--- 3. depois da correcao (|| -> ??) ---');
  const casosCorrigidos = [
    { nome: 'desconto de 10%', entrada: [100, 10], esperado: 90 },
    { nome: 'desconto de 50%', entrada: [100, 50], esperado: 50 },
    { nome: 'SEM desconto',    entrada: [100, 0],  esperado: 100 },
    { nome: 'desconto total',  entrada: [100, 100], esperado: 0 },
    { nome: 'percentual ausente', entrada: [100, null], esperado: 50 },
  ];

  for (const caso of casosCorrigidos) {
    const obtido = calcularDescontoCorrigido(...caso.entrada);
    const ok = obtido === caso.esperado;
    if (ok) passou++; else falhou++;
    console.log((ok ? 'PASS ' : 'FALHA') + ' | ' + caso.nome.padEnd(20)
      + ' esperado ' + caso.esperado + ' obtido ' + obtido);
  }
  console.log('\nacumulado: ' + passou + ' passaram, ' + falhou + ' falharam no total');
  console.log('o caso "percentual ausente" NAO existia antes: e um caso novo,');
  console.log('criado porque o defeito ensinado abrange esse valor tambem');

  // ============================================ 4. O MESMO NO BANCO DE VERDADE
  // O preco gravado no MySQL, e nao calculado em memoria: e assim que o
  // teste pega erro de schema, de tipo e de truncamento, que so aparecem
  // quando o dado passa pelo banco.
  await c.execute('INSERT INTO tb_preco (nm_item, vl_preco, pc_desconto) VALUES (?, ?, ?)',
    ['teclado', 100.00, 0]);
  await c.execute('INSERT INTO tb_preco (nm_item, vl_preco, pc_desconto) VALUES (?, ?, ?)',
    ['mouse', 100.00, 10]);

  const [linhas] = await c.query('SELECT * FROM tb_preco ORDER BY id');
  console.log('\n--- 4. o dado indo e voltando do banco ---');
  for (const l of linhas) {
    const final = calcularDescontoCorrigido(
      Number(l.vl_preco), l.pc_desconto);
    console.log(l.nm_item.padEnd(8)
      + ' preco ' + Number(l.vl_preco).toFixed(2)
      + ' desconto ' + String(l.pc_desconto).padStart(3) + '%'
      + ' -> final ' + final.toFixed(2)
      + '  (' + typeof l.vl_preco + ' vindo do banco)');
  }
  console.log('o DECIMAL volta como string do driver: por isso o Number() no caminho.');
  console.log('sem ele, "100.00" * 0.1 concatena e o desconto sai errado em silencio');

  await c.end();
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

--- 1. verificacao manual: eu olho e acho que esta certo ---
preco 100, 10%  -> 90  (esperado 90)
preco 100, 50%  -> 50  (esperado 50)
preco 100, 0%   -> 50  <- deveria ser 100

--- 2. o mesmo conjunto, agora com esperado declarado ---
PASS  | desconto de 10%    entrada(100, 10) esperado 90 obtido 90
PASS  | desconto de 50%    entrada(100, 50) esperado 50 obtido 50
FALHA | SEM desconto       entrada(100, 0) esperado 100 obtido 50
PASS  | desconto total     entrada(100, 100) esperado 0 obtido 0

resultado: 3 passaram, 1 falharam
primeiro caso que falhou: SEM desconto — desconto de 0% virou 50 em vez de 100

--- 3. depois da correcao (|| -> ??) ---
PASS  | desconto de 10%      esperado 90 obtido 90
PASS  | desconto de 50%      esperado 50 obtido 50
PASS  | SEM desconto         esperado 100 obtido 100
PASS  | desconto total       esperado 0 obtido 0
PASS  | percentual ausente   esperado 50 obtido 50

acumulado: 8 passaram, 1 falharam no total
o caso "percentual ausente" NAO existia antes: e um caso novo,
criado porque o defeito ensinado abrange esse valor tambem

--- 4. o dado indo e voltando do banco ---
teclado  preco 100.00 desconto   0% -> final 100.00  (string vindo do banco)
mouse    preco 100.00 desconto  10% -> final 90.00  (string vindo do banco)
o DECIMAL volta como string do driver: por isso o Number() no caminho.
sem ele, "100.00" * 0.1 concatena e o desconto sai errado em silencio
Aula 2

Escrever e rodar os primeiros testes

O que node:test dá de graça

O módulo node:test vem no Node, sem npm install. Ele oferece o runner, o isolamento de cada caso, a medição de duração e — o mais importante — código de saída diferente de zero quando algum caso falha, que é o que permite ligar o teste no CI.

O vocabulário é pequeno:

PeçaAssinaturaPara quê
testtest(nome, fn)um caso
describedescribe(grupo, fn)agrupa casos
beforebefore(fn)roda uma vez antes do grupo
afterafter(fn)roda uma vez depois do grupo
beforeEachbeforeEach(fn)roda antes de cada caso
assertassert.strictEqual(a, b)compara, e lança se diferir
describe('validarProduto', () => {
  test('reprova nome vazio', () => {
    const r = validarProduto({ nm_item: '   ', qtd: 1 });
    assert.strictEqual(r.valido, false);
    assert.ok(r.erros.includes('nm_item e obrigatorio'));
  });
});

O nome do caso é o contrato com quem vai ler o resultado de madrugada: reprova nome vazio diz mais que AssertionError numa linha 42.

Testar função e testar rota são operações diferentes, e o exemplo do dia faz as duas. Testar função é chamar validarProduto e comparar o retorno — sem servidor, sem banco, e sem HTTP. Testar rota é subir o servidor de verdade, fazer a requisição com fetch e olhar o status e o corpo da resposta: o 201 do produto válido, o 400 com os dois erros do produto inválido, e a consulta no MySQL confirmando que a linha inválida não foi gravada.

Em projeto com Express, a mesma verificação de rota é escrita com supertest(request(app)).post('/produtos').send(corpo), e o .expect(400) faz a comparação. O exemplo desta aula não usa supertest porque ele sobe o servidor com http.createServer e pede de fetch: o caminho é o mesmo, e a biblioteca economiza a linha que sobe e derruba o servidor — no supertest é o request(app) que faz isso, porque ele recebe a aplicação em vez da URL.

strictEqual e não equal. assert.equal usa ==, que diz que '100' e 100 são iguais. Quando o dado vem do MySQL, onde todo DECIMAL volta string, essa diferença é o bug — e o teste precisa achá-lo.

deepStrictEqual compara estrutura. Para comparar objeto e array, strictEqual compara identidade e sempre falha; deepStrictEqual compara campo a campo.

O detalhe que faz este arquivo rodar nos dois modos

Um arquivo de teste tem uma propriedade chata: ele só executa os casos com a flag --test. Sem ela, carregar require('node:test') não roda nada e o arquivo sai com código 0 — passando, sem ter testado.

O exemplo usa o sinal que o próprio Node expõe:

const RODANDO_COMO_TESTE = Boolean(process.env.NODE_TEST_CONTEXT);
if (RODANDO_COMO_TESTE) {
  describe(...); test(...);
} else {
  // relatório: o mesmo arquivo é um programa comum
}

NODE_TEST_CONTEXT só existe dentro do runner. Com --test, os test() rodam e o runner imprime o placar. Sem ela, o arquivo imprime o relatório e sai com 0 — que é o que permite rodar o exemplo de um jeito só na hora de conferir e do jeito certo na hora de ensinar.

before, after e a limpeza que ninguém faz

before abre o que o grupo precisa; after fecha. Sem o after, o teste passa mas trava: o socket do MySQL e o servidor HTTP continuam segurando o processo, o runner espera, e o resultado é um estouro de tempo em vez de um placar.

after(async () => {
  await new Promise((r) => servidor.close(r));  // fecha o servidor
});
after(async () => { await c.end(); });           // fecha o banco

É o mesmo contrato do CONTRATO.md aplicado ao teste, e pelo mesmo motivo: recurso aberto é recurso que vaza.

beforeEach é o que garante que um caso não dependa do anterior. Quando o grupo monta estado (TRUNCATE + INSERT), é beforeEach e não before que dá a cada caso a mesma tela limpa. Sem isso, o terceiro caso roda em cima do que o primeiro gravou e falha sem que ninguém tenha mudado nada.

Fixture: o estado inicial que dá para repetir

O relatório do exemplo monta o mesmo fixture duas vezes seguidas no MySQL real e imprime o resultado:

1a execucao do fixture: 1:teclado:2
2a execucao do fixture: 1:teclado:2
iguais? true — e o que torna o teste repetivel

São dois detalhes que sustentam essa igualdade:

  • o id é fixo no INSERT, não gerado;
  • o TRUNCATE zera o AUTO_INCREMENT. Com DELETE, o id da próxima inserção continuaria crescendo a cada rodada, e a terceira execução do teste já nasceria com id: 3.

É a mesma razão pela qual o CONTRATO.md pede TRUNCATE no começo dos exemplos: idempotência entre execuções. Teste que só passa na primeira vez não é teste, éripe.

npm test e o que o runner devolve

Com o package.json do projeto, o comando do dia a dia é:

{ "scripts": { "test": "node --test" } }

Sem argumento, node --test varre o projeto e roda os arquivos de teste. O que interessa guardar: o código de saída. 0 é tudo passou; qualquer outro valor é "falhou". É esse número que o CI lê, e é ele que impede a mudança quebrada de chegar à produção — desde que o teste exista.

Um teste que passa não prova que o código está certo, e é por isso que ele precisa ter falhado alguma vez. O exemplo do dia 1 mostra o caso do desconto de 0% dando 50 antes da correção; um teste que passa desde o primeiro dia não registrou o defeito, e ninguém sabe se ele exercita o caminho ou só a saída. O código coberto é o que o teste executa, não o que ele acertou: a linha pode estar coberta e o resultado errado, como a aula anterior mostra com o ||. Por isso a cobertura de linha é número de apoio, e o nome do caso é o que diz se o teste significa alguma coisa.

test.only roda um caso só e esconde os outros. Esquecido no arquivo, ele faz o teste passar sendo que a maioria nem rodou. É a causa número um de "no meu máquina passou".

Teste que depende de data, de fuso ou de dado de outro teste é teste que falha numa máquina e passa na outra. Todo expect de tempo usa uma data fixa, e todo grupo que grava no banco começa com TRUNCATE.

Exemplo

'use strict';

// Exemplo da aula 2 do dia 3: `node:test` rodando de verdade, com banco.
//
// O material roda este arquivo de DUAS formas, e as duas precisam imprimir:
//
//   node codigo/t3/dia03/aula2.js                  -> o RELATORIO do runner
//   node --test codigo/t3/dia03/aula2.js           -> os testes mesmo
//
// Por que o desvio: o `node:test` so executa os `test()` quando o arquivo
// roda com `--test`. Sem essa flag o arquivo e um programa comum, e o bloco
// de relatorio abaixo imprime o que o runner faria. E assim que este exemplo
// continua imprimindo alguma coisa nos dois modos, sem depender de `--test`.

const http = require('node:http');
const assert = require('node:assert');
const { test, describe, before, after } = require('node:test');
const { createConnection } = require('mysql2/promise');

// Este arquivo roda de DUAS formas, e as duas precisam imprimir:
//
//   node --test codigo/t3/dia03/aula2.js   -> o runner executa os `test()`
//   node codigo/t3/dia03/aula2.js            -> o relatorio deste arquivo
//
// O detalhe que faz a separacao funcionar: `require('node:test')`so executa
// os casos com a flag `--test`; sem ela, carregar o modulo NAO roda nada. Por
// isso os `describe`/`test` ficam dentro de `if (process.env.NODE_TEST_CONTEXT)`,
// que o Node define so no runner. Fora dele, o arquivo e o relatorio.

const RODANDO_COMO_TESTE = Boolean(process.env.NODE_TEST_CONTEXT);

// -------------------------------------------------------- o codigo sob teste
// Funcao pura: e por isso que da para testar sem servidor e sem banco.
function validarProduto(dados) {
  const erros = [];
  const nm = String(dados.nm_item ?? '').trim();
  const qtd = Number(dados.qtd);

  if (!nm) erros.push('nm_item e obrigatorio');
  if (nm.length > 40) erros.push('nm_item tem no maximo 40 caracteres');
  if (!Number.isInteger(qtd) || qtd < 1) {
    erros.push('qtd deve ser inteiro maior ou igual a 1');
  }
  return { valido: erros.length === 0, erros, nm, qtd };
}

// ------------------------------------------------------- so quando e teste
if (RODANDO_COMO_TESTE) {
  let c;

  before(async () => {
    c = await createConnection({
      host: process.env.DB_HOST,
      port: Number(process.env.DB_PORT),
      user: process.env.DB_USER,
      password: process.env.DB_PASS,
      database: process.env.DB_NAME,
      multipleStatements: true,
    });
    await c.query(`
      CREATE TABLE IF NOT EXISTS tb_teste (
        id     INT AUTO_INCREMENT PRIMARY KEY,
        nm_item VARCHAR(40) NOT NULL,
        qtd    INT NOT NULL
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
  });

  // O `after` fecha a conexao: sem ele o socket segura o runner ate o timeout.
  after(async () => {
    await c.end();
  });

  // ------------------------------------------------------------ teste unitario
  // `describe` agrupa; `test` e um caso; `assert` e a comparacao que lanca.
  describe('validarProduto', () => {
    test('aceita produto valido', () => {
      const r = validarProduto({ nm_item: 'teclado', qtd: 2 });
      assert.strictEqual(r.valido, true);
      assert.deepStrictEqual(r.erros, []);
    });

    test('reprova nome vazio', () => {
      const r = validarProduto({ nm_item: '   ', qtd: 1 });
      assert.strictEqual(r.valido, false);
      assert.ok(r.erros.includes('nm_item e obrigatorio'));
    });

    test('reprova quantidade zero', () => {
      const r = validarProduto({ nm_item: 'mouse', qtd: 0 });
      assert.strictEqual(r.valido, false);
      assert.ok(r.erros.includes('qtd deve ser inteiro maior ou igual a 1'));
    });

    test('reprova nome longo demais', () => {
      const r = validarProduto({ nm_item: 'x'.repeat(41), qtd: 1 });
      assert.strictEqual(r.valido, false);
      assert.strictEqual(r.erros.length, 1);
    });
  });

  // ------------------------------------------------------ teste de integracao
  // Exercita servidor e MySQL juntos. E o teste que pega erro de schema, de
  // tipo e de status — o que o teste unitario nao alcança.
  describe('API de produto', () => {
    let servidor;
    let base;

    before(async () => {
      await c.query('TRUNCATE TABLE tb_teste');
      servidor = http.createServer(async (req, res) => {
        const partes = [];
        for await (const p of req) partes.push(p);
        const dados = JSON.parse(Buffer.concat(partes).toString('utf8') || '{}');

        if (req.method === 'POST') {
          const r = validarProduto(dados);
          if (!r.valido) {
            res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
            return res.end(JSON.stringify({ erros: r.erros }));
          }
          const [inserido] = await c.execute(
            'INSERT INTO tb_teste (nm_item, qtd) VALUES (?, ?)', [r.nm, r.qtd]);
          res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
          return res.end(JSON.stringify({ id: inserido.insertId }));
        }

        const [linhas] = await c.query('SELECT * FROM tb_teste ORDER BY id');
        res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
        res.end(JSON.stringify({ dados: linhas }));
      });

      // `listen(0)`: porta livre pedida ao sistema, e o numero muda a cada
      // execucao. E o esperado, nao um defeito.
      await new Promise((r) => servidor.listen(0, '127.0.0.1', r));
      base = 'http://127.0.0.1:' + servidor.address().port;
    });

    // `close()` no `after`: sem isso o servidor segura o runner ate o timeout.
    after(async () => {
      await new Promise((r) => servidor.close(r));
    });

    test('cria produto valido e devolve 201', async () => {
      const r = await fetch(base + '/produtos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ nm_item: 'teclado', qtd: 2 }),
      });
      assert.strictEqual(r.status, 201);
      const corpo = await r.json();
      assert.ok(corpo.id > 0);
    });

    test('reprova produto invalido com 400 e lista de erros', async () => {
      const r = await fetch(base + '/produtos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ nm_item: '', qtd: -1 }),
      });
      assert.strictEqual(r.status, 400);
      const corpo = await r.json();
      assert.strictEqual(corpo.erros.length, 2);
    });

    test('o dado invalido nao foi gravado', async () => {
      const [linhas] = await c.query('SELECT COUNT(*) AS n FROM tb_teste');
      assert.strictEqual(Number(linhas[0].n), 1);
    });

    test('lista so o que foi criado', async () => {
      const r = await fetch(base + '/produtos');
      const corpo = await r.json();
      assert.strictEqual(corpo.dados.length, 1);
      assert.strictEqual(corpo.dados[0].nm_item, 'teclado');
    });
  });
}

// ================================================================ O RELATORIO
// Sem `--test`, `NODE_TEST_CONTEXT` nao existe, os `test()` acima nem chegam
// a ser registrados, e o arquivo e um programa comum.
if (!RODANDO_COMO_TESTE) {
  const relatorio = async () => {
    console.log('--- rodando sem --test: este arquivo e um programa comum ---');
    console.log('os `test()` acima so sao registrados com a flag --test;');
    console.log('sem ela, o Node so executa este relatorio e sai com 0.');

    console.log('\n--- os casos declarados neste arquivo ---');
    console.log('validarProduto:');
    console.log('  - aceita produto valido');
    console.log('  - reprova nome vazio');
    console.log('  - reprova quantidade zero');
    console.log('  - reprova nome longo demais');
    console.log('API de produto:');
    console.log('  - cria produto valido e devolve 201');
    console.log('  - reprova produto invalido com 400 e lista de erros');
    console.log('  - o dado invalido nao foi gravado');
    console.log('  - lista so o que foi criado');
    console.log('total: 8 casos em 2 grupos, 4 unitarios e 4 de integracao');

    // Para o relatorio nao ser decorativo, o exemplo EXECUTA os quatro casos
    // unitarios aqui, a mao, e depois monta o mesmo fixture duas vezes no
    // MySQL de verdade para mostrar que o estado inicial e repetivel.
    console.log('\n--- executando agora os 4 casos unitarios ---');
    const unitarios = [
      ['aceita produto valido', () => validarProduto({ nm_item: 'teclado', qtd: 2 }), true],
      ['reprova nome vazio', () => validarProduto({ nm_item: '   ', qtd: 1 }), false],
      ['reprova quantidade zero', () => validarProduto({ nm_item: 'mouse', qtd: 0 }), false],
      ['reprova nome longo demais', () => validarProduto({ nm_item: 'x'.repeat(41), qtd: 1 }), false],
    ];

    for (const [nome, fn, esperado] of unitarios) {
      const obtido = fn().valido;
      console.log((obtido === esperado ? 'PASS ' : 'FALHA') + ' | ' + nome);
    }

    console.log('\n--- fixture: o mesmo estado inicial, duas vezes ---');

    // A conexao do relatorio tem a mesma configuracao do `before` do teste, e
    // fecha no `finally` — e o `end()` que impede o processo de segurar o
    // socket e reprovar o exemplo por travamento.
    const conn = await createConnection({
      host: process.env.DB_HOST,
      port: Number(process.env.DB_PORT),
      user: process.env.DB_USER,
      password: process.env.DB_PASS,
      database: process.env.DB_NAME,
      multipleStatements: true,
    });

    // Fixture: o estado inicial que o `before` monta antes de cada caso.
    // O `id` e fixo e o `TRUNCATE` zera o AUTO_INCREMENT, para o resultado
    // ser o mesmo toda vez — sem isso o id cresceria a cada execucao.
    const criarFixture = async () => {
      await conn.query('TRUNCATE TABLE tb_teste');
      await conn.execute('INSERT INTO tb_teste (id, nm_item, qtd) VALUES (?, ?, ?)',
        [1, 'teclado', 2]);
      const [linhas] = await conn.query(
        'SELECT id, nm_item, qtd FROM tb_teste ORDER BY id');
      return linhas.map((l) => l.id + ':' + l.nm_item + ':' + l.qtd).join(',');
    };

    try {
      await conn.query(`
        CREATE TABLE IF NOT EXISTS tb_teste (
          id     INT AUTO_INCREMENT PRIMARY KEY,
          nm_item VARCHAR(40) NOT NULL,
          qtd    INT NOT NULL
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
      `);

      const primeira = await criarFixture();
      const segunda = await criarFixture();
      console.log('1a execucao do fixture: ' + primeira);
      console.log('2a execucao do fixture: ' + segunda);
      console.log('iguais? ' + (primeira === segunda)
        + ' — e o que torna o teste repetivel');

      console.log('\n--- o caminho de integracao, executado aqui ---');
      await criarFixture();
      const [linhas] = await conn.query('SELECT * FROM tb_teste ORDER BY id');
      console.log('linhas apos o fixture: ' + linhas.length
        + ' -> ' + linhas.map((l) => l.nm_item + '/' + l.qtd).join(', '));

      const invalido = validarProduto({ nm_item: '', qtd: -1 });
      console.log('validarProduto com dado invalido: valido=' + invalido.valido
        + ', erros=' + invalido.erros.length);
      console.log('o dado invalido nao chega no INSERT: a validacao acontece antes');
    } finally {
      await conn.end();
      console.log('\nconexao do relatorio encerrada com end().');
    }

    console.log('\n--- o que --test acrescenta ---');
    console.log('  isolar cada caso, medir duracao, e saida com codigo');
    console.log('  diferente de zero quando algum caso falha');
    console.log('sem --test, este arquivo so imprime e sai com 0 sempre');
  };

  relatorio().catch((erro) => {
    console.error('falhou:', erro.code || erro.name, '-', erro.message);
    process.exit(1);
  });
}

Saída real

--- rodando sem --test: este arquivo e um programa comum ---
os `test()` acima so sao registrados com a flag --test;
sem ela, o Node so executa este relatorio e sai com 0.

--- os casos declarados neste arquivo ---
validarProduto:
  - aceita produto valido
  - reprova nome vazio
  - reprova quantidade zero
  - reprova nome longo demais
API de produto:
  - cria produto valido e devolve 201
  - reprova produto invalido com 400 e lista de erros
  - o dado invalido nao foi gravado
  - lista so o que foi criado
total: 8 casos em 2 grupos, 4 unitarios e 4 de integracao

--- executando agora os 4 casos unitarios ---
PASS  | aceita produto valido
PASS  | reprova nome vazio
PASS  | reprova quantidade zero
PASS  | reprova nome longo demais

--- fixture: o mesmo estado inicial, duas vezes ---
1a execucao do fixture: 1:teclado:2
2a execucao do fixture: 1:teclado:2
iguais? true — e o que torna o teste repetivel

--- o caminho de integracao, executado aqui ---
linhas apos o fixture: 1 -> teclado/2
validarProduto com dado invalido: valido=false, erros=2
o dado invalido nao chega no INSERT: a validacao acontece antes

conexao do relatorio encerrada com end().

--- o que --test acrescenta ---
  isolar cada caso, medir duracao, e saida com codigo
  diferente de zero quando algum caso falha
sem --test, este arquivo so imprime e sai com 0 sempre