feat: testing framework

This commit is contained in:
thetek 2023-06-23 18:03:26 +02:00
parent 930dafa31f
commit a1b9ef6022
4 changed files with 189 additions and 35 deletions

View File

@ -21,12 +21,13 @@ class File:
deps: list[str]
class CompilationMode(enum.Enum):
class Compilation_Mode(enum.Enum):
'''
used to specify if the program should be compiled in debug or release mode.
'''
Debug = 'debug'
Release = 'release'
Test = 'test'
class Color(enum.Enum):
@ -35,38 +36,58 @@ class Color(enum.Enum):
'''
Reset = '\x1b[0m'
Red = '\x1b[31m'
Green = '\x1b[32m'
Cyan = '\x1b[36m'
def get_source_files() -> list[str]:
def get_source_files(compilation_mode: Compilation_Mode) -> list[str]:
'''
get a list of source files located in the `src/` folder. only matches files
ending in `*.c`.
'''
path = pathlib.Path('src/')
files = [str(p) for p in path.rglob('*.c')]
if compilation_mode == Compilation_Mode.Test:
test_path = pathlib.Path('test/')
files.remove(config.SRC_MAIN)
files.extend([str(p) for p in test_path.rglob('*.c')])
return files
def get_object_location(src_file: str, compilation_mode: CompilationMode) -> str:
def get_object_location(src_file: str, compilation_mode: Compilation_Mode) -> str:
'''
get the location of the resulting object file. for example, a source file
`src/foo/bar/baz.c` will have the object location `obj/debug/foo/bar/baz.c`
if compiled in debug mode.
'''
result = src_file.replace('src/', f'obj/{compilation_mode.value}/')
if compilation_mode == Compilation_Mode.Test:
subfolder = Compilation_Mode.Debug.value
else:
subfolder = compilation_mode.value
result = src_file.replace('src/', f'obj/{subfolder}/')
result = result.replace('test/', 'obj/test/')
result = result[:-2] + '.o'
return result
def get_include_directories(src_file: str) -> list[str]:
'''
get a list the directories that header files are located in, to be used as
compiler flags.
'''
if src_file.startswith('test/'):
return ['-Iinc', '-Itest']
else:
return ['-Iinc']
def get_dependencies(src_file: str) -> list[str]:
'''
obtain a list of header dependencies for a source file by running `cc -M`.
since `cc -M` uses make as its output format, it needs to be converted to a
regular python list.
'''
includes = get_include_directories(src_file)
# obtain raw make-compatible output from `cc -M` and remove linebreaks
output_raw = subprocess.check_output([config.COMPILER, '-Iinc', '-M', '-MM', src_file])
output_raw = subprocess.check_output([config.COMPILER, *includes, '-M', '-MM', src_file])
output = output_raw.decode('utf-8').replace('\n', '').replace('\\', '')
# remove make rule to get space-delimited list of dependencies
deps = output.split(':')[1].split(' ')
@ -75,13 +96,13 @@ def get_dependencies(src_file: str) -> list[str]:
return deps
def get_file_list(compilation_mode: CompilationMode) -> list[File]:
def get_file_list(compilation_mode: Compilation_Mode) -> list[File]:
'''
get a list of `File` objects that contain information about where source
files are, where their object files will be located and which headers they
depend on.
'''
src_files = get_source_files()
src_files = get_source_files(compilation_mode)
files: list[File] = []
for src in src_files:
@ -131,7 +152,7 @@ def check_if_need_compile(file: File) -> bool:
return False
def compile_file(file: File, compilation_mode: CompilationMode) -> int:
def compile_file(file: File, compilation_mode: Compilation_Mode) -> int:
'''
compile a source file to an object file.
'''
@ -139,23 +160,24 @@ def compile_file(file: File, compilation_mode: CompilationMode) -> int:
return 0
path = pathlib.Path(file.obj)
includes = get_include_directories(file.src)
obj_folder = str(path.parent)
os.makedirs(obj_folder, exist_ok=True)
if compilation_mode == CompilationMode.Debug:
mode_specific_flags = config.FLAGS_DEBUG
elif compilation_mode == CompilationMode.Release:
if compilation_mode == Compilation_Mode.Release:
mode_specific_flags = config.FLAGS_RELEASE
else:
mode_specific_flags = config.FLAGS_DEBUG
log_message(Color.Green, 'cc', file.obj)
log_message(Color.Cyan, 'cc', file.obj)
return_code = subprocess.call([
config.COMPILER,
*config.FLAGS.split(' '),
*config.FLAGS_WARN.split(' '),
*mode_specific_flags.split(' '),
'-Iinc',
*includes,
'-c', file.src,
'-o', file.obj,
])
@ -163,7 +185,14 @@ def compile_file(file: File, compilation_mode: CompilationMode) -> int:
return return_code
def link_program(files: list[File], compilation_mode: CompilationMode) -> int:
def get_target_name(compilation_mode: Compilation_Mode) -> str:
'''
get the name of the target executable based on the compilation mode.
'''
return f'bin/{config.TARGET}-{compilation_mode.value}'
def link_program(files: list[File], compilation_mode: Compilation_Mode) -> int:
'''
link the generated object files to an executable binary.
'''
@ -171,14 +200,14 @@ def link_program(files: list[File], compilation_mode: CompilationMode) -> int:
objs = [file.obj for file in files]
if compilation_mode == CompilationMode.Debug:
mode_specific_flags = config.FLAGS_DEBUG
elif compilation_mode == CompilationMode.Release:
if compilation_mode == Compilation_Mode.Release:
mode_specific_flags = config.FLAGS_RELEASE
else:
mode_specific_flags = config.FLAGS_DEBUG
target = f'bin/{config.TARGET}-{compilation_mode.value}'
target = get_target_name(compilation_mode)
log_message(Color.Green, 'link', target)
log_message(Color.Cyan, 'link', target)
return_code = subprocess.call([
config.COMPILER,
@ -194,21 +223,11 @@ def link_program(files: list[File], compilation_mode: CompilationMode) -> int:
return return_code
def main():
# parse command line arguments
if len(sys.argv) == 1:
compilation_mode = CompilationMode.Debug
elif sys.argv[1] == 'debug':
compilation_mode = CompilationMode.Debug
elif sys.argv[1] == 'release':
compilation_mode = CompilationMode.Release
elif sys.argv[1] == 'clean':
subprocess.call(['rm', '-rf', 'bin/', 'obj/'])
exit(0)
else:
log_message(Color.Red, 'err', f'unknown subcommand `{sys.argv[1]}`')
exit(1)
def compile_executable(compilation_mode: Compilation_Mode):
'''
compile an executable. the parameter `compilation_mode` describes if it
should be a debug or release build.
'''
# obtain source files
files = get_file_list(compilation_mode)
@ -226,5 +245,38 @@ def main():
exit(success)
def clean():
'''
remove build artifacts such as object files and binaries.
'''
subprocess.call(['rm', '-rf', 'bin/', 'obj/'])
def run_tests():
'''
compile a test executable and run the tests specified in the `test/`
directory.
'''
compile_executable(Compilation_Mode.Test)
subprocess.call([get_target_name(Compilation_Mode.Test)])
def main():
# parse command line arguments
if len(sys.argv) == 1:
compile_executable(Compilation_Mode.Debug)
elif sys.argv[1] == 'debug':
compile_executable(Compilation_Mode.Debug)
elif sys.argv[1] == 'release':
compile_executable(Compilation_Mode.Release)
elif sys.argv[1] == 'clean':
clean()
elif sys.argv[1] == 'test':
run_tests()
else:
log_message(Color.Red, 'err', f'unknown subcommand `{sys.argv[1]}`')
exit(1)
if __name__ == '__main__':
main()

View File

@ -5,3 +5,4 @@ FLAGS_DEBUG = '-O0 -ggdb3 -fsanitize=address -fsanitize=undefined -fno-omit-fr
FLAGS_RELEASE = '-O3 -march=native -DNDEBUG'
LIBS = ''
TARGET = 'c-template'
SRC_MAIN = 'src/main.c'

45
test/main.c Normal file
View File

@ -0,0 +1,45 @@
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
#include "test.h"
/** declarations **************************************************************/
static Test_Result test_add (void);
static u64 add(u64 a, u64 b);
/** functions *****************************************************************/
int
main (const int argc, const char *const argv[])
{
(void) argc;
(void) argv;
t_start_tests ();
t_run (test_add);
t_end_tests ();
return EXIT_SUCCESS;
}
/** static functions **********************************************************/
static Test_Result
test_add (void)
{
t_start ();
t_assert (add (42, 69) == 111, "add_1");
t_assert (add (43, 69) == 112, "add_2");
t_assert (add (44, 69) == 113, "add_3");
t_end ();
}
static u64
add (u64 a, u64 b)
{
return a + b;
}

56
test/test.h Normal file
View File

@ -0,0 +1,56 @@
#ifndef TEST_H_
#define TEST_H_
#include "common.h"
#include "log.h"
/** structs *******************************************************************/
typedef struct {
usize success;
usize failure;
} Test_Result;
/** macros ********************************************************************/
#define t_start() \
Test_Result __test_result = { .success = 0u, .failure = 0u }
#define t_assert(check, msg) \
do { \
if (check) { \
__test_result.success += 1; \
} else { \
log_err ("\x1b[90m(\x1b[35m" __FILE__ "\x1b[90m:\x1b[34m%d\x1b[90m)" \
"\x1b[0m assertion '" msg "' (" #check ") failed\n", __LINE__); \
__test_result.failure += 1; \
} \
} while (0)
#define t_end() \
return __test_result
#define t_run(func) \
do { \
Test_Result res = func (); \
__test_results.success += res.success; \
__test_results.failure += res.failure; \
} while (0)
#define t_start_tests() \
Test_Result __test_results = { .success = 0u, .failure = 0u }
#define t_end_tests() \
do { \
if (__test_results.failure == 0u) { \
log_ok ("successfully executed %zu tests.\n", __test_results.success); \
} else { \
log_err ("%zu out of %zu tests failed.\n", __test_results.failure, \
__test_results.success + __test_results.failure); \
} \
} while (0)
#endif // TEST_H_