From 662c7c620e6db915c5705bd0afa84925bb84704d Mon Sep 17 00:00:00 2001 From: Yaroslav de la Peña Smirnov Date: Thu, 15 Oct 2026 22:51:45 +0300 Subject: utest: v2, now with more isolation * C23 required. * Less macro voodoo magic. * The test declaration API changed. * Now there's a test type with the test data. * Now it's possible to set a time out for each test. * It's now also possible to initialize data for each test separately. * More importantly, each test is now isolated in its own process, so that we beautifully report a crash and continue with the rest. * Still less than 500loc even with all the comments! --- utest/Makefile | 2 +- utest/utest-test.c | 40 ++++-- utest/utest.h | 374 ++++++++++++++++++++++++++++++++++------------------- 3 files changed, 270 insertions(+), 146 deletions(-) diff --git a/utest/Makefile b/utest/Makefile index 0ecb1c5..3cecbd1 100644 --- a/utest/Makefile +++ b/utest/Makefile @@ -1,5 +1,5 @@ CC?=gcc -CFLAGS+=-Wall -Werror -Wno-unused -std=gnu11 -Og -g +CFLAGS+=-Wall -Werror -Wno-unused -std=c23 -Og -g all: $(CC) $(CFLAGS) -o utest-test utest-test.c diff --git a/utest/utest-test.c b/utest/utest-test.c index 591ee32..df7d51b 100644 --- a/utest/utest-test.c +++ b/utest/utest-test.c @@ -2,23 +2,28 @@ static int a, b; -/* This should pass */ -TEST_BEGIN(test_pass) +void test_pass_run(utest *t) { - asserteq(a, 0); - assertneq(a, b); - expect(b > a, "expected b > a"); - TEST_OUT + asserteq(t, a, 0); + assertneq(t, a, b); + expect(t, b > a, "expected b > a"); } -TEST_END -/* This should fail */ -TEST_BEGIN(test_fail) +void test_fail_run(utest *t) { - expect(0, "oopsie!"); - TEST_OUT + expect(t, 0, "oopsie!"); +} + +void test_crash_run(utest *t) +{ + int *x = NULL; + *x = 8; +} + +void test_timeout_run(utest *t) +{ + sleep(10); } -TEST_END void test_init(void) { @@ -26,9 +31,16 @@ void test_init(void) printf("hello, world!\n\n"); } -void test_destroy(void) +void test_cleanup(void) { printf("\ngood bye!\n"); } -RUN_TEST_SUITE(test_init, test_destroy, test_pass, test_fail); +utest tests[] = { + UTEST_INIT_S(test_pass, 0), + UTEST_INIT_S(test_fail, 0), + UTEST_INIT_S(test_crash, 0), + UTEST_INIT_S(test_timeout, 1), +}; + +RUN_TEST_SUITE(test_init, test_cleanup, tests); diff --git a/utest/utest.h b/utest/utest.h index 4222183..30dc229 100644 --- a/utest/utest.h +++ b/utest/utest.h @@ -1,26 +1,42 @@ /* SPDX-License-Identifier: LGPL-2.1 */ /** - * utest.h - header-only unit testing micro "framework". v1.0.0 + * utest.h - header-only unit testing micro "framework". v2.0.0 * - * Copyright (c) 2021-2023 Yaroslav de la Peña Smirnov + * Copyright (c) 2021-2026 Yaroslav de la Peña Smirnov + * + * Changes from v1 + * =============== + * + * - C23 required. + * - Less macro voodoo magic. + * - The test declaration API changed. + * - Now there's a test type with the test data. + * - Now it's possible to set a time out for each test. + * - It's now also possible to initialize data for each test separately. + * - More importantly, each test is now isolated in its own process, so that we + * beautifully report a crash and continue with the rest. + * - Still less than 500loc even with all these comments! */ #ifndef UTEST_H #define UTEST_H -#include + +#ifndef _POSIX_C_SOURCE +#define _POSIX_C_SOURCE 200809L +#endif + +#include +#include +#include #include #include #include +#include +#include #include +#include -/* - * TODO?: test runners that run in different processes in order to not crash the - * whole test unit when a single test crashes. - */ - -/** - * Constants - * ========= - */ +/* Copy this header and change the include path as needed */ +#include "../utils.h" /** * Terminal escape codes @@ -48,51 +64,108 @@ #endif #define TDEL "\33[2K\r" +typedef struct utest utest; + /** - * Typedefs - * ======== + * test_fn - A test function. + * @t: the test data; required for the macros. */ - -typedef bool (*test_fn)(void); +typedef void (*test_fn)(utest *t); typedef void (*setup_fn)(void); +enum utest_result : uint8_t { + UTEST_SUCCESS, + UTEST_FAILURE, + UTEST_TIMEOUT, +}; +static_assert(UTEST_SUCCESS == 0, "UTEST_SUCCESS should be equal to zero"); + /** - * Internal variables - * ================== + * struct utest - A single test. + * @name: the test's name, usually the same as the function's (@run) name. + * @run: the function with the test; must NOT be NULL. + * @pre_run: optional function to initialize test data, run before the test. + * @post_run: optional cleanup function, run's after a test. + * @timeout_s: test timeout in secs; if 0 no timeout; the test will be killed + * and marked as failed after the set time expires. + * @result: used to pass the result of the test back to the wrapper func. + * + * This is an opaque type. Only functions in this header should access the + * fields in this struct. */ +struct utest { + const char *name; + test_fn run; + setup_fn pre_run; + setup_fn post_run; + uint32_t timeout_s; + enum utest_result result; +}; -static setup_fn tests_init = NULL; -static setup_fn tests_destroy = NULL; +static setup_fn ut_init = NULL; +static setup_fn ut_cleanup = NULL; + +static sigset_t sset; + +/** + * UTEST_INIT - Initialize a single test. + * @_name: the test name. + * @pre_run_fn: data initialization function. + * @post_run_fn: data cleanup function. + * @_timeout_s: timeout in seconds; 0 - no timeout. + * + * The test function should be named "`_name`_run". + */ +#define UTEST_INIT(nam, pre_run_fn, post_run_fn, timout_s) \ + { \ + .name = #nam, \ + .run = nam##_run, \ + .pre_run = pre_run_fn, \ + .post_run = post_run_fn, \ + .timeout_s = timout_s, \ + } /** - * Functions and function-like macros - * ================================== + * UTEST_INIT_S - Initialize a single test, without data initializer/destroyer. + * @_name: the test name. + * @_timeout_s: timeout in seconds; 0 - no timeout. + * + * See also UTEST_INIT. */ +#define UTEST_INIT_S(name, timeout_s) UTEST_INIT(name, NULL, NULL, timeout_s) + +#define ut_prfail(t, reason...) \ + ({ \ + printf(TDEL " [" TBLD TRED "×" TRST "]\t%s: " TBLD TRED \ + "FAILED!\n" TRST, \ + t->name); \ + printf("\t﹂" reason); \ + }) + +#define ut_prfail_errno(t, desc) ut_prfail(t, desc ": %s\n", strerror(errno)); /** * FAIL_TEST - fail the test and print the @reason. * @reason: a variable list of arguments, where the first argument is the format * text. */ -#define FAIL_TEST(reason...) \ - printf(TDEL " [" TBLD TRED "×" TRST "]\t%s: " TBLD TRED "FAILED!\n" TRST, \ - __this_name); \ - printf("\t﹂%s:%d: ", __FILE__, __LINE__); \ - printf(reason); \ - printf("\n"); \ - __ret = false; \ - goto test_out +#define FAIL_TEST(t, reason...) \ + ut_prfail(t, "%s:%d: ", __FILE__, __LINE__); \ + printf(reason); \ + printf("\n"); \ + t->result = UTEST_FAILURE; \ + return; /** * asserteq - assert @a and @b's equality; fail test if not equal. * @a: argument to compare to b. * @b: argument to compare to a. */ -#define asserteq(a, b) \ - ({ \ - if ((a) != (b)) { \ - FAIL_TEST("assertion " TBLD TBLU #a " == " #b TRST " failed"); \ - } \ +#define asserteq(t, a, b) \ + ({ \ + if ((a) != (b)) { \ + FAIL_TEST(t, "assertion " TBLD TBLU #a " == " #b TRST " failed"); \ + } \ }) /** @@ -100,11 +173,11 @@ static setup_fn tests_destroy = NULL; * @a: argument to compare to b. * @b: argument to compare to a. */ -#define assertneq(a, b) \ - ({ \ - if ((a) == (b)) { \ - FAIL_TEST("assertion " TBLD TBLU #a " != " #b TRST " failed"); \ - } \ +#define assertneq(t, a, b) \ + ({ \ + if ((a) == (b)) { \ + FAIL_TEST(t, "assertion " TBLD TBLU #a " != " #b TRST " failed"); \ + } \ }) /** @@ -113,107 +186,146 @@ static setup_fn tests_destroy = NULL; * @reason: a variable list of arguments, where the first argument is the format * text. */ -#define expect(cond, reason...) \ - ({ \ - if (!(cond)) { \ - FAIL_TEST(reason); \ - } \ +#define expect(t, cond, reason...) \ + ({ \ + if (!(cond)) { \ + FAIL_TEST(t, reason); \ + } \ }) -/** - * TEST_BEGIN - declare test function @name. - * @name: name of test function to declare. - * - * Should be followed by TEST_OUT and TEST_END. - */ -#define TEST_BEGIN(name) \ - int name(void) \ - { \ - const char *__this_name = #name; \ - bool __ret = true; \ - printf(" [⏳]\t%s: " TBLU "running..." TRST, __this_name); \ - fflush(stdout); +[[noreturn]] +static void ut_run_child(struct utest *t) +{ + if (t->pre_run) + t->pre_run(); + printf(" [⏳]\t%s: " TBLU "running..." TRST, t->name); + fflush(stdout); -/** - * TEST_OUT - code that should be executed on test exit goes after this. - * This declares a goto label, so that on test failure anything that needs to be - * cleaned up is cleaned up. You need to use this even if there's nothing to be - * cleaned up. - */ -#define TEST_OUT \ -test_out:; + t->result = UTEST_SUCCESS; + struct timespec ts[2]; + clock_gettime(CLOCK_MONOTONIC, &ts[0]); + t->run(t); + clock_gettime(CLOCK_MONOTONIC, &ts[1]); + ts[1].tv_sec -= ts[1].tv_sec; + if (ts[1].tv_nsec < ts[0].tv_nsec) { + ts[1].tv_nsec = ts[0].tv_nsec - ts[1].tv_nsec; + ts[1].tv_sec -= 1; + } else { + ts[1].tv_nsec = ts[1].tv_nsec - ts[0].tv_nsec; + } -/** - * TEST_END - close the test scope. - */ -#define TEST_END \ - if (__ret) { \ - printf(TDEL " [" TBLD TGRN "✓" TRST "]\t%s: " TBLD TGRN \ - "PASSED!\n" TRST, \ - __this_name); \ - } \ - return __ret; \ + if (t->result == UTEST_SUCCESS) + printf(TDEL " [" TBLD TGRN "✓" TRST "]\t%s: " TBLD TGRN "PASSED!" TRST + ": ran %lu.%09lus\n", + t->name, + ts[1].tv_sec, + ts[1].tv_nsec); + + if (t->post_run) + t->post_run(); + exit(t->result ? EXIT_FAILURE : EXIT_SUCCESS); +} + +static enum utest_result ut_run_test(struct utest *t) +{ + if (t->timeout_s && sigprocmask(SIG_BLOCK, &sset, NULL) < 0) { + ut_prfail_errno(t, "sigprocmask"); + return UTEST_FAILURE; } -#define BENCH_BEGIN(name) \ - int name(void) \ - { \ - const char *__this_name = #name; \ - struct timespec __t1, __t2; \ - clock_gettime(CLOCK_MONOTONIC, &__t1); \ - printf(" [⏳]\t%s: " TBLU "running..." TRST, __this_name); \ - fflush(stdout); - -#define BENCH_END \ - clock_gettime(CLOCK_MONOTONIC, &__t2); \ - __t2.tv_sec -= __t1.tv_sec; \ - if (__t2.tv_nsec < __t1.tv_nsec) { \ - __t2.tv_nsec = __t1.tv_nsec - __t2.tv_nsec; \ - __t2.tv_sec -= 1; \ - } else { \ - __t2.tv_nsec = __t2.tv_nsec - __t1.tv_nsec; \ - } \ - printf(TDEL " [" TBLD TGRN "✓" TRST "]\t%s: ran in\t" TBLD TBLU \ - "%lu.%09lus\n" TRST, \ - __this_name, __t2.tv_sec, __t2.tv_nsec); \ - return true; \ + auto pid = fork(); + if (pid < 0) { + ut_prfail_errno(t, "fork"); + return UTEST_FAILURE; } -/** - * __run_tests() - runs tests; for internal use. - */ -static inline int __run_tests(const char *const unit_name, ...) -{ - va_list args; - test_fn test; - size_t passed = 0, total = 0, failed = 0; + /* Child, the test being run */ + if (pid == 0) + ut_run_child(t); + + siginfo_t si = {0}; + if (t->timeout_s) { + struct timespec ts = { + .tv_sec = t->timeout_s, + .tv_nsec = 0, + }; + auto wres = sigtimedwait(&sset, &si, &ts); + if (sigprocmask(SIG_UNBLOCK, &sset, NULL) < 0) { + ut_prfail_errno(t, "sigprocmask"); + return UTEST_FAILURE; + } + if (wres < 0) { + kill(pid, SIGKILL); + if (errno == EAGAIN) { + ut_prfail(t, "test timed out after %d seconds\n", t->timeout_s); + waitpid(pid, NULL, 0); + return UTEST_TIMEOUT; + } + ut_prfail_errno(t, "sigtimedwait"); + wait(NULL); + return UTEST_FAILURE; + } + wait(NULL); + } else if (waitid(P_PID, pid, &si, WEXITED | WSTOPPED) < 0) { + ut_prfail_errno(t, "waitid"); + return UTEST_FAILURE; + } + + switch (si.si_code) { + case CLD_EXITED: + return si.si_status ? UTEST_FAILURE : UTEST_SUCCESS; + case CLD_KILLED: + case CLD_DUMPED: + case CLD_STOPPED: + auto signum = si.si_status; + ut_prfail(t, "test terminated abruptly: %s\n", strsignal(signum)); + return UTEST_FAILURE; + } + + kill(pid, SIGKILL); + ut_prfail(t, "unexpected test child process condition\n"); + wait(NULL); + return UTEST_FAILURE; +} - if (tests_init) { - tests_init(); +static inline int ut_run_tests(const char *const unit_name, + size_t tests_cnt, + utest tests[tests_cnt]) +{ + if (sigemptyset(&sset) < 0) { + perror("sigemptyset"); + return -1; + } + if (sigaddset(&sset, SIGCHLD) < 0) { + perror("sigaddset"); + return -1; } + size_t passed = 0, total = 0, failed = 0; + + if (ut_init) + ut_init(); + printf("[***]\t" TBLD "running:" TRST TBLU " %s " TRST "tests\n", - unit_name); + unit_name); - va_start(args, unit_name); - test = va_arg(args, test_fn); - while (test) { - test() ? passed++ : 0; + for (size_t i = 0; i < tests_cnt; i++) { + !ut_run_test(tests + i) ? passed++ : 0; total++; - test = va_arg(args, test_fn); } - va_end(args); failed = total - passed; printf("[***]\t" TBLD "finished:" TRST TBLU " %s: " TRST "tests: " TBLU - "%lu" TRST ", passed: " TGRN "%lu" TRST ", failed: " TRED "%lu" TRST - "\n", - unit_name, total, passed, failed); + "%lu" TRST ", passed: " TGRN "%lu" TRST ", failed: " TRED "%lu" TRST + "\n", + unit_name, + total, + passed, + failed); - if (tests_destroy) { - tests_destroy(); - } + if (ut_cleanup) + ut_cleanup(); return failed; } @@ -221,26 +333,26 @@ static inline int __run_tests(const char *const unit_name, ...) /** * RUN_TEST_SUITE - runs the @tests given as arguments. * @init_fn: initialization function. - * @destroy_fn: cleanup function. - * @tests...: tests to run. + * @cleanup_fn: cleanup function. + * @tests: tests to run (utest[]). * * @init_fn and @destroy_fn are used to initialize and cleanup test-related data * respectively. */ -#define RUN_TEST_SUITE(init_fn, destroy_fn, tests...) \ - int main(void) \ - { \ - tests_init = init_fn; \ - tests_destroy = destroy_fn; \ - return __run_tests(__FILE__, tests, NULL); \ +#define RUN_TEST_SUITE(init_fn, cleanup_fn, tests) \ + int main(void) \ + { \ + ut_init = init_fn; \ + ut_cleanup = cleanup_fn; \ + return ut_run_tests(__FILE__, ARRAY_SIZE(tests), tests); \ } /** * RUN_TESTS - runs the @tests given as arguments. - * @tests...: tests to run. + * @tests: tests to run (utest[]). * * Same as RUN_TEST_SUITE, but without initialization and cleanup functions */ -#define RUN_TESTS(tests...) RUN_TEST_SUITE(NULL, NULL, tests) +#define RUN_TESTS(tests) RUN_TEST_SUITE(NULL, NULL, tests) #endif /* UTEST_H */ -- cgit v1.3.1