Pular para o conteúdo

31 · Qualidade

3 exercícios: check, lint, cobertura e o que o CI cobra.

bash
python3 exercicios/run_all.py 31

Os exercícios#

#TítuloEnunciado
227Cobertura: o que os testes NAO exercitaram
228Instantaneo, banco isolado e teste instavel
229Depurar sem 'out'

227 · Cobertura: o que os testes NAO exercitaram#

exercicios/31-qualidade/227_cobertura.df
// ════════════════════════════════════════════════════════════
//  Exercicio 227 — Cobertura: o que os testes NAO exercitaram
//
//  Uma suite verde nao diz nada sobre o que ela nao toca. Num sistema
//  de 200 arquivos, o codigo que ninguem exercitou e exatamente onde
//  o bug mora.
//
//  Este exercicio nao mede a si mesmo — a cobertura se pede na linha
//  de comando:
//
//      dataforge test --cobertura
//      dataforge test --minimo=80        # reprova abaixo disso
//      dataforge test --cobertura --linhas
//      dataforge crucible --cobertura
//
//  O que ele mostra e o que a medicao ENXERGA, e o que ela nao.
// ════════════════════════════════════════════════════════════

adopt Arcane.OS as OS
adopt Arcane.IO as IO
adopt Arcane.Process as P

// ── 1. Um projeto pequeno, com um ramo nao testado ──────────

raiz := IO.join(OS.temp_dir(), "df_cobertura_exercicio")
given IO.exists(raiz):
    IO.remove_tree(raiz)
IO.mkdir(IO.join(raiz, "src"))
IO.mkdir(IO.join(raiz, "tests"))

IO.write(IO.join(raiz, "forge.toml"),
    '[project]\nname = "cob"\nversion = "1.0.0"\nentry = "src/main.df"\n')

IO.write(IO.join(raiz, "src", "conta.df"), """
action taxa(valor, tipo):
    given tipo is "premium":
        yield valor * 0.01
    orif tipo is "comum":
        yield valor * 0.03
    yield valor * 0.05

action nunca_chamada(x):
    yield x * 2

relay taxa, nunca_chamada
""")

IO.write(IO.join(raiz, "src", "main.df"), """
adopt ./conta as C

out C.taxa(100, "comum")
""")

IO.write(IO.join(raiz, "tests", "conta_test.df"), """
adopt Arcane.Test as T
adopt ../src/conta as C

action test_premium():
    T.assert_eq(C.taxa(100, "premium"), 1.0)
""")

// ── 2. Medir ────────────────────────────────────────────────

r := P.run(["dataforge", "test", "--cobertura", "--linhas", "--no-color"],
    cwd := raiz)
saida := r["stdout"]

// So a secao de cobertura interessa: o nome do arquivo de teste
// aparece na linha de resultado dele, que e outra coisa.
// A barra invertida do Windows vira barra: o relatorio mostra o
// caminho como o SISTEMA o escreve, e um teste que procura "src/conta"
// passa em Linux e reprova no Windows.
relatorio := saida.split("Cobertura")[1].replace("\\", "/")

// O arquivo de codigo e medido; o de teste, nao — o que interessa
// medir e o codigo, nao a suite.
assert "src/conta.df" in relatorio
assert "conta_test.df" not in relatorio

// A acao que ninguem chamou aparece POR NOME. E a informacao que
// resolve: "58% coberto" nao diz o que fazer; "sem teste:
// nunca_chamada" diz.
assert "nunca_chamada" in relatorio

// E as linhas descobertas saem em faixas — uma lista de setenta
// numeros e ilegivel.
assert "linhas:" in relatorio

// ── 3. O minimo reprova, e isso e o que serve no CI ─────────

alto := P.run(["dataforge", "test", "--minimo=95", "--no-color"], cwd := raiz)
assert alto["exit_code"] is 1
assert "abaixo do mínimo" in alto["stdout"]

baixo := P.run(["dataforge", "test", "--minimo=10", "--no-color"], cwd := raiz)
assert baixo["exit_code"] is 0

