Pular para o conteúdo

Testes avançados em Rust: mocks, testes de propriedade e benchmarks

Você já sabe escrever um teste com #[test] e assert_eq! (se ainda não, comece por Testes em Rust). Para programas pequenos, isso basta. Mas em um projeto de verdade logo aparecem perguntas novas. Onde os testes devem ficar? Como testar um código que envia e-mails ou consulta o relógio? Como achar justamente aquela entrada em que você não pensou? E a sua versão “mais rápida” é mesmo mais rápida?

Pense em uma oficina mecânica. Os testes básicos conferem se cada peça funciona. Esta página traz o resto da oficina: uma bancada para testar peças soltas (fakes e mocks), uma máquina que sacode o carro com milhares de solavancos aleatórios (testes de propriedade), uma foto de como tudo estava da última vez (snapshots) e um cronômetro em que você pode confiar (benchmarks).

Um aviso sincero antes de começar: o botão Run do Rust Playground roda a main, não os testes, e o Playground não tem os crates de teste usados aqui. Por isso, a maioria dos exemplos desta página mostra a saída real de cargo test e cargo bench de um projeto pequeno em um computador comum. Quando o programa tem uma main, há um botão “Experimente” como sempre.

Esta é a estrutura da pequena biblioteca shop usada nesta página:

shop/
├── Cargo.toml
├── src/
│ ├── lib.rs <- código + testes unitários em `mod tests`
│ └── orders.rs
├── tests/
│ ├── checkout.rs <- um teste de integração
│ └── common/
│ └── mod.rs <- helpers compartilhados pelos testes de integração
└── benches/
└── stock.rs <- um benchmark

Rust tem três tipos de teste, e o cargo test roda todos eles:

  • Testes unitários ficam ao lado do código, em um bloco #[cfg(test)] mod tests. Eles também enxergam as funções privadas.
  • Testes de integração ficam na pasta tests/. Cada arquivo é compilado como um crate separado que usa a sua biblioteca de fora, exatamente como um usuário faria. Por isso, eles só podem chamar itens pub.
  • Doc tests (testes de documentação) são os exemplos de código dentro dos comentários ///. Eles provam que a sua documentação continua funcionando. A página Documentação em Rust fala deles em detalhes.

Aqui está uma parte do src/lib.rs. Repare no doc test de apply_discount e nas funções helper e fixture dentro de mod tests:

/// Uma linha de um carrinho de compras. Os preços são em centavos.
#[derive(Debug, Clone, PartialEq)]
pub struct Item {
pub name: String,
pub price_cents: u32,
pub qty: u32,
}
pub fn cart_total(items: &[Item]) -> u32 {
items.iter().map(|i| i.price_cents * i.qty).sum()
}
/// Tira `percent` (uma porcentagem) de um preço.
///
/// ```
/// use shop::apply_discount;
///
/// assert_eq!(apply_discount(1000, 25), 750);
/// ```
pub fn apply_discount(price_cents: u32, percent: u32) -> u32 {
assert!(percent <= 100, "discount must be at most 100%, got {percent}%");
price_cents - price_cents * percent / 100
}
/// Transforma "12.50" em 1250 centavos.
pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// Transforma 1250 centavos em "12.50".
pub fn format_price(cents: u32) -> String {
format!("{}.{:02}", cents / 100, cents % 100)
}
#[cfg(test)]
mod tests {
use super::*;
// Um helper: curto de chamar, e todos os testes criam itens do mesmo jeito.
fn item(name: &str, price_cents: u32, qty: u32) -> Item {
Item { name: name.to_string(), price_cents, qty }
}
// Um fixture: um carrinho realista que vários testes compartilham.
fn sample_cart() -> Vec<Item> {
vec![item("coffee", 450, 2), item("bagel", 300, 1)]
}
#[test]
fn total_of_sample_cart() {
assert_eq!(cart_total(&sample_cart()), 1200);
}
#[test]
fn empty_cart_costs_nothing() {
assert_eq!(cart_total(&[]), 0);
}
#[test]
#[should_panic(expected = "at most 100%")]
fn discount_over_100_panics() {
apply_discount(1000, 150);
}
// Um teste pode devolver Result, então você pode usar `?` dentro dele.
#[test]
fn parses_a_price() -> Result<(), String> {
let cents = parse_price("12.50")?;
assert_eq!(cents, 1250);
Ok(())
}
#[test]
#[ignore = "slow: checks ten million prices"]
fn every_price_round_trips() {
for cents in 0..10_000_000 {
assert_eq!(parse_price(&format_price(cents)), Ok(cents));
}
}
}

