From 115c1ba2bbebf4502b2bc1f4597bd0ec2e6ada84 Mon Sep 17 00:00:00 2001 From: Dennis Smirnov Date: Sun, 6 Sep 2026 15:32:53 +0300 Subject: [PATCH 1/2] feat(test): add batch test selection to the test runner Allow -r to accept multiple test names and start them in the specified order. Validate all requested test names before starting any test and report unknown names. Reject duplicate names immediately to avoid running the same TestCase instance more than once. Add CLI regression coverage for single and batch selection, invalid arguments, test listing, and help output. --- lib/src/drogon_test.cc | 98 ++++++++++++++++--------- lib/tests/CMakeLists.txt | 6 ++ lib/tests/DrogonTestRunnerCliTest.cmake | 92 +++++++++++++++++++++++ 3 files changed, 161 insertions(+), 35 deletions(-) create mode 100644 lib/tests/DrogonTestRunnerCliTest.cmake diff --git a/lib/src/drogon_test.cc b/lib/src/drogon_test.cc index 18f8d9187a..f0e59d22ff 100644 --- a/lib/src/drogon_test.cc +++ b/lib/src/drogon_test.cc @@ -1,6 +1,8 @@ #include #include +#include +#include #include #include @@ -61,10 +63,12 @@ static void printHelp(std::string_view argv0) print() << "A Drogon Test application:\n\n" << "Usage: " << argv0 << " [options]\n" << "options:\n" - << " -r Run a specific test\n" + << " -r Run one or more specific tests\n" << " -s Print successful tests\n" << " -l List available tests\n" - << " -h | --help Print this help message\n"; + << " -h | --help Print this help message\n" + << "\n" + << "Example: " << argv0 << " -r $(cat selected-tests.txt)\n"; } void printTestStats() @@ -144,26 +148,33 @@ int run(int argc, char **argv) internal::numTestCases = 0; internal::printSuccessfulTests = false; - std::string targetTest; + std::vector orderedTestNames; + std::unordered_set uniqueTestNames; bool listTests = false; for (int i = 1; i < argc; i++) { const std::string param = argv[i]; if (param == "-r") { - if (!targetTest.empty()) - { - printErr() << "Only one test can be specified to run\n"; - exit(1); - } - else if (i + 1 >= argc) + if (i + 1 >= argc || argv[i + 1][0] == '-') { printErr() << "Missing test name after -r.\n"; exit(1); } - targetTest = argv[i + 1]; - i++; + while (i + 1 < argc && argv[i + 1][0] != '-') + { + const std::string testName = argv[++i]; + if (uniqueTestNames.emplace(testName).second) + { + orderedTestNames.emplace_back(std::move(testName)); + } + else + { + printErr() << "Duplicate test name: " << testName << "\n"; + exit(1); + } + } } else if (param == "-h" || param == "--help") { @@ -205,40 +216,57 @@ int run(int argc, char **argv) exit(0); } - std::vector> testCases; - // NOTE: Registering a dummy case prevents the test-end signal to be - // emitted too early as there's always an case that hasn't finish - std::shared_ptr dummyCase = std::make_shared("__dummy_dummy_"); + std::unordered_map> + availableTestCases; + std::vector availableTestNames; for (const auto &name : classNames) { - if (name.find(DROGON_TESTCASE_PREIX_STR_) == 0) + if (name.find(DROGON_TESTCASE_PREIX_STR_) != 0) + continue; + + auto obj = std::shared_ptr(DrClassMap::newObject(name)); + auto test = std::dynamic_pointer_cast(obj); + if (test == nullptr) { - auto obj = - std::shared_ptr(DrClassMap::newObject(name)); - auto test = std::dynamic_pointer_cast(obj); - if (test == nullptr) - { - LOG_WARN << "Class " << name - << " seems to be a test case. But type information " - "disagrees."; - continue; - } - if (targetTest.empty() || test->name() == targetTest) - { - internal::numTestCases++; - test->doTest_(std::make_shared(test->name())); - testCases.emplace_back(std::move(test)); - } + LOG_WARN << "Class " << name + << " seems to be a test case. But type information " + "disagrees."; + continue; } + const auto testName = test->name(); + if (availableTestCases.emplace(testName, std::move(test)).second) + availableTestNames.emplace_back(testName); } - dummyCase = {}; - if (targetTest != "" && internal::numTestCases == 0) + std::vector missingTestNames; + for (const auto &name : orderedTestNames) + { + if (availableTestCases.find(name) == availableTestCases.end()) + missingTestNames.emplace_back(name); + } + if (!missingTestNames.empty()) { - printErr() << "Cannot find test named " << targetTest << "\n"; + printErr() << "Cannot find test(s) named:\n"; + for (const auto &name : missingTestNames) + printErr() << " " << name << "\n"; exit(1); } + std::vector> testCases; + // NOTE: Registering a dummy case prevents the test-end signal to be + // emitted too early as there's always an case that hasn't finish + std::shared_ptr dummyCase = std::make_shared("__dummy_dummy_"); + const auto &testNames = + orderedTestNames.empty() ? availableTestNames : orderedTestNames; + for (const auto &name : testNames) + { + auto &test = availableTestCases.at(name); + internal::numTestCases++; + test->doTest_(std::make_shared(test->name())); + testCases.emplace_back(std::move(test)); + } + dummyCase = {}; + std::unique_lock l(internal::mtxRegister); if (internal::registeredTests.empty() == false) { diff --git a/lib/tests/CMakeLists.txt b/lib/tests/CMakeLists.txt index 0eca5c3e52..23ee5024c6 100644 --- a/lib/tests/CMakeLists.txt +++ b/lib/tests/CMakeLists.txt @@ -149,3 +149,9 @@ set_property(TARGET ${tests} PROPERTY CXX_EXTENSIONS OFF) ParseAndAddDrogonTests(unittest) ParseAndAddDrogonTests(cookie_same_site) ParseAndAddDrogonTests(real_ip_resolver) + +add_test( + NAME drogon_test_runner_cli + COMMAND ${CMAKE_COMMAND} + -DTEST_RUNNER=$ + -P ${CMAKE_CURRENT_SOURCE_DIR}/DrogonTestRunnerCliTest.cmake) diff --git a/lib/tests/DrogonTestRunnerCliTest.cmake b/lib/tests/DrogonTestRunnerCliTest.cmake new file mode 100644 index 0000000000..0e5cad489c --- /dev/null +++ b/lib/tests/DrogonTestRunnerCliTest.cmake @@ -0,0 +1,92 @@ +function(run_test_runner result output) + execute_process( + COMMAND "${TEST_RUNNER}" ${ARGN} + RESULT_VARIABLE command_result + OUTPUT_VARIABLE command_output + ERROR_VARIABLE command_error) + set(${result} "${command_result}" PARENT_SCOPE) + set(${output} "${command_output}${command_error}" PARENT_SCOPE) +endfunction() + +function(require_success description result output) + if(NOT result EQUAL 0) + message(FATAL_ERROR "${description} failed with exit code ${result}:\n${output}") + endif() +endfunction() + +function(require_failure description result output) + if(result EQUAL 0) + message(FATAL_ERROR "${description} unexpectedly succeeded:\n${output}") + endif() +endfunction() + +run_test_runner(result output -r TestFrameworkSelfTest) +require_success("single test selection" "${result}" "${output}") +if(NOT output MATCHES "1 tests cases") + message(FATAL_ERROR "single test selection changed unexpectedly:\n${output}") +endif() + +run_test_runner(result output -s -r TestFrameworkSelfTest URLCodec) +require_success("batch test selection" "${result}" "${output}") + +string(FIND "${output}" "In test case TestFrameworkSelfTest" first_test_position) +string(FIND "${output}" "In test case URLCodec" second_test_position) +if(first_test_position EQUAL -1 OR second_test_position EQUAL -1 OR + first_test_position GREATER second_test_position) + message(FATAL_ERROR "selected tests did not start in the requested order:\n${output}") +endif() +if(NOT output MATCHES "2 tests cases") + message(FATAL_ERROR "batch test selection changed unexpectedly:\n${output}") +endif() + +run_test_runner(result output -r TestFrameworkSelfTest URLCodec TestFrameworkSelfTest) +require_failure("duplicate test selection" "${result}" "${output}") +if(NOT output MATCHES "Duplicate test name: TestFrameworkSelfTest") + message(FATAL_ERROR "duplicate test name was not reported:\n${output}") +endif() +if(output MATCHES "In test case") + message(FATAL_ERROR "a test started despite a duplicate requested name:\n${output}") +endif() + +run_test_runner(result output -r missing-one TestFrameworkSelfTest missing-two) +require_failure("unknown test selection" "${result}" "${output}") +foreach(test_name missing-one missing-two) + if(NOT output MATCHES "${test_name}") + message(FATAL_ERROR "missing test name ${test_name} was not reported:\n${output}") + endif() +endforeach() +if(output MATCHES "In test case") + message(FATAL_ERROR "a test started despite an unknown requested name:\n${output}") +endif() + +run_test_runner(result output -r) +require_failure("missing name after -r" "${result}" "${output}") +if(NOT output MATCHES "Missing test name after -r") + message(FATAL_ERROR "missing-name error was not reported:\n${output}") +endif() + +run_test_runner(result output --unknown) +require_failure("unknown option" "${result}" "${output}") +if(NOT output MATCHES "Unknown parameter: --unknown") + message(FATAL_ERROR "unknown-option error was not reported:\n${output}") +endif() + +run_test_runner(result output -l) +require_success("test listing" "${result}" "${output}") +if(NOT output MATCHES "Available Tests:" OR NOT output MATCHES "URLCodec") + message(FATAL_ERROR "test listing changed unexpectedly:\n${output}") +endif() + +run_test_runner(result output --help) +require_success("help output" "${result}" "${output}") +string(FIND "${output}" "-r " batch_syntax_position) +string(FIND "${output}" "-r $(cat selected-tests.txt)" batch_example_position) +if(batch_syntax_position EQUAL -1 OR batch_example_position EQUAL -1) + message(FATAL_ERROR "batch selection help was not reported:\n${output}") +endif() + +run_test_runner(result output -h) +require_success("short help output" "${result}" "${output}") +if(NOT output MATCHES "A Drogon Test application") + message(FATAL_ERROR "short help output changed unexpectedly:\n${output}") +endif() From c794fe5703abbfec87bc897163b696259872b9f6 Mon Sep 17 00:00:00 2001 From: Dennis Smirnov Date: Mon, 7 Sep 2026 12:11:34 +0300 Subject: [PATCH 2/2] fix(test): return from test runner instead of exiting Avoid calling exit() from test::run() on CLI early-exit paths. exit() bypasses the unittest main's shutdown sequence, leaving the app event-loop thread running while static objects are destroyed. This can race with Drogon teardown and cause a use-after-free when listing tests. Return the corresponding status instead, so main() can quit the app and join the thread before process teardown. --- lib/src/drogon_test.cc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/drogon_test.cc b/lib/src/drogon_test.cc index f0e59d22ff..818dc86c7f 100644 --- a/lib/src/drogon_test.cc +++ b/lib/src/drogon_test.cc @@ -159,7 +159,7 @@ int run(int argc, char **argv) if (i + 1 >= argc || argv[i + 1][0] == '-') { printErr() << "Missing test name after -r.\n"; - exit(1); + return 1; } while (i + 1 < argc && argv[i + 1][0] != '-') @@ -172,14 +172,14 @@ int run(int argc, char **argv) else { printErr() << "Duplicate test name: " << testName << "\n"; - exit(1); + return 1; } } } else if (param == "-h" || param == "--help") { printHelp(argv[0]); - exit(0); + return 0; } else if (param == "-s") { @@ -193,7 +193,7 @@ int run(int argc, char **argv) { printErr() << "Unknown parameter: " << param << "\n"; printHelp(argv[0]); - exit(1); + return 1; } } auto classNames = DrClassMap::getAllClassName(); @@ -213,7 +213,7 @@ int run(int argc, char **argv) print() << " " << ptr->name() << "\n"; } } - exit(0); + return 0; } std::unordered_map> @@ -249,7 +249,7 @@ int run(int argc, char **argv) printErr() << "Cannot find test(s) named:\n"; for (const auto &name : missingTestNames) printErr() << " " << name << "\n"; - exit(1); + return 1; } std::vector> testCases;