Тестирование

2025-04-18 4240 20

Когда вы пишете программу на низкоуровневом языке, таком как Zig, тестирование становится важной и неотъемлемой частью процесса разработки. В высокоуровневых языках часть ошибок может быть устранена за счёт более «умного» рантайма, использования сложных абстракций или встроенного контроля времени жизни переменных. В Zig же вам приходится самостоятельно проверять и контролировать поведение программы во всех аспектах.

Разумеется, язык предоставляет ряд инструментов, позволяющих выявлять ошибки как на этапе компиляции, так и во время выполнения. Однако наиболее надёжным способом обеспечения корректности остаётся написание тестов.

Допустим, у нас есть функция, которая должна возвращать сумму двух чисел. В её сигнатуре указано, что она принимает два целых числа и возвращает целое число. В таком случае компилятор может проверить, что мы передали параметры нужного типа и корректно обработали возвращаемое значение.

Однако компилятор не способен гарантировать, что функция действительно выполняет именно ту логику, которую мы задумали. Единственный способ это проверить — написать тесты, которые убедятся в правильности работы функции на различных входных данных. Мы можем написать тесты, которые например проверяют, что при передаче в функцию чисел 1 и 2 она вернёт 3. И мы можем выполнять наши тесты каждый раз, когда вносим изменения в код, чтобы убедится, что поведение нашей функции не изменилось и по прежнему правильное.

Тем не менее, многие программисты не любят писать тесты. Кто-то избегает этого, потому что написание тестов требует времени и усилий, и возникает мысль: зачем тратить ресурсы, если функция и так «работает правильно»? Кто-то не любит писать тесты потому, что язык программирования или инфраструктура проекта предоставляет неудобный или слишком сложный интерфейс для их написания и запуска — особенно когда после обновления инструментов приходится разбираться заново и чинить сломанные тесты.

Однако мой опыт разработки достаточно крупных проектов показывает: наличие тестов почти всегда оправдывает затраченные усилия — и не один раз. Каждый раз, внося изменения в код, вы можете быть уверены, что ваша функция продолжает работать корректно.

Создание тестов

Чтобы добавить тесты в ваш код на языке Zig, достаточно объявить специальный тестовый блок (test) в нужном месте и поместить в него код, проверяющий необходимую функциональность. Это делается очень просто и не требует дополнительной настройки или подключения внешних инструментов.

Тестовые блоки можно размещать как непосредственно рядом с тестируемым кодом, так и в конце файла модуля — выбор зависит от ваших предпочтений и стиля проекта. Это скорее вопрос вкуса и привычки. Тем не менее, если вы заглянете в исходный код стандартной библиотеки Zig, то заметите, что там принят подход, при котором тесты обычно располагаются прямо рядом с тем кодом, который они проверяют. Это делает тесты более удобными для поддержки и улучшает читаемость, особенно в небольших модулях.

В самом базовом случае тест представляет собой обычную функцию, внутри тестового блока. Однако для того, чтобы эта функция действительно выполняла проверку корректности работы кода, в ней нужно явно указать условия, которые должны быть выполнены. Это делается с помощью функций из встроенного модуля std.testing.

Например, давайте напишем простой тест для нашей функции add, которая складывает два числа. Сначала определим саму функцию, а затем проверим её работу с помощью теста:

const std = @import("std");
const expect = std.testing.expect;

pub fn sum(a: i32, b: i32) i32 {
    return a + b;
}

test "testing simple sum" {
    const a: i32 = 1;
    const b: i32 = 2;
    try expect(sum(a, b) == 3);
}

Теперь давайте разберёмся, как запустить наши тесты. Для этого достаточно выполнить команду zig test в терминале, указав путь к файлу, содержащему тесты. Например, если файл с тестами находится в директории src, можно использовать следующую команду:

$ zig test src/root.zig

В этом случае компилятор скомпилирует тестовый код и запустит его с помощью встроенного тестового раннера из стандартной библиотеки Zig.