Algumas coisas que vale notar:

  • Helpers e fixtures são só funções. Rust não tem um sistema especial de fixtures, e você quase nunca vai sentir falta de um. Como eles ficam dentro de #[cfg(test)], nunca vão parar no seu programa de verdade.
  • #[should_panic(expected = "...")] só passa se a mensagem do panic contiver esse texto, então um panic causado por outro bug não conta. Testes em Rust explica isso com mais exemplos.
  • Testes que devolvem Result deixam você usar ?. Se o teste devolver Err, ele falha e mostra o erro, por exemplo Error: "missing '.' in \"12,50\"".
  • #[ignore] pula um teste lento ou especial por padrão. O texto depois do = é o motivo, e o cargo test mostra esse texto.

As mensagens e os nomes dos testes ficam em inglês para bater com a saída real do cargo test que você vai ver logo abaixo.

O teste de integração usa a biblioteca pela API pública. O helper compartilhado dele fica em tests/common/mod.rs:

tests/common/mod.rs
use shop::Item;
pub fn breakfast_cart() -> Vec<Item> {
vec![
Item { name: "coffee".into(), price_cents: 450, qty: 2 },
Item { name: "bagel".into(), price_cents: 300, qty: 1 },
]
}
tests/checkout.rs
mod common;
use shop::{apply_discount, cart_total, format_price};
#[test]
fn discounted_breakfast_total() {
let total = cart_total(&common::breakfast_cart());
let to_pay = apply_discount(total, 10);
println!("to pay: {}", format_price(to_pay));
assert_eq!(format_price(to_pay), "10.80");
}

Rodar cargo test mostra uma seção para cada tipo de teste:

Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 5 tests
test tests::every_price_round_trips ... ignored, slow: checks ten million prices
test tests::parses_a_price ... ok
test tests::empty_cart_costs_nothing ... ok
test tests::total_of_sample_cart ... ok
test tests::discount_over_100_panics - should panic ... ok
test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s
Running tests/checkout.rs (target/debug/deps/checkout-c1e4aa0cf6375c2a)
running 1 test
test discounted_breakfast_total ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests shop
running 1 test
test src/lib.rs - apply_discount (line 16) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

No dia a dia, nem sempre você quer rodar todos os testes. Estes comandos ajudam. Tudo o que vem depois de um -- sozinho vai para o próprio programa de teste, não para o Cargo.

Terminal window
cargo test discount # só os testes cujo nome contém "discount"
cargo test --test checkout # só o arquivo tests/checkout.rs
cargo test --lib # só os testes unitários em src/
cargo test --doc # só os doc tests
cargo test -- --ignored # só os testes com #[ignore]
cargo test -- --include-ignored # tudo, inclusive os ignorados
cargo test -- --nocapture # mostra a saída de println! dos testes que passam
cargo test -- --test-threads=1 # roda um teste de cada vez

O filtro procura uma parte do nome completo do teste, em todos os arquivos de teste. Aqui, discount encontra um teste unitário e um teste de integração:

Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 1 test
test tests::discount_over_100_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.09s
Running tests/checkout.rs (target/debug/deps/checkout-630939367802bf1e)
running 1 test
test discounted_breakfast_total ... ok

O teste lento só roda quando você pede. Dez milhões de idas e voltas levaram uns 3 segundos aqui:

running 1 test
test tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20s

Por padrão, Rust esconde a saída de println! dos testes que passam, para o relatório ficar limpo. O --nocapture mostra essa saída:

