Remove the boost test runner#38
Conversation
896e74d to
742edb9
Compare
270a034 to
ef29ae0
Compare
e780299 to
ae72cf9
Compare
|
Concept ACK, nice work
|
ae72cf9 to
ff95d8c
Compare
josibake
left a comment
There was a problem hiding this comment.
Concept ACK
Overall, looking great! Did some testing and uncovered a lil bug, will continue reading through the code tomorrow. There are also some stale document comments , not a priority but something to fix before the final pass.
| std::string_view arg = argv[i]; | ||
| if (arg == "--") { | ||
| for (int j{i + 1}; j < argc; ++j) { | ||
| opts.passthrough.emplace_back(argv[j]); |
There was a problem hiding this comment.
I don't think this is being used. I ran some old justfile commands I had laying around and testdatadir wasn't used, but the tests ran. I suspect this is because test/main.cpp reads framework::user_args() but I don't see opts.passthrough being copied in.
There was a problem hiding this comment.
Was this fixed? Haven't looked for myself yet
ccf607f to
77bf05d
Compare
josibake
left a comment
There was a problem hiding this comment.
Did another pass, apologies if the review is a bit nitty. I am trying to be a bit more thorough considering I do think this is an excellent candidate for upstreaming to Bitcoin Core.
| /** Construct-on-first-use list of test cases. Prevents use of a global variable before initialization. */ | ||
| inline std::vector<TestCase>& registry() | ||
| { | ||
| static std::vector<TestCase> tests; | ||
| return tests; | ||
| } | ||
|
|
||
| /** Construct-on-first-use suite name. */ | ||
| inline const char*& current_test_suite() | ||
| { | ||
| static const char* s = ""; | ||
| return s; | ||
| } | ||
|
|
||
| /** Convenience struct for adding functions to the registry. */ | ||
| struct Registrar { | ||
| Registrar(const char* name, void (*fn)()) | ||
| { | ||
| registry().emplace_back(TestCase{current_test_suite(), name, fn}); | ||
| } | ||
| }; | ||
|
|
||
| /** Name of the test currently running, in `suite::case` form. */ | ||
| inline std::string& current_test_full_name() | ||
| { | ||
| static std::string s; | ||
| return s; | ||
| } | ||
|
|
||
| /** Single test metadata */ | ||
| struct TestStats { | ||
| int checks = 0; | ||
| int failed_checks = 0; | ||
| }; | ||
|
|
||
| inline TestStats& current_stats() | ||
| { | ||
| static TestStats stats; | ||
| return stats; | ||
| } |
There was a problem hiding this comment.
Lots of globals here. I'd suggest instead introducing a TestContext, something like:
struct TestContext {
std::string full_name;
TestStats stats;
LogLevel log_level;
};
inline thread_local TestContext* active_context = nullptr;Not too invasive, and now we can do things like:
TestContext ctx{
.full_name = test_name(test_case),
.log_level = opts.log_level,
};
active_context = &ctx;
test_case.fn();
active_context = nullptr;
// in record_check
active_context->statsI didn't implement this but it seems simple enough to add as some future proofing for concurrency.
There was a problem hiding this comment.
I'll mess with this tomorrow but makes sense conceptually
There was a problem hiding this comment.
I think there might be some globals elsewhere that I didn't mention explicitly. I agree with trying to keep this design simple, so feel free to push back if I'm over complicating. But I do think its worth making sure that adding concurrency later is additive to this design and not a full rewrite
There was a problem hiding this comment.
After poking at this I don't think we gain much from this refactor in the sequential case. I'm going to table this for now.
There was a problem hiding this comment.
heh, I said feel free to push back but looking at this again, I realise I feel pretty strongly about this. I don't think we gain anything from my suggestion if we intend to keep this framework sequential, forever. But I don't think that's wise or a good design. By using global state you are backing this into a design corner prematurely. This necessarily means if/when someone comes along later to add concurrency, they need to redesign the entire framework because you built it around global state.
I think it would be much better to have a more robust architecture that allows us to add concurrency as a feature and not a total rewrite. I think my suggestion is simple enough and gets us there, but I'm open to other suggestions that are simpler. What I'm not open to is baking in sequential processing by building this around global state.
There was a problem hiding this comment.
Perhaps we are talking past each other, or my wording before was too vague. I am not suggesting everything go into a context. My concrete suggestion was:
struct TestContext {
std::string full_name;
TestStats stats;
LogLevel log_level;
};which captures per test state. I think there is an argument that user args and log level could be per test state, as well, but would accept the push back that they are not (at this stage).
I disagree, however, that introducing a TestContext (or perhaps TestCaseContext) is intrusive. This facilitates concurrency in the future by allowing a parallel runner to create one context per test case, run it on a worker thread, and collect the results back (test_name, stats). By doing this now, we avoid needing to redesign record_check, REQUIRE , CHECK etc later.
I was suspicious of registry and current_test_suite, which is why I mentioned other globals, but I don't see any risks as of now. The main concern I am calling out is a future concurrency model will operate at the test case level, so we should absolutely avoid using global state for any test case level state.
Happy to elaborate more or throw up a patch that shows what I'm talking about.
There was a problem hiding this comment.
I see the distinction now. I was particularly confused as I don't see a strong motivation for log level to be test-level at the moment, but encapsulating the statistics and other test-level state makes sense. Even so, I think we will have to live with a global current_test_name, as the fixtures depend on it, but making the internals like record_check thread-local seems reasonable.
There was a problem hiding this comment.
I experimented a bit with this change. The simplest construction inline thread_local TestContext* active_context = nullptr;, does not work, as the unit tests themselves spawn threads. If these child threads call CHECK, for example, the thread_local context will be nullptr even though the parent thread has a context set. To circumvent this I tried making a custom framework::spawn that passes the current context to child threads, but that also does not work as some threads are spawned outside of the test case.
I'll fallback on the opinion that I'm not sure this needs to be accounted for now. A concurrent design would likely have to implement some form of shared state or message passing between the children processes to the parent. I am open to other suggestions, but I suspect accounting for concurrency would be a far more involved endeavor. The tests take around 70-75 seconds to run on my machine which I find reasonable.
There was a problem hiding this comment.
There was a problem hiding this comment.
Good catch with the child thread case. Based on our discussion on the call, I do think this is less about concurrency, and more about where mutable state lives. If we forget about concurrency for a second, I still think a test context is the right thing to do here since per test global state is bad, even for a sequential runner.
A good indication of this is some of the comments in the child thread tests that we have, and bypassing the old test framework with asserts. I got something working that I'm pretty happy with. Earlier I said it was only 46 lines but thats because I was using a mutex and your comment on the call made me realise this was indeed a performance hit (not a big one but enough to make me go blegh).
While whate I came up with makes things a little more complex, it feels conceptually like a better design and being able to remove all the asserts (done in a second commit) is evidence of that. I also had to change a few of the assert loops because CHECK is a bit heavier (and they didn't need to be run like that anyways, imo).
I pushed to this branch so you can easily check that all the tests pass but of course feel free to re-write / squash in this code however you see fit. I'd consider this very much hacked together proof of concept code:
e549d3a to
a221693
Compare
|
In the spirit of not breaking scripts, 210cb9d changes the shorthand from |
-BEGIN VERIFY SCRIPT-
set -eu
MACRO_RE='BOOST_CHECK|BOOST_REQUIRE|BOOST_CHECK_MESSAGE|BOOST_REQUIRE_MESSAGE|BOOST_CHECK_NO_THROW|BOOST_REQUIRE_NO_THROW'
FILES=$(git grep -lE "\b(${MACRO_RE})[[:space:]]*\(" -- \
':(glob)src/test/**/*.cpp' ':(glob)src/test/**/*.h' \
':(glob)src/test/*.cpp' ':(glob)src/test/*.h' \
':(glob)src/ipc/test/**/*.cpp' ':(glob)src/ipc/test/**/*.h' \
':(glob)src/ipc/test/*.cpp' ':(glob)src/ipc/test/*.h' 2>/dev/null || true)
if [ -z "$FILES" ]; then
echo "no matching files"
exit 0
fi
perl -i -0777 -pe '
use strict;
use warnings;
my $names = "BOOST_CHECK|BOOST_REQUIRE|BOOST_CHECK_MESSAGE|BOOST_REQUIRE_MESSAGE|BOOST_CHECK_NO_THROW|BOOST_REQUIRE_NO_THROW";
my $re = qr/\b($names)\s*\(/;
my @DIGIT_SEP_PREV = (0) x 256;
$DIGIT_SEP_PREV[ord($_)] = 1 for split //, "0123456789abcdefABCDEF" . chr(39);
sub is_char_open {
my ($s, $i) = @_;
return 1 if $i == 0;
return $DIGIT_SEP_PREV[ord(substr($$s, $i-1, 1))] ? 0 : 1;
}
sub close_paren {
my ($s, $open) = @_;
my ($depth, $i, $in_str, $in_char) = (0, $open, 0, 0);
my $n = length($$s);
while ($i < $n) {
my $c = substr($$s, $i, 1);
if ($in_str) {
if ($c eq "\\") { $i += 2; next; }
$in_str = 0 if $c eq q{"};
} elsif ($in_char) {
if ($c eq "\\") { $i += 2; next; }
$in_char = 0 if $c eq chr(39);
} elsif ($c eq q{"}) { $in_str = 1; }
elsif ($c eq chr(39) && is_char_open($s, $i)) { $in_char = 1; }
elsif ($c =~ /[(\[{]/) { $depth++; }
elsif ($c =~ /[)\]}]/) { $depth--; return $i if $depth == 0; }
$i++;
}
return -1;
}
sub split_first_comma {
my ($s) = @_;
my ($depth, $i, $in_str, $in_char) = (0, 0, 0, 0);
my $n = length($s);
while ($i < $n) {
my $c = substr($s, $i, 1);
if ($in_str) {
if ($c eq "\\") { $i += 2; next; }
$in_str = 0 if $c eq q{"};
} elsif ($in_char) {
if ($c eq "\\") { $i += 2; next; }
$in_char = 0 if $c eq chr(39);
} elsif ($c eq q{"}) { $in_str = 1; }
elsif ($c eq chr(39) && is_char_open(\$s, $i)) { $in_char = 1; }
elsif ($c =~ /[(\[{]/) { $depth++; }
elsif ($c =~ /[)\]}]/) { $depth--; }
elsif ($c eq "," && $depth == 0) {
return (substr($s, 0, $i), substr($s, $i));
}
$i++;
}
return ($s, "");
}
sub top_level_and_positions {
my ($e) = @_;
my @ops;
my ($depth, $i, $in_str, $in_char) = (0, 0, 0, 0);
my $n = length($e);
while ($i < $n) {
my $c = substr($e, $i, 1);
if ($in_str) {
if ($c eq "\\") { $i += 2; next; }
$in_str = 0 if $c eq q{"};
$i++; next;
} elsif ($in_char) {
if ($c eq "\\") { $i += 2; next; }
$in_char = 0 if $c eq chr(39);
$i++; next;
}
if ($c eq q{"}) { $in_str = 1; $i++; next; }
if ($c eq chr(39) && is_char_open(\$e, $i)) { $in_char = 1; $i++; next; }
if ($c =~ /[(\[{]/) { $depth++; $i++; next; }
if ($c =~ /[)\]}]/) { $depth--; $i++; next; }
if ($depth == 0 && $i + 1 < $n && substr($e, $i, 2) eq "&&") {
push @ops, $i;
$i += 2; next;
}
$i++;
}
return @ops;
}
my $text = $_;
my $out = "";
my $cur = 0;
while ($text =~ /$re/g) {
my $macro = $1;
my $m_start = $-[0];
my $p_open = $+[0] - 1;
my $p_close = close_paren(\$text, $p_open);
next if $p_close < 0;
my $args = substr($text, $p_open + 1, $p_close - $p_open - 1);
my ($expr_raw, $message) = split_first_comma($args);
my $expr = $expr_raw;
$expr =~ s/^\s+//;
$expr =~ s/\s+$//;
my @ats = top_level_and_positions($expr);
next unless @ats;
# Compose the replacement with original indentation.
my $line_start = rindex(substr($text, 0, $m_start), "\n") + 1;
my $indent = substr($text, $line_start, $m_start - $line_start);
$indent =~ s/[^\s].*//s;
my @Parts;
my $last = 0;
for my $p (@ats) {
my $piece = substr($expr, $last, $p - $last);
$piece =~ s/^\s+//; $piece =~ s/\s+$//;
push @Parts, $piece;
$last = $p + 2;
}
my $tail = substr($expr, $last);
$tail =~ s/^\s+//; $tail =~ s/\s+$//;
push @Parts, $tail;
my $sep = ";\n" . $indent;
my $replacement = join($sep, map { "$macro($_$message)" } @Parts);
$out .= substr($text, $cur, $m_start - $cur);
$out .= $replacement;
$cur = $p_close + 1;
# Resume the global match where the new content ends.
pos($text) = $cur;
}
$out .= substr($text, $cur);
$_ = $out;
' -- $FILES
-END VERIFY SCRIPT-
-BEGIN VERIFY SCRIPT-
set -eu
MACRO_RE='BOOST_CHECK|BOOST_REQUIRE|BOOST_CHECK_MESSAGE|BOOST_REQUIRE_MESSAGE|BOOST_CHECK_NO_THROW|BOOST_REQUIRE_NO_THROW'
FILES=$(git grep -lE "\b(${MACRO_RE})[[:space:]]*\(" -- \
':(glob)src/test/**/*.cpp' ':(glob)src/test/**/*.h' \
':(glob)src/test/*.cpp' ':(glob)src/test/*.h' \
':(glob)src/ipc/test/**/*.cpp' ':(glob)src/ipc/test/**/*.h' \
':(glob)src/ipc/test/*.cpp' ':(glob)src/ipc/test/*.h' 2>/dev/null || true)
if [ -z "$FILES" ]; then
echo "no matching files"
exit 0
fi
perl -i -0777 -pe '
use strict;
use warnings;
my $names = "BOOST_CHECK|BOOST_REQUIRE|BOOST_CHECK_MESSAGE|BOOST_REQUIRE_MESSAGE|BOOST_CHECK_NO_THROW|BOOST_REQUIRE_NO_THROW";
my $re = qr/\b($names)\s*\(/;
my @DIGIT_SEP_PREV = (0) x 256;
$DIGIT_SEP_PREV[ord($_)] = 1 for split //, "0123456789abcdefABCDEF" . chr(39);
sub is_char_open {
my ($s, $i) = @_;
return 1 if $i == 0;
return $DIGIT_SEP_PREV[ord(substr($$s, $i-1, 1))] ? 0 : 1;
}
sub close_paren {
my ($s, $open) = @_;
my ($depth, $i, $in_str, $in_char) = (0, $open, 0, 0);
my $n = length($$s);
while ($i < $n) {
my $c = substr($$s, $i, 1);
if ($in_str) {
if ($c eq "\\") { $i += 2; next; }
$in_str = 0 if $c eq q{"};
} elsif ($in_char) {
if ($c eq "\\") { $i += 2; next; }
$in_char = 0 if $c eq chr(39);
} elsif ($c eq q{"}) { $in_str = 1; }
elsif ($c eq chr(39) && is_char_open($s, $i)) { $in_char = 1; }
elsif ($c =~ /[(\[{]/) { $depth++; }
elsif ($c =~ /[)\]}]/) { $depth--; return $i if $depth == 0; }
$i++;
}
return -1;
}
sub split_first_comma {
my ($s) = @_;
my ($depth, $i, $in_str, $in_char) = (0, 0, 0, 0);
my $n = length($s);
while ($i < $n) {
my $c = substr($s, $i, 1);
if ($in_str) {
if ($c eq "\\") { $i += 2; next; }
$in_str = 0 if $c eq q{"};
} elsif ($in_char) {
if ($c eq "\\") { $i += 2; next; }
$in_char = 0 if $c eq chr(39);
} elsif ($c eq q{"}) { $in_str = 1; }
elsif ($c eq chr(39) && is_char_open(\$s, $i)) { $in_char = 1; }
elsif ($c =~ /[(\[{]/) { $depth++; }
elsif ($c =~ /[)\]}]/) { $depth--; }
elsif ($c eq "," && $depth == 0) {
return (substr($s, 0, $i), substr($s, $i));
}
$i++;
}
return ($s, "");
}
sub has_top_level_or {
my ($e) = @_;
my ($depth, $i, $in_str, $in_char) = (0, 0, 0, 0);
my $n = length($e);
while ($i < $n) {
my $c = substr($e, $i, 1);
if ($in_str) {
if ($c eq "\\") { $i += 2; next; }
$in_str = 0 if $c eq q{"};
$i++; next;
} elsif ($in_char) {
if ($c eq "\\") { $i += 2; next; }
$in_char = 0 if $c eq chr(39);
$i++; next;
}
if ($c eq q{"}) { $in_str = 1; $i++; next; }
if ($c eq chr(39) && is_char_open(\$e, $i)) { $in_char = 1; $i++; next; }
if ($c =~ /[(\[{]/) { $depth++; $i++; next; }
if ($c =~ /[)\]}]/) { $depth--; $i++; next; }
if ($depth == 0 && $i + 1 < $n && substr($e, $i, 2) eq "||") {
return 1;
}
$i++;
}
return 0;
}
my $text = $_;
my $out = "";
my $cur = 0;
while ($text =~ /$re/g) {
my $macro = $1;
my $m_start = $-[0];
my $p_open = $+[0] - 1;
my $p_close = close_paren(\$text, $p_open);
next if $p_close < 0;
my $args = substr($text, $p_open + 1, $p_close - $p_open - 1);
my ($expr_raw, $message) = split_first_comma($args);
my $expr = $expr_raw;
$expr =~ s/^\s+//;
$expr =~ s/\s+$//;
next unless has_top_level_or($expr);
$out .= substr($text, $cur, $m_start - $cur);
$out .= "$macro(($expr)$message)";
$cur = $p_close + 1;
pos($text) = $cur;
}
$out .= substr($text, $cur);
$_ = $out;
' -- $FILES
-END VERIFY SCRIPT-
Adds src/test/util/framework.hpp as a lightweight Boost.Test replacement. This commit only introduces the header; build-system integration and call-site migration follow in subsequent commits. Includes: - `CHECK`, valid with any comparison operator, optional message - `REQUIRE`, valid with any comparison operator, optional message - `CHECK_EQUAL_RANGES`, better debugging for vectors - `THROW_*`, macros for checking throwing conditions - Info and warn messages
Integrates src/test/util/framework.hpp into the build (CMake, main.cpp) and replaces the Boost.Test macros across the unit test suite via a scripted diff.
a221693 to
7665580
Compare
Replaces the
boosttest runner with a simple header-only variant. Simplifies the macros to the following:CHECK, valid with any comparison operator (e.g.==,!=), optional messageREQUIRE, valid with any comparison operator, optional messageCHECK_EQUAL_RANGES, better debugging for vectorsTHROW_*, macros for checking throwing conditionsThis framework has 4 log levels, offers test filtering, and also includes suites and fixtures. The runner executes in approximately the same time as boost, and should compile faster as well.
A list of a few advantages over
boostin addition to macro simplification:ToStringare printed whenCHECKorREQUIREfailsABORTorASSERTmacros that fail-fast)To try it out:
On commit 1 and commit 2, these are given by guidance of
Catch2.&&conditions are unrolled into two separate checks.Replace all boost macros (third commit)