解答13: テストの実践

概要

この解答では、Zigの組み込みテストフレームワークを活用して、単体テスト、データ構造のテスト、テーブル駆動テスト、メモリリーク検出テストについて学びます。

解答例

Part 1: 基本的な単体テスト

const std = @import("std");

// 文字列を反転する関数
fn reverseString(allocator: std.mem.Allocator, str: []const u8) ![]u8 {
    const result = try allocator.alloc(u8, str.len);
    for (str, 0..) |c, i| {
        result[str.len - 1 - i] = c;
    }
    return result;
}

// 配列の最大値を返す関数
fn findMax(numbers: []const i32) ?i32 {
    if (numbers.len == 0) return null;

    var max = numbers[0];
    for (numbers[1..]) |num| {
        if (num > max) max = num;
    }
    return max;
}

// 文字列が回文か判定する関数
fn isPalindrome(str: []const u8) bool {
    if (str.len == 0) return true;

    var i: usize = 0;
    var j: usize = str.len - 1;

    while (i < j) {
        if (str[i] != str[j]) return false;
        i += 1;
        j -= 1;
    }

    return true;
}

// 階乗を計算する関数
fn factorial(n: u32) u64 {
    if (n == 0) return 1;
    return n * factorial(n - 1);
}

// フィボナッチ数を計算する関数
fn fibonacci(n: u32) u64 {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// ===== テスト =====

test "reverseString" {
    const result = try reverseString(std.testing.allocator, "hello");
    defer std.testing.allocator.free(result);

    try std.testing.expectEqualStrings("olleh", result);
}

test "reverseString - empty string" {
    const result = try reverseString(std.testing.allocator, "");
    defer std.testing.allocator.free(result);

    try std.testing.expectEqualStrings("", result);
}

test "reverseString - single char" {
    const result = try reverseString(std.testing.allocator, "a");
    defer std.testing.allocator.free(result);

    try std.testing.expectEqualStrings("a", result);
}

test "findMax - normal case" {
    const numbers = [_]i32{ 3, 7, 2, 9, 1 };
    const max = findMax(&numbers);

    try std.testing.expectEqual(@as(?i32, 9), max);
}

test "findMax - empty array" {
    const numbers = [_]i32{};
    const max = findMax(&numbers);

    try std.testing.expectEqual(@as(?i32, null), max);
}

test "findMax - negative numbers" {
    const numbers = [_]i32{ -5, -2, -10, -1 };
    const max = findMax(&numbers);

    try std.testing.expectEqual(@as(?i32, -1), max);
}

test "isPalindrome - true cases" {
    try std.testing.expect(isPalindrome("racecar"));
    try std.testing.expect(isPalindrome("a"));
    try std.testing.expect(isPalindrome(""));
    try std.testing.expect(isPalindrome("noon"));
}

test "isPalindrome - false cases" {
    try std.testing.expect(!isPalindrome("hello"));
    try std.testing.expect(!isPalindrome("ab"));
    try std.testing.expect(!isPalindrome("world"));
}

test "factorial" {
    try std.testing.expectEqual(@as(u64, 1), factorial(0));
    try std.testing.expectEqual(@as(u64, 1), factorial(1));
    try std.testing.expectEqual(@as(u64, 2), factorial(2));
    try std.testing.expectEqual(@as(u64, 6), factorial(3));
    try std.testing.expectEqual(@as(u64, 24), factorial(4));
    try std.testing.expectEqual(@as(u64, 120), factorial(5));
}

test "fibonacci" {
    try std.testing.expectEqual(@as(u64, 0), fibonacci(0));
    try std.testing.expectEqual(@as(u64, 1), fibonacci(1));
    try std.testing.expectEqual(@as(u64, 1), fibonacci(2));
    try std.testing.expectEqual(@as(u64, 2), fibonacci(3));
    try std.testing.expectEqual(@as(u64, 3), fibonacci(4));
    try std.testing.expectEqual(@as(u64, 5), fibonacci(5));
    try std.testing.expectEqual(@as(u64, 8), fibonacci(6));
}

Part 2: データ構造のテスト

const std = @import("std");

fn Stack(comptime T: type) type {
    return struct {
        items: []T,
        len: usize,
        allocator: std.mem.Allocator,

        const Self = @This();
        const StackError = error{StackOverflow};

        pub fn init(allocator: std.mem.Allocator, capacity: usize) !Self {
            const items = try allocator.alloc(T, capacity);
            return Self{
                .items = items,
                .len = 0,
                .allocator = allocator,
            };
        }

        pub fn deinit(self: *Self) void {
            self.allocator.free(self.items);
        }

        pub fn push(self: *Self, item: T) !void {
            if (self.len >= self.items.len) {
                return StackError.StackOverflow;
            }
            self.items[self.len] = item;
            self.len += 1;
        }

        pub fn pop(self: *Self) ?T {
            if (self.len == 0) return null;
            self.len -= 1;
            return self.items[self.len];
        }

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

        pub fn size(self: Self) usize {
            return self.len;
        }

        pub fn isEmpty(self: Self) bool {
            return self.len == 0;
        }
    };
}

// ===== テスト =====

test "Stack - basic operations" {
    var stack = try Stack(i32).init(std.testing.allocator, 10);
    defer stack.deinit();

    try std.testing.expect(stack.isEmpty());
    try std.testing.expectEqual(@as(usize, 0), stack.size());

    // プッシュ
    try stack.push(1);
    try stack.push(2);
    try stack.push(3);

    try std.testing.expectEqual(@as(usize, 3), stack.size());
    try std.testing.expect(!stack.isEmpty());

    // ピーク
    try std.testing.expectEqual(@as(?i32, 3), stack.peek());
    try std.testing.expectEqual(@as(usize, 3), stack.size());

    // ポップ
    try std.testing.expectEqual(@as(?i32, 3), stack.pop());
    try std.testing.expectEqual(@as(?i32, 2), stack.pop());
    try std.testing.expectEqual(@as(usize, 1), stack.size());
}

test "Stack - empty pop" {
    var stack = try Stack(i32).init(std.testing.allocator, 10);
    defer stack.deinit();

    try std.testing.expectEqual(@as(?i32, null), stack.pop());
}

test "Stack - capacity overflow" {
    var stack = try Stack(i32).init(std.testing.allocator, 2);
    defer stack.deinit();

    try stack.push(1);
    try stack.push(2);

    try std.testing.expectError(error.StackOverflow, stack.push(3));
}

test "Stack - different types" {
    var int_stack = try Stack(i32).init(std.testing.allocator, 5);
    defer int_stack.deinit();

    try int_stack.push(42);
    try std.testing.expectEqual(@as(?i32, 42), int_stack.pop());

    var bool_stack = try Stack(bool).init(std.testing.allocator, 5);
    defer bool_stack.deinit();

    try bool_stack.push(true);
    try std.testing.expectEqual(@as(?bool, true), bool_stack.pop());
}

Part 3: テーブル駆動テスト

const std = @import("std");

fn intToString(allocator: std.mem.Allocator, value: i32) ![]u8 {
    return try std.fmt.allocPrint(allocator, "{}", .{value});
}

fn stringToInt(str: []const u8) !i32 {
    return try std.fmt.parseInt(i32, str, 10);
}

const Operation = enum {
    Add,
    Subtract,
    Multiply,
    Divide,
};

fn calculate(op: Operation, a: i32, b: i32) !i32 {
    return switch (op) {
        .Add => a + b,
        .Subtract => a - b,
        .Multiply => a * b,
        .Divide => {
            if (b == 0) return error.DivisionByZero;
            return @divTrunc(a, b);
        },
    };
}

// ===== テスト =====

test "intToString - table driven" {
    const test_cases = [_]struct {
        input: i32,
        expected: []const u8,
    }{
        .{ .input = 0, .expected = "0" },
        .{ .input = 1, .expected = "1" },
        .{ .input = 42, .expected = "42" },
        .{ .input = -1, .expected = "-1" },
        .{ .input = -42, .expected = "-42" },
        .{ .input = 123456, .expected = "123456" },
    };

    for (test_cases) |tc| {
        const result = try intToString(std.testing.allocator, tc.input);
        defer std.testing.allocator.free(result);

        try std.testing.expectEqualStrings(tc.expected, result);
    }
}

test "stringToInt - table driven" {
    const test_cases = [_]struct {
        input: []const u8,
        expected: i32,
    }{
        .{ .input = "0", .expected = 0 },
        .{ .input = "1", .expected = 1 },
        .{ .input = "42", .expected = 42 },
        .{ .input = "-1", .expected = -1 },
        .{ .input = "-42", .expected = -42 },
        .{ .input = "123456", .expected = 123456 },
    };

    for (test_cases) |tc| {
        const result = try stringToInt(tc.input);
        try std.testing.expectEqual(tc.expected, result);
    }
}

test "calculate - table driven" {
    const test_cases = [_]struct {
        op: Operation,
        a: i32,
        b: i32,
        expected: i32,
    }{
        .{ .op = .Add, .a = 2, .b = 3, .expected = 5 },
        .{ .op = .Add, .a = -2, .b = 3, .expected = 1 },
        .{ .op = .Subtract, .a = 5, .b = 3, .expected = 2 },
        .{ .op = .Subtract, .a = 3, .b = 5, .expected = -2 },
        .{ .op = .Multiply, .a = 4, .b = 5, .expected = 20 },
        .{ .op = .Multiply, .a = -4, .b = 5, .expected = -20 },
        .{ .op = .Divide, .a = 10, .b = 2, .expected = 5 },
        .{ .op = .Divide, .a = -10, .b = 2, .expected = -5 },
    };

    for (test_cases) |tc| {
        const result = try calculate(tc.op, tc.a, tc.b);
        try std.testing.expectEqual(tc.expected, result);
    }
}

test "calculate - division by zero" {
    try std.testing.expectError(error.DivisionByZero, calculate(.Divide, 10, 0));
}

Part 4: メモリリーク検出

const std = @import("std");

const StringList = struct {
    items: std.ArrayList([]u8),
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator) StringList {
        return StringList{
            .items = std.ArrayList([]u8).init(allocator),
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *StringList) void {
        for (self.items.items) |str| {
            self.allocator.free(str);
        }
        self.items.deinit();
    }

    pub fn append(self: *StringList, str: []const u8) !void {
        const owned = try self.allocator.alloc(u8, str.len);
        @memcpy(owned, str);
        try self.items.append(owned);
    }

    pub fn get(self: StringList, index: usize) ?[]const u8 {
        if (index >= self.items.items.len) return null;
        return self.items.items[index];
    }

    pub fn len(self: StringList) usize {
        return self.items.items.len;
    }
};

// ===== テスト =====

test "StringList - no leaks" {
    var list = StringList.init(std.testing.allocator);
    defer list.deinit();

    try list.append("hello");
    try list.append("world");
    try list.append("!");

    try std.testing.expectEqual(@as(usize, 3), list.len());
    try std.testing.expectEqualStrings("hello", list.get(0).?);
    try std.testing.expectEqualStrings("world", list.get(1).?);
}

test "StringList - empty list" {
    var list = StringList.init(std.testing.allocator);
    defer list.deinit();

    try std.testing.expectEqual(@as(usize, 0), list.len());
    try std.testing.expectEqual(@as(?[]const u8, null), list.get(0));
}

test "StringList - multiple operations" {
    var list = StringList.init(std.testing.allocator);
    defer list.deinit();

    // 10個の文字列を追加
    var i: usize = 0;
    while (i < 10) : (i += 1) {
        const str = try std.fmt.allocPrint(
            std.testing.allocator,
            "item{}",
            .{i}
        );
        defer std.testing.allocator.free(str);

        try list.append(str);
    }

    try std.testing.expectEqual(@as(usize, 10), list.len());

    // すべての文字列を確認
    i = 0;
    while (i < 10) : (i += 1) {
        const expected = try std.fmt.allocPrint(
            std.testing.allocator,
            "item{}",
            .{i}
        );
        defer std.testing.allocator.free(expected);

        try std.testing.expectEqualStrings(expected, list.get(i).?);
    }
}

ポイント解説

1. テストアロケータの重要性

std.testing.allocatorは、メモリリークを自動的に検出します。

2. テーブル駆動テストの利点

  • 多数のテストケースを簡潔に記述
  • テストケースの追加が容易
  • パターンの可視化
  • 3. expectEqual vs expectEqualStrings

  • expectEqual: 値の等価性をチェック
  • expectEqualStrings: 文字列の等価性をチェック(メッセージが分かりやすい)
  • 4. deferの活用

    テストでは必ずdeferでリソースを解放します。

    よくある間違い

    1. アロケータの使い忘れ

    // 間違い
    test "bad test" {
        const data = try std.heap.page_allocator.alloc(u8, 100);
        defer std.heap.page_allocator.free(data);
    }
    
    // 正しい
    test "good test" {
        const data = try std.testing.allocator.alloc(u8, 100);
        defer std.testing.allocator.free(data);
    }
    

    2. expectEqualの型不一致

    // 間違い
    try std.testing.expectEqual(42, some_u32);  // 型が一致しない
    
    // 正しい
    try std.testing.expectEqual(@as(u32, 42), some_u32);
    

    発展課題

    Challenge 1: モッククライアント

    HTTP クライアントのモックを作成し、ネットワークなしでテストできるようにしてください。

    Challenge 2: プロパティベーステスト

    ランダムな入力でソート関数の性質(順序性、要素の保存)をテストしてください。

    Challenge 3: ファズテスト

    パーサーにランダムな入力を与えて、クラッシュしないことを確認してください。

    Challenge 4: カバレッジ測定

    テストのコードカバレッジを計測するツールを作成してください。

    Challenge 5: テストフィクスチャ

    セットアップとティアダウンを持つテストフレームワークを実装してください。

    まとめ

    この課題を通じて、以下を学びました:

  • 単体テスト: 基本的なテストの書き方
  • データ構造のテスト: 複雑な型のテスト
  • テーブル駆動テスト: 効率的なテストケース管理
  • メモリリーク検出: テストアロケータの活用

Zigのテストシステムは、シンプルかつ強力です。適切なテストを書くことで、バグを早期に発見できます。