Важно отметить, что при обычной сборке приложения (через zig build или компиляцию исполняемого файла) все тестовые блоки будут проигнорированы — они не попадут в финальный бинарник и не повлияют на производительность или размер итоговой программы.

Итак, если мы теперь запустим наш тест с помощью команды zig test, то увидим примерно следующий вывод:

$ zig test src/root.zig
All 1 tests passed.

Как мы видим, наш тест успешно прошёл, и компилятор вывел статистику: был запущен один тест, и он завершился без ошибок. Это подтверждает, что функция add работает корректно для заданных входных данных.

При определении тестового блока в Zig мы можем указать для него имя — это особенно полезно при большом количестве тестов, так как позволяет легко идентифицировать каждый из них в выводе компилятора. Именованные тесты делают результаты выполнения более наглядными и упрощают отладку в случае ошибок.

Давайте теперь добавим ещё один тест, но специально допустим в нём ошибку, чтобы он не завершился успешно. Посмотрим, как в этом случае изменится вывод компилятора и что именно он покажет нам при падении теста. Это поможет понять, как Zig сообщает об ошибках в тестах и насколько удобно их анализировать.

const std = @import("std");
const expect = std.testing.expect;

pub fn sum(a: i32, b: i32) i32 {
    return a + b;
}

test "testing simple sum" {
    const a: i32 = 1;
    const b: i32 = 2;
    try expect(sum(a, b) == 3);
}

test "testing failed sum" {
    return error.Failed;
}

Если мы запустим тесты теперь, то увидим следующий вывод:

$ zig test src/root.zig
2/2 root.test.testing failed sum...FAIL (Failed)
/Users/roman/Projects/zig/simple/src/root.zig:15:5: 0x10225dc73 in test.testing failed sum (test)
    return error.Failed;
    ^
1 passed; 0 skipped; 1 failed.

Как мы видим, один из наших тестов завершился с ошибкой. Об этом сообщает компилятор, указав имя тестового блока, а также точную строку, на которой произошёл сбой. Это помогает быстро локализовать проблему и приступить к её устранению.

Ещё один важный момент, который стоит отметить: если из тестового блока возвращается ошибка, тест считается неуспешным. То есть, любое возвращаемое значение типа error интерпретируется как провал теста. Это значит, что для написания простых тестов вам не обязательно использовать функции из модуля std.testing. Вы можете просто вернуть ошибку напрямую из теста, чтобы указать на некорректное поведение.

Тем не менее, использование функций из модуля testing — таких как expect, expectEqual, expectError и других — делает тесты не только более выразительными, но и улучшает читаемость вывода в случае ошибок. Такие функции предоставляют более подробную информацию о том, что именно пошло не так, и значительно упрощают анализ и отладку тестов, особенно в больших проектах.

Вы можете комбинировать несколько проверок в одном тестовом блоке, либо наоборот выносить каждую проверку в отдельный тестовый блок с уникальным именем. В этом плане Zig предоставляет вам простой и очень гибкий инструмент для написания тестов, где каждый выбирает тот стиль создания тестов, который ему кажется наиболее удобным. Обычно правда можно встретить микс из комбинированных проверок и отдельных тестов.

Документирующие тесты

Если при создании тестового блока вместо текстового описания указать имя функции или переменной, то такой тест будет считаться документирующим тестом и будет включён в документацию по модулю.

Хорошей практикой считается добавлять документирующие тесты в тех случаях, когда они помогают пользователю вашей библиотеки понять, как использовать функции или переменные. Однако не стоит включать такие тесты, если они не несут полезной информации или касаются только внутренних деталей реализации.

Давайте рассмотрим пример документирующего теста:

//! This module provides arithmetic functions.

const std = @import("std");
const expect = std.testing.expect;

/// Sum two integers.
pub fn sum(a: i32, b: i32) i32 {
    return a + b;
}

test sum {
    const a: i32 = 1;
    const b: i32 = 2;
    try expect(sum(a, b) == 3);
}

Если мы теперь сгенерируем документацию к нашему проекту, выполнив команду zig build-lib -femit-docs src/root.zig, то при просмотре документации по нашей функции sum мы увидим блок Example Usage, где будет приведен наш тест:

Function
pub fn sum(a: i32, b: i32) i32
Sum two integers.

Parameters
a: i32
b: i32

Example Usage
test sum {
    const a: i32 = 1;
    const b: i32 = 2;
    try expect(sum(a, b) == 3);
}

Source Code
pub fn sum(a: i32, b: i32) i32 {
    return a + b;
}

В нашем примере тест, конечно, не особо полезен — мы добавили его лишь для демонстрации. В реальных проектах стоит добавлять такие тесты только в том случае, если они действительно принесут пользу тем, кто будет использовать вашу библиотеку.

Встроенные тестовые функции

Стандартная библиотека Zig предоставляет довольно много встроенных тестовых функций, которые можно использовать для написания тестов. Например, функция std.testing.expect используется для проверки равенства значений и мы уже использовали ее в наших примерах. Давайте рассмотрим еще несколько встроенных функций:

Рассмотрим пример использования функций:

const std = @import("std");
const testing = std.testing;

const Foo = struct {
    const name: []const u8 = "Foo";
    const nums: []const i32 = &[_]i32{ 1, 2, 3 };

    pub fn sum(a: i32, b: i32) i32 {
        return a + b;
    }
};

test "using expect" {
    const a: i32 = 1;
    const b: i32 = 2;

    try testing.expect(Foo.sum(a, b) == 3);
}

test "using expectEqual" {
    const first = Foo.sum(1, 2);
    const second = Foo.sum(2, 1);
    try testing.expectEqual(first, second);
}

test "using expectEqualString" {
    try testing.expectEqualStrings("Foo", Foo.name);
}

test "using expectEqualSlices" {
    try testing.expectEqualSlices(i32, Foo.nums, &[_]i32{ 1, 2, 3 });
}

Если мы запустим наши тесты, то компилятор просто выведет нам что все наши тесты успешно завершились. Давайте исправим наши тесты так, чтобы они завершались с ошибкой и посмотрим что нам выведет компилятор.

const std = @import("std");
const testing = std.testing;

const Foo = struct {
    const name: []const u8 = "Foo";
    const nums: []const i32 = &[_]i32{ 1, 2, 3 };

    pub fn sum(a: i32, b: i32) i32 {
        return a + b;
    }
};

test "using expect" {
    const a: i32 = 1;
    const b: i32 = 2;

    try testing.expect(Foo.sum(a, b) == 4);
}

test "using expectEqual" {
    const first = Foo.sum(1, 2);
    const second = Foo.sum(2, 2);
    try testing.expectEqual(first, second);
}

test "using expectEqualString" {
    try testing.expectEqualStrings("Foo1", Foo.name);
}

test "using expectEqualSlices" {
    try testing.expectEqualSlices(i32, Foo.nums, &[_]i32{ 11, 22, 33 });
}

После запуска наших тестов мы увидим следующее:

$ zig test src/root.zig
1/4 root.test.using expect...FAIL (TestUnexpectedResult)
/Users/roman/.zvm/0.14.0/lib/std/testing.zig:580:14: 0x10471c7a3 in expect (test)
    if (!ok) return error.TestUnexpectedResult;
             ^
/Users/roman/Projects/zig/simple/src/root.zig:17:5: 0x10471c8af in test.using expect (test)
    try testing.expect(Foo.sum(a, b) == 4);
    ^
expected 3, found 4
2/4 root.test.using expectEqual...FAIL (TestExpectedEqual)
/Users/roman/.zvm/0.14.0/lib/std/testing.zig:103:17: 0x1047a5cff in expectEqualInner__anon_13971 (test)
                return error.TestExpectedEqual;
                ^
/Users/roman/Projects/zig/simple/src/root.zig:23:5: 0x1047a5dbf in test.using expectEqual (test)
    try testing.expectEqual(first, second);
    ^

====== expected this output: =========
Foo1␃

======== instead found this: =========
Foo␃

======================================
First difference occurs on line 1:
expected:
Foo1
   ^ ('\x31')
found:
Foo
   ^ (end of string)