$ cargo test --test checkout -- --nocapture
running 1 test
to pay: 10.80
test discounted_breakfast_total ... ok

Tem código que conversa com o mundo lá fora: envia e-mails, chama uma API ou consulta o relógio. Você não quer que seus testes enviem e-mails de verdade. E também não dá para testar “o que acontece às 21h” se o código sempre pergunta a hora ao relógio real.

A resposta idiomática em Rust não precisa de nenhuma biblioteca. Descreva a dependência como um trait, faça a sua função aceitar qualquer coisa que o implemente e escreva um pequeno fake (uma versão de mentira) para os testes. O programa de verdade passa a peça real. Os testes passam o fake.

Aqui, confirmar um pedido envia um e-mail para o cliente, e os pedidos grandes também avisam a equipe de vendas:

trait Mailer {
fn send(&mut self, to: &str, body: &str);
}
// O mailer real. Em um app de verdade, ele chamaria um serviço de e-mail.
struct ConsoleMailer;
impl Mailer for ConsoleMailer {
fn send(&mut self, to: &str, body: &str) {
println!("e-mail para {to}: {body}");
}
}
struct Order {
id: u32,
email: String,
total_cents: u32,
}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) {
let body = format!("Pedido #{} confirmado", order.id);
mailer.send(&order.email, &body);
if order.total_cents >= 10_000 {
mailer.send("[email protected]", &format!("Pedido grande #{}", order.id));
}
}
fn main() {
let mut mailer = ConsoleMailer;
let small = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
let big = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&small, &mut mailer);
confirm_order(&big, &mut mailer);
}
#[cfg(test)]
mod tests {
use super::*;
// Um fake: ele não envia nada, só anota cada chamada.
#[derive(Default)]
struct FakeMailer {
sent: Vec<(String, String)>,
}
impl Mailer for FakeMailer {
fn send(&mut self, to: &str, body: &str) {
self.sent.push((to.to_string(), body.to_string()));
}
}
#[test]
fn small_order_sends_one_email() {
let mut fake = FakeMailer::default();
let order = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1);
assert_eq!(fake.sent[0].0, "[email protected]");
}
#[test]
fn big_order_also_tells_sales() {
let mut fake = FakeMailer::default();
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2);
assert_eq!(fake.sent[1], ("[email protected]".to_string(), "Pedido grande #8".to_string()));
}
}

Saída:

e-mail para [email protected]: Pedido #7 confirmado
e-mail para [email protected]: Pedido #8 confirmado
e-mail para [email protected]: Pedido grande #8
▶ Experimente você mesmo

Este exemplo não usa crates externos, então no Playground você também pode escolher Test no menu ao lado de Run:

running 2 tests
test tests::small_order_sends_one_email ... ok
test tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

A confirm_order não sabe nem quer saber qual mailer recebe. Esse é todo o truque. A mesma ideia funciona para um trait Clock (os testes usam um relógio parado às 21h), um PaymentGateway ou um trait Storage que, nos testes, guarda tudo em um HashMap. Você vai praticar a versão do relógio mais abaixo.

Escrever fakes à mão funciona bem para um ou dois traits. Quando são muitos, o crate mockall (em inglês) pode gerá-los para você. Adicione-o como dev-dependency (dependência de desenvolvimento), para que ele só seja usado em testes e exemplos (Cargo e crates explica as seções do Cargo.toml):

Terminal window
cargo add --dev mockall

Coloque #[automock] no trait, e o mockall cria um tipo MockMailer. A parte cfg_attr(test, ...) quer dizer “só faça isso ao rodar os testes”, então o mock nunca vai parar no seu build de verdade. Features do Cargo e #[cfg] explica o cfg com mais detalhes.