// ── 4. O que a medicao NAO ve ───────────────────────────────
//
// Ela e de LINHA, e nao de ramo: 'given a and b' conta como coberta
// mesmo que 'b' nunca tenha sido avaliado. Medir ramo dobraria o
// custo, e cobertura de linha ja responde a pergunta que importa —
// "existe codigo que ninguem testou".
//
// Duas escolhas que mudam o que o numero diz:
//
//   - a linha do 'action' nao conta, so o corpo. Uma acao nunca
//     chamada aparece com 0%, e nao com 20%.
//   - um arquivo que NENHUM teste toca aparece com 0% em vez de sumir
//     do relatorio. Sumir e o que faz uma cobertura de 95% conviver
//     com metade do sistema sem teste.

assert "0.0%" in relatorio  // o main.df, que nenhum teste importa

IO.remove_tree(raiz)
out "225 ok — cobertura"

O problema#

Uma suíte verde não diz nada sobre o que ela não exercita. Num sistema de 200 arquivos, o código que ninguém tocou é exatamente onde o bug mora — e 13 passaram não distingue "o sistema está testado" de "os treze caminhos fáceis estão testados".

Como se pede#

bash
dataforge test --cobertura
dataforge test --cobertura --linhas     # as linhas, em faixas
dataforge test --minimo=80              # reprova abaixo disso (saída 1)
dataforge crucible --cobertura
text
  src/main.df         ░░░░░░░░░░░░░░░░░░░░   0.0%  0/94
                      sem teste: montar_cli, mostrar, principal
  src/repositorio.df  ████████████████████ 100.0%  38/38
  src/tarefa.df       ████████████████████ 100.0%  14/14

  total  35.6%  52 de 146 linhas executáveis

--minimo aceita 80, 80% e 0.8. A fronteira é em 1 inclusive: --minimo=1 é um por cento, porque ninguém exige cobertura total digitando 1.

O que faz o número significar algo#

Os dois lados da fração têm um jeito próprio de mentir:

MetadeDe onde vemComo mentiria
denominadoro parser: quais linhas são executáveiscontar comentário e linha vazia dá um número sempre pessimista, que ninguém olha duas vezes
numeradora execução, instrumentadacom a compilação de corpos ligada, toda ação daria 0%

E duas escolhas que mudam o que se lê:

A linha do `action` não conta; o corpo conta. Assim uma ação nunca chamada aparece com 0%, e não com 20%.

Um arquivo que nenhum teste toca aparece com 0%, em vez de sumir do relatório. Sumir é o que faz uma cobertura de 95% conviver com metade do sistema sem teste.

A informação que resolve#

58% coberto não diz o que fazer. sem teste: nunca_chamada diz.

Por isso o relatório lista, para cada arquivo, os nomes das ações cujo corpo nunca rodou — e com --linhas, as linhas em faixas (3-5, 9, 11-12), porque uma lista de setenta números é ilegível.

O que ela NÃO mede#

É de linha, e não de ramo: given a and b conta como coberta mesmo que b nunca tenha sido avaliado. Medir ramo exigiria instrumentar a avaliação de expressão, o que dobraria o custo — e cobertura de linha já responde a pergunta que importa, que é "existe código que ninguém testou".

Uma advertência#

Cobertura alta não é qualidade. Um teste que chama tudo e não verifica nada dá 100%. O número serve para achar o que está a zero, e é aí que ele vale quase tudo o que custa.

228 · Instantaneo, banco isolado e teste instavel#

exercicios/31-qualidade/228_instantaneo_e_isolamento.df
// ════════════════════════════════════════════════════════════
//  Exercicio 228 — Instantaneo, banco isolado e teste instavel
//
//  Tres ferramentas do Crucible para o que um teste comum nao alcanca
//  bem: resultado grande, estado que sobra entre testes, e falha que
//  vai e volta.
// ════════════════════════════════════════════════════════════

adopt Crucible
adopt Arcane.Database as Banco

// ── 1. O banco de exemplo ───────────────────────────────────

db := Banco.memory()
Banco.create_table(db, "livros", {
        "id": "INTEGER PRIMARY KEY AUTOINCREMENT",
        "titulo": "TEXT NOT NULL",
        "preco": "REAL NOT NULL"
    })
Banco.insert_many(db, "livros", [
        {"titulo": "Duna", "preco": 79.9},
        {"titulo": "Neuromancer", "preco": 64.9}
    ])