3/4 root.test.using expectEqualString...FAIL (TestExpectedEqual)
/Users/roman/.zvm/0.14.0/lib/std/testing.zig:641:9: 0x1047a6ca3 in expectEqualStrings (test)
        return error.TestExpectedEqual;
        ^
/Users/roman/Projects/zig/simple/src/root.zig:27:5: 0x1047a776f in test.using expectEqualString (test)
    try testing.expectEqualStrings("Foo1", Foo.name);
    ^
slices differ. first difference occurs at index 0 (0x0)

============ expected this output: =============  len: 3 (0x3)

[0]: 1
[1]: 2
[2]: 3

============= instead found this: ==============  len: 3 (0x3)

[0]: 11
[1]: 22
[2]: 33

================================================

4/4 root.test.using expectEqualSlices...FAIL (TestExpectedEqual)
/Users/roman/.zvm/0.14.0/lib/std/testing.zig:435:5: 0x1047aabab in expectEqualSlices__anon_14687 (test)
    return error.TestExpectedEqual;
    ^
/Users/roman/Projects/zig/simple/src/root.zig:31:5: 0x1047aad2f in test.using expectEqualSlices (test)
    try testing.expectEqualSlices(i32, Foo.nums, &[_]i32{ 11, 22, 33 });
    ^
0 passed; 0 skipped; 4 failed.

Из вывода наших тестов можно заметить, что если бы мы не использовали специальные функции из стандартной библиотеки для тестирования, а писали бы все проверки вручную, например, только с использованием expect, вывод был бы гораздо менее информативным.

Специальные функции, такие как expectEqual, expectEqualSlices, expectError и другие, значительно улучшают читаемость ошибок. Они предоставляют подробную информацию о том, что именно пошло не так в тесте.

Например, expectEqualSlices покажет, какие именно элементы в срезах не совпадают и на каких позициях, а expectEqual наглядно укажет, какие значения ожидались и какие были получены. Это упрощает анализ проблем и ускоряет процесс отладки.

Стандартная библиотека Zig предоставляет множество вспомогательных функций, которые значительно упрощают процесс написания и выполнения тестов. Перед тем как начинать писать собственные тесты, определённо стоит ознакомиться с документацией по тестированию в Zig — это поможет лучше понять доступные инструменты и писать более надёжный и читаемый тестовый код.

Собственные тестовые функции

При написании тестов для сложных структур данных не обязательно ограничиваться только стандартными функциями. Можно создавать свои специальные функции для проверки свойств и поведения структур. Давайте рассмотрим пример стека и тестов для проверки его валидности:

const std = @import("std");
const testing = std.testing;
const expect = testing.expect;
const expectEqual = testing.expectEqual;
const print = std.debug.print;

// Реализация стека
const Stack = struct {
    items: std.ArrayList(i32),

    fn init(allocator: std.mem.Allocator) Stack {
        return Stack{
            .items = std.ArrayList(i32).init(allocator),
        };
    }

    fn deinit(self: *Stack) void {
        self.items.deinit();
    }

    fn push(self: *Stack, value: i32) !void {
        try self.items.append(value);
    }

    fn pop(self: *Stack) ?i32 {
        return if (self.items.pop()) |val| val else null;
    }

    fn peek(self: *Stack) ?i32 {
        if (self.items.items.len == 0) return null;
        return self.items.items[self.items.items.len - 1];
    }

    fn size(self: *Stack) usize {
        return self.items.items.len;
    }
};

