MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C语言单元测试框架设计与实践

2021-03-274.4k 阅读

一、C 语言单元测试框架概述

在软件开发过程中,确保代码的正确性和可靠性至关重要。单元测试作为一种重要的测试手段,专注于对程序中的最小可测试单元(通常是函数)进行验证。C 语言作为一种广泛使用的编程语言,也需要有效的单元测试框架来辅助开发人员保证代码质量。

一个好的 C 语言单元测试框架应该具备以下特点:

  1. 简单易用:开发人员能够轻松上手,不需要复杂的配置和学习过程。
  2. 丰富的断言:提供多种断言方式,以便对函数的返回值、输出结果等进行全面验证。
  3. 可扩展性:能够适应不同规模和复杂度的项目需求,方便添加新的测试用例和功能。
  4. 平台无关性:可以在不同的操作系统和编译器环境下使用。

(一)常见的 C 语言单元测试框架

  1. Check
    • Check 是一个轻量级的 C 语言单元测试框架。它具有简洁的语法,易于使用。例如,下面是一个简单的 Check 测试用例:
#include <check.h>

START_TEST (test_add) {
    int result = add(2, 3);
    ck_assert_int_eq(result, 5);
}
END_TEST

Suite * add_suite(void) {
    Suite *s;
    TCase *tc_core;

    s = suite_create("Addition");
    tc_core = tcase_create("Core");

    tcase_add_test(tc_core, test_add);
    suite_add_tcase(s, tc_core);

    return s;
}

int main(void) {
    int number_failed;
    Suite *s;
    SRunner *sr;

    s = add_suite();
    sr = srunner_create(s);

    srunner_run_all(sr, CK_NORMAL);
    number_failed = srunner_ntests_failed(sr);
    srunner_free(sr);

    return (number_failed == 0)? EXIT_SUCCESS : EXIT_FAILURE;
}
  • 在上述代码中,首先定义了一个测试用例 test_add,使用 ck_assert_int_eq 断言来验证 add 函数的返回值是否等于预期值。然后通过 SuiteTCase 的相关函数来组织测试用例,并在 main 函数中运行测试。
  1. Unity
    • Unity 也是一个流行的 C 语言单元测试框架,它以其简单性和灵活性受到开发者喜爱。以下是一个 Unity 测试用例示例:
#include "unity.h"
#include "math_operations.h"

void setUp(void) {
    // 初始化代码,在每个测试用例执行前运行
}

void tearDown(void) {
    // 清理代码,在每个测试用例执行后运行
}

void test_addition(void) {
    int result = add(2, 3);
    TEST_ASSERT_EQUAL_INT(5, result);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_addition);
    return UNITY_END();
}
  • Unity 框架中,setUptearDown 函数分别用于在每个测试用例执行前后进行初始化和清理工作。TEST_ASSERT_EQUAL_INT 是断言函数,用于验证两个整数是否相等。通过 UNITY_BEGINUNITY_END 来管理测试的开始和结束,并通过 RUN_TEST 运行具体的测试用例。

二、设计 C 语言单元测试框架的核心要素

(一)断言机制

断言是单元测试框架的核心部分,它用于验证测试结果是否符合预期。在设计断言机制时,需要考虑以下几个方面:

  1. 类型兼容性:要支持多种数据类型的断言,如整数、浮点数、指针等。例如,对于整数断言,可以定义如下函数:
void assert_int_eq(int expected, int actual, const char *file, int line, const char *func) {
    if (expected!= actual) {
        printf("Assertion failed in function %s at file %s line %d: expected %d but got %d\n", func, file, line, expected, actual);
        // 可以在这里添加更多的错误处理逻辑,如记录日志等
    }
}
  • 上述函数 assert_int_eq 用于比较两个整数是否相等,如果不相等则输出错误信息,包含失败的函数名、文件名和行号等,方便开发者定位问题。
  1. 浮点数断言:由于浮点数在计算机中的存储方式,不能简单地使用 == 进行比较。通常需要考虑一定的精度范围。以下是一个浮点数断言函数示例:
