Intro
Don't burn me at the stake (yet), I'm not saying that you should not write unit tests, they are important, I'm just saying that in some case, you never have to run them yourself. Let me explain why.
The anecdote
Few weeks ago I had to write a function to concatenate 2 std::array into one, the function is quite simple and look like this :
#include <type_traits>
#include <array>
#include <algorithm>
template <typename T, std::size_t aSize, std::size_t bSize>
constexpr auto concat_array(const std::array<T, aSize>& a, const std::array<T, bSize>& b)
{
std::array<T, aSize + bSize> result_array;
std::ranges::copy(a, result_array.begin());
std::ranges::copy(b, result_array.begin() + aSize);
return result_array;
}
It just takes two std::array of the same type, create a new std::array with whose size is that of the two other std::array added together, then it copies the content of the first one, then the content of the second one.
I have written my function, now it's time to write the tests, some could argue that I should have written them before and use tdd, but I didn't and that's not the point of this article at all. For this article simplicity's sake, I will only show my first test and it looked like this :
// In reality I use Doctest instead of just some assert, but it is simpler to show it this way
int basic_test()
{
const std::array<int, 3> a = { 1, 2, 3 };
const std::array<int, 2> b = { 1, 2 };
const auto res = concat_array(a, b);
assert((res == std::array<int, 5>{ 1, 2, 3, 1, 2 }));
}
My test pass, I'm happy and was ready to go on with my life write more tests, but suddenly I thought : "My function is marked constexpr, this means that it can be computed during compilation time, so instead of making an assert, I can make a static_assert". So, I did that :
constexpr std::array<int, 3> a = { 1, 2, 3 };
constexpr std::array<int, 2> b = { 1, 2 };
constexpr auto res = concat_array(a, b);
static_assert(res == std::array<int, 5>{ 1, 2, 3, 1, 2 });
It compiled without any error, it meant that my test passed, and I didn't even have to run my test, I just need to use my compiler and it runs the test for me. It works for all code that can run during compilation (constexpr functions, consteval functions, template stuffs, etc).
Constexpr as much as possible
I hear you say that not all code can run during compilation, well now with C++ 20 you can do a lot of stuff (even more when C++23 will be here). I mean, you can for example allocate memory with new, yes, you can use std::vector and std::string in your constexpr function. You even can have a constexpr virtual method.
Let's have create a function adding all digit contained in an ascii string:
#include <vector>
#include <string_view>
#include <numeric>
// std::isdigit is not constexpr
constexpr bool is_digit(char c)
{
return c >= '0' && c <= '9';
}
constexpr unsigned int accumulate_string_digits(std::string_view str)
{
std::vector<unsigned int> digits;
for (auto c: str)
{
if (is_digit(c))
digits.push_back(c - 48);
}
return std::accumulate(digits.begin(), digits.end(), 0);
}
We could have easily done this without creating a std::vector but then it would not fit my example.
And now the tests:
static_assert(accumulate_string_digits("") == 0);
static_assert(accumulate_string_digits("1") == 1);
static_assert(accumulate_string_digits("12345") == 15);
static_assert(accumulate_string_digits("1a23c45c") == 15);
static_assert(accumulate_string_digits("Hello, World!") == 0);
It works ! Unfornately for now only with a recent version of msvc (the compiler of Microsoft shipped with Visual Studio), Clang and Gcc did not implement constexpr std::vector yet.
Conclusion
When I said that you don't have to run your unit tests I twisted a bit the truth, you can just sometimes let the compiler run them for you during compilation time. Also a lot of code can't be constexpr, even with C++20 (or C++23 in the future) so this does not apply to all your code, but when it is possible, it is a powerful tool!
From now on that's what I do when I have a very recent compiler available, at least on my pet projects. I keep these tests in a different file with my classic runtime unit tests to not increase the compilation time too much.
Sources
- https://en.cppreference.com/w/cpp/container/array
- https://en.cppreference.com/w/cpp/language/constexpr
- https://en.cppreference.com/w/cpp/language/static_assert
- https://en.cppreference.com/w/cpp/language/consteval
- std::array code example on compiler explorer
- accumulate_string_digits code example on compiler explorer
- https://en.cppreference.com/w/cpp/language/new
Top comments (4)
There is one thing that unit tests can tests very easily: edge cases. Cases that never happens in real life and that are, as a consequence, very hard to generate in integration testing.
Something special: libraries, for which integration testing don't really exist (well.... that's called "clients' code" in fact ^^)
When you say "Unit tests make developers feel warm and fuzzy", I think you could even say "Unit tests make developers and team leaders feel warm and fuzzy". I have seen unit tests that were a complete waste a time, because there are only testing basic, redundant cases. But people were happy to them....
I am also convinced that a unique solution to find bugs (any kind of testing, code review, etc) is not even. Quality comes from a mix a several techniques. Unit tests are one them. Good unit tests are one them.
With unit tests it is easy to spot where the error because its scope should be small.
In my example, a higher-level test would probably spot easily the error, but would it pinpoint exactly which module is at fault?
I have far less experience but when the unit tests were a mess, most of the time it was because the scope of the tests were too big or the code not well modularized.
I strongly agree on one point : QA people > unit tests
Interesting approach, thank you for sharing!
Good Article!
Thank you so much!