src/orders.rs
#[cfg(test)]
use mockall::automock;
#[cfg_attr(test, automock)]
pub trait Mailer {
fn send(&mut self, to: &str, body: &str);
}
// Order e confirm_order são iguais aos de cima.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn big_order_also_tells_sales() {
let mut mailer = MockMailer::new();
mailer
.expect_send()
.withf(|to, _body| to == "[email protected]")
.times(1)
.return_const(());
mailer
.expect_send()
.withf(|to, body| to == "[email protected]" && body == "Pedido grande #8")
.times(1)
.return_const(());
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut mailer);
}
}

Cada expect_send() é uma expectativa (expectation): “espero que send seja chamado com argumentos que satisfaçam o withf, exatamente uma vez”. O return_const(()) diz o que o mock devolve (aqui, nada). Quando o mock é descartado no fim do teste, o mockall confere se todas as expectativas foram cumpridas.

Para ver o mockall pegando um bug, mude a regra em confirm_order para order.total_cents > 12_000. Agora o e-mail para vendas nunca é enviado:

thread 'orders::tests::big_order_also_tells_sales' (12345) panicked at src/orders.rs:4:18:
MockMailer::send: Expectation(<function>) called 0 time(s) which is fewer than expected 1

A mensagem do mockall fica em inglês, como no seu terminal: ela diz que send foi chamado 0 vezes, menos do que a 1 vez esperada.

Fake ou mock? Comece com um fake escrito à mão. É Rust comum, fácil de ler e não traz nenhuma dependência extra. Parta para o mockall quando você tiver muitos traits para fingir, ou quando realmente importar que algo seja “chamado exatamente N vezes com estes argumentos”.

Um teste normal confere um exemplo que você escolheu: “12.50 vira 1250”. Um teste de propriedade (property test) descreve uma regra que deve valer para qualquer entrada, e depois uma biblioteca tenta centenas de entradas aleatórias para quebrá-la. É como contratar alguém muito paciente para testar todos os valores estranhos em que você não pensou.

Boas propriedades costumam ter esta cara:

  • Fazer algo duas vezes desfaz o que foi feito: inverter uma lista duas vezes devolve a lista original.
  • Uma ida e volta devolve o que você tinha no começo: parse(format(x)) == x.
  • Algum fato sempre vale: uma lista ordenada tem o mesmo tamanho da lista de entrada.

Adicione o proptest (em inglês) com cargo add --dev proptest e escreva os testes dentro da macro proptest!. Cada linha nome in estratégia diz que tipo de valor aleatório gerar:

tests/properties.rs
use proptest::prelude::*;
use shop::{format_price, parse_price};
proptest! {
#[test]
fn reversing_twice_gives_back_the_original(list in prop::collection::vec(any::<i32>(), 0..100)) {
let mut copy = list.clone();
copy.reverse();
copy.reverse();
prop_assert_eq!(copy, list);
}
#[test]
fn price_survives_format_then_parse(cents in 0u32..10_000_000) {
prop_assert_eq!(parse_price(&format_price(cents)), Ok(cents));
}
}

any::<i32>() quer dizer “qualquer i32”, 0u32..10_000_000 quer dizer um número nesse intervalo, e vec(..., 0..100) cria listas de até 99 itens. Por padrão, cada propriedade roda 256 casos aleatórios. Dentro de proptest!, use prop_assert_eq! em vez de assert_eq!, para o proptest conseguir tratar a falha direitinho. Os dois passam:

running 2 tests
test price_survives_format_then_parse ... ok
test reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Repare que essa é a mesma verificação do teste lento com #[ignore] lá de cima. Aquele teste tentou todos os dez milhões de preços em 3 segundos. O teste de propriedade tenta 256 preços aleatórios em um centésimo de segundo, então ele pode rodar sempre.

Agora vamos errar de propósito. Alguém “melhora” a format_price para mostrar um separador de milhar, como 1,000.00. Fica mais bonito, mas a pessoa esquece da parse_price:

pub fn format_price(cents: u32) -> String {
let euros = cents / 100;
let euros = if euros >= 1000 {
format!("{},{:03}", euros / 1000, euros % 1000)
} else {
euros.to_string()
};
format!("{euros}.{:02}", cents % 100)
}

Esta é a saída real de cargo test --test properties, um pouco resumida:

---- price_survives_format_then_parse stdout ----
proptest: Saving this and future failures in tests/properties.proptest-regressions
thread 'price_survives_format_then_parse' (12345) panicked at tests/properties.rs:4:1:
Test failed: assertion failed: `(left == right)`
left: `Err("bad euros in \"1,000.00\"")`,
right: `Ok(100000)` at tests/properties.rs:15.
minimal failing input: cents = 100000

Olhe para minimal failing input: cents = 100000 (“menor entrada que falha”). O primeiro valor aleatório que falhou provavelmente era algo bagunçado, como 7,384,912. Aí o proptest o reduz (shrinking): ele continua tentando valores menores que ainda falham e mostra o mais simples. 100000 centavos são exatamente 1,000.00, o primeiro preço que ganha uma vírgula. Isso aponta direto para o bug.

O proptest também salva o caso que falhou em um arquivo .proptest-regressions ao lado do teste, e tenta esse caso primeiro em todas as execuções seguintes. Faça commit desse arquivo, para o bug nunca voltar sem você perceber. Se você preferir uma biblioteca menor, o quickcheck (em inglês) faz o mesmo trabalho com menos recursos.

Algumas saídas são longas: um recibo, um relatório de erros, um documento JSON grande. Escrever um assert_eq! com o texto esperado inteiro cansa. Um teste de snapshot salva a saída em um arquivo na primeira vez e, nas execuções seguintes, compara a saída nova com essa “foto” salva.

Com o insta (em inglês; cargo add --dev insta), o teste tem uma linha só:

pub fn receipt(items: &[Item]) -> String {
let mut out = String::new();
for i in items {
let line = format_price(i.price_cents * i.qty);
out.push_str(&format!("{:<10} x{} {:>7}\n", i.name, i.qty, line));
}
out.push_str(&format!("{:<14}{:>7}\n", "TOTAL", format_price(cart_total(items))));
out
}
#[test]
fn receipt_layout() {
insta::assert_snapshot!(receipt(&sample_cart()));
}

A primeira execução falha de propósito e grava um arquivo .snap.new para você revisar. Depois que você o aceita (com cargo insta review, da ferramenta cargo-insta, ou renomeando o arquivo para .snap), ele fica em src/snapshots/:

---
source: src/lib.rs
assertion_line: 84
expression: receipt(&sample_cart())
---
coffee x2 9.00
bagel x1 3.00
TOTAL 12.00

Mais tarde, se alguém trocar "TOTAL" por "Total", o teste falha com um diff bem claro:

-old snapshot
+new results
────────────┬───────────────────────────────────────────────────────────────────
1 1 │ coffee x2 9.00
2 2 │ bagel x1 3.00
3 │-TOTAL 12.00
3 │+Total 12.00
────────────┴───────────────────────────────────────────────────────────────────
To update snapshots run `cargo insta review` or set `INSTA_UPDATE=always`

Se a mudança foi de propósito, você aceita o snapshot novo. Se não foi, você acabou de encontrar um bug. Snapshots são ótimos para conferir que “isto não mudou sem querer”, mas eles não dizem se a saída está certa. Leia o primeiro snapshot com calma antes de aceitá-lo.

Quando você quer saber se um código é rápido, meça. Chutar dá errado com uma frequência surpreendente. Cronometrar o código dentro de um #[test] também não funciona bem: os testes são compilados sem otimizações, e uma única execução tem ruído demais.

O criterion (em inglês) é a ferramenta mais usada no Rust estável. Ele roda o seu código muitas vezes, faz as contas de estatística para você e lembra o último resultado para dizer se você ficou mais rápido ou mais lento. Adicione-o com cargo add --dev criterion, depois avise o Cargo sobre o arquivo de benchmark e desligue o harness (executor de testes) embutido:

[[bench]]
name = "stock"
harness = false

Este benchmark pergunta: para saber se um código de produto está em estoque, o que é mais rápido com 1.000 códigos, um Vec ou um HashSet?