void assert_float_eq(float expected, float actual, float tolerance, const char *file, int line, const char *func) {
    if (fabs(expected - actual) > tolerance) {
        printf("Assertion failed in function %s at file %s line %d: expected %.2f but got %.2f with tolerance %.2f\n", func, file, line, expected, actual, tolerance);
    }
}
  • 在这个函数中,使用 fabs 函数计算两个浮点数的差值,并与给定的 tolerance(容忍度)进行比较。如果差值超过容忍度,则断言失败并输出相应信息。

(二)测试用例管理

  1. 测试用例的定义:需要提供一种清晰的方式来定义测试用例。可以使用函数来封装每个测试用例,例如:
void test_function_1(void) {
    int result = function_1(10);
    assert_int_eq(20, result, __FILE__, __LINE__, __func__);
}
  • 这里 test_function_1 是一个测试用例函数,通过调用 assert_int_eq 来验证 function_1 函数的返回值。__FILE____LINE____func__ 是 C 语言的预定义宏,分别表示当前文件名、当前行号和当前函数名,用于在断言失败时提供详细的错误信息。
  1. 测试用例的组织:对于大型项目,可能有大量的测试用例。需要一种机制来将相关的测试用例组织在一起,形成测试套件(Test Suite)。可以使用结构体和链表来实现测试套件的管理。例如:
typedef struct TestCase {
    void (*test_func)(void);
    const char *name;
    struct TestCase *next;
} TestCase;

typedef struct TestSuite {
    const char *name;
    TestCase *head;
} TestSuite;

void add_test_case(TestSuite *suite, void (*test_func)(void), const char *name) {
    TestCase *new_case = (TestCase *)malloc(sizeof(TestCase));
    new_case->test_func = test_func;
    new_case->name = name;
    new_case->next = suite->head;
    suite->head = new_case;
}
  • 在上述代码中,TestCase 结构体用于表示一个测试用例,包含测试函数指针 test_func、测试用例名称 name 和指向下一个测试用例的指针 nextTestSuite 结构体用于表示一个测试套件,包含套件名称 name 和指向测试用例链表头的指针 headadd_test_case 函数用于向测试套件中添加测试用例。

(三)测试执行引擎

  1. 初始化和清理:在运行测试用例之前,可能需要进行一些初始化工作,如设置环境变量、初始化全局数据等。在测试用例执行完毕后,可能需要进行清理工作,如释放内存、关闭文件等。可以通过注册初始化和清理函数来实现这一功能。例如:
void (*setup_func)(void) = NULL;
void (*teardown_func)(void) = NULL;

void register_setup(void (*func)(void)) {
    setup_func = func;
}

void register_teardown(void (*func)(void)) {
    teardown_func = func;
}

void run_tests(TestSuite *suite) {
    TestCase *current = suite->head;
    if (setup_func) {
        setup_func();
    }
    while (current) {
        printf("Running test case: %s\n", current->name);
        current->test_func();
        current = current->next;
    }
    if (teardown_func) {
        teardown_func();
    }
}
  • 在上述代码中,定义了 setup_functeardown_func 两个全局函数指针,用于存储初始化和清理函数。register_setupregister_teardown 函数用于注册这些函数。run_tests 函数在运行测试用例之前先调用初始化函数 setup_func,在所有测试用例运行完毕后调用清理函数 teardown_func
  1. 测试结果统计:需要统计测试用例的执行结果,如通过的测试用例数、失败的测试用例数等。可以在断言函数中增加计数变量来实现这一功能。例如:
int passed_count = 0;
int failed_count = 0;

void assert_int_eq(int expected, int actual, const char *file, int line, const char *func) {
    if (expected == actual) {
        passed_count++;
    } else {
        failed_count++;
        printf("Assertion failed in function %s at file %s line %d: expected %d but got %d\n", func, file, line, expected, actual);
    }
}

void print_test_summary(void) {
    printf("Test summary: Passed %d, Failed %d\n", passed_count, failed_count);
}
  • 这里定义了 passed_countfailed_count 两个全局变量来分别统计通过和失败的测试用例数。在 assert_int_eq 断言函数中根据断言结果更新这两个变量。print_test_summary 函数用于输出测试结果的总结信息。

三、C 语言单元测试框架实践

(一)一个简单的单元测试框架实现

下面我们基于前面讨论的核心要素,实现一个简单的 C 语言单元测试框架。

#include <stdio.h>
#include <stdlib.h>
#include <math.h>