// Самописная тестовая функция
fn testStackOperations(allocator: std.mem.Allocator, values: []const i32) !void {
    var stack = Stack.init(allocator);
    defer stack.deinit();

    print("\n=== Начало теста с значениями: {any} ===\n", .{values});

    // Проверка пустого стека
    if (stack.size() != 0) {
        print("[ОШИБКА] Размер нового стека должен быть 0, получено: {d}\n", .{stack.size()});
        return error.TestFailed;
    }

    if (stack.pop() != null) {
        print("[ОШИБКА] Pop из пустого стека должен возвращать null\n", .{});
        return error.TestFailed;
    }

    // Добавление элементов
    print("\nДобавляем элементы:\n", .{});
    for (values, 0..) |val, i| {
        print("  Добавляем {d} (ожидаемый размер: {d})\n", .{ val, i + 1 });
        try stack.push(val);

        if (stack.peek()) |peek_val| {
            if (peek_val != val) {
                print("[ОШИБКА] Ожидался peek = {d}, получено {d}\n", .{ val, peek_val });
                return error.TestFailed;
            }
        } else {
            print("[ОШИБКА] Peek после push вернул null\n", .{});
            return error.TestFailed;
        }

        const current_size = stack.size();
        if (current_size != i + 1) {
            print("[ОШИБКА] Ожидался размер = {d}, получено {d}\n", .{ i + 1, current_size });
            return error.TestFailed;
        }
    }

    // Проверка размера
    print("\nПроверка размера стека:\n", .{});
    const final_size = stack.size();
    if (final_size != values.len) {
        print("[ОШИБКА] Ожидался размер = {d}, получено {d}\n", .{ values.len, final_size });
        return error.TestFailed;
    }
    print("  Размер стека корректный: {d}\n", .{final_size});

    // Извлечение элементов
    print("\nИзвлекаем элементы:\n", .{});
    var i: usize = values.len;
    while (i > 0) : (i -= 1) {
        const expected_val = values[i - 1];
        if (stack.pop()) |popped| {
            print("  Извлечено {d} (ожидалось {d})", .{ popped, expected_val });

            if (popped == expected_val) {
                print(" - OK\n", .{});
            } else {
                print(" - [ОШИБКА]\n", .{});
                return error.TestFailed;
            }
        } else {
            print("[ОШИБКА] Pop вернул null, ожидалось {d}\n", .{expected_val});
            return error.TestFailed;
        }

        // Проверка размера после извлечения
        const expected_size = i - 1;
        const actual_size = stack.size();
        if (actual_size != expected_size) {
            print("[ОШИБКА] После pop ожидался размер = {d}, получено {d}\n", .{ expected_size, actual_size });
            return error.TestFailed;
        }
    }

    // Финальная проверка пустого стека
    print("\nФинальные проверки:\n", .{});
    const final_empty_size = stack.size();
    if (final_empty_size != 0) {
        print("[ОШИБКА] Ожидался пустой стек (размер 0), получено {d}\n", .{final_empty_size});
        return error.TestFailed;
    }
    print("  Размер стека после извлечения: 0 - OK\n", .{});

    if (stack.pop() != null) {
        print("[ОШИБКА] Финальный pop должен вернуть null\n", .{});
        return error.TestFailed;
    }
    print("  Финальный pop вернул null - OK\n", .{});

    print("=== Тест успешно завершён ===\n", .{});
}

// Основные тесты
test "stack operations with integers" {
    const allocator = testing.allocator;
    try testStackOperations(allocator, &[_]i32{ 1, 2, 3, 4, 5 });
}

test "stack operations with empty stack" {
    const allocator = testing.allocator;
    try testStackOperations(allocator, &[_]i32{});
}

test "stack operations with single value" {
    const allocator = testing.allocator;
    try testStackOperations(allocator, &[_]i32{42});
}

Если мы запустим наш пример, то получим следующий вывод:

$ zig test src/root.zig

=== Начало теста с значениями: { 1, 2, 3, 4, 5 } ===

Добавляем элементы:
  Добавляем 1 (ожидаемый размер: 1)
  Добавляем 2 (ожидаемый размер: 2)
  Добавляем 3 (ожидаемый размер: 3)
  Добавляем 4 (ожидаемый размер: 4)
  Добавляем 5 (ожидаемый размер: 5)

Проверка размера стека:
  Размер стека корректный: 5

Извлекаем элементы:
  Извлечено 5 (ожидалось 5) - OK
  Извлечено 4 (ожидалось 4) - OK
  Извлечено 3 (ожидалось 3) - OK
  Извлечено 2 (ожидалось 2) - OK
  Извлечено 1 (ожидалось 1) - OK

