← back to blog

Testing a C lexer with Criterion: what worked and what didn’t

Criterion is a C testing framework with a clean API and good output formatting. I chose it over Unity and Check because test registration is macro-based and does not require a central test list — each test file is self-contained.

Finding Criterion with pkg-config

Criterion does not ship a CMake config file, so find_package(Criterion) will not work. Use pkg-config instead:

find_package(PkgConfig REQUIRED)
pkg_check_modules(CRITERION REQUIRED criterion)

target_link_libraries(first-test
  PRIVATE libfirst ${CRITERION_LIBRARIES})
target_include_directories(first-test
  PRIVATE ${CRITERION_INCLUDE_DIRS})

This works as long as Criterion is installed system-wide. On Ubuntu: apt install libcriterion-dev. On macOS: brew install criterion.

The fmemopen pattern

For lexer tests, the most useful pattern is to open a string as a FILE* using fmemopen(), create a lexer from it, and assert on the token sequence. This got common enough that it's now a shared helper in test/test_helpers.c rather than repeated per test:

Lexer *create_lexer_for_string(const char *input) {
  if (input == NULL) {
    return lex_create(NULL);
  }
  size_t length = strlen(input);
  FILE *stream = fmemopen((void *)input, length, "r");
  return lex_create(stream);
}

Which makes each individual test a one-liner to set up:

Test(lexer, number_token) {
  Token tok;
  Lexer *l = create_lexer_for_string("42");

  lex_next_token(l, &tok);
  cr_assert_eq(tok.type, TT_NUMBER);
  cr_assert_str_eq(tok.value, "42");
  free(tok.value);

  lex_destroy(l);
}

Note that lex_destroy() calls fclose() on the stream, so you do not close it separately. Also note lex_create() takes only the FILE* now, not a filename — worth double-checking against include/lexer.h if you're working from an older reference.

What did not work

Running ctest directly without building first. CTest does not automatically trigger a build — cmake --build build && ctest --test-dir build is the correct sequence. The other footgun: forgetting to free(tok.value) between assertions. Criterion runs each test in a subprocess so leaks do not accumulate across tests, but it is still worth keeping tidy.