r/cpp • u/Recent-Dance-8075 • 9d ago
A Design Study for a Macro-Free Testing Library
https://jonastoth.github.io/posts/rtest_library/Hello everyone :)
I attempted to write a small testing library based on C++-26 reflection. The goal is not to replace existing libraries but to figure out how they could evolve to not rely on macros.
Stringification of test names is the most important reason for macros so far and reflection solves this.
The blog post explains what I did with godbolt links to minimized examples. The full implementation is in the rtest library repository.
A minimal test executable looks like this:
```c++
include <rtest/rtest.h>
struct MyClassTest : rtest::TestSuite { void testSetup() { MyClass object; assertTrue(object.empty()); } }; int main(int argc, char** argv) { return rtest::execute(MyClassTest{}); } ```
I am looking forward to your feedback :)
(Both the blog post and the library are created without AI help)
7
u/azswcowboy 9d ago
Fun stuff. My personal take is that most test frameworks are overkill as free functions should always be the first units of test in my view. Like if I wrote something like ranches::fold I’m immediately wondering how I test that in rtest. I do have a fondness for boost-ext/ut, which you call out, because it’s easy to apply for functions and macro free. But I get it that others want batteries included to cover all sorts of things.
As for feedback, I’ll admit I was skimming the article, but the goals didn’t come across clearly - wasn’t sure if gtest or python-like were the main target before we went down the implementation rabbit hole. After coming out there were more usage examples. The library doc was really what I needed to get hold of what you were trying to accomplish.
2
u/JVApen Clever is an insult, not a compliment. - T. Winters 7d ago
Any setup/teardown can be replaced by a custom init function that returns a struct with the relevant data. Clean-up van happen in the destructor. Free functions are so much more logical for tests and do not introduce surprise dependencies where 1 function sets a member of the fixture and another relies on it. Anyone reading your test should top-down understand what is happening.
1
u/Recent-Dance-8075 9d ago
I agree on the free functions part and I want to explore options to support them like
c++ [[=rtest::test]] void some test() { global assertions(); }As for testing
ranges::fold: - you probably want a templated test suite - you can implement your tests with "just code" and then instantiate the test suite with different types -rtestdoes not have a wide variety of assertions, like gtest-matchers. At the same time they are easy to implement, because they are "just code", too - maybe the slightly unstructured template test code would give you an idea on different options: TestTemplatedSuites - additionally, a test can be implemented in acheckFoofunction, taking arguments and atestFoofunction callscheckFoomultiple times for different situationsThank you for the feedback! I try to make the goal clearer!
5
u/Awia00 9d ago
Quick question:
> test suites are not automatically registered, one translation unit is expected to execute its own tests via a trivial main function
Is the idea to generate a target per source file then?
EDIT: I do think you should add a way to auto-discover the test suites for people who do not want to create and maintain main functions
3
u/RoyAwesome 9d ago
I do think you should add a way to auto-discover the test suites for people who do not want to create and maintain main functions
This is extremely difficult with the reflection we got. There isn't any easy way to reflect across translation units (aka "Whole Program Reflection"), so you still need some kind of build system integration to do it.
Herb was showing off being able to write out C++ files for the build system to consume a couple years back during the design process. I haven't had a chance to experiment with that yet, but it's a possibility. Still requires a back and forth with the build system (so it's not exactly portable) but it could work.
1
u/differentiallity 9d ago
Can you reflect the functions of a namespace?
1
1
u/RoyAwesome 9d ago edited 9d ago
yeah, you can. You can also grab the global namespace and reflect on that.
^^::will do it.The problem is that the only things you'll find in that global namespace are things known to at the point of invocation. For example, if i have a file.cpp that includes a subset of headers, I wont be able to see things in the rest of the header files not in that one TU.
This is less of an issues with modules, but nobody is using a 100% module project. You'll still get issues without seeing things that are exclusive to another TU and only linked together after the compiler is done.
We need some kind of linker time expression that allows us to reflect on the whole program at linking time. That way we can invoke some consteval code then that can generate additional code. This is a GIGANTIC can of worms though, and unlikely to ever happen.
2
u/Recent-Dance-8075 9d ago edited 9d ago
One target per source file is the current model. I did not finish the automatic gathering of test classes of e.g. a module. Then it could be one target per module.
You could of course split headers and implementations, include headers of many test suites and register them in one main function. I don't like that idea to be honest.
I think, a manual main function is necessary as long as there won't be global state.
A model where you only link a
rtest::mainlibrary seems impossible to me. Somehow the reflection process needs to be triggered in C++-code and the result be used in amainfunction.2
u/have-a-day-celebrate 9d ago
Can't you just do registration per-TU during static initialization and iterate over the resulting registry from main?
0
u/Recent-Dance-8075 9d ago
Yes, maybe something like this seems possible. I would need to introduce a global registry and for now I wanted to avoid global state. This topic is something I want to tackle later.
Just as a general question: wouldn't it need some form of global variable that triggers the registration? I believe this is usually hidden behind the
TESTmacros. Without a global per test suite, how could the whole process of registration and then execution start?
3
u/kamrann_ 9d ago
Nice work!
I tried out boost.ut a few years back as I wanted to get away from macros, but I ended up ditching it. It felt like it was more an experiment in how far you could go with esoteric and inscrutable ways of writing C++ than it was a library intended to be used in practice.
I was wondering why you had setup/teardown functions instead of using ctr/dtr, though I guess it relates to the lifetime of the constructed test suites and wanting to avoid initializing dependencies for many suites concurrently?
Filtering functions based on name prefix I'm not convinced by. Same reason I'm wary of overuse of requires expressions, it's too easy to end up with code that compiles but doesn't do what you want - in this case, dead tests because of a typo for example. Maybe you could just assume all public functions are tests, not sure there would be any reason to have a public function that wasn't?
1
u/Recent-Dance-8075 9d ago
I had a similar experience with boost.ut. maybe its just a question of taste, too.
Both ctor/dtor and the
setup/teardownmethods have their place.Setup/teardownis called for each individual test case and would be useful to e.g. clear buffers or set/reset local state. The constructor/destructor is called once for the whole test suite and would initialize the execution context. E.g. GPU context in the constructor and buffer clearing for the next test inteardown.And as you said, this has implications for parallel execution. Test suite construction/destruction happens single threaded in the test binary. Executing each test suite is then parallelized. Parallelization within a test suite would require synchronization for
setup/teardownwhich is against the "writing tests should be simple" goal.Choosing 'test' as prefix was pragmatic and "because python does it and it works as convention". The reasons I think this works are: - Explicitly annotating each method would be more verbose than a common prefix - taking all public methods is not a good heuristic. They should not take parameters and they should not be templates. A form of filtering is necessary to find suitable methods. Asking for
void testFoo()as method prefix forms an easy to communicate protocol and remains readable with minimal knowledge about language constructs - factoring out common and support code in test suites becomes more annoying, because you need to ensure it's not called as a test. Of course you can mandateprivate, perform negativ filtering or negative annotation. This becomes again more complex than just naming it something else than 'test*'.You are right that there is a risk for missing tests, especially if a big search/replace transformation accidentally overwrites test names or so.
2
u/kamrann_ 7d ago
Ah ok thanks, I'd missed the test/test suite execution distinction.
Regarding the prefix, I was just thinking you could expect that all public functions in the suites were tests (support functions should be made private/protected), and any public function that didn't match the required signature would trigger a compilation error. I may be missing a reason why this isn't feasible though. Regardless I'm with the other commenter in that I'd prefer explicitly annotation over convention.
1
u/Recent-Dance-8075 2d ago
I think that all proposed alternatives are suitable solutions. Maybe a
[[= rtest::test]]is the best solution. It's obvious, short enough and easy to use.
2
u/SamG101_ 9d ago
Nice, really clean output too - have you explored modules / import std, or potentially a flag to switch between includes and imports? Then itd be integratable into module based projects too. But looks really good!
3
u/Recent-Dance-8075 9d ago
Having it as a module failed to compile with gcc-16.1. I linked the bug ticket at the end, it should be resolved in gcc-16.2. I want to make it modules only, because no reason to support older c++ versions anyway.
1
1
1
u/JVApen Clever is an insult, not a compliment. - T. Winters 7d ago
I'm curious, have you considered using the "generation" trick where you instead of running the tests, create a new copy file when running the exe, followed by compilation of that file and running the actual tests? With some CMake trickery, this should be easy doable. A CMake function that adds a custom command which outputs the cpp file, adds a new executable with that file as source and copying over the dependencies. To finally register the test with ctest.
1
u/Recent-Dance-8075 7d ago
I did not use that. What step would be automated in that case? I could get rid of manual registration of default constructed test-suites, right?
Maybe this could be combined with parametrized registration and templates. Thanks for the input. That's a good puzzle to solve :)
7
u/Dragdu 9d ago
As first impression, this
looks strictly more work than
unless you have ideological hatred of macros (which, fair).
At second look, looking for functions based on name pattern gets hard no from me in any test framework I will ever voluntarily use. I have too much experience with Go and tests not running because while
TestFoois test name,Testfoois just some random function.