action relatorio():
    linhas := Banco.aggregate(db, "livros",
        {"quantos":["count", "*"],
            "total":["sum", "preco"]})
    yield {"acervo": linhas[0]["quantos"], "valor": linhas[0]["total"]}

// ── 2. Instantaneo ──────────────────────────────────────────
//
// Para o que e grande demais para escrever a mao no teste: o HTML de
// uma pagina, o relatorio de trinta linhas, o JSON de uma rota.
// Escrever o esperado a mao para isso da um teste que ninguem mantem.
//
// Na PRIMEIRA vez ele grava e passa — e o unico jeito de comecar. O
// arquivo vai no controle de versao, e e no diff do commit que alguem
// confere se o novo esperado esta certo.
//
// Para aceitar uma mudanca intencional:
//     DF_ATUALIZAR_SNAPSHOT=1 dataforge crucible
//
// Atualizar por padrao seria pior que nao ter instantaneo: o teste
// passaria sempre, gravando o errado por cima do certo.

crucible "o relatorio":
    trial "nao muda sem aviso":
        Crucible.snapshot("relatorio_do_acervo", relatorio())

    trial "e o texto grande tambem entra":
        html := "<table>\n  <tr><td>Duna</td></tr>\n</table>"
        Crucible.snapshot("tabela_html", html)

// ── 3. Banco que se desfaz ──────────────────────────────────
//
// O problema: um teste que grava deixa a linha la, e o teste seguinte
// a encontra. A suite passa na ordem em que foi escrita e falha em
// qualquer outra — e '--aleatorio' expoe isso de um jeito que parece
// intermitente.
//
// 'Crucible.banco(db)' abre uma transacao e a desfaz no fim do trial,
// SEMPRE. Apagar tudo entre testes seria a alternativa, e e mais lenta
// e mais fragil: ela precisa saber a ordem das chaves estrangeiras.

crucible "isolamento de banco":
    Crucible.before(lambda suite: Crucible.banco(db))

    trial "um teste que grava":
        Banco.insert(db, "livros", {"titulo": "Solaris", "preco": 54.9})
        Crucible.expect(Banco.count(db, "livros")).to_be(3)

    trial "e o seguinte nao ve o que ele gravou":
        Crucible.expect(Banco.count(db, "livros")).to_be(2)

    trial "nem na ordem inversa":
        Banco.insert(db, "livros", {"titulo": "Fundacao", "preco": 49.9})
        Crucible.expect(Banco.count(db, "livros")).to_be(3)

// ── 4. Teste instavel ──────────────────────────────────────
//
// Existe para o que depende de rede, de relogio ou de escalonamento —
// e NAO para esconder um bug. Ele conta as tentativas: um teste que
// precisa de tres toda vez nao e instavel, esta quebrado, e o numero e
// o que denuncia isso.

crucible "instabilidade":
    trial "tenta de novo antes de desistir":
        tentativas := {"n": 0}

        action falha_duas_vezes():
            tentativas["n"] := tentativas["n"] + 1
            given tentativas["n"] smaller 3:
                trigger "a rede ainda nao respondeu"
            yield yes

        r := Crucible.flaky(falha_duas_vezes, 5)
        Crucible.expect(r["ok"]).to_be(yes)
        Crucible.expect(r["tentativas"]).to_be(3)

    trial "e desiste depois do limite":
        // 'to_raise' nao serve aqui: o que 'flaky' levanta ao desistir
        // e a propria falha de expectativa do Crucible, e ela e o
        // sinal de teste reprovado — nao um erro a capturar.
        desistiu := no
        monitor:
            Crucible.flaky(lambda: trigger("sempre falha"), 2)
        handle Error as e:
            desistiu := "2 tentativas" in e.message
        Crucible.expect(desistiu).to_be(yes)

// ── 5. Rodar ────────────────────────────────────────────────
//
// Fora do 'dataforge crucible', os instantaneos precisam saber de qual
// arquivo sao — quem roda pelo comando nao precisa disto.

Crucible.snapshot_dir(__file__)
r := Crucible.run()
assert r["falhou"] is 0, $"{r["falhou"]} falharam"
assert r["erro"] is 0
assert r["passou"] is 7

out "226 ok — instantaneo e isolamento"

Instantâneo#

dataforge
trial "o relatorio nao muda sem aviso":
    Crucible.snapshot("relatorio", gerar_relatorio())

