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. This PR removes boost for writing and running unit tests. In the case of Boost.Test, there are a number of issues which are listed below.
My problems with Boost.Test
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_EQUALand does not use usual operandsBOOST_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 forexpr1andexpr2
To 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, and BOOST_CHECK_EQUAL is unintuitive)
<details> <summary>Example boost outputs</summary>
BOOST_AUTO_TEST_CASE(mustfail)
{
auto a{1};
auto b{2};
BOOST_CHECK(a == b);
BOOST_CHECK_EQUAL(a, b);
BOOST_TEST(a == b);
COutPoint outpoint_1{Txid{"0000000000000000000000000000000000000000000000000000000000000100"}, 0};
COutPoint outpoint_2{Txid{"0000000000000000000000000000000000000000000000000000000000000100"}, 1};
BOOST_CHECK(outpoint_1 == outpoint_2);
// Doesn't even compile
// BOOST_TEST(outpoint_1 == outpoint_2);
// BOOST_CHECK_EQUAL(outpoint_1, outpoint_2);
}
Running 1 test case...
test/argsman_tests.cpp(31): error: in "argsman_tests/mustfail": check a == b has failed
test/argsman_tests.cpp(32): error: in "argsman_tests/mustfail": check a == b has failed [1 != 2]
test/argsman_tests.cpp(33): error: in "argsman_tests/mustfail": check a == b has failed [1 != 2]
test/argsman_tests.cpp(36): error: in "argsman_tests/mustfail": check outpoint_1 == outpoint_2 has failed
</details>
<details> <summary>New outputs</summary>
TEST_CASE(mustfail)
{
auto a{1};
auto b{2};
CHECK(a == b);
COutPoint outpoint_1{Txid{"0000000000000000000000000000000000000000000000000000000000000100"}, 0};
COutPoint outpoint_2{Txid{"0000000000000000000000000000000000000000000000000000000000000100"}, 1};
CHECK(outpoint_1 == outpoint_2);
}
Running 1 test cases...
[FAIL]: test/argsman_tests.cpp:30: CHECK(a == b)
1 == 2
[FAIL]: test/argsman_tests.cpp:33: CHECK(outpoint_1 == outpoint_2)
COutPoint(0000000000, 0) == COutPoint(0000000000, 1)
[FAIL] mustfail (2/2 checks failed)
</details>
No repository context
Many types implement ToString, but boost has no way of meaningfully using these representations (will change w/ future std::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 #35139, implement string representations for more types, and implement ideas from #8670.
Other issues
More issues with boost are raised in #34666, #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 includes catch_system_errors which is always no. The log_levels have 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 rangesTEST_CASE(name)to add a testFIXTURE_TEST_CASE(name, Fixture)to add a test with a fixtureTEST_SUITE_BEGIN/ENDto declare a suite
FIXTURE_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 with FIXTURE if 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 CHECK calls. 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 more technically difficult to review. IMO it is best to review each callsite change, albeit many of them. The script is still posted below for reviewers to confirm the last commit is entirely mechanical, if desired.
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
HEAD~2 is the addition of the framework. Most commits are small but do not have much overlap. 1-16 are all small and disjoint commits that prepare for the migration.
<details> <summary>Migration script</summary>
import re
import sys
from pathlib import Path
_TOKEN_RE = re.compile(
r'"(?:\\.|[^"\\])*"'
r"|(?<![0-9a-fA-F'])'(?:\\.|[^'\\])*'"
r"|(?P<open>[(\[{])"
r"|(?P<close>[)\]}])"
r"|(?P<id>[A-Za-z_][A-Za-z0-9_]*)"
r"|(?P<op>&&|\|\||==|!=|<=|>=)"
r"|(?P<other>.)",
re.DOTALL,
)
def _walk(text, start=0):
depth = 0
for m in _TOKEN_RE.finditer(text, start):
kind = m.lastgroup
if kind == "close":
depth -= 1
yield depth, m
if kind == "open":
depth += 1
def find_close(text, open_idx):
for depth, m in _walk(text, open_idx):
if m.lastgroup == "close" and depth == 0:
return m.end()
def split_args(args):
parts, prev = [], 0
for depth, m in _walk(args):
if depth == 0 and m.lastgroup == "other" and m.group() == ",":
parts.append(args[prev:m.start()])
prev = m.end()
parts.append(args[prev:])
return parts
_ALWAYS = {"&&", "||", "?", "|", "&", "^"}
_COMPARE = {"==", "!=", "<=", ">="}
def has_top_level(text, ops):
for depth, m in _walk(text):
if depth != 0:
continue
tok = m.group()
if tok not in ops:
continue
if m.lastgroup == "op" or tok == "?":
return True
if tok in "|&^" and text[m.end():m.end() + 1] != "=":
prev = text[:m.start()].rstrip()
if prev and (prev[-1].isalnum() or prev[-1] in "_)]}\"'"):
return True
return False
def wrap(expr, ops):
expr = expr.strip()
return f"({expr})" if has_top_level(expr, ops) else expr
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_BOOST_RE = re.compile(r"\b(BOOST_[A-Z0-9_]+)\s*\(")
_FILE_NS_RE = re.compile(
r"^[ \t]*namespace[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*\{", re.MULTILINE
)
def ident(arg):
name = arg.strip()
if not _IDENT_RE.match(name):
raise ValueError(f"not a valid C++ identifier: {name!r}")
return name
def iter_pair(begin, end):
"""`X.begin()`/`X.end()` collapse to `X`; `->begin/end` to `*X`; else subrange."""
b, e = begin.strip(), end.strip()
for sep, deref in ((r"\.", ""), (r"->", "*")):
mb = re.match(rf"^(.+){sep}begin\s*\(\s*\)$", b)
me = re.match(rf"^(.+){sep}end\s*\(\s*\)$", e)
if mb and me and (base := mb.group(1).strip()) == me.group(1).strip():
return f"*({base})" if deref else base
return f"std::ranges::subrange({b}, {e})"
_RENAMES = {
"BOOST_CHECK_NO_THROW": "CHECK_NOTHROW",
"BOOST_REQUIRE_NO_THROW": "REQUIRE_NOTHROW",
"BOOST_CHECK_THROW": "CHECK_THROWS_AS",
"BOOST_CHECK_EXCEPTION": "CHECK_EXCEPTION",
"BOOST_TEST_MESSAGE": "TEST_MESSAGE",
"BOOST_WARN_MESSAGE": "WARN_MESSAGE",
}
_COMPARE_OP = {
f"BOOST_{k}_{n}": (k, o)
for k in ("CHECK", "REQUIRE")
for n, o in (("EQUAL", "=="), ("NE", "!="), ("LT", "<"), ("LE", "<="), ("GT", ">"), ("GE", ">="))
}
def rewrite_macros(text):
while True:
declared_ns = set(_FILE_NS_RE.findall(text))
suite_stack = []
out, cursor = [], 0
for m in _BOOST_RE.finditer(text):
start = m.start()
if start < cursor:
continue
macro = m.group(1)
close_end = find_close(text, m.end() - 1)
if close_end is None:
continue
args = text[m.end():close_end - 1]
replacement = None
if macro in ("BOOST_CHECK", "BOOST_TEST", "BOOST_REQUIRE", "BOOST_TEST_REQUIRE"):
target = "REQUIRE" if "REQUIRE" in macro else "CHECK"
body = f"({args})" if has_top_level(args, _ALWAYS) else args
replacement = f"{target}({body})"
elif macro in _RENAMES:
replacement = f"{_RENAMES[macro]}({args})"
elif macro == "BOOST_ERROR":
replacement = f"RECORD_ERROR({args.strip()})"
elif macro in ("BOOST_CHECK_MESSAGE", "BOOST_REQUIRE_MESSAGE"):
parts = split_args(args)
if len(parts) >= 2:
target = "REQUIRE" if "REQUIRE" in macro else "CHECK"
replacement = f"{target}({wrap(parts[0], _ALWAYS)}, {','.join(parts[1:]).strip()})"
elif macro in _COMPARE_OP:
target, op = _COMPARE_OP[macro]
parts = split_args(args)
if len(parts) == 2:
a, b = wrap(parts[0], _ALWAYS | _COMPARE), wrap(parts[1], _ALWAYS | _COMPARE)
replacement = f"{target}({a} {op} {b})"
elif macro == "BOOST_CHECK_EQUAL_COLLECTIONS":
parts = split_args(args)
if len(parts) == 4:
replacement = f"CHECK_EQUAL_RANGES({iter_pair(parts[0], parts[1])}, {iter_pair(parts[2], parts[3])})"
elif macro == "BOOST_AUTO_TEST_CASE":
name = ident(split_args(args)[0])
fixture = suite_stack[-1][0] if suite_stack else None
replacement = (f"FIXTURE_TEST_CASE({name}, {fixture})"
if fixture else f"TEST_CASE({name})")
elif macro == "BOOST_FIXTURE_TEST_CASE":
parts = split_args(args)
if len(parts) >= 2:
replacement = f"FIXTURE_TEST_CASE({ident(parts[0])}, {parts[1].strip()})"
elif macro in ("BOOST_AUTO_TEST_SUITE", "BOOST_FIXTURE_TEST_SUITE"):
parts = split_args(args)
name = ident(parts[0])
fixture = parts[1].strip() if macro == "BOOST_FIXTURE_TEST_SUITE" else None
# Re-open the namespace Boost's *_TEST_SUITE opened implicitly,
# if the suite name matches a file-scope namespace.
ns = name if name in declared_ns else None
suite_stack.append((fixture, ns))
prefix = f"namespace {ns} {{\n" if ns else ""
replacement = f"{prefix}TEST_SUITE_BEGIN({name})"
elif macro == "BOOST_AUTO_TEST_SUITE_END":
ns = suite_stack.pop()[1] if suite_stack else None
suffix = f"\n}} // namespace {ns}" if ns else ""
replacement = f"TEST_SUITE_END(){suffix}"
elif macro in ("BOOST_TEST_INFO", "BOOST_TEST_INFO_SCOPE"):
replacement = f"/* {macro}({args}) */"
if replacement is None:
continue
out.append(text[cursor:start])
out.append(replacement)
cursor = close_end
out.append(text[cursor:])
new = "".join(out)
if new == text:
return new
text = new
def rewrite_includes(text):
text = re.sub(r"^#include\s*<boost/test/[^>]+>\s*$",
"#include <test/util/framework.h>\n", text, flags=re.MULTILINE)
text = re.sub(r"^#define\s+BOOST_TEST_MODULE\b.*$",
"#define BITCOIN_TEST_MAIN\n", text, flags=re.MULTILINE)
text = re.sub(r"(?:#include <test/util/framework\.h>\n+)+",
"#include <test/util/framework.h>\n\n", text)
return text.replace("boost::unit_test::framework::master_test_suite().argv[0]",
"framework::executable_path()")
_FILE_SWAPS = {
"test/lint/lint-includes.py": [
("boost/test/included/unit_test.hpp", None),
("boost/test/unit_test.hpp", None),
],
"test/lint/lint-tests.py": [
("grep_boost_test_suites", "grep_test_suites"),
(r'r"^(BOOST_FIXTURE_TEST_SUITE|BOOST_AUTO_TEST_SUITE)\("', r'r"^TEST_SUITE_BEGIN\("'),
(r'r"/(.*?)\.cpp:(?:BOOST_FIXTURE_TEST_SUITE|BOOST_AUTO_TEST_SUITE)\(\1(_[a-z0-9]+)?[,)]"',
r'r"/(.*?)\.cpp:TEST_SUITE_BEGIN\(\1(_[a-z0-9]+)?\)"'),
(r're.search(r"\((.*?)[,)]", x)', r're.search(r"\((.*?)\)", x)'),
],
}
def main():
paths = [Path(a) for a in sys.argv[1:]] or sys.exit(__doc__.strip())
files = []
for p in paths:
if p.is_file():
files.append(p)
elif p.is_dir():
files.extend(f for f in p.rglob("*") if f.is_file())
for suffix in _FILE_SWAPS:
target = Path(suffix)
if target.exists() and target not in files:
files.append(target)
for f in files:
if f.name == "new_main.cpp":
f.with_name("main.cpp").write_text(f.read_text())
f.unlink()
print(f"renamed {f}")
continue
if f.name == "CMakeLists.txt":
old = f.read_text()
new = (old
.replace("(BOOST_FIXTURE_TEST_SUITE|BOOST_AUTO_TEST_SUITE)", "TEST_SUITE_BEGIN")
.replace("--catch_system_error=no ", ""))
elif f.suffix in (".cpp", ".h"):
old = f.read_text()
new = rewrite_macros(rewrite_includes(old))
elif any(str(f).endswith(s) for s in _FILE_SWAPS):
old = new = f.read_text()
else:
continue
for suffix, swaps in _FILE_SWAPS.items():
if str(f).endswith(suffix):
for o, r in swaps:
if r is None:
new = re.sub(rf"^.*{re.escape(o)}.*\n", "", new, flags=re.MULTILINE)
else:
new = new.replace(o, r)
if new != old:
f.write_text(new)
print(f"rewrote {f}")
if __name__ == "__main__":
main()
</details>