// 断言函数
void assert_int_eq(int expected, int actual, const char *file, int line, const char *func) {
    if (expected!= actual) {
        printf("Assertion failed in function %s at file %s line %d: expected %d but got %d\n", func, file, line, expected, actual);
    }
}

void assert_float_eq(float expected, float actual, float tolerance, const char *file, int line, const char *func) {
    if (fabs(expected - actual) > tolerance) {
        printf("Assertion failed in function %s at file %s line %d: expected %.2f but got %.2f with tolerance %.2f\n", func, file, line, expected, actual, tolerance);
    }
}

// 测试用例和测试套件相关结构体
typedef struct TestCase {
    void (*test_func)(void);
    const char *name;
    struct TestCase *next;
} TestCase;

typedef struct TestSuite {
    const char *name;
    TestCase *head;
} TestSuite;

// 向测试套件中添加测试用例的函数
void add_test_case(TestSuite *suite, void (*test_func)(void), const char *name) {
    TestCase *new_case = (TestCase *)malloc(sizeof(TestCase));
    new_case->test_func = test_func;
    new_case->name = name;
    new_case->next = suite->head;
    suite->head = new_case;
}

// 初始化和清理函数相关
void (*setup_func)(void) = NULL;
void (*teardown_func)(void) = NULL;

void register_setup(void (*func)(void)) {
    setup_func = func;
}

void register_teardown(void (*func)(void)) {
    teardown_func = func;
}

// 运行测试套件的函数
void run_tests(TestSuite *suite) {
    TestCase *current = suite->head;
    if (setup_func) {
        setup_func();
    }
    while (current) {
        printf("Running test case: %s\n", current->name);
        current->test_func();
        current = current->next;
    }
    if (teardown_func) {
        teardown_func();
    }
}

// 测试结果统计变量和函数
int passed_count = 0;
int failed_count = 0;

void print_test_summary(void) {
    printf("Test summary: Passed %d, Failed %d\n", passed_count, failed_count);
}

// 示例被测试函数
int add(int a, int b) {
    return a + b;
}

float divide(float a, float b) {
    if (b == 0) {
        return -1; // 简单处理除零情况
    }
    return a / b;
}

// 测试用例函数
void test_addition(void) {
    int result = add(2, 3);
    assert_int_eq(5, result, __FILE__, __LINE__, __func__);
    if (result == 5) {
        passed_count++;
    } else {
        failed_count++;
    }
}

void test_division(void) {
    float result = divide(10.0f, 2.0f);
    assert_float_eq(5.0f, result, 0.001f, __FILE__, __LINE__, __func__);
    if (fabs(result - 5.0f) <= 0.001f) {
        passed_count++;
    } else {
        failed_count++;
    }
}

int main(void) {
    TestSuite suite;
    suite.name = "Arithmetic Tests";
    suite.head = NULL;

    add_test_case(&suite, test_addition, "Test addition");
    add_test_case(&suite, test_division, "Test division");

    register_setup(NULL);
    register_teardown(NULL);

    run_tests(&suite);
    print_test_summary(void);

    // 清理测试用例链表
    TestCase *current = suite.head;
    TestCase *next;
    while (current) {
        next = current->next;
        free(current);
        current = next;
    }

    return 0;
}

在上述代码中,首先定义了断言函数 assert_int_eqassert_float_eq,用于验证整数和浮点数的预期结果。接着定义了测试用例和测试套件相关的结构体及操作函数,如 add_test_case 用于向测试套件中添加测试用例。还实现了初始化和清理函数的注册以及运行测试套件的函数 run_tests。同时,定义了测试结果统计变量和函数 print_test_summary。最后,通过示例的被测试函数 adddivide 以及对应的测试用例函数 test_additiontest_division 展示了如何使用这个简单的单元测试框架。

(二)在实际项目中的应用

假设我们正在开发一个小型的数学库,包含多个数学运算函数,如 addsubtractmultiplydivide。为了确保这些函数的正确性,我们可以使用上述的单元测试框架。

  1. 项目结构
    • math_operations.c:实现数学运算函数。
    • math_operations.h:声明数学运算函数。
    • test_math_operations.c:编写测试用例并使用单元测试框架进行测试。
  2. math_operations.c 代码示例
#include "math_operations.h"

int add(int a, int b) {
    return a + b;
}

int subtract(int a, int b) {
    return a - b;
}

