2

I'm writing a dynamic array for a personal project and am trying to unit test all the functions. I'm trying to write a unit test for util_dyn_array_check_index() but am having problems doing so because I made the design decision to call exit(-1) if there is an index out of bounds. I want to check in my unit test that it calls exit() when provided with an invalid index. But if I ever give it an invalid index, it just exits my testing program. Is it possible to somehow catch that a call to exit() was thrown, or redefine exit() in my testing program to prevent it from ending the tests?

From this answer I've looked into atexit(), but it looks like that doesn't stop an exit, just performs one or more user-defined functions before exiting. This doesn't work for me because I have other tests to run after this one. My last thought is I could make util_dyn_array_check_index() a macro instead of a function, and redefine exit() to be a different function in my testing program, but I'd rather not make it a macro if I can avoid it.

Here's my code:

The details of this struct don't really matter, just provided for completeness

//basically a Vec<T>
typedef struct {
    //a pointer to the data stored
    void * data;
    //the width of the elements to be stored in bytes
    size_t stride;
    //the number of elements stored
    size_t len;
    //the number of elements able to be stored without reallocating
    size_t capacity;
} util_dyn_array;

Here is the function I want to test.

//exits with -1 if index is out of bounds
inline void util_dyn_array_check_index(util_dyn_array * self, size_t index) {
    if (index >= self->len) {
        exit(-1);
    }
    return;
}

