惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

美团技术团队
阮一峰的网络日志
阮一峰的网络日志
T
The Blog of Author Tim Ferriss
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
宝玉的分享
宝玉的分享
L
LangChain Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Last Week in AI
Last Week in AI
博客园 - 司徒正美
M
MIT News - Artificial intelligence
人人都是产品经理
人人都是产品经理
WordPress大学
WordPress大学
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
博客园 - Franky
B
Blog
V
V2EX
J
Java Code Geeks
D
Docker
博客园 - 叶小钗
The Cloudflare Blog
量子位
博客园_首页
MongoDB | Blog
MongoDB | Blog

网上冲浪指南

成都双流区凤翔湖公园 四川雅安龙苍沟 Zig 中的类型替换 在 Zig 中实现 TaskCompletionSource 如何配置 ZigBrains 用 Zig 实现一个简单的任务队列 密码保护:借助图生图模型创建电子桌游地图 用 Cloudflare 给 WordPress 减负 接二连三修电脑 修复小新 Pad Pro 2021 蓝牙耳机没有声音的问题 手动升级一下 OpenWRT 如何安全地面向公网提供本地 NAS 上的 Web 服务 徒步·金堂开照寺二道坪山脊环线 Hello, ActivityPub 迁移博客到 VPS 优化博客网站的性能 解决 Qsirch 无法搜索文件夹的问题 N100 小主机遭遇 NVMe 硬盘故障:一次系统的诊断与反思 外接显示器 EDID 损坏如何处理 How to switch GitHub CLI account automatically
自定义 zig test runner
Zeeko · 2026-08-28 · via 网上冲浪指南
const runner: std.Build.Step.Compile.TestRunner = .{ .path = b.path("test_runner.zig"), .mode = .simple };

我最开始想到要替换默认 test runner 的原因是 Zig 默认的 test runner 会在被测试代码往标准错误中写 log 时显示测试失败。而我更习惯往代码中加些日志方便我排查问题或者获得一些关于代码运行的反馈,于是我找到了 Custom Zig Test Runner 。这个 test runner 不仅提供了更美观的输出,还会展示每个测试用例的运行时长,并且会列出运行速度最慢的用例。

在使用一段时间过后,我发现他对测试用例输出日志的处理并不让我满意,比如一些用例经常会输出一大片日志淹没我的终端。于是我在上面的 test runner 基础上,又做了一些调整,允许日志被有选择性的输出:

// This is for the Zig 0.16.

// See https://gist.github.com/karlseguin/c6bea5b35e4e8d26af6f81c22cb5d76b/eb15512d6ae49663fa9df6c7a9725b20dab43edd
// for a version that workson Zig 0.15.2.

// See https://gist.github.com/karlseguin/c6bea5b35e4e8d26af6f81c22cb5d76b/1f317ebc9cd09bc50fd5591d09c34255e15d1d85
// for a version that workson Zig 0.14.1.

// in your build.zig, you can specify a custom test runner:
// const tests = b.addTest(.{
//    .root_module = $MODULE_BEING_TESTED,
//    .test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple },
// });

const std = @import("std");
const Io = std.Io;
const builtin = @import("builtin");

const Allocator = std.mem.Allocator;

const BORDER = "=" ** 80;

var log_entries = std.ArrayList(LogEntry).empty;
var log_entries_allocator: Allocator = std.heap.page_allocator;

const LogEntry = struct {
    level: std.log.Level,
    scope: [] const u8,
    message: []const u8,
};
fn log(
    comptime level: std.log.Level,
    comptime scope: @EnumLiteral(),
    comptime format: []const u8,
    args: anytype,
) void {
    const entry = LogEntry{
        .level = level,
        .scope = @tagName(scope),
        .message = std.fmt.allocPrint(log_entries_allocator, format ++ "\n", args) catch @panic("OOM"),
    };
    log_entries.append(log_entries_allocator, entry) catch @panic("OOM");
}

pub const std_options = std.Options {
    .log_level = .debug,
    .logFn = log,
};

fn clearLogs() void {
    for (log_entries.items) |entry| {
        log_entries_allocator.free(entry.message);
    }
    log_entries.clearAndFree(log_entries_allocator);
}
fn dumpLogs() void {
    if (log_entries.items.len == 0) return;
    const io = std.Options.debug_io;
    const prev = io.swapCancelProtection(.blocked);
    defer _ = io.swapCancelProtection(prev);
    var buffer: [64]u8 = undefined;
    const t = std.debug.lockStderr(&buffer).terminal();
    defer std.debug.unlockStderr();
    defer {
        clearLogs();
    }
    for (log_entries.items) |entry| {
        const level = entry.level;
        const scope = entry.scope;
        const message = entry.message;

        t.setColor(switch (level) {
            .err => .red,
            .warn => .yellow,
            .info => .green,
            .debug => .magenta,
        }) catch {};
        t.setColor(.bold) catch {};
        t.writer.writeAll(switch(level) {
            .err => "ERR",
            .warn => "WRN",
            .info => "INF",
            .debug => "DBG",
        }) catch {};
        t.setColor(.reset) catch {};
        t.setColor(.dim) catch {};
        t.setColor(.bold) catch {};
        if (!std.mem.eql(u8, scope, "default")) t.writer.print("({s})", .{scope}) catch {};
        t.writer.writeAll(": ") catch {};
        t.setColor(.reset) catch {};
        t.writer.writeAll(message) catch {};
    }
}