Para o que é grande demais para escrever à mão no teste: o HTML de uma página, o relatório de trinta linhas, o JSON de uma rota. Escrever o esperado à mão para isso dá um teste que ninguém mantém — e um teste que ninguém mantém vira um teste que alguém comenta.

Na primeira vez ele grava e passa. É o único jeito de começar, e por isso o arquivo vai no controle de versão: é no diff do commit que alguém confere se o novo esperado está certo.

Para aceitar uma mudança intencional:

bash
DF_ATUALIZAR_SNAPSHOT=1 dataforge crucible

Atualizar por padrão seria pior que não ter instantâneo: o teste passaria sempre, gravando o errado por cima do certo.

Os arquivos ficam em __snapshots__/<arquivo>.snap.json, ao lado do teste — assim andam junto num git mv, e o diff mostra os dois lado a lado. As chaves saem ordenadas: um vault que muda de ordem de inserção faria o instantâneo falhar sem nada ter mudado de verdade.

Quando muda, a mensagem traz o diff, e não os dois textos inteiros — trezentas linhas lado a lado num terminal são ilegíveis, e ter trezentas linhas é justamente o motivo de usar instantâneo.

Banco que se desfaz#

dataforge
crucible "cadastro":
    Crucible.before(lambda suite: Crucible.banco(db))

    trial "grava":
        Banco.insert(db, "livros", {...})
        Crucible.expect(Banco.count(db, "livros")).to_be(3)

    trial "e o seguinte nao ve":
        Crucible.expect(Banco.count(db, "livros")).to_be(2)

O problema: um teste que grava deixa a linha lá, e o teste seguinte a encontra. A suíte passa na ordem em que foi escrita e falha em qualquer outra — e --aleatorio expõe isso de um jeito que parece intermitente.

Crucible.banco(db) abre uma transação e a desfaz no fim do trial, sempre. Apagar tudo entre testes seria a alternativa, e é mais lenta e mais frágil: ela precisa saber a ordem das chaves estrangeiras.

Teste instável#

dataforge
r := Crucible.flaky(consultar_a_api, 3, 0.5)
Crucible.expect(r["ok"]).to_be(yes)

Existe para o que depende de rede, de relógio ou de escalonamento — e não para esconder um bug. Por isso ele devolve o número de tentativas: um teste que precisa de três toda vez não é instável, está quebrado, e o número é o que denuncia isso.

Armadilha#

Crucible.expect(…).to_raise() não captura o que flaky levanta ao desistir: aquilo é a própria falha de expectativa do Crucible, que é o sinal de teste reprovado — não um erro a capturar. Use monitor/handle quando quiser conferir a desistência.

Continua em#

229 · Depurar sem 'out'#

exercicios/31-qualidade/229_depurar.df
// ════════════════════════════════════════════════════════════
//  Exercicio 229 — Depurar sem 'out'
//
//  'out' no meio do codigo e o depurador mais usado do mundo, e ele
//  tem tres defeitos: muda o que voce esta medindo, tem de ser
//  removido depois, e nao mostra o que voce NAO pensou em imprimir.
//
//  Este exercicio nao usa o depurador interativo — ele exige um
//  terminal ou um editor. Ele exercita as DUAS pecas em que o
//  depurador se apoia, e que dao para verificar com 'assert': saber
//  quais linhas sao executaveis, e ler o valor no quadro certo.
// ════════════════════════════════════════════════════════════

// ── O programa que vamos investigar ─────────────────────────
//
// Ele tem um bug de verdade: a media de uma lista vazia.

action media(nums):
    soma := 0
    cycle n in nums:
        soma := soma + n
    yield soma / len(nums)

// ── 1. O erro diz o arquivo, a linha e a coluna ─────────────
//
// E o primeiro depurador: um stack trace que aponta o lugar.

monitor:
    media([])
    assert no  // nao chega aqui
handle Error as e:
    assert e.type is "DivisionByZeroError"
    // A linha e a do 'yield', dentro da acao — e nao a da chamada.
    assert e.line is 22

// ── 2. A pilha diz quem chamou quem ─────────────────────────
//
// Numa acao chamada de cinco lugares, "deu erro em media()" nao
// ajuda: o que importa e QUAL das cinco chamadas.

