Property-Based Testing (PBT) é uma técnica onde você define propriedades que seu código deve satisfazer, e o framework gera automaticamente centenas ou milhares de casos de teste para verificar essas propriedades.
Em vez de testar: reverse([1,2,3]) == [3,2,1]
Você testa: reverse(reverse(x)) == x para qualquer x
Neste guia, você aprenderá a implementar PBT em Zig desde o básico até técnicas avançadas.
Por que Property-Based Testing?
| Aspecto | Unit Testing | Property-Based Testing |
|---|---|---|
| Casos de teste | Manuais (você escreve) | Gerados automaticamente |
| Cobertura | Limitada pelos exemplos | Explora espaço de entrada |
| Edge cases | Você precisa imaginar | Descobertos automaticamente |
| Manutenção | Alto (atualiza exemplos) | Baixo (propriedades raramente mudam) |
| Documentação | Exemplos específicos | Regras gerais |
Implementando Geradores
Gerador Básico
const std = @import("std");
const rand = std.rand;
const testing = std.testing;
// Tipo para funções geradoras
fn Generator(comptime T: type) type {
return *const fn (
rand: std.Random,
allocator: std.mem.Allocator,
) anyerror!T;
}
// Gerador de inteiros com range
fn genIntRange(
comptime T: type,
min: T,
max: T,
) Generator(T) {
return struct {
fn generate(r: std.Random, _: std.mem.Allocator) !T {
return r.intRangeLessThan(T, min, max);
}
}.generate;
}
// Gerador de arrays
fn genArray(
comptime T: type,
min_len: usize,
max_len: usize,
elem_gen: Generator(T),
) Generator([]T) {
return struct {
fn generate(r: std.Random, a: std.mem.Allocator) ![]T {
const len = r.intRangeLessThan(usize, min_len, max_len);
const arr = try a.alloc(T, len);
errdefer a.free(arr);
for (arr) |*elem| {
elem.* = try elem_gen(r, a);
}
return arr;
}
}.generate;
}
Geradores Comuns
// Gerador de inteiros positivos
fn genU32(r: std.Random, _: std.mem.Allocator) !u32 {
return r.int(u32);
}
// Gerador de strings ASCII
fn genString(r: std.Random, a: std.mem.Allocator) ![]u8 {
const len = r.intRangeLessThan(usize, 0, 100);
const str = try a.alloc(u8, len);
errdefer a.free(str);
for (str) |*c| {
c.* = r.intRangeLessThan(u8, 'a', 'z' + 1);
}
return str;
}
// Gerador de floats
fn genF64(r: std.Random, _: std.mem.Allocator) !f64 {
return r.float(f64);
}
// Gerador de enums
fn genEnum(comptime E: type) Generator(E) {
return struct {
fn generate(r: std.Random, _: std.mem.Allocator) !E {
const values = std.enums.values(E);
const idx = r.intRangeLessThan(usize, 0, values.len);
return values[idx];
}
}.generate;
}
// Gerador de Optionals
fn genOptional(
comptime T: type,
inner_gen: Generator(T),
) Generator(?T) {
return struct {
fn generate(r: std.Random, a: std.mem.Allocator) !?T {
if (r.boolean()) {
return try inner_gen(r, a);
}
return null;
}
}.generate;
}
Framework de Property Testing
// Estrutura principal de teste de propriedades
pub const PropertyTest = struct {
allocator: std.mem.Allocator,
rng: std.Random,
pub fn init(allocator: std.mem.Allocator, seed: u64) !PropertyTest {
var prng = std.rand.DefaultPrng.init(seed);
return .{
.allocator = allocator,
.rng = prng.random(),
};
}
// Testa uma propriedade com múltiplos casos gerados
pub fn property(
self: *PropertyTest,
comptime T: type,
gen: Generator(T),
comptime iterations: usize,
check: fn (T) bool,
) !void {
var i: usize = 0;
while (i < iterations) : (i += 1) {
const value = try gen(self.rng, self.allocator);
defer if (@typeInfo(T) == .Pointer) {
self.allocator.free(value);
};
if (!check(value)) {
std.debug.print(
"Propriedade falhou no caso {d}: {any}\n",
.{ i, value }
);
return error.PropertyFailed;
}
}
}
// Testa propriedade com dois geradores
pub fn property2(
self: *PropertyTest,
comptime T1: type,
comptime T2: type,
gen1: Generator(T1),
gen2: Generator(T2),
comptime iterations: usize,
check: fn (T1, T2) bool,
) !void {
var i: usize = 0;
while (i < iterations) : (i += 1) {
const v1 = try gen1(self.rng, self.allocator);
defer if (@typeInfo(T1) == .Pointer) self.allocator.free(v1);
const v2 = try gen2(self.rng, self.allocator);
defer if (@typeInfo(T2) == .Pointer) self.allocator.free(v2);
if (!check(v1, v2)) {
std.debug.print(
"Propriedade falhou: v1={any}, v2={any}\n",
.{ v1, v2 }
);
return error.PropertyFailed;
}
}
}
};
Propriedades Comuns
1. Inversão (Round-trip)
// Se encode seguido de decode deve retornar o original
fn testRoundTrip() !void {
var pt = try PropertyTest.init(testing.allocator, 42);
try pt.property(
[]const u8,
genString,
100,
struct {
fn check(input: []const u8) bool {
const encoded = base64Encode(input);
const decoded = base64Decode(encoded);
return std.mem.eql(u8, input, decoded);
}
}.check
);
}
2. Idempotência
// Aplicar duas vezes é igual a aplicar uma vez
fn testIdempotent() !void {
var pt = try PropertyTest.init(testing.allocator, 42);
try pt.property(
[]const u8,
genString,
100,
struct {
fn check(s: []const u8) bool {
const once = trim(s);
const twice = trim(once);
return std.mem.eql(u8, once, twice);
}
}.check
);
}
3. Simetria
// Operações inversas se anulam
fn testSymmetry() !void {
var pt = try PropertyTest.init(testing.allocator, 42);
try pt.property(
i32,
genIntRange(i32, -1000, 1000),
1000,
struct {
fn check(x: i32) bool {
return negate(negate(x)) == x;
}
}.check
);
}
4. Ordem/Monotonicidade
// Sort preserva ordem
fn testSortOrder() !void {
var pt = try PropertyTest.init(testing.allocator, 42);
const IntArrayGen = genArray(i32, 0, 50, genIntRange(i32, -100, 100));
try pt.property(
[]i32,
IntArrayGen,
100,
struct {
fn check(arr: []i32) bool {
const sorted = sort(arr);
// Verifica ordenação
for (sorted[0 .. sorted.len - 1], sorted[1..]) |a, b| {
if (a > b) return false;
}
// Verifica que é permutação
return sameElements(arr, sorted);
}
}.check
);
}
5. Algebraic Properties
// Testa propriedades algébricas
fn testAlgebraic() !void {
var pt = try PropertyTest.init(testing.allocator, 42);
const IntGen = genIntRange(i32, -100, 100);
// Comutatividade: a + b == b + a
try pt.property2(
i32, i32,
IntGen, IntGen,
100,
struct {
fn check(a: i32, b: i32) bool {
return add(a, b) == add(b, a);
}
}.check
);
// Associatividade: (a + b) + c == a + (b + c)
// Implementação similar com property3
// Elemento neutro: a + 0 == a
try pt.property(
i32,
IntGen,
100,
struct {
fn check(a: i32) bool {
return add(a, 0) == a;
}
}.check
);
}
Casos de Teste Reais
Testando uma Stack
const Stack = struct {
items: std.ArrayList(i32),
pub fn push(self: *Stack, item: i32) void {
self.items.append(item) catch unreachable;
}
pub fn pop(self: *Stack) ?i32 {
return self.items.popOrNull();
}
pub fn peek(self: Stack) ?i32 {
if (self.items.items.len == 0) return null;
return self.items.items[self.items.items.len - 1];
}
pub fn len(self: Stack) usize {
return self.items.items.len;
}
};
test "stack properties" {
var pt = try PropertyTest.init(testing.allocator, 42);
// Propriedade: push seguido de pop retorna o elemento
try pt.property(
i32,
genIntRange(i32, -10000, 10000),
100,
struct {
fn check(x: i32) bool {
var stack = Stack{ .items = std.ArrayList(i32).init(testing.allocator) };
defer stack.items.deinit();
stack.push(x);
return stack.pop() == x;
}
}.check
);
// Propriedade: peek não modifica a stack
try pt.property(
[]i32,
genArray(i32, 1, 20, genIntRange(i32, -100, 100)),
50,
struct {
fn check(items: []i32) bool {
var stack = Stack{ .items = std.ArrayList(i32).init(testing.allocator) };
defer stack.items.deinit();
for (items) |item| stack.push(item);
const before = stack.len();
_ = stack.peek();
const after = stack.len();
return before == after;
}
}.check
);
}
Testando Parser JSON
test "json parser properties" {
var pt = try PropertyTest.init(testing.allocator, 42);
// Propriedade: parse de string válida não falha
try pt.property(
[]const u8,
genJsonString, // Gerador de JSON válido
100,
struct {
fn check(json: []const u8) bool {
_ = parseJson(json) catch |err| switch (err) {
error.InvalidJson => return false,
else => return true, // Outros erros são aceitáveis
};
return true;
}
}.check
);
// Propriedade: parse e stringify são inversos
try pt.property(
JsonValue,
genJsonValue,
50,
struct {
fn check(value: JsonValue) bool {
const str = stringify(value);
const parsed = parseJson(str) catch return false;
return valuesEqual(value, parsed);
}
}.check
);
}
Shrinking (Minimização de Casos de Falha)
Quando uma propriedade falha, queremos o menor caso que causa a falha:
fn shrinkInt(x: i32) []const i32 {
var candidates = std.ArrayList(i32).init(testing.allocator);
defer candidates.deinit();
// Tenta metade
if (x != 0) {
candidates.append(x / 2) catch {};
candidates.append(-x / 2) catch {};
}
// Tenta valores próximos de zero
if (x > 0) candidates.append(x - 1) catch {};
if (x < 0) candidates.append(x + 1) catch {};
// Tenta zero
candidates.append(0) catch {};
return candidates.toOwnedSlice() catch &[]i32{};
}
fn findMinimal(
comptime T: type,
value: T,
shrink: fn (T) []const T,
property: fn (T) bool,
) T {
var minimal = value;
var candidates = shrink(value);
defer testing.allocator.free(candidates);
for (candidates) |candidate| {
if (!property(candidate)) {
// Encontrou caso menor que falha
const smaller = findMinimal(T, candidate, shrink, property);
return smaller;
}
}
return minimal;
}
Integração com CI
// test_properties.zig
const std = @import("std");
pub fn main() !void {
const seed = std.time.milliTimestamp();
std.debug.print("Seed: {d}\n", .{seed});
var pt = try PropertyTest.init(std.heap.page_allocator, @intCast(seed));
// Roda todas as propriedades
try testRoundTrip(&pt);
try testSymmetry(&pt);
try testAlgebraic(&pt);
try testStack(&pt);
std.debug.print("✅ Todas as propriedades passaram!\n");
}
Próximos Passos
- 🔗 Testes Unitários em Zig — Fundamentos de testes
- ⚡ Debug em Zig — Debug quando propriedades falham
- 🧠 Comptime em Zig — Geração de testes em compile-time
- 📦 CI/CD para Zig — Integração property tests em pipeline
Quais propriedades você testa em seus projetos? Compartilhe padrões!