// use in custom panic handler
var current_test: ?[]const u8 = null;

pub fn main(init: std.process.Init) !void {
    var mem: [8192]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&mem);

    const allocator = fba.allocator();

    const env = Env.init(init.environ_map);

    std.testing.io_instance = .init(init.gpa, .{
        .argv0 = .init(init.minimal.args),
        .environ = init.minimal.environ,
    });
    defer std.testing.io_instance.deinit();

    const io = std.testing.io;

    var slowest = SlowTracker.init(allocator, io, 5);
    defer slowest.deinit();

    var pass: usize = 0;
    var fail: usize = 0;
    var skip: usize = 0;
    var leak: usize = 0;

    Printer.fmt("\r\x1b[0K", .{}); // beginning of line and clear to end of line

    for (builtin.test_functions) |t| {
        if (isSetup(t)) {
            t.func() catch |err| {
                Printer.status(.fail, "\nsetup \"{s}\" failed: {}\n", .{ t.name, err });
                return err;
            };
        }
    }

    for (builtin.test_functions) |t| {
        if (isSetup(t) or isTeardown(t) or isSetup(t)) {
            continue;
        }

        var status = Status.pass;
        slowest.startTiming(io);

        const is_unnamed_test = isUnnamed(t);
        if (env.filter) |f| {
            if (!is_unnamed_test and std.mem.indexOf(u8, t.name, f) == null) {
                continue;
            }
        }

        const friendly_name = blk: {
            const name = t.name;
            var it = std.mem.splitScalar(u8, name, '.');
            while (it.next()) |value| {
                if (std.mem.eql(u8, value, "test")) {
                    const rest = it.rest();
                    break :blk if (rest.len > 0) rest else name;
                }
            }
            break :blk name;
        };

        current_test = friendly_name;
        std.testing.allocator_instance = .{};
        const result = t.func();
        if (current_test != null and std.mem.startsWith(u8, current_test.?, "f:l")) {
            dumpLogs();
        }
        current_test = null;

        const ns_taken = slowest.endTiming(io, friendly_name);

        if (std.testing.allocator_instance.deinit() == .leak) {
            leak += 1;
            Printer.status(.fail, "\n{s}\n\"{s}\" - Memory Leak\n{s}\n", .{ BORDER, friendly_name, BORDER });
        }

        if (result) |_| {
            pass += 1;
        } else |err| switch (err) {
            error.SkipZigTest => {
                skip += 1;
                status = .skip;
                clearLogs();
            },
            else => {
                status = .fail;
                fail += 1;
                dumpLogs();
                Printer.status(.fail, "\n{s}\n\"{s}\" - {s}\n{s}\n", .{ BORDER, friendly_name, @errorName(err), BORDER });
                if (@errorReturnTrace()) |trace| {
                    std.debug.dumpErrorReturnTrace(trace);
                }
                if (env.fail_first) {
                    break;
                }
            },
        }

        if (env.verbose) {
            const ms = @as(f64, @floatFromInt(ns_taken)) / 1_000_000.0;
            Printer.status(status, "{s} ({d:.2}ms)\n", .{ friendly_name, ms });
        } else {
            Printer.status(status, ".", .{});
        }
    }

    for (builtin.test_functions) |t| {
        if (isTeardown(t)) {
            t.func() catch |err| {
                Printer.status(.fail, "\nteardown \"{s}\" failed: {}\n", .{ t.name, err });
                return err;
            };
        }
    }

    const total_tests = pass + fail;
    const status = if (fail == 0) Status.pass else Status.fail;
    Printer.status(status, "\n{d} of {d} test{s} passed\n", .{ pass, total_tests, if (total_tests != 1) "s" else "" });
    if (skip > 0) {
        Printer.status(.skip, "{d} test{s} skipped\n", .{ skip, if (skip != 1) "s" else "" });
    }
    if (leak > 0) {
        Printer.status(.fail, "{d} test{s} leaked\n", .{ leak, if (leak != 1) "s" else "" });
    }
    Printer.fmt("\n", .{});
    try slowest.display();
    Printer.fmt("\n", .{});
    std.process.exit(if (fail == 0) 0 else 1);
}

const Printer = struct {
    fn fmt(comptime format: []const u8, args: anytype) void {
        std.debug.print(format, args);
    }

    fn status(s: Status, comptime format: []const u8, args: anytype) void {
        switch (s) {
            .pass => std.debug.print("\x1b[32m", .{}),
            .fail => std.debug.print("\x1b[31m", .{}),
            .skip => std.debug.print("\x1b[33m", .{}),
            else => {},
        }
        std.debug.print(format ++ "\x1b[0m", args);
    }
};

