]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
test/neorados: Harness and convenience for Neorados tests
authorAdam C. Emerson <aemerson@redhat.com>
Sat, 21 Jan 2023 02:35:41 +0000 (21:35 -0500)
committerAdam Emerson <aemerson@redhat.com>
Thu, 14 Sep 2023 21:08:57 +0000 (17:08 -0400)
Google Test does not support C++ coroutines, so kludge together a test
harness that supports coroutines reasonably well.

Also add a couple utility functions.

Signed-off-by: Adam C. Emerson <aemerson@redhat.com>
src/test/neorados/CMakeLists.txt
src/test/neorados/common_tests.cc
src/test/neorados/common_tests.h

index 31e79a6615c46b66bc1ab2ec2406dd8e9c6a3d01..e3caaaf07d9e5895743e41e6a3a960ab46d70d2c 100644 (file)
@@ -21,7 +21,7 @@ target_link_libraries(ceph_test_neorados_op_speed
 
 add_library(neoradostest-support STATIC common_tests.cc)
 target_link_libraries(neoradostest-support
-  libneorados fmt::fmt)
+  libneorados fmt::fmt GTest::GTest)
 
 add_executable(ceph_test_neorados_list_pool list_pool.cc)
 target_link_libraries(ceph_test_neorados_list_pool
index 4e4b6c0af14c8c9585d087111b1eadc8efe84ae0..6f274551d95e62f02d478d730ecff9b244051889 100644 (file)
@@ -12,7 +12,6 @@
  * Foundation.  See file COPYING.
  */
 
-#include <cstring>
 #include <string>
 #include <string_view>
 
 #include "common_tests.h"
 #include "include/neorados/RADOS.hpp"
 
-namespace ba = boost::asio;
-namespace R = neorados;
+namespace asio = boost::asio;
 
 std::string get_temp_pool_name(std::string_view prefix)
 {
-  static auto hostname = ba::ip::host_name();
+  static auto hostname = asio::ip::host_name();
   static auto num = 1ull;
   return fmt::format("{}{}-{}-{}", prefix, hostname, getpid(), num++);
 }
index 7afdf55c1bfa9df14448e81cfa223a4c6bb5289b..1a33a8a57502e631fff80acadf7eed6e8abcbe60 100644 (file)
  * Foundation.  See file COPYING.
  */
 
+#pragma once
+
+#include <coroutine>
+#include <cstddef>
+#include <exception>
+#include <initializer_list>
+#include <optional>
 #include <string>
 #include <string_view>
+#include <utility>
+
+#include <boost/asio/async_result.hpp>
+#include <boost/asio/awaitable.hpp>
+#include <boost/asio/co_spawn.hpp>
+#include <boost/asio/detached.hpp>
+#include <boost/asio/io_context.hpp>
+#include <boost/asio/use_awaitable.hpp>
+
+#include <boost/asio/experimental/co_composed.hpp>
+
+#include <boost/system/error_code.hpp>
+#include <boost/system/system_error.hpp>
 
 #include "include/neorados/RADOS.hpp"
 
+#include "common/dout.h"
+
+#include "gtest/gtest.h"
+
+/// \file test/neorados/common_tests.h
+///
+/// \brief Tools for testing neorados code
+///
+/// This is a set of utilities for testing code using the neorados
+/// library, as well as for tests using C++20 Coroutines more generally.
+
+/// \brief Get a random, unique pool name
+///
+/// Return a uniquified pool name specific to the host on which we are running.
+///
+/// \param prefix A prefix for the returned pool name
+///
+/// \return A unique pool name
 std::string get_temp_pool_name(std::string_view prefix = {});
 
-template<typename CompletionToken>
-auto create_pool(neorados::RADOS& r, std::string pname,
+/// \brief Create a RADOS pool
+///
+/// Create a RADOS pool, returning its ID on success.
+///
+/// \param r RADOS handle
+/// \param pname Pool name
+/// \param token Boost.Asio completion token
+///
+/// \return The ID of the newly created pool
+template<boost::asio::completion_token_for<
+          void(boost::system::error_code, int64_t)> CompletionToken>
+auto create_pool(neorados::RADOS& r,
+                std::string pname,
                 CompletionToken&& token)
 {
-  boost::asio::async_completion<CompletionToken,
-                               void(boost::system::error_code,
-                                    std::int64_t)> init(token);
-  r.create_pool(pname, std::nullopt,
-               [&r, pname = std::string(pname),
-                h = std::move(init.completion_handler)]
-               (boost::system::error_code ec) mutable {
-                 r.lookup_pool(
-                   pname,
-                   [h = std::move(h)]
-                   (boost::system::error_code ec, std::int64_t pool) mutable {
-                     std::move(h)(ec, pool);
-                   });
-               });
-  return init.result.get();
+  namespace asio = boost::asio;
+  using boost::system::error_code;
+  using boost::system::system_error;
+
+  return asio::async_initiate<CompletionToken, void(error_code, int64_t)>
+    (asio::experimental::co_composed<void(error_code, int64_t)>
+     ([](auto state, neorados::RADOS& r, std::string pname) -> void {
+       try {
+        co_await r.create_pool(pname, std::nullopt, asio::deferred);
+        auto pool = co_await r.lookup_pool(pname, asio::deferred);
+        co_return {error_code{}, pool};
+       } catch (const system_error& e) {
+        co_return {e.code(), int64_t{}};
+       }
+     }, r.get_executor()),
+     token, std::ref(r), std::move(pname));
+}
+
+/// \brief Create a new, empty RADOS object
+///
+/// \param r RADOS handle
+/// \param oid Object name
+/// \param ioc Locator
+/// \param token Boost.Asio completion token
+template<boost::asio::completion_token_for<
+  void(boost::system::error_code)> CompletionToken>
+auto create_obj(neorados::RADOS& r, std::string_view oid,
+               const neorados::IOContext& ioc,
+               CompletionToken&& token)
+{
+  neorados::WriteOp op;
+  op.create(true);
+  return r.execute(oid, ioc, std::move(op),
+                  std::forward<CompletionToken>(token));
 }
+
+/// \brief Expect one of several errors from a coroutine
+///
+/// \param coro Awaitable coroutine
+/// \param ec Valid errors
+boost::asio::awaitable<void>
+expect_error_code(auto&& coro, auto ...ecs) {
+  bool failed = false;
+  try {
+    co_await std::move(coro);
+  } catch (const boost::system::system_error& e) {
+    failed = true;
+    auto h = [c = e.code()](auto t) -> bool { return t == c; };
+    EXPECT_TRUE((h(ecs) || ...))
+      << "Got unexpected error code " << e.code().message() << ".";
+  }
+  EXPECT_TRUE(failed) << "Operation did not error at all.";
+  co_return;
+}
+
+/// \brief Test harness for C++20 Coroutines
+///
+/// C++20 coroutines are better than what we had before, but don't
+/// play well with RAII. There's no good way to run a coroutine from a
+/// destructor, especially in a single-threaded, non-blocking
+/// program.
+///
+/// To be fair to C++20, this is difficult and even rust doesn't have
+/// async drop yet.
+///
+/// GTest has explicit SetUp and TearDown methods, however they're
+/// just regular functions. So we get Coroutine analogues of SetUp and
+/// TearDown that we then call from our custom TestBody. The user
+/// writes their tests in CoTestBody.
+class CoroTest : public testing::Test {
+private:
+  std::exception_ptr eptr;
+protected:
+  boost::asio::io_context asio_context; ///< The context on which the
+                                       ///  coroutine runs.
+public:
+  /// Final override that does nothing. Actual setup code should go in CoSetUp
+  void SetUp() override final { };
+  /// Final override that does nothing. Actual teardown code should go
+  /// in CotearDown.
+  void TearDown() override final { };
+
+  /// \brief SetUp coroutine
+  ///
+  /// Called before the test body. Indicate failure by throwing
+  /// an exception. If an exception is thrown, neither the test body
+  /// nor teardown code are run.
+  virtual boost::asio::awaitable<void> CoSetUp() {
+    co_return;
+  }
+
+  /// \brief TearDown coroutine
+  ///
+  /// Called after the test body exits.
+  ///
+  /// \note This function is not run if CoSetup fails
+  virtual boost::asio::awaitable<void> CoTearDown() {
+    co_return;
+  }
+
+  /// \brief TestBody coroutine
+  ///
+  /// Run after setup.
+  virtual boost::asio::awaitable<void> CoTestBody() = 0;
+
+  /// \brief Run our coroutines
+  ///
+  /// This is marked final, since the actual test body belongs in
+  /// CoTestBody.
+  ///
+  /// Run CoSetUp and, if CoSetUp succeeded, CoTestBody and
+  /// CoTearDown.
+  ///
+  /// Error reporting of failures in CoSetUp and CoTearDown leaves
+  /// something to be desired as GTest thinks everything is the test
+  /// proper.
+  void TestBody() override final {
+    boost::asio::co_spawn(
+      asio_context,
+      [](CoroTest* t) -> boost::asio::awaitable<void> {
+       co_await t->CoSetUp();
+       try {
+         co_await t->CoTestBody();
+       } catch (...) {
+         t->eptr = std::current_exception();
+       }
+       co_await t->CoTearDown();
+       if (t->eptr) {
+         std::rethrow_exception(t->eptr);
+       }
+       co_return;
+      }(this),
+      [](std::exception_ptr e) {
+       if (e) std::rethrow_exception(e);
+      });
+    asio_context.run();
+  }
+};
+
+/// \brief C++20 coroutine test harness for NeoRados
+///
+/// CoTestBody has access to `rados`, a `neorados::RADOS` handle, and
+/// `pool`, a `neorados::IOContext` representing a pool that will be
+/// destroyed when the test exits.
+class NeoRadosTest : public CoroTest {
+private:
+  const std::string prefix_{std::string{"test framework "} +
+                           testing::UnitTest::GetInstance()->
+                           current_test_info()->name() +
+                           std::string{": "}};
+
+  std::optional<neorados::RADOS> rados_;
+  const std::string pool_name_ = get_temp_pool_name(
+    testing::UnitTest::GetInstance()->current_test_info()->name());
+  neorados::IOContext pool_;
+  std::unique_ptr<DoutPrefix> dpp_;
+
+protected:
+
+  /// \brief Return reference to RADOS
+  ///
+  /// \warning This function should only be called from test bodies
+  /// (i.e. after `CoSetUp()`)
+  neorados::RADOS& rados() noexcept { return *rados_; }
+
+  /// \brief Return name of created pool
+  ///
+  /// \warning This function should only be called from test bodies
+  /// (i.e. after `CoSetUp()`)
+  const std::string& pool_name() const noexcept { return pool_name_; }
+
+  /// \brief Return reference to pool
+  ///
+  /// \warning This function should only be called from test bodies
+  /// (i.e. after `CoSetUp()`)
+  const neorados::IOContext& pool() const noexcept { return pool_; }
+
+  /// \brief Return prefix for this test run
+  std::string_view prefix() const noexcept { return prefix_; }
+
+  /// \brief Return DoutPrefixProvider*
+  ///
+  /// \warning This function should only be called from test bodies
+  /// (i.e. after `CoSetUp()`)
+  const DoutPrefixProvider* dpp() const noexcept { return dpp_.get(); }
+
+  auto execute(std::string_view oid, neorados::WriteOp&& op,
+              std::uint64_t* ver = nullptr) {
+    return rados().execute(oid, pool(), std::move(op),
+                          boost::asio::use_awaitable, ver);
+  }
+
+  auto execute(std::string_view oid, neorados::ReadOp&& op,
+              std::uint64_t* ver = nullptr) {
+    return rados().execute(oid, pool(), std::move(op), nullptr,
+                          boost::asio::use_awaitable, ver);
+  }
+
+  boost::asio::awaitable<ceph::buffer::list>
+  read(std::string_view oid, std::uint64_t off = 0, std::uint64_t len = 0) {
+    ceph::buffer::list bl;
+    neorados::ReadOp op;
+    op.read(off, len, &bl);
+    co_await rados().execute(oid, pool(), std::move(op),
+                            nullptr, boost::asio::use_awaitable);
+    co_return bl;
+  }
+
+public:
+
+  /// \brief Create RADOS handle and pool for the test
+  boost::asio::awaitable<void> CoSetUp() override {
+    rados_ = co_await neorados::RADOS::Builder{}
+      .build(asio_context, boost::asio::use_awaitable);
+    dpp_ = std::make_unique<DoutPrefix>(rados().cct(), 0, prefix().data());
+    pool_.set_pool(co_await create_pool(rados(), pool_name(),
+                                   boost::asio::use_awaitable));
+    co_return;
+  }
+
+  ~NeoRadosTest() override = default;
+
+  /// \brief Delete pool used for testing
+  boost::asio::awaitable<void> CoTearDown() override {
+    co_await rados().delete_pool(pool().get_pool(),
+                                boost::asio::use_awaitable);
+    co_return;
+  }
+};
+
+/// \brief Helper macro for defining coroutine tests with a fixture
+///
+/// Defines a test using a coroutine fixture for
+/// SetUp/TearDown. Fixtures must be descendants of `CoroTest`.
+///
+/// \note Uses more of GTest's internals that I would like.
+///
+/// \warning Use `EXPECT_*` only, not `ASSERT_*`. `ASSERT_` macros
+/// return from the calling function and will not work in a
+/// coroutine.
+///
+/// \param test_suite_name Name of the test suite
+/// \param test_name Name of the test
+/// \param fixture Fixture class to use (descendent of CoroTest)
+#define CORO_TEST_F(test_suite_name, test_name, fixture)                       \
+  static_assert(sizeof(GTEST_STRINGIFY_(test_suite_name)) > 1,                 \
+               "test_suite_name must not be empty");                          \
+  static_assert(sizeof(GTEST_STRINGIFY_(test_name)) > 1,                       \
+               "test_name must not be empty");                                \
+  class GTEST_TEST_CLASS_NAME_(test_suite_name, test_name) : public fixture {  \
+  public:                                                                      \
+    GTEST_TEST_CLASS_NAME_(test_suite_name, test_name)() = default;            \
+    ~GTEST_TEST_CLASS_NAME_(test_suite_name, test_name)() override = default;  \
+    GTEST_DISALLOW_COPY_AND_ASSIGN_(GTEST_TEST_CLASS_NAME_(test_suite_name,    \
+                                                          test_name));        \
+    GTEST_DISALLOW_MOVE_AND_ASSIGN_(GTEST_TEST_CLASS_NAME_(test_suite_name,    \
+                                                          test_name));        \
+                                                                              \
+  private:                                                                     \
+    boost::asio::awaitable<void> CoTestBody() override;                        \
+    static ::testing::TestInfo *const test_info_ GTEST_ATTRIBUTE_UNUSED_;      \
+  };                                                                           \
+                                                                              \
+  ::testing::TestInfo *const GTEST_TEST_CLASS_NAME_(test_suite_name,           \
+                                                   test_name)::test_info_ =   \
+      ::testing::internal::MakeAndRegisterTestInfo(                            \
+         #test_suite_name, #test_name, nullptr, nullptr,                      \
+         ::testing::internal::CodeLocation(__FILE__, __LINE__),               \
+         (::testing::internal::GetTypeId<fixture>()),                         \
+         ::testing::internal::SuiteApiResolver<fixture>::GetSetUpCaseOrSuite( \
+             __FILE__, __LINE__),                                             \
+         ::testing::internal::SuiteApiResolver<                               \
+             fixture>::GetTearDownCaseOrSuite(__FILE__, __LINE__),            \
+         new ::testing::internal::TestFactoryImpl<GTEST_TEST_CLASS_NAME_(     \
+             test_suite_name, test_name)>);                                   \
+  boost::asio::awaitable<void> GTEST_TEST_CLASS_NAME_(test_suite_name,         \
+                                                     test_name)::CoTestBody()
+
+/// \brief Helper macro for defining coroutine tests
+///
+/// Tests created this way are direct descendants of `CoroTest`.
+///
+/// The Boost.Asio IO Context is `io_context`.
+///
+/// \warning Use `EXPECT_*` only, not `ASSERT_*`. `ASSERT_` macros
+/// return from the calling function and will not work in a
+/// coroutine.
+///
+/// \param test_suite_name Name of the test suite
+/// \param test_name Name of the test
+#define CORO_TEST(test_suite_name, test_name)                                  \
+  CORO_TEST_F(test_suite_name, test_name, CoroTest)