Финальные проверки:
  Размер стека после извлечения: 0 - OK
  Финальный pop вернул null - OK
=== Тест успешно завершён ===

=== Начало теста с значениями: {  } ===

Добавляем элементы:

Проверка размера стека:
  Размер стека корректный: 0

Извлекаем элементы:

Финальные проверки:
  Размер стека после извлечения: 0 - OK
  Финальный pop вернул null - OK
=== Тест успешно завершён ===

=== Начало теста с значениями: { 42 } ===

Добавляем элементы:
  Добавляем 42 (ожидаемый размер: 1)

Проверка размера стека:
  Размер стека корректный: 1

Извлекаем элементы:
  Извлечено 42 (ожидалось 42) - OK

Финальные проверки:
  Размер стека после извлечения: 0 - OK
  Финальный pop вернул null - OK
=== Тест успешно завершён ===
All 3 tests passed.

Для удобного тестирования нашего стека мы написали тестовую функцию, которая подробно выводит информацию во время выполнения. Это позволяет быстрее находить и анализировать ошибки в случае, если тесты завершатся неудачно. В целом хорошие тесты это микс из самописных тестовых функций и функций из стандартной библиотеки.

Тестирование ошибок

Довольно распространённая задача при тестировании библиотеки — это проверка того, что функции корректно возвращают ожидаемые ошибки в определённых ситуациях. Например, вы хотите убедиться, что при некорректном вводе или нарушении условий выполнения вызывается конкретная ошибка, предусмотренная логикой вашей программы.

Для таких случаев в стандартной библиотеке Zig предусмотрена специальная функция — testing.expectError. С её помощью можно явно указать, какую ошибку вы ожидаете от функции, и тест будет провален, если функция вернёт что-то другое или вовсе не вернёт ошибку.

Давайте рассмотрим пример её использования:

const testing = @import("std").testing;
const MyError = error{InvalidInput};

fn mightFail(value: i32) !void {
    if (value < 0) return MyError.InvalidInput;
}

test "ошибка при отрицательном значении" {
    try testing.expectError(MyError.InvalidInput, mightFail(-1));
}

В этом примере функция mightFail возвращает ошибку InvalidInput, если получает отрицательное значение. С помощью expectError мы проверяем, что при передаче -1 действительно будет сгенерирована именно эта ошибка.

Такой подход позволяет делать тесты более надёжными и самодокументируемыми, особенно когда важна корректная обработка исключительных ситуаций.

Отключение тестов

Иногда во время разработки и отладки бывает удобно временно отключить один из тестов, особенно если он в данный момент не проходит, но вы хотите сосредоточиться на других частях кода. Позже вы можете вернуться к нему и разобраться в причине сбоя.

К сожалению, в языке Zig нет такого удобного механизма для отключения тестов, как, например, в Rust, где достаточно просто аннотировать тест с помощью #[ignore]. Однако в Zig тоже есть способ временно “пропустить” тест, и он заключается в том, чтобы принудительно вернуть специальную ошибку error.SkipZigTest из тестового блока. Когда тестовая система встречает эту ошибку, она считает, что тест был пропущен.

const std = @import("std");
const expect = std.testing.expect;

pub fn sum(a: i32, b: i32) i32 {
    return a + b;
}

test "testing simple sum" {
    if (true) return error.SkipZigTest;

    const a: i32 = 1;
    const b: i32 = 2;
    try expect(sum(a, b) == 3);
}

test "testing failed sum" {
    return;
}

Если мы теперь запустим наши тесты то увидим:

$ zig test src/root.zig
1/2 root.test.testing simple sum...SKIP
1 passed; 1 skipped; 0 failed.

Такой подход позволяет сохранить тест в коде, не удаляя его и не комментируя вручную, а также явно сигнализирует, что он был пропущен намеренно. Это удобно как для вас, так и для других разработчиков, которые читают код — они сразу увидят, что тест отключён осознанно, и смогут при необходимости включить его обратно.