Here is a skeleton of what I would like the test to be (omitted some macro magic I'm using to make writing tests nicer, for clarity).

bool test_dyn_array_check_index() {
    util_dyn_array vector = util_dyn_array_new(sizeof(int), 16);
    for(int i = 0; i < 16; i++) {
        util_dyn_array_push(&vector, (void*)&i);
    }

    for(int i = 0; i < 16; i++) {
        //if nothing happens, its successful
        util_dyn_array_check_index(&vector, i);
    }

    //somehow check that it calls exit without letting it crash my program
    {
        util_dyn_array_check_index(&vector, 16);
    }

    return true;

}

Obviously I could change my code to return a bool or write to errno, but I'd prefer it to exit as its usually an unrecoverable bug.

Acorn
  • 24,970
  • 5
  • 40
  • 69
David Sullivan
  • 462
  • 7
  • 16
  • 2
    This is usually handled by redesigning the unit test invocation so that each unit test runs as its own binary/process, also not least because that catches crashes as well. – Siguza Oct 04 '20 at 03:25
  • That makes sense. Any suggestions where to learn how to do this, or what to search for? – David Sullivan Oct 04 '20 at 03:27
  • Please specify your OS and toolchain, as anything that happens after `exit()` is outside the scope of standard C. For instance, on Unix, you can use `fork()` to run the test in a child process. – Nate Eldredge Oct 04 '20 at 03:34
  • Running MacOS Catalina, Clang 11.0.3, and c11 – David Sullivan Oct 04 '20 at 03:37
  • Your unit testing problem is hinting that using `exit` to handle argument errors is inflexible and difficult to recover from. Consider using a different error mechanism. Alternately, write a thin wrapper function around exit in a shared library and compile in a mock version for testing. – Schwern Oct 04 '20 at 03:48
  • 2
    On most (all?) modern Unix systems, you just define your own `exit()` function which will override the weak symbol from the standard C library. Eg. `void exit(int s){ for(;;) printf("tits\n"); }`. Put it in a C file and then link it either statically or dynamically with your code. This won't override the implicit exit() called after main() has returned, nor any exit() call internal to the C library. –  Oct 04 '20 at 03:55
  • Is this production code? If so, consider using [an existing library](https://developer.gnome.org/glib/2.62/glib-Arrays.html) which is already developed and tested. – Schwern Oct 04 '20 at 04:00
  • @Schwern, it's intentionally unrecoverable. I could return a bool or write to errno or something, but in my setting, an out of bounds index is definitely a major logic bug, and "recovering" from it doesn't make sense. And if this were production code, I agree, I should use an existing library. It's just a dogfood project with the goal of improving my knowledge of C – David Sullivan Oct 04 '20 at 04:05
  • @DavidSullivan Your goal is noble, but you're trying to implement exceptions in C. People have written entire programming languages trying to add features to C. For example, you could switch to C++. Otherwise, I would recommend accepting that error handling is done by the caller in C. – Schwern Oct 04 '20 at 04:09
  • @Schwern I'm not. Exceptions are meant to be handled, and this isn't. Not that I don't appreceate your feedback, but I'm just trying test that it exits when I want to exit, and not when I don't want it to. Siguza, Nate Eldredge, and user4141777 have all suggested paths to test functions that can call exit. – David Sullivan Oct 04 '20 at 04:15
  • In scenarios, such as your proposing, suggest you have a call to `atexit()` as the first thing in the `main()` function. – user3629249 Oct 05 '20 at 16:01

5 Answers5

2

It is UB to define another exit() in standard C (both as a function and as a macro if the header is included). In many environments, you are likely to be able to get away with it, though.

With that said, doing such a thing makes no sense here, because we are talking about exit(). The function is not expecting to continue execution after that, so that pretty much forces you to replace it with a longjmp() or go with textual replacement to convert it to a return (assuming a void return type). In both cases, it means you need to assume the function is not leaving things in a broken state (like holding some resource). That is a lot to assume, but it may be a reasonable way out if your unit testing framework is meant to be tied to this particular project.

Instead of trying to modify the behavior of the tested function, I suggest you add support for running tests in their own process to your test framework. There are many advantages apart from being able to test things like these. For instance, you get the ability of running tests in parallel for free and isolates many side-effects between them.

Acorn
  • 24,970
  • 5
  • 40
  • 69
2

Another solution: use an #ifdef macro to call a different function in your debug build.

#ifdef DEBUG
 #define exit_badindex(...) my_debug_function(__VA_ARGS__)
#else
 #define exit_badindex(...) exit(__VA_ARGS__)
  #ifndef NDEBUG
   #warning NDEBUG undefined in non-DEBUG build: my_debug_function will not be called
  #endif
#endif
l.k
  • 199
  • 8
1

If it's for unit testing, you don't need to physically catch the call to exit().

Before the solution, I would like to suggest redesigning your library. You have a few alternatives:

  • Having util_dyn_array include a callback that is called if out-of-bound mode is encountered, and default it to exit(1) (not exit(-1), that doesn't work well when the program is called from a shell).
  • Having a global out-of-bound handler (which again defaults to exit(1)), and allow the program to change the handler at runtime by calling something like set_oob_handler(new_handler).
  • Doing integration tests instead of unit tests. As suggested by multiple people here, if the library can exit or crash, this goes to the realm of integration (with the calling process/OS).

My solution:

main.c:

#include <stdio.h>

void func(void);

int main(int argc, char **argv)
{
    printf("starting\n");
    func();
    printf("ending\n");
}

something.c:

void my_exit(int status)
{
    printf("my_exit(%d)\n", status);
#ifdef UNIT_TEST
    printf("captured exit(%d)\n", status); // you can even choose to call a global callback here, only in unit tests.
#else
    exit(status);
#endif
}

void func(void) {
    my_exit(1);
}

makefile:

# these targets are MUTUALLY EXCLUSIVE!!

release:
    cc -g -c -fpic something.c
    cc -shared -o libsomething.so something.o
    cc -g -o main main.c -L. -lsomething

fortest:
    cc -DUNIT_TEST=1 -g -c -fpic something.c
    cc -shared -o libsomething.so something.o
    cc -g -o main main.c -L. -lsomething
$ make release
cc -g -c -fpic something.c
[...]
$ LD_LIBRARY_PATH=. ./main
starting
my_exit(1)
$ make fortest
cc -DUNIT_TEST=1 -g -c -fpic something.c
[...]
$ LD_LIBRARY_PATH=. ./main
starting
my_exit(1)
captured exit(1)
ending

(note that this was tested on Linux, I don't have a Mac to test on, so minor makefile modifications might be required).

root
  • 5,528
  • 1
  • 7
  • 15
  • Modifying the tested functions/program allows for anything to be done, of course, but it is not a solution for the question. – Acorn Oct 04 '20 at 04:37
  • "*Doing integration tests instead of unit tests ... this goes to the realm of integration*" Testing that a function stops the program on invalid inputs does not mean it is an integration test. – Acorn Oct 04 '20 at 04:40
1

The exit function is a weak symbol, so you can create your own copy of the function to catch the case where it gets called. Additionally, you can make use of setjmp and longjmp in your test code to detect a proper call to exit:

For example:

#include "file_to_test.c"

static int expected_code;    // the expected value a tested function passes to exit
static int should_exit;      // 1 if exit should have been called
static int done;             // set to 1 to prevent stubbing behavior and actually exit

static jmp_buf jump_env;

static int rslt;    
#define test_assert(x) (rslt = rslt && (x))

// stub function
void exit(int code)
{
    if (!done)
    {
        test_assert(should_exit==1);
        test_assert(expected_code==code);
        longjmp(jump_env, 1);
    }
    else
    {
        _exit(code);
    }
}

bool test_dyn_array_check_index() {
    int jmp_rval;
    done = 0;
    rslt = 1;

    util_dyn_array vector = util_dyn_array_new(sizeof(int), 16);
    for(int i = 0; i < 16; i++) {
        util_dyn_array_push(&vector, (void*)&i);
    }

    for(int i = 0; i < 16; i++) {
        //if nothing happens, its successful
        should_exit = 0;
        if (!(jmp_rval=setjmp(jump_env)))
        {
            util_dyn_array_check_index(&vector, i);
        }
        test_assert(jmp_rval==0);

    }

    // should call exit(-1)
    {
        should_exit = 1;
        expected_code = 2;
        if (!(jmp_rval=setjmp(jump_env)))
        {
            util_dyn_array_check_index(&vector, 16);
        }

        test_assert(jmp_rval==1);
    }
    done = 1
 
    return rslt;

}    

Before calling a function that could call exit, call setjmp to set a jump point. The stubbed exit function then checks whether exit should have been called and with which exit code, then calls longjmp to jump back out to the test.

If exit was called then the return value of setjmp is 1, indicating it came from a call to longjmp. If not longjmp is not called and the return value of setjmp will be 0 after the function returns.

dbush
  • 205,898
  • 23
  • 218
  • 273
1

Run the code in a fork and check the exit value of the fork.

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <unistd.h>
#include <sys/wait.h>

void util_dyn_array_check_index() {
    exit(-1);
}

int main(void) {
    pid_t pid = fork();
    assert(pid >= 0);
    if( pid == 0 ) {
        util_dyn_array_check_index();
        exit(0);
    }
    else {
        int child_status;
        wait(&child_status);
        printf("util_dyn_array_check_index exited with %d\n", WEXITSTATUS(child_status));
    }
}

Note that the exit status will be 255 because despite accepting an integer POSIX exit statuses are unsigned. Consider using exit(1) instead or define an ARGUMENT_ERROR_EXIT_STATUS macro.


Note that I used an assert to check if my fork failed. This might be a better way to implement your checks.

inline void util_dyn_array_check_index(util_dyn_array * self, size_t index) {
    assert(index < self->len);
}

This will provide more information on error, and it can be switched off in production for performance.

Assertion failed: (index < self->len), function util_dyn_array_check_index, file test.c, line 9.

assert calls abort. In your tests you'd close stderr to avoid the assert message from cluttering up the output, and check that WTERMSIG(status) == SIGABRT.

void util_dyn_array_check_index() {
    assert(43 < 42);
}

int main(void) {
    pid_t pid = fork();
    assert(pid >= 0);
    if( pid == 0 ) {
        // Suppress the assert output
        fclose(stderr);
        util_dyn_array_check_index();
        exit(0);
    }
    else {
        int status;
        wait(&status);
        if( WTERMSIG(status) == SIGABRT ) {
            puts("Pass");
        }
        else {
            puts("Fail");
        }
    }
}
Schwern
  • 153,029
  • 25
  • 195
  • 336
  • This method worked extremely well. The tip to close `stderr` in the child process was a great extra nugget that I implemented. – David Sullivan Oct 17 '20 at 01:51