Property-Based Testing em Zig: Testes Baseados em Propriedades

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?

AspectoUnit TestingProperty-Based Testing
Casos de testeManuais (você escreve)Gerados automaticamente
CoberturaLimitada pelos exemplosExplora espaço de entrada
Edge casesVocê precisa imaginarDescobertos automaticamente
ManutençãoAlto (atualiza exemplos)Baixo (propriedades raramente mudam)
DocumentaçãoExemplos específicosRegras 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

  1. 🔗 Testes Unitários em Zig — Fundamentos de testes
  2. Debug em Zig — Debug quando propriedades falham
  3. 🧠 Comptime em Zig — Geração de testes em compile-time
  4. 📦 CI/CD para Zig — Integração property tests em pipeline

Quais propriedades você testa em seus projetos? Compartilhe padrões!

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.