Тем не менее, не стоит злоупотреблять этим механизмом. Пропущенные тесты легко забыть включить обратно, особенно в больших проектах. Поэтому желательно использовать error.SkipZigTest только на время отладки и сопровождать такие тесты комментариями, поясняющими, почему тест был отключён.

Пользовательский тест раннер

Встроенный в стандартную библиотеку Zig тестовый раннер отлично справляется с основной задачей — запуском тестов и выводом их результатов. Однако в некоторых случаях этого может быть недостаточно. Например, вам может потребоваться вывод результатов в специальном формате для интеграции с CI/CD-системами или сбор дополнительной информации о ходе выполнения тестов.

В таких ситуациях можно воспользоваться возможностью создания собственного тест-раннера. Это позволит расширить стандартное поведение, добавляя, например, вывод времени выполнения каждого теста, логгирование в файл, отображение прогресса или даже интеграцию с внешними системами анализа.

С помощью пользовательского тест-раннера вы можете:

Итак, давайте рассмотрим как реализовать и встроить собственный тестовый раннер в Zig. Для того, чтобы передать собственный тестовый раннер при запуске тестов необходимо использовать флаг --test-runner и указать путь к исполняемому файлу. Но что из себя представляет тестовый раннер? Это просто исполняемый файл, который использует встроенные переменные для запуска тестов. Давайте рассмотрим пример самого простого тестового раннера:

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

pub fn main() !void {
    for (builtin.test_functions) |t| {
        t.func() catch |err| {
            std.debug.print("{s} fail: {}\n", .{ t.name, err });
            continue;
        };
        std.debug.print("{s} passed\n", .{t.name});
    }
}

Если мы запустим тесты с использованием нашего тестового раннера, то получим следующий результат:

$ zig test --test-runner ./src/test_runner.zig src/root.zig
root.test.testing simple sum passed
root.test.testing failed sum passed

Как мы видим список всех тестов найденных в исходных файлах доступен нам в виде массива builtin.test_functions. Этот массив содержит информацию о каждом тесте, включая его имя и функцию, которая выполняет тест. При этом имя теста будет содержать в себе полный путь к нашему файлу, например для файла src/server/storage.zig и теста test "testing" в параметре name мы увидим server.storage.test.testing.

Давайте теперь изменим наш код, чтобы вывести время выполнения каждого теста, а также выведем имя нашего теста без указания полного пути, что будет более удобным для чтения. Еще давайте немного изменим наши тесты, убрав описание у одного из тестов:

// файл test_runner.zig
const std = @import("std");
const builtin = @import("builtin");

pub fn main() !void {
    for (builtin.test_functions) |t| {
        const start = std.time.milliTimestamp();
        const result = t.func();
        const elapsed = std.time.milliTimestamp() - start;

        const name = extractName(t);
        if (result) |_| {
            std.debug.print("{s} passed ({d}ms)\n", .{ name, elapsed });
        } else |err| {
            std.debug.print("{s} failed {}\n", .{ t.name, err });
        }
    }
}

fn extractName(t: std.builtin.TestFn) []const u8 {
    const marker = std.mem.lastIndexOf(u8, t.name, ".test.") orelse return t.name;
    return t.name[marker + 6 ..];
}

// файл root.zig
const std = @import("std");
const expect = std.testing.expect;

pub fn sum(a: i32, b: i32) i32 {
    return a + b;
}

test "testing simple sum" {
    const a: i32 = 1;
    const b: i32 = 2;
    try expect(sum(a, b) == 3);
}

test {
    return;
}

В результате запуска наших тестов мы увидим:

$ zig test --test-runner ./src/test_runner.zig src/root.zig
testing simple sum passed (0ms)
root.test_0 passed (0ms)

Как мы видим, теперь в выводе отображается время выполнения каждого теста, а также названия тестов стали короче — без указания полного пути к файлу. Это делает вывод более чистым и удобным для чтения.