benches/stock.rs
use std::collections::HashSet;
use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
fn stock_lookup(c: &mut Criterion) {
// 1.000 códigos de produto, como "SKU-0", "SKU-1", ...
let codes: Vec<String> = (0..1000).map(|n| format!("SKU-{n}")).collect();
let code_set: HashSet<String> = codes.iter().cloned().collect();
let wanted = String::from("SKU-999"); // o pior caso para um Vec: o último
c.bench_function("vec contains", |b| {
b.iter(|| codes.contains(black_box(&wanted)))
});
c.bench_function("hashset contains", |b| {
b.iter(|| code_set.contains(black_box(&wanted)))
});
}
criterion_group!(benches, stock_lookup);
criterion_main!(benches);

b.iter(...) é o código que está sendo medido. O std::hint::black_box esconde um valor do otimizador. Sem ele, o compilador poderia perceber que a resposta nunca muda, calcular tudo uma vez só, e você estaria cronometrando o nada.

Rode com cargo bench, que sempre compila no modo otimizado (release). Os seus números vão ser diferentes no seu computador:

vec contains time: [2.4222 µs 2.4470 µs 2.4742 µs]
Found 2 outliers among 100 measurements (2.00%)
1 (1.00%) low mild
1 (1.00%) high mild
hashset contains time: [18.514 ns 18.891 ns 19.312 ns]
Found 8 outliers among 100 measurements (8.00%)
4 (4.00%) high mild
4 (4.00%) high severe

Como ler isso: os três tempos formam um intervalo. O do meio é a melhor estimativa, e os das pontas dizem “o valor real quase certamente está entre estes dois”. Aqui o HashSet leva uns 19 nanossegundos e o Vec uns 2,4 microssegundos, então o conjunto é mais de 100 vezes mais rápido neste caso. Os “outliers” (valores fora da curva) são algumas execuções anormalmente lentas ou rápidas, muitas vezes causadas por outros programas no seu computador. Ter alguns é normal.

Rode de novo depois de uma mudança, e o criterion compara com a última execução:

vec contains time: [2.5124 µs 2.5430 µs 2.5773 µs]
change: [−0.5279% +0.8368% +2.1133%] (p = 0.22 > 0.05)
No change in performance detected.

A mudança é menor que 1% e pode ser só ruído, então o criterion diz “No change” (nenhuma mudança). Ele também gera um relatório em HTML em target/criterion/. Para saber quando (e quando não) se preocupar com velocidade, veja Desempenho em Rust.

Em código mais antigo, você também pode ver funções com #[bench]. Elas usam o test::Bencher embutido, que só funciona no compilador nightly:

#![feature(test)] // só no nightly
extern crate test;
#[bench]
fn bench_total(b: &mut test::Bencher) {
b.iter(|| (1..=1000u64).sum::<u64>());
}

No Rust estável, use o criterion.

Depois que você tem testes, mais duas ferramentas ajudam. Você as instala com cargo install, como as outras ferramentas de Ferramentas do Rust.

O cargo-nextest é um executor de testes mais rápido. Ele roda cada teste em um processo próprio, mostra uma saída mais clara e pode repetir testes instáveis (flaky). Um detalhe importante: ele não roda doc tests, então rode cargo test --doc também.

Terminal window
cargo install cargo-nextest --locked
cargo nextest run

A cobertura (coverage) mostra quais linhas os seus testes realmente executaram. O cargo-llvm-cov usa o suporte a cobertura que já vem no compilador e funciona no Linux, no macOS e no Windows:

Terminal window
cargo install cargo-llvm-cov
cargo llvm-cov # tabela de resumo no terminal
cargo llvm-cov --html # um relatório navegável em target/llvm-cov/html

O cargo-tarpaulin é uma alternativa mais antiga e popular. Ele funciona melhor no Linux e é uma escolha comum em CI:

Terminal window
cargo install cargo-tarpaulin
cargo tarpaulin --out Html