int multiply(int a, int b) {
    return a * b;
}

float divide(float a, float b) {
    if (b == 0) {
        return -1;
    }
    return a / b;
}
  1. math_operations.h 代码示例
#ifndef MATH_OPERATIONS_H
#define MATH_OPERATIONS_H

int add(int a, int b);
int subtract(int a, int b);
int multiply(int a, int b);
float divide(float a, float b);

#endif
  1. test_math_operations.c 代码示例
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include "math_operations.h"

// 断言函数
void assert_int_eq(int expected, int actual, const char *file, int line, const char *func) {
    if (expected!= actual) {
        printf("Assertion failed in function %s at file %s line %d: expected %d but got %d\n", func, file, line, expected, actual);
    }
}

void assert_float_eq(float expected, float actual, float tolerance, const char *file, int line, const char *func) {
    if (fabs(expected - actual) > tolerance) {
        printf("Assertion failed in function %s at file %s line %d: expected %.2f but got %.2f with tolerance %.2f\n", func, file, line, expected, actual, tolerance);
    }
}

// 测试用例和测试套件相关结构体
typedef struct TestCase {
    void (*test_func)(void);
    const char *name;
    struct TestCase *next;
} TestCase;

typedef struct TestSuite {
    const char *name;
    TestCase *head;
} TestSuite;

// 向测试套件中添加测试用例的函数
void add_test_case(TestSuite *suite, void (*test_func)(void), const char *name) {
    TestCase *new_case = (TestCase *)malloc(sizeof(TestCase));
    new_case->test_func = test_func;
    new_case->name = name;
    new_case->next = suite->head;
    suite->head = new_case;
}

// 初始化和清理函数相关
void (*setup_func)(void) = NULL;
void (*teardown_func)(void) = NULL;

void register_setup(void (*func)(void)) {
    setup_func = func;
}

void register_teardown(void (*func)(void)) {
    teardown_func = func;
}

// 运行测试套件的函数
void run_tests(TestSuite *suite) {
    TestCase *current = suite->head;
    if (setup_func) {
        setup_func();
    }
    while (current) {
        printf("Running test case: %s\n", current->name);
        current->test_func();
        current = current->next;
    }
    if (teardown_func) {
        teardown_func();
    }
}

// 测试结果统计变量和函数
int passed_count = 0;
int failed_count = 0;

void print_test_summary(void) {
    printf("Test summary: Passed %d, Failed %d\n", passed_count, failed_count);
}

// 测试用例函数
void test_addition(void) {
    int result = add(2, 3);
    assert_int_eq(5, result, __FILE__, __LINE__, __func__);
    if (result == 5) {
        passed_count++;
    } else {
        failed_count++;
    }
}

void test_subtraction(void) {
    int result = subtract(5, 3);
    assert_int_eq(2, result, __FILE__, __LINE__, __func__);
    if (result == 2) {
        passed_count++;
    } else {
        failed_count++;
    }
}

void test_multiplication(void) {
    int result = multiply(2, 3);
    assert_int_eq(6, result, __FILE__, __LINE__, __func__);
    if (result == 6) {
        passed_count++;
    } else {
        failed_count++;
    }
}

void test_division(void) {
    float result = divide(10.0f, 2.0f);
    assert_float_eq(5.0f, result, 0.001f, __FILE__, __LINE__, __func__);
    if (fabs(result - 5.0f) <= 0.001f) {
        passed_count++;
    } else {
        failed_count++;
    }
}

int main(void) {
    TestSuite suite;
    suite.name = "Math Operations Tests";
    suite.head = NULL;

    add_test_case(&suite, test_addition, "Test addition");
    add_test_case(&suite, test_subtraction, "Test subtraction");
    add_test_case(&suite, test_multiplication, "Test multiplication");
    add_test_case(&suite, test_division, "Test division");

    register_setup(NULL);
    register_teardown(NULL);

    run_tests(&suite);
    print_test_summary(void);

    // 清理测试用例链表
    TestCase *current = suite.head;
    TestCase *next;
    while (current) {
        next = current->next;
        free(current);
        current = next;
    }

    return 0;
}

通过这种方式,在实际项目中,我们可以为每个函数编写相应的测试用例,使用单元测试框架来验证函数的正确性。随着项目的发展和函数的修改,重新运行测试用例可以及时发现引入的错误,保证项目代码的质量。