const Status = enum {
    pass,
    fail,
    skip,
    text,
};

const SlowTracker = struct {
    max: usize,
    slowest: SlowestQueue,
    start: Io.Timestamp,
    allocator: Allocator,

    const SlowestQueue = std.PriorityDequeue(TestInfo, void, compareTiming);

    fn init(allocator: Allocator, io: Io, count: u32) SlowTracker {
        const timestamp = Io.Clock.awake.now(io);
        var slowest: SlowestQueue = .empty;
        slowest.ensureTotalCapacity(allocator, count) catch @panic("OOM");
        return .{
            .max = count,
            .start = timestamp,
            .slowest = slowest,
            .allocator = allocator,
        };
    }

    const TestInfo = struct {
        ns: u64,
        name: []const u8,
    };

    fn deinit(self: *SlowTracker) void {
        self.slowest.deinit(self.allocator);
    }

    fn startTiming(self: *SlowTracker, io: Io) void {
        self.start = Io.Clock.awake.now(io);
    }

    fn endTiming(self: *SlowTracker, io: Io, test_name: []const u8) u64 {
        const timestamp = Io.Clock.awake.now(io);
        const start = self.start;
        self.start = timestamp;
        const ns: u64 = @intCast(start.durationTo(timestamp).toNanoseconds());

        var slowest = &self.slowest;

        if (slowest.count() < self.max) {
            // Capacity is fixed to the # of slow tests we want to track
            // If we've tracked fewer tests than this capacity, than always add
            slowest.push(self.allocator, TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing");
            return ns;
        }

        {
            // Optimization to avoid shifting the dequeue for the common case
            // where the test isn't one of our slowest.
            const fastest_of_the_slow = slowest.peekMin() orelse unreachable;
            if (fastest_of_the_slow.ns > ns) {
                // the test was faster than our fastest slow test, don't add
                return ns;
            }
        }

        // the previous fastest of our slow tests, has been pushed off.
        _ = slowest.popMin();
        slowest.push(self.allocator, TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing");
        return ns;
    }

    fn display(self: *SlowTracker) !void {
        var slowest = self.slowest;
        const count = slowest.count();
        Printer.fmt("Slowest {d} test{s}: \n", .{ count, if (count != 1) "s" else "" });
        while (slowest.popMin()) |info| {
            const ms = @as(f64, @floatFromInt(info.ns)) / 1_000_000.0;
            Printer.fmt("  {d:.2}ms\t{s}\n", .{ ms, info.name });
        }
    }

    fn compareTiming(context: void, a: TestInfo, b: TestInfo) std.math.Order {
        _ = context;
        return std.math.order(a.ns, b.ns);
    }
};

const Env = struct {
    verbose: bool,
    fail_first: bool,
    filter: ?[]const u8,

    fn init(map: *const std.process.Environ.Map) Env {
        return .{
            .verbose = readEnvBool(map, "TEST_VERBOSE", true),
            .fail_first = readEnvBool(map, "TEST_FAIL_FIRST", false),
            .filter = readEnv(map, "TEST_FILTER"),
        };
    }

    fn readEnv(map: *const std.process.Environ.Map, key: []const u8) ?[]const u8 {
        return map.get(key);
    }

    fn readEnvBool(map: *const std.process.Environ.Map, key: []const u8, deflt: bool) bool {
        const value = readEnv(map, key) orelse return deflt;
        return std.ascii.eqlIgnoreCase(value, "true");
    }
};

pub const panic = std.debug.FullPanic(struct {
    pub fn panicFn(msg: []const u8, first_trace_addr: ?usize) noreturn {
        if (current_test) |ct| {
            std.debug.print("\x1b[31m{s}\npanic running \"{s}\"\n{s}\x1b[0m\n", .{ BORDER, ct, BORDER });
        }
        std.debug.defaultPanic(msg, first_trace_addr);
    }
}.panicFn);

fn isUnnamed(t: std.builtin.TestFn) bool {
    const marker = ".test_";
    const test_name = t.name;
    const index = std.mem.indexOf(u8, test_name, marker) orelse return false;
    _ = std.fmt.parseInt(u32, test_name[index + marker.len ..], 10) catch return false;
    return true;
}

fn isSetup(t: std.builtin.TestFn) bool {
    return std.mem.endsWith(u8, t.name, "tests:beforeAll");
}

fn isTeardown(t: std.builtin.TestFn) bool {
    return std.mem.endsWith(u8, t.name, "tests:afterAll");
}

fn isSkip(t: std.builtin.TestFn) bool {
    return std.mem.startsWith(u8, t.name, "skip:");
}

上面的这个 runner 默认会将日志写入 page allocator 管理的内存中,当测试运行失败或者测试名称以 f:l 开头的时候才会在测试运行完成后打印日志。

除此之外,我还支持了 skip: 标记,当一个测试以 skip:开头的时候,这个测试会被编译,但不会被执行。这里的跳过行为跟 build test 所支持的 filter 特性是不一样的,被 filter 过滤掉的测试甚至不会被编译,也就无法被 check 命令检查。