Trate a cobertura como um mapa, não como uma nota. Ela mostra o código que nenhum teste toca. Ela não consegue dizer se os seus asserts conferem a coisa certa. 80% de cobertura com bons asserts vale mais que 100% com asserts fracos.

Em CI (GitHub Actions, GitLab e afins), um bom ponto de partida são os mesmos três comandos que você roda na sua máquina, assim uma verificação que falha quer dizer a mesma coisa em todo lugar:

Terminal window
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test

Testes de integração para um crate só com binário. Se o seu projeto tem apenas src/main.rs, os arquivos em tests/ não conseguem importar nada dele:

error[E0432]: unresolved import `todo`
--> tests/tasks.rs:1:5
|
1 | use todo::add_task;
| ^^^^ use of unresolved module or unlinked crate `todo`

O compilador diz que não conseguiu resolver o import todo: para os testes, esse crate de biblioteca simplesmente não existe.

Solução: mova a lógica para src/lib.rs e deixe o main.rs pequeno. Aí o main.rs chama a biblioteca, e os seus testes também. Isso ainda deixa o código mais fácil de reaproveitar.

Colocar os helpers compartilhados em tests/common.rs. Todo arquivo .rs que fica direto dentro de tests/ é tratado como um arquivo de teste próprio, então aparece uma seção extra e inútil na saída:

Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 tests

Solução: use tests/common/mod.rs e coloque mod common; em cada arquivo de teste que precisar dele.

Crates de teste em [dependencies]. proptest, mockall, insta e criterion ficam em [dev-dependencies] (use cargo add --dev). Se não, todo mundo que usar o seu crate vai baixar e compilar esses crates.

Testes que compartilham estado. Os testes rodam em paralelo. Se dois testes escrevem no mesmo arquivo ou mudam a mesma variável de ambiente, eles falham de forma aleatória. Dê a cada teste a sua própria pasta temporária (o crate tempfile ajuda) ou, em último caso, rode com --test-threads=1.

Fazer benchmark sem black_box, ou dentro de um teste. O otimizador pode apagar um trabalho cujo resultado não é usado, e cronometrar um #[test] em modo debug diz muito pouco. Use o criterion com black_box.

Esta função não pode ser testada de forma confiável, porque ela sempre pergunta a hora ao relógio real:

fn greeting() -> &'static str {
let hour = current_hour(); // lê a hora do sistema
match hour {
5..=11 => "Bom dia",
12..=17 => "Boa tarde",
_ => "Boa noite",
}
}

Reescreva a função para receber um trait Clock com um método, fn hour(&self) -> u64. Escreva um FixedClock que sempre devolve a mesma hora e teste os limites: 11 e 12, 17 e 18. Dica: struct FixedClock(u64); já basta. O app de verdade passaria um SystemClock que lê a hora.