四、单元测试框架的优化与扩展

(一)错误处理优化

在现有的断言函数中,只是简单地输出错误信息。可以进一步优化错误处理,例如将错误信息记录到日志文件中,以便更好地跟踪和分析问题。

#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <time.h>

// 日志文件指针
FILE *log_file = NULL;

// 初始化日志文件
void init_log_file(void) {
    time_t now;
    struct tm *tm_info;
    time(&now);
    tm_info = localtime(&now);

    char log_file_name[256];
    strftime(log_file_name, 256, "test_log_%Y%m%d_%H%M%S.txt", tm_info);
    log_file = fopen(log_file_name, "w");
    if (log_file == NULL) {
        perror("Failed to open log file");
        exit(EXIT_FAILURE);
    }
}

// 关闭日志文件
void close_log_file(void) {
    if (log_file) {
        fclose(log_file);
    }
}

// 断言函数
void assert_int_eq(int expected, int actual, const char *file, int line, const char *func) {
    if (expected!= actual) {
        if (log_file) {
            fprintf(log_file, "Assertion failed in function %s at file %s line %d: expected %d but got %d\n", func, file, line, expected, actual);
        }
        printf("Assertion failed in function %s at file %s line %d: expected %d but got %d\n", func, file, line, expected, actual);
    }
}

void assert_float_eq(float expected, float actual, float tolerance, const char *file, int line, const char *func) {
    if (fabs(expected - actual) > tolerance) {
        if (log_file) {
            fprintf(log_file, "Assertion failed in function %s at file %s line %d: expected %.2f but got %.2f with tolerance %.2f\n", func, file, line, expected, actual, tolerance);
        }
        printf("Assertion failed in function %s at file %s line %d: expected %.2f but got %.2f with tolerance %.2f\n", func, file, line, expected, actual, tolerance);
    }
}

在上述代码中,首先定义了 log_file 用于指向日志文件。init_log_file 函数用于初始化日志文件,根据当前时间生成日志文件名并打开文件。close_log_file 函数用于关闭日志文件。在断言函数 assert_int_eqassert_float_eq 中,当断言失败时,除了在控制台输出错误信息外,还将错误信息写入日志文件。

(二)支持更多的数据类型断言

可以扩展断言机制,支持对结构体、枚举等数据类型的断言。例如,对于结构体断言:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 假设定义一个结构体
typedef struct {
    int id;
    char name[50];
} Person;

// 结构体断言函数
void assert_struct_eq(Person *expected, Person *actual, const char *file, int line, const char *func) {
    if (expected->id!= actual->id || strcmp(expected->name, actual->name)!= 0) {
        printf("Assertion failed in function %s at file %s line %d: structs are not equal\n", func, file, line);
    }
}

在上述代码中,定义了一个 Person 结构体,并实现了 assert_struct_eq 函数用于比较两个 Person 结构体是否相等。如果结构体的 id 字段或 name 字段不相等,则断言失败并输出相应信息。

(三)集成代码覆盖率工具

代码覆盖率是衡量单元测试质量的一个重要指标,它表示测试代码对被测试代码的覆盖程度。可以将代码覆盖率工具集成到单元测试框架中。例如,使用 gcov 工具(GNU 代码覆盖率工具)。

  1. 编译时选项:在编译被测试代码和测试代码时,需要添加特定的编译选项来生成代码覆盖率相关信息。例如,对于 math_operations.c 文件:
gcc -g -O0 -fprofile-arcs -ftest-coverage -c math_operations.c
  • 对于 test_math_operations.c 文件:
gcc -g -O0 -fprofile-arcs -ftest-coverage -c test_math_operations.c
  1. 链接生成可执行文件
gcc -o test_math_operations math_operations.o test_math_operations.o -lgcov
  1. 运行测试并生成覆盖率报告
./test_math_operations
gcov math_operations.c
  • 运行 gcov 命令后,会生成一个 math_operations.c.gcov 文件,该文件详细记录了代码的覆盖率信息,如哪些行被执行了,哪些行未被执行等。通过分析这些信息,可以进一步完善测试用例,提高代码覆盖率。

通过以上优化和扩展,可以使 C 语言单元测试框架更加完善和强大,更好地满足实际项目的需求,提高软件的质量和可靠性。