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.
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:
There was a problem hiding this comment.
Had a look at the example. I'm a bit confused with:
inline TestCaseContext& active_test_context()
{
if (auto* context{local_test_context()}) return *context;
if (auto* context{active_test_context_storage().load(std::memory_order_acquire)}) return *context;
throw std::logic_error{"test framework assertion used outside an active test case"};
}This is also global state for the current test case. What exactly does the architecture buy us? I see the main function uses a scoped context, but that one inevitably uses globals internally. Is this just to make a refactor easier in the future?
There was a problem hiding this comment.
Fair question. The last push was a bit hasty, I tried to clean it up to make the benefit more clear. What we get from this approach imo is this: even for the sequential runner, test mutable state should have a per test lifetime. There is still a global because the assertion macros need to find the active test, but the state itself is now owned by the running TestCaseContext instead of living as separate globals.
I pushed a cleaned up version that tries to make that distinction clear:
TestCaseContextowns the test name and check countersCHECKworks from joined child threads via atomic countersREQUIRE/REQUIRE_NOTHROWare limited to the owning test thread, since throwing from a child thread cannot abort the parent test- exception failures now preserve the checks recorded before the exception
This keeps the global strictly for the lookup mechanism for the currently active test. As evidence that this is the right direction: this lets us remove the raw asserts from the threaded tests and use framework checks there too. Failures from those paths now go through the same reporting/counting machinery as the rest of the test suite.
Pushed the commits to the same branch as before, and added a doc fixup commit for some stale wording: https://github.com/2140-dev/bitcoin/commits/pr-38-test-context/
There was a problem hiding this comment.
11d0e67 adopts this style in my interpretation
e549d3a to
a221693
Compare
|
In the spirit of not breaking scripts, 210cb9d changes the shorthand from |
a221693 to
7665580
Compare
ismaelsadeeq
left a comment
There was a problem hiding this comment.
I did a quick pass at this and have a few comments, nothing major.
https://github.com/2140-dev/bitcoin/commits/pr-38-test-context/ seems to remove the need for the assert in threaded tests, and I like the per-test framework context.
I think it's a bit cleaner.
| } while (false) | ||
|
|
||
| /** Emits a warning message when `expr` is false. Does not fail the test */ | ||
| #define WARN_MESSAGE(expr, msg) \ |
There was a problem hiding this comment.
In "test: Add header-only test framework
" 8a52141
Perhaps add an example test file that demonstrates usage?
There was a problem hiding this comment.
I added a doc comment. Example test file is overkill IMO, the usage is similar to the boost counterpart.
| @@ -49,7 +49,7 @@ code. | |||
| naming style](#internal-interface-naming-style) for an exception to this | |||
There was a problem hiding this comment.
In Remove remaining BOOST references 7665580
There seems to be some remaining boost test references in the lint file and univalue test.
src/univalue/test/object.cpp: BOOST_CHECK(!v.read("@{}"));
src/univalue/test/object.cpp: BOOST_CHECK(!v.read("{} garbage"));
src/univalue/test/object.cpp: BOOST_CHECK(!v.read("[]{}"));
src/univalue/test/object.cpp: BOOST_CHECK(!v.read("{}[]"));
src/univalue/test/object.cpp: BOOST_CHECK(!v.read("{} 42"));
test/lint/lint-includes.py:EXPECTED_BOOST_INCLUDES = [
test/lint/lint-includes.py: for expected_boost in EXPECTED_BOOST_INCLUDES:
test/lint/lint-includes.py: for expected_boost in EXPECTED_BOOST_INCLUDES:
test/lint/lint-includes.py: "Please remove it from EXPECTED_BOOST_INCLUDES in test/lint/lint-includes.py "
test/lint/test_runner/src/lint_cpp.rs: r"BOOST_ASSERT\(",
test/lint/test_runner/src/lint_cpp.rs:BOOST_ASSERT must be replaced with Assert, REQUIRE, or CHECK to avoid an unnecessary
test/lint/test_runner/src/lint_cpp.rs: r"BOOST_(AUTO_TEST_|FIXTURE_TEST_|GLOBAL_FIXTURE|DATA_TEST_|CHECK|REQUIRE|TEST|WARN)",
test/lint/test_runner/src/lint_cpp.rs:Boost.Test macros (BOOST_CHECK*, BOOST_REQUIRE*, BOOST_AUTO_TEST_*, BOOST_FIXTURE_TEST_*,
test/lint/test_runner/src/lint_cpp.rs:BOOST_TEST*, BOOST_WARN*, BOOST_GLOBAL_FIXTURE, BOOST_DATA_TEST_*) are no longer used.
There was a problem hiding this comment.
The lints are to inform the user to stop using BOOST_*, the lint includes are still required as multi-index is used in the benches, and univalue is a subtree that needs a separate update.
Most test frameworks use a technique called expression decomposition, which captures the values of test expressions. The `&&` is `delete` when using value decomposition. ref: https://fekir.info/post/decomposing-an-expression/ -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-
`||` is also `delete` when decomposing an expression. See previous commit message. ref: https://fekir.info/post/decomposing-an-expression/ -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-
f5a2bc2 to
919eb1c
Compare
|
958f817 addressed review comments |
919eb1c to
958f817
Compare
|
PR description updated |
josibake
left a comment
There was a problem hiding this comment.
Looks great, thanks for incorporating the suggested changes. It feels like the require check on child threads is still incomplete, so I offered a suggestion that I think does what we actually want.
I also noticed the test cleanups are not in this PR, which makes sense considering this today just a replacement and meant to show that its drop in for the existing framework. I do think, however, its worth highlighting in the PR description that this new framework has better functionality than boost in that it has better thread safety and enforcement, which would allow us to remove the asserts in a follow up PR and make the entire test suite use the framework checks.
Overall, stellar work dude!
| if (!btc_test_ctx_.on_owner_thread()) { \ | ||
| btc_test_res_ = ::framework::Result::failed("REQUIRE checks must not be thrown on spawned threads."); \ | ||
| } \ | ||
| ::framework::record_check(btc_test_ctx_, btc_test_res_, "REQUIRE_NOTHROW", \ | ||
| #expr, __FILE__, __LINE__); \ | ||
| if (!btc_test_res_.is_ok()) throw ::framework::RequireFailed{}; \ |
There was a problem hiding this comment.
This detects child-thread REQUIRE, but it still stores that as a failed Result and then runs the shared failure path:
record_check(...);
if (!btc_test_res_.is_ok()) throw RequireFailed{};So from a child thread REQUIRE(...) still throws RequireFailed. The test runner only catches RequireFailed around test_case.fn() on the owner thread; it cannot catch exceptions escaping a spawned std::thread unless that thread catches them itself.
I think this would still lead to an uncaught exception in a std::thread which calls std::terminate (brutal shutdown of the whole suite). Perhaps a better direction for a a graceful enforcement of the condition:
diff --git a/src/test/util/framework.hpp b/src/test/util/framework.hpp
index 327f4f505d..aa66e848fd 100644
--- a/src/test/util/framework.hpp
+++ b/src/test/util/framework.hpp
@@ -629,25 +629,27 @@ inline int run(int argc, char** argv)
} while (false)
/** Like CHECK, but aborts the current test on failure by throwing.
- * Subsequent checks in the test are skipped.
+ * Subsequent checks in the test are skipped. Use CHECK from child threads,
+ * because throwing in a child thread cannot abort the parent test.
*
* Accepts the same optional stream-style failure message as CHECK. */
#define REQUIRE(expr, ...) \
do { \
auto& btc_test_ctx_{::framework::test_context()}; \
- BITCOIN_TEST_DIAG_PUSH \
- ::framework::Result btc_test_res_ = ::framework::Result::ok(); \
- if (btc_test_ctx_.on_owner_thread()) { \
- btc_test_res_ = ::framework::Decomposer{} <= expr; \
+ if (!btc_test_ctx_.on_owner_thread()) { \
+ ::framework::Result btc_test_res_{::framework::Result::failed( \
+ "REQUIRE used from a child thread; use CHECK in child threads.")}; \
+ ::framework::record_check(btc_test_ctx_, btc_test_res_, "REQUIRE", #expr, __FILE__, __LINE__); \
} else { \
- btc_test_res_ = ::framework::Result::failed("REQUIRE checks must not be thrown on spawned threads."); \
+ BITCOIN_TEST_DIAG_PUSH \
+ ::framework::Result btc_test_res_ = ::framework::Decomposer{} <= expr; \
+ BITCOIN_TEST_DIAG_POP \
+ std::ostringstream btc_test_os_; \
+ __VA_OPT__(if (!btc_test_res_.is_ok()) btc_test_os_ << __VA_ARGS__;) \
+ ::framework::record_check(btc_test_ctx_, btc_test_res_, "REQUIRE", #expr, __FILE__, __LINE__, \
+ btc_test_res_.is_ok() ? std::string{} : btc_test_os_.str()); \
+ if (!btc_test_res_.is_ok()) throw ::framework::RequireFailed{}; \
} \
- BITCOIN_TEST_DIAG_POP \
- std::ostringstream btc_test_os_; \
- __VA_OPT__(if (!btc_test_res_.is_ok()) btc_test_os_ << __VA_ARGS__;) \
- ::framework::record_check(btc_test_ctx_, btc_test_res_, "REQUIRE", #expr, __FILE__, __LINE__, \
- btc_test_res_.is_ok() ? std::string{} : btc_test_os_.str()); \
- if (!btc_test_res_.is_ok()) throw ::framework::RequireFailed{}; \
} while (false)
/** Passes if `expr` throws any exception. */
@@ -679,22 +681,26 @@ inline int run(int argc, char** argv)
#expr, __FILE__, __LINE__); \
} while (false)
-/** Like CHECK_NOTHROW, but aborts the current test on failure. */
+/** Like CHECK_NOTHROW, but aborts the current test on failure. Use
+ * CHECK_NOTHROW from child threads. */
#define REQUIRE_NOTHROW(expr) \
do { \
auto& btc_test_ctx_{::framework::test_context()}; \
- ::framework::Result btc_test_res_{::framework::Result::ok()}; \
- try { \
- expr; \
- } catch (...) { \
- btc_test_res_ = ::framework::Result::failed("unexpectedly threw"); \
- } \
if (!btc_test_ctx_.on_owner_thread()) { \
- btc_test_res_ = ::framework::Result::failed("REQUIRE checks must not be thrown on spawned threads."); \
+ ::framework::Result btc_test_res_{::framework::Result::failed( \
+ "REQUIRE_NOTHROW used from a child thread; use CHECK_NOTHROW in child threads.")}; \
+ ::framework::record_check(btc_test_ctx_, btc_test_res_, "REQUIRE_NOTHROW", #expr, __FILE__, __LINE__); \
+ } else { \
+ ::framework::Result btc_test_res_{::framework::Result::ok()}; \
+ try { \
+ expr; \
+ } catch (...) { \
+ btc_test_res_ = ::framework::Result::failed("unexpectedly threw"); \
+ } \
+ ::framework::record_check(btc_test_ctx_, btc_test_res_, "REQUIRE_NOTHROW", \
+ #expr, __FILE__, __LINE__); \
+ if (!btc_test_res_.is_ok()) throw ::framework::RequireFailed{}; \
} \
- ::framework::record_check(btc_test_ctx_, btc_test_res_, "REQUIRE_NOTHROW", \
- #expr, __FILE__, __LINE__); \
- if (!btc_test_res_.is_ok()) throw ::framework::RequireFailed{}; \
} while (false)
/** Passes only if `expr` throws an exception derived from `ExceptionType`. */(I resisted the urge to write a helper function out of respect for u @rustaceanrob 🥲 )
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.
958f817 to
35b7ded
Compare
|
ACK 35b7ded |
It does already :) |
ismaelsadeeq
left a comment
There was a problem hiding this comment.
LGTM, just a minor issue noticed.
| @@ -81,11 +81,15 @@ BOOST_AUTO_TEST_CASE(GetFeeTest) | |||
| // Previously, precision was limited to three decimal digits | |||
| // due to only supporting satoshis per kB, so CFeeRate(CAmount(1), 1001) was equal to CFeeRate(0) | |||
There was a problem hiding this comment.
In "scripted-diff: Unroll && conditions in tests" 4cdcaf2
It is worth northing that this changes behaviour a bit, splitting BOOST_CHECK(a && b && c) to BOOST_CHECK(a); BOOST_CHECK(b); BOOST_CHECK(c); pinpoints which sub-condition failed.
If you don't want to change the behaviour u can unroll like the subsequent commit, or mention this in the commit body with why.
| private: | ||
| std::atomic<int> m_checks{0}; | ||
| std::atomic<int> m_failed{0}; | ||
| bool m_did_throw{false}; |
There was a problem hiding this comment.
In "test: Add header-only test framework" abdf6c3
Hmm, this line is smelly, a mutable and not thread safe! I think a comment on why this bool is not atomic and thread safe would be helpful.
I would suggest to move it out?
suggestion
diff --git a/src/test/util/framework.hpp b/src/test/util/framework.hpp
index 9e8e79de48..d0bf3e7d69 100644
--- a/src/test/util/framework.hpp
+++ b/src/test/util/framework.hpp
@@ -155,7 +155,6 @@ class TestContext
private:
std::atomic<int> m_checks{0};
std::atomic<int> m_failed{0};
- bool m_did_throw{false};
const std::string m_full_name;
const std::thread::id m_owner_thread{std::this_thread::get_id()};
@@ -183,14 +182,9 @@ public:
}
}
- void exception_occured()
+ bool did_fail() const noexcept
{
- m_did_throw = true;
- }
-
- bool did_fail()
- {
- return m_did_throw || m_failed.load(std::memory_order_relaxed) > 0;
+ return m_failed.load(std::memory_order_relaxed) > 0;
}
TestStats stats() const noexcept
@@ -502,25 +496,30 @@ inline int run(int argc, char** argv)
}
TestContext ctx{test_name(test_case)};
+ bool threw = false;
try {
test_case.fn();
} catch (const RequireFailed&) {
} catch (const std::exception& e) {
- ctx.exception_occured();
+ threw = true;
log(LogLevel::Error, "EXCEPTION in %s: %s\n", test_case.name, e.what());
} catch (...) {
- ctx.exception_occured();
+ threw = true;
log(LogLevel::Error, "EXCEPTION in %s: %s\n", test_case.name, "unknown exception");
}
const auto stats = ctx.stats();
summary.total_checks += stats.checks;
- if (!ctx.did_fail()) {
+ // A test fails if it threw an unexpected exception or any check failed.
+ // `threw` is reported separately because an exception aborts the body
+ // and can leave zero failed checks recorded; the exception text itself
+ // was already logged above.
+ if (!threw && !ctx.did_fail()) {
++summary.passed;
log(LogLevel::Info, "[ OK ] %s (%d checks)\n", test_case.name, stats.checks);
} else {
++summary.failed;
There was a problem hiding this comment.
I like the concept of encapsulating all test state within TestContext, but indeed I think this should be an atomic bool in that case. Maybe it can be left as a quick follow up or otherwise I can get to it tomorrow.
There was a problem hiding this comment.
Yeah, it doesn't feel like a test state since it's not mutated within the class itself.
m_failed belongs to the context because it is mutated within apply_result when the result is not ok.
You can also make m_did_throw atomic and expose a setter, but that just makes it more obvious :P. Except if you are going to somehow set m_did_throw within apply_result as well.
Note: I don't feel strongly about this. I am fine when it's atomic with a setter, just bringing the issue up for discussion.
There was a problem hiding this comment.
Personally, I'd be fine with a quick follow-up. I agree it is test state because it is relevant when a single test is running and would need to be reset when we move to the next test.
Why I'd be fine with a follow-up: for me the structure of the framework is solid. This feels like a "fix racy bool" follow-up. Good catch, tho, @ismaelsadeeq !
|
lgtm + ack, sounds like a merge :D |
tl;dr: Make an equivalent test runner to boost with simpler macros and tighter coupling to bitcoin-specific code.
Motivation
There are a number of problems with using dependencies in general. Bugs must either be promptly upstreamed or patched, projects may be abandoned, and they are not tailored to a particular use case.
bitcoindhas a small number of dependencies and may have fewer very soon (bitcoin#34411). One of the final remaining dependencies is boost, which is responsible for the unit tests and multi-index. This PR removes boost for writing and running unit tests.My problems with boost
Macros
When using boost, there are 3 ways of writing the same thing, each with different outcomes
BOOST_CHECK(expr1 == expr2): failures show the expression, but no values forexpr1andexpr2BOOST_CHECK_EQUAL(expr1, expr2): failures show the expression and values, but requires developers rememberCHECK_EQUALBOOST_TEST(expr1 == expr2): failures show the expression and values, and developers may use familiar operands (==,!=)All three are preserved to maintain backwards compatibility - presumably, but this results in all 3 being used throughout the repository. As a bonus there is a 4th way, with added context
BOOST_CHECK_MESSAGE(expr1 == expr2, "the context"): failures show the expression and message, but no values forexpr1andexpr2To make debugging and review easier, all of these should be unified to a single macro. Test sites use operands, print helpful messages - values are always included, and do not vary in approach (deciding between
BOOST_TEST,CHECK, andBOOST_CHECK_EQUALis unintuitive)Example boost outputs
New outputs
No repository context
Many types implement
ToString, but boost has no way of meaningfully using these representations (will change w/ futurestd::format). With a repository-specific test runner we can use these today. See example above.Not extensible
With the test runner in this repository we can add things such as bitcoin#35139, implement string representations for more types, and implement ideas from bitcoin#8670.
Other issues
More issues with boost are raised in bitcoin#34666, bitcoin#8670. This PR does not resolve all of them, but one it does address immediately is banning comparisons of integers with different signs, previously allowed by boost. Using a test runner within the source also makes IWYU easier to work with.
High level changes
For those running tests, not a tremendous amount has changed in this PR. For instance, the doc page remains valid. Changes to the runner options include anything that is not
help, run_test, log_level, list_content. This includescatch_system_errorswhich is always no. Thelog_levelshave been reduced to 5 levels.Changes in writing tests
CHECKto compare two values with any==,!=,>, etcCHECK(a == b, "my message")to append a messageREQUIRE, same asCHECK, but will fail the test immediatelyCHECK_EQUAL_RANGES(it1, it2)to compare two iteratorsTEST_CASE(name)to add a testFIXTURE_TEST_CASE(name, Fixture)to add a test with a fixtureTEST_SUITE_BEGIN/ENDto declare a suiteFIXTURE_TEST_SUITE_*is intentionally omitted. The readability of this macro is unclear as to if all unit tests share the same fixture or reinitialize the fixture. All test cases should be declared withFIXTUREif they use one.Anticipated questions
Q: Why not name the macros
BOOST_*?A: This would defeat the advantages of using a new framework, which unifies
CHECKcalls. We should just rip the bandaid off now.Q: Why not run the last commit as a
scripted-diff?A: The migration script is essentially a small tokenizer and would be difficult to review. IMO it is easier to review each callsite change.
Q: Why no concurrency?
A: The unit tests rely on the "current test case," so this would have to change to adopt a concurrent runner. I considered this out of scope for an already large change.
Migration
Migration script