Ver solução
trait Clock {
fn hour(&self) -> u64; // de 0 a 23
}
// Um relógio que sempre marca a mesma hora. Perfeito para testes.
struct FixedClock(u64);
impl Clock for FixedClock {
fn hour(&self) -> u64 {
self.0
}
}
fn greeting(clock: &impl Clock) -> &'static str {
match clock.hour() {
5..=11 => "Bom dia",
12..=17 => "Boa tarde",
_ => "Boa noite",
}
}
fn main() {
for hour in [8, 12, 21] {
println!("{hour}:00 -> {}", greeting(&FixedClock(hour)));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn morning_ends_at_noon() {
assert_eq!(greeting(&FixedClock(11)), "Bom dia");
assert_eq!(greeting(&FixedClock(12)), "Boa tarde");
}
#[test]
fn evening_starts_at_six() {
assert_eq!(greeting(&FixedClock(17)), "Boa tarde");
assert_eq!(greeting(&FixedClock(18)), "Boa noite");
}
#[test]
fn very_early_is_still_evening() {
assert_eq!(greeting(&FixedClock(3)), "Boa noite");
}
}

Saída:

8:00 -> Bom dia
12:00 -> Boa tarde
21:00 -> Boa noite
▶ Experimente você mesmo

A greeting não decide mais de onde vem a hora: quem decide é quem a chama. Essa única mudança deixa todas as horas testáveis em milissegundos. Escolha Test no menu do Playground, e os 3 testes passam.

Aqui está uma função que transforma uma tag de blog como " Web Dev " em "web-dev":

fn normalize_tag(tag: &str) -> String {
tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")
}

Escreva dois testes de propriedade com proptest: normalizar uma tag que já está normalizada não muda nada, e o resultado nunca contém espaços em branco. Dica: a estratégia ".*" gera strings aleatórias (é uma expressão regular). Para “sem espaços em branco”, prop_assert!(!s.contains(char::is_whitespace)) funciona.

Ver solução
fn normalize_tag(tag: &str) -> String {
tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")
}
fn main() {
for tag in ["Rust", " Web Dev ", "GAME dev"] {
println!("{tag:?} -> {:?}", normalize_tag(tag));
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn normalizing_twice_changes_nothing(tag in ".*") {
let once = normalize_tag(&tag);
prop_assert_eq!(normalize_tag(&once), once);
}
#[test]
fn result_has_no_whitespace(tag in ".*") {
let normalized = normalize_tag(&tag);
prop_assert!(!normalized.contains(char::is_whitespace));
}
}
}

Saída:

"Rust" -> "rust"
" Web Dev " -> "web-dev"
"GAME dev" -> "game-dev"
▶ Experimente você mesmo

O Run funciona no Playground, mas o Test não, porque o Playground não tem o proptest. No seu próprio projeto, adicione-o com cargo add --dev proptest e rode cargo test:

running 2 tests
test tests::result_has_no_whitespace ... ok
test tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

As strings aleatórias incluem tabs, quebras de linha e letras de outros alfabetos, coisas que você provavelmente nunca digitaria em um teste escrito à mão. “Fazer duas vezes é o mesmo que fazer uma” é uma propriedade que vale conferir em qualquer função de limpeza.

  • Testes unitários ficam em #[cfg(test)] mod tests, ao lado do código; testes de integração ficam em tests/ (com os helpers compartilhados em tests/common/mod.rs); e doc tests ficam nos comentários ///.
  • Use #[ignore] para testes lentos e rode-os com cargo test -- --ignored; filtre pelo nome com cargo test algum_nome; e veja a saída de println! com -- --nocapture.
  • Para testar código que conversa com o mundo lá fora, coloque essa dependência atrás de um trait e passe um fake nos testes. Use o mockall quando escrever fakes à mão ficar cansativo.
  • Testes de propriedade (proptest) conferem uma regra com centenas de entradas aleatórias e reduzem uma falha ao exemplo mais simples. Testes de snapshot (insta) pegam mudanças acidentais em saídas grandes.
  • Meça a velocidade com o criterion e o cargo bench, e envolva as entradas em std::hint::black_box. O #[bench] precisa do nightly.
  • O cargo-nextest roda os testes mais rápido; o cargo-llvm-cov e o cargo-tarpaulin mostram quais linhas os seus testes cobrem.

Teste o que aprendeu

1. Vários arquivos em tests/ precisam da mesma função helper. Onde ela deve ficar?

2. O que cargo test -- --ignored faz?

3. Uma propriedade do proptest falha. O que o proptest mostra como menor entrada que falha (minimal failing input)?

4. Por que os benchmarks do criterion envolvem os valores em std::hint::black_box?

Pontuação: 0 / 4

  • Writing Automated Tests — The Rust Book (em inglês): o capítulo oficial sobre como escrever, rodar e organizar testes unitários e de integração.
  • Testing — Rust by Example (em inglês): exemplos curtos de testes unitários, doc tests, testes de integração e dev-dependencies.
  • The proptest book (em inglês): estratégias, shrinking, configuração e uma comparação entre o proptest e o quickcheck.
  • Documentação do mockall (em inglês): #[automock], expectativas, como comparar argumentos, valores de retorno e mocks de structs.
  • Criterion.rs user guide (em inglês): primeiros passos, como ler a saída, como comparar funções e os relatórios em HTML.