trompeloeil icon indicating copy to clipboard operation
trompeloeil copied to clipboard

Mocking non-awaitable coroutines

Open chghehe opened this issue 1 year ago • 9 comments

Using Conan package trompeloeil/48 and gcc-14.

The code below

#include <catch2/catch_all.hpp>
#include <catch2/trompeloeil.hpp>

#include <generator>

class Mock
{
public:
  MAKE_MOCK0(fibonacci,std::generator<int>());
};

TEST_CASE("fibonacci 7")
{
  Mock m;
  ALLOW_CALL(m, fibonacci())
    .CO_YIELD(0)
    .CO_YIELD(1)
    .CO_YIELD(1)
    .CO_YIELD(2)
    .CO_YIELD(3)
    .CO_YIELD(5)
    .CO_YIELD(8)
    .CO_RETURN()
    ;

  REQUIRE_THAT(m.fibonacci(), Catch::Matchers::RangeEquals(std::array{0, 1, 1, 2, 3, 5, 8}));
}

produces compile time error:

trompeloeil/coro.hpp:54:56: error: ‘class std::generator’ has no member named ‘await_resume’

Seems that non-awaitable coroutines are not supported (yet).

More than that, I cannot directly mock these functions (like Mock::fibonacci()) with RETURN() statement due to static assertions.

Since at this moment std::generator<> is implemented in gcc-14 only, the following implementations might be used for reproduction purposes:

  • <experimental/generator> with MSVC 192
  • reference implementation from https://github.com/lewissbaker/generator
  • any other implementation of synchronous co_yield generator

chghehe avatar Aug 14 '24 07:08 chghehe

Thank you. I will have a look and see what I can do.

rollbear avatar Aug 14 '24 18:08 rollbear

Hi rollbear I hope you're doing well. I wanted to check in and see if you had a chance to look into

isliser avatar Feb 03 '25 05:02 isliser

Thank you. Yes, I have, several times, but I'm afraid I have not been very successful. There's obviously something that I don't understand. It wouldn't surprise me if it's easy to do once I get it, but I'm not there yet, unfortunately.

rollbear avatar Feb 03 '25 06:02 rollbear

Hi, @rollbear!

I tried to add

else if constexpr (requires {typename std::ranges::range_value_t<T>;})
{
  return type_wrapper<std::ranges::range_value_t<T>>{};
}

to trompeloeil::coro_value_type::func() and it works for generator-like coroutines since they are ranges as well. At least in g++-14.

So, I am sure, it is enough to distinguish range-coroutines and awaitable coroutines:

  template <typename T>
  struct coro_value_type;

  template <typename T>
  requires(requires (T coro) { coro.operator co_await().await_resume(); })
  struct coro_value_type<T> 
  {
    using type = typename type_wrapper<decltype(std::declval<T>().operator co_await().await_resume())>::type;
  };

  template <typename T>
  requires(requires (T coro) { coro.await_resume(); })
  struct coro_value_type<T> 
  {
    using type = typename type_wrapper<decltype(std::declval<T>().await_resume())>::type;
  };

  template <std::ranges::input_range T>
  struct coro_value_type<T>
  {
    using type = typename type_wrapper<std::ranges::range_value_t<T>>::type;
  };

I need to dive in a little bit to understand, why do we need this coro_value_type. From my perspective, it is enough for mocked function to return a coroutine (with well known type from function prototype) based on a generated lambda with corresponding sequence of co_yield and co_return calls. Something like

template <typename T>
struct return_value
{
  using type = T;

  template <typename... Args>
  return_value(Args &&...args) : value_(std::forward<Args>(args)...) {}

  constexpr
  T value() const noexcept
  {
    return value_;    
  }

private:
  T value_;
};

template <typename T>
return_value(T&&) -> return_value<std::decay_t<T>>;

template <>
struct return_value<void>
{
  using type = void;

  constexpr
  void value() const noexcept
  {
  }
};

template <typename T>
struct yield_value
{
  using type = T;

  template <typename... Args>
  yield_value(Args &&...args) : value_(std::forward<Args>(args)...) {}

  constexpr
  T value() const noexcept
  {
    return value_;    
  }

private:
  T value_;
};

template <typename T>
yield_value(T&&) -> yield_value<std::decay_t<T>>;

template <>
struct yield_value<void>
{
  using type = void;

  constexpr
  void value() const noexcept
  {
  }
};

constexpr return_value<void> return_void;
constexpr yield_value<void> yield_void;

template <typename Coro, typename R, typename... Y>
auto make_coro(return_value<R> ret, yield_value<Y>... yields) -> Coro
{
  (co_yield yields.value(), ...);
  co_return ret.value();
}

...

make_coro<std::generator<int>>(return_void, yield_value(5), yield_value(10), yield_value(200));
make_coro<coro::task<int>>(return_value(5));
make_coro<some_coro_type>(return_void, yield_value(5), yield_void, yield_value("hello"));

Also, as far as I know, co_yield might be used with different value types (including void).

chghehe avatar Mar 24 '25 19:03 chghehe

I've noticed in tests, that provided coro::generator<> is awaitable and used via co_await, but generators are not awaitables and shall be used as range.

Also, TIMES() directive doesn't work properly with coroutines. The code below will succeed with less than 5 calls to gen().

    co_mock m;
    REQUIRE_CALL(m, gen())
      .CO_YIELD(5)
      .CO_YIELD(8)
      .CO_YIELD(3)
      .CO_RETURN()
      .TIMES(5);

And as far as I understand CO_RETURN() is required but it is not mandatory in a coroutine which has at least one co_yield. So,

    co_mock m;
    REQUIRE_CALL(m, gen())
      .CO_YIELD(5)
      .CO_YIELD(8)
      .CO_YIELD(3);

should be enough.

chghehe avatar Mar 25 '25 07:03 chghehe

After some research I've come to the conclusion that it is impossible to mock general c++20 coroutines, mainly because expressions co_yield, co_await and co_return must be inlined in the function (coroutine) body. No way to dispatch these expressions using well known techniques. So, my approach is to generate some kind of coroutines for simple cases and to make user possible to directly create coroutines. The last one (direct creation of coroutines) is quite important, due to it is impossible now and the user shall override virtual methods manually (to hide coroutines from prototype) and mock helper methods instead of mocking original interface.

co_mock m;
REQUIRE_CALL(m, gen()).CO_YIELD(1, "hello", yield_void);
REQUIRE_CALL(m, gen()).CO_YIELD();
REQUIRE_CALL(m, gen()).CO_RETURN(90);
REQUIRE_CALL(m, gen()).CO_THROW(std::runtime_error("error"));
REQUIRE_CALL(m, foo()).WILL({ co_yield 1; co_await _2.send(); co_yield 2; co_yield 3; throw; }); //< or CO_WILL?

Here CO_RETURN(), CO_YIELD() and CO_THROW() are mutually exclusive. WILL prepares lambda header with argument placeholders and return type. Alto, it might be used with usual functions as well:

REQUIRE_CALL(m, foo()).WILL({ /* do something*/; return 100; });

WILL shall be mutual exclusive with other side-effects (or overwrite them, but better to statically assert if possible).

What do you think?

chghehe avatar Mar 26 '25 08:03 chghehe

I'm calling this fixed, with your PR. Maybe open new issues for remaining problems?

rollbear avatar Mar 30 '25 13:03 rollbear

Sure, it was thoughts aloud :)

chghehe avatar Mar 30 '25 13:03 chghehe

I'm leaving this open until a release has been tagged. It reduces the risk that someone else reports this issue until then.

rollbear avatar Mar 31 '25 07:03 rollbear