Однако, вы могли заметить странное имя одного из тестов: root.test_0. Откуда оно взялось? Это имя было сгенерировано автоматически компилятором Zig, потому что у этого теста отсутствовало явное описание. Когда вы не задаёте имя тесту, Zig присваивает ему имя по умолчанию в формате имя_модуля.test_#, где # — это порядковый номер безымянного теста в данном модуле. Такой подход позволяет компилятору всё же идентифицировать тест в отчёте, даже если вы не дали ему явного названия.

В целом, при использовании собственного тестового раннера вы можете добавить любую необходимую функциональность: например, реализовать более гибкую фильтрацию тестов с помощью переменных окружения или параметров командной строки, настраиваемый вывод результатов в нужном вам формате (JSON, CSV и др.), логирование, отображение прогресса, сбор статистики и многое другое. Такой подход особенно полезен при интеграции с системами CI/CD или при разработке крупных проектов, где стандартного поведения может быть недостаточно.

Однако есть один важный нюанс, о котором стоит помнить при создании кастомного тестового раннера. Если вы хотите, чтобы ваш раннер по-прежнему отслеживал утечки памяти во время выполнения тестов (что является крайне полезной функцией Zig) через тестовый аллокатор, вам необходимо вручную устанавливать глобальную переменную std.testing.allocator_instance перед запуском каждого теста.

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

pub fn main() !void {
    for (builtin.test_functions) |t| {
        const start = std.time.milliTimestamp();
        std.testing.allocator_instance = .{};

        const result = t.func();
        const name = extractName(t);

        if (std.testing.allocator_instance.deinit() == .leak) {
            std.debug.print("{s} leaked memory\n", .{name});
        }
        const elapsed = std.time.milliTimestamp() - start;

        if (result) |_| {
            std.debug.print("{s} passed ({d}ms)\n", .{ name, elapsed });
        } else |err| {
            std.debug.print("{s} failed {}\n", .{ t.name, err });
        }
    }
}

fn extractName(t: std.builtin.TestFn) []const u8 {
    const marker = std.mem.lastIndexOf(u8, t.name, ".test.") orelse return t.name;
    return t.name[marker + 6 ..];
}

Мы добавили инициализацию std.testing.allocator_instance перед каждым тестом, а после выполнения теста проверяем небыло ли у нас утечек во время теста и если были выводим информацию.

Подсчет покрытия кода тестами

Часто бывает полезно знать, какая часть вашего кода покрыта тестами. Это позволяет понять, не остались ли какие-то участки без проверки, и помогает повысить надёжность программы. Обычно для этого используется метрика покрытия кода тестами, которую можно получить с помощью специальных инструментов.

В языке Zig вы можете воспользоваться утилитой kcov, которая позволяет измерить покрытие и наглядно показать, какие участки кода были выполнены во время запуска тестов.

Для начала необходимо установить kcov на вашу систему. Например, если вы используете macOS, то это можно сделать с помощью Homebrew:

brew install kcov

После установки вы сможете запускать ваши тесты с использованием kcov, чтобы получить отчёт о покрытии. Это особенно полезно при разработке библиотек или сложных приложений, где важно убедиться, что каждая ветка логики протестирована.

Итак давайте запустим наши тесты и получим покрытие:

zig test -femit-bin=./library src/root.zig
kcov --clean --include-pattern=src/root.zig ./coverage ./library

В результате выполнения команды kcov будет создан каталог coverage, содержащий отчёт о покрытии кода тестами. Вы можете открыть этот отчёт в браузере, чтобы получить наглядное представление о покрытии кода.

Заключение

В языке Zig тестирование является важной частью разработки, и стандартная библиотека предоставляет все необходимые инструменты для этого. Не избегайте написания тестов, даже если кажется, что они отнимают время на начальных этапах разработки — это вложение обязательно окупится в будущем, когда потребуется вносить изменения или исправлять ошибки.

Регулярное тестирование вашего кода обеспечивает уверенность в его работоспособности и помогает выявлять проблемы на ранних стадиях, что значительно снижает стоимость их исправления. Используйте инструменты, которые мы рассмотрели в этой главе, чтобы сделать ваш код более надёжным и свободным от ошибок.

#testing #unittest #zig #zigbook

0%