action relatorio(vendas):
    yield media(vendas)

monitor:
    relatorio([])
handle Error as e:
    nomes := [q["name"] cycle q in e.stack]
    assert "media" in nomes
    assert "relatorio" in nomes
    // Do mais externo para o mais interno: e a ordem em que se le
    // "quem chamou quem".
    assert nomes.index("relatorio") smaller nomes.index("media")

// ── 3. Consertado, com o caso que faltava ───────────────────

action media2(nums):
    given len(nums) is 0:
        yield 0.0
    soma := 0
    cycle n in nums:
        soma := soma + n
    yield soma / len(nums)

assert media2([]) is 0.0
assert media2([2, 4, 6]) is 4.0

// ── 4. 'defer' mostra sem sujar o caminho de saida ──────────
//
// Um 'out' antes de cada 'yield' precisa ser repetido em cada saida
// da acao — e a que voce esquecer e justamente a que da errado.
// 'defer' roda na saida, qualquer uma delas.

saidas := []

action classificar(n):
    // 'defer' abre BLOCO — nao aceita expressao na mesma linha.
    defer:
        saidas.append($"classificar({n}) saiu")
    given n smaller 0:
        yield "negativo"
    given n is 0:
        yield "zero"
    yield "positivo"

assert classificar(-5) is "negativo"
assert classificar(0) is "zero"
assert classificar(7) is "positivo"

// Tres saidas diferentes, tres registros — nenhum esquecido.
assert len(saidas) is 3
assert saidas[0] is "classificar(-5) saiu"

// ── 5. A pilha atravessa 'monitor' aninhado ─────────────────
//
// O quadro onde o erro NASCEU e o que importa, e nao o ponto onde
// alguem decidiu capturar. Um 'handle' tres niveis acima ainda ve o
// caminho inteiro.

action nivel3():
    trigger "o disco encheu"

action nivel2():
    yield nivel3()

action nivel1():
    monitor:
        yield nivel2()
    handle RuntimeError:
        // NAO pega: 'trigger' levanta TriggerError. A armadilha 18.
        yield "nao passa por aqui"

monitor:
    nivel1()
    assert no
handle Error as e:
    nomes := [q["name"] cycle q in e.pilha]
    assert nomes is ["nivel1", "nivel2", "nivel3"]

    // Cada quadro diz onde estava a chamada — arquivo inclusive, que e
    // o que faz a pilha servir num projeto de 200 arquivos.
    cycle q in e.pilha:
        assert q["line"] bigger 0
        assert "229_depurar" in q["file"]

// ── 6. Duas linhas diferentes, e as duas estao certas ──────
//
// E a confusao mais comum ao ler uma pilha:
//
//   e.line          onde o erro NASCEU
//   quadro["line"]  onde a CHAMADA foi feita
//
// Sao numeros diferentes de propósito. Para consertar, voce quer o
// primeiro; para entender por que aquela acao foi chamada com aquele
// argumento, o segundo.

monitor:
    media([])
handle Error as e:
    // Onde estourou: dentro de 'media', no 'yield'.
    assert e.line is 22

    // Onde 'media' foi chamada: aqui, DEPOIS da declaracao — e por
    // isso o numero e maior. Sao dois numeros diferentes, e as duas
    // respostas estao certas para perguntas diferentes.
    assert e.pilha[0]["name"] is "media"
    assert e.pilha[0]["line"] bigger e.line

    // E o arquivo, em cada quadro. Uma acao declarada num arquivo e
    // chamada de outro ja reportou a linha certa com o NOME do arquivo
    // errado — e isso manda a pessoa depurar o arquivo errado.
    assert "229_depurar.df" in e.pilha[0]["file"]

out "229 ok — depurar"

Por que não `out`#

out no meio do código é o depurador mais usado do mundo, e tem três defeitos:

DefeitoConsequência
muda o que você está medindoo out num laço apertado altera o tempo que você queria medir
tem de ser removido depoise o que sobrar vira ruído na saída de produção
só mostra o que você pensou em imprimiro valor que explica o bug é justamente o que você não suspeitou

O terceiro é o pior. Um depurador mostra tudo que está vivo naquele ponto, incluindo o que você não sabia que precisava ver.

O que este exercício exercita#

O depurador interativo precisa de um terminal (dataforge debug) ou de um editor (F5). Nenhum dos dois cabe num assert. O que cabe — e é o que este exercício cobra — são as informações em que ele se apoia, e que a linguagem entrega ao programa:

dataforge
monitor:
    media([])
handle Error as e:
    out e.type       // "DivisionByZeroError"
    out e.line       // 22  a linha do yield, onde nasceu
    out e.pilha      // quem chamou quem

`e.pilha` — quem chamou quem#

Numa ação chamada de cinco lugares, "deu erro em `media()`" não ajuda: o que importa é qual das cinco chamadas.

dataforge
handle Error as e:
    nomes := [q["name"] cycle q in e.pilha]
    assert nomes is ["nivel1", "nivel2", "nivel3"]

Do mais externo para o mais interno — a ordem em que se lê "quem chamou quem", e a mesma em que o stack trace desenha. Inverter aqui faria o programa e a tela discordarem sobre a mesma pilha.

Cada quadro é um vault com name, line, column e file. O file é o que faz a pilha servir num projeto de 200 arquivos.

e.stack é o mesmo — o nome em inglês, para quem já conhece a palavra.

As duas linhas, e as duas estão certas#

É a confusão mais comum ao ler uma pilha:

CampoO que é
e.lineonde o erro nasceu
quadro["line"]onde a chamada foi feita
dataforge
monitor:
    media([])            // ← quadro["line"] aponta para cá
handle Error as e:
    assert e.line is 22  // ← dentro de 'media', no 'yield'
    assert e.pilha[0]["line"] bigger e.line

Números diferentes, de propósito. Para consertar você quer o primeiro; para entender por que aquela ação foi chamada com aquele argumento, o segundo.

Um erro numa ação declarada num arquivo e chamada de outro já reportou a linha certa com o nome do arquivo errado — e o trecho desenhado embaixo da seta vinha do arquivo de quem chamou. Isso manda a pessoa depurar o arquivo errado, e é o pior tipo de mensagem de erro: confiante e errada.

`handle RuntimeError` não pega um `trigger`#

A armadilha 18, e ela aparece neste exercício de propósito:

dataforge
action nivel1():
    monitor:
        yield nivel2()
    handle RuntimeError:
        yield "nao passa por aqui"      // nunca roda

trigger levanta TriggerError. Para pegar qualquer coisa, handle Error. Um handle do tipo errado é invisível: o código parece tratar o erro, e o erro passa por cima dele.

`defer` mostra sem sujar o caminho de saída#

Um out antes de cada yield precisa ser repetido em cada saída da ação — e a que você esquecer é justamente a que dá errado.

dataforge
action classificar(n):
    defer:
        saidas.append($"classificar({n}) saiu")
    given n smaller 0:
        yield "negativo"
    given n is 0:
        yield "zero"
    yield "positivo"

Três saídas diferentes, três registros, nenhum esquecido. defer abre bloco — não aceita expressão na mesma linha.

O depurador de verdade#

Quando você tem terminal ou editor:

bash
dataforge debug conta.df              # para na primeira instrução
dataforge debug conta.df --parar=42   # só na linha 42
dataforge debug conta.df --vigiar=saldo   # para quando 'saldo' mudar

Dentro dele: p passo, n próximo, f sai da ação, c continua, vars lista o escopo, pilha mostra quem chamou quem — e qualquer expressão é avaliada no quadro onde você parou. w saldo vigia o valor: a próxima parada é na linha que o mudou, com o antes e o depois.

No VS Code, clique na margem e aperte F5. Os breakpoints, a pilha, as variáveis em árvore e o console de avaliação ficam no painel. O adaptador é dataforge dap, que fala Debug Adapter Protocol — o mesmo protocolo do Neovim, do Helix e do Emacs.

Duas coisas que o depurador faz e que não são óbvias:

  • uma parada em comentário é movida para a próxima linha executável,

e o painel mostra onde ficou. Uma parada que nunca dispara, mostrada acesa, é o pior dos dois mundos;

  • as 228 embutidas não aparecem no painel de variáveis. Elas vivem

no escopo global, e despejá-las enterra as três variáveis que você parou para ver.


Rode um isolado com dataforge run exercicios/31-qualidade/227_cobertura.df.