boostorg / cobalt

Coroutines for C++20 & asio
https://www.boost.org/doc/libs/master/libs/cobalt/doc/html/index.html
228 stars 25 forks source link

How to get more than one value out of a generator coroutine in a unit test? #202

Closed LegalizeAdulthood closed 1 month ago

LegalizeAdulthood commented 1 month ago

In my sample code to go with the video Using Coroutines With Boost.Cobalt, I was attempting to write unit tests around my generator coroutine. Because the generators in Cobalt are eager, I was always able to get the first value out of the generator, but I wasn't able to get a second value out.

I suspect this is user error on my part, not understanding exactly how Cobalt interacts with the executor that I created via boost.asio, immitating the steps in Cobalt's run_main. I tried using poll(), run(), and run_one() on the executor, but I could never seem to get another invocation of my generator coroutine to happen.

What am I doing wrong?

Perhaps some examples demonstrating unit-testing of coroutines could be added to show how to unit-test coroutines under controlled circumstances.

Thanks.

klemens-morgenstern commented 1 month ago

Can you link the actual code? Your sample link points to a repo.

LegalizeAdulthood commented 1 month ago

Look at test-cobalt.cpp in the tests folder of that repo

LegalizeAdulthood commented 1 month ago

https://github.com/LegalizeAdulthood/cobalt-comics/blob/master/tests/test-cobalt.cpp

klemens-morgenstern commented 1 month ago

Ah got it. Well everything in cobalt is mean to be asynchronous, i.e. the generator is mean to be used with co_await. You're calling it from a regular function.

You should create a cobalt::task in your test and co_await the generator from there. You can run that task blocking with cobalt::run from the test function. E.g.:

cobalt::task<void> TestComicsCobalt_notReadyFromNoMatchingSquences_impl()
{
   MockDatabasePtr db{createMockDatabase()};
    ParsedJson sequences("[]");
    EXPECT_CALL(*db, getSequences()).WillOnce(Return(sequences.m_document));
    boost::cobalt::generator coro{matches(db, comics::CreditField::SCRIPT, SCRIPT_NAME)};

    const SearchResult value1 = co_await coro;
    const auto value2 = co_await coro;
}

TEST_F(TestComicsCobalt, notReadyFromNoMatchingSquences)
{
     // go into async land here.
     cobalt::run(TestComicsCobalt_notReadyFromNoMatchingSquences_impl());
}
LegalizeAdulthood commented 1 month ago

I don't think it will work verbatim as you have it, but I'm trying something out.

LegalizeAdulthood commented 1 month ago

Gtest is macro crazy/happy. You can't have gtest macro invocations outside of a test case; yes, it's a dumb design decision, but that's how it is. So I have to write a task that does the co_await to get the two values and return them to the caller so the assertions can be done in the test case. I tried this:

boost::cobalt::task<std::vector<SearchResult>> twoValues()
{
    MockDatabasePtr db{createMockDatabase()};
    ParsedJson issues{ISSUES};
    ParsedJson sequences{SEQUENCES};
    EXPECT_CALL(*db, getSequences()).WillOnce(Return(sequences.m_document));
    EXPECT_CALL(*db, getIssues()).WillOnce(Return(issues.m_document));
    boost::cobalt::generator coro{matches(db, comics::CreditField::SCRIPT, SCRIPT_NAME)};

    const SearchResult match{co_await coro};
    const SearchResult secondMatch{co_await coro};
    co_return std::vector<SearchResult>{match,secondMatch};
}

TEST_F(TestComicsCobalt, readyFromFirstMatchOfMultiple)
{
    std::vector<SearchResult> result{boost::cobalt::run(twoValues)};
}

but I get a compile error trying to compile the invocation of run:

1>D:\legalize\utahcpp\cobalt\cobalt-comics\tests\test-cobalt.cpp(238,53): error C2664: 'T boost::cobalt::run<std::vector<comics::cobalt::SearchResult,std::allocator<comics::cobalt::SearchResult>>>(boost::cobalt::task<std::vector<comics::cobalt::SearchResult,std::allocator<comics::cobalt::SearchResult>>>)': cannot convert argument 1 from 'boost::cobalt::task<std::vector<comics::cobalt::SearchResult,std::allocator<comics::cobalt::SearchResult>>> (__cdecl *)(void)' to 'boost::cobalt::task<std::vector<comics::cobalt::SearchResult,std::allocator<comics::cobalt::SearchResult>>>'
1>D:\legalize\utahcpp\cobalt\cobalt-comics\tests\test-cobalt.cpp(238,53): error C2664:         with
1>D:\legalize\utahcpp\cobalt\cobalt-comics\tests\test-cobalt.cpp(238,53): error C2664:         [
1>D:\legalize\utahcpp\cobalt\cobalt-comics\tests\test-cobalt.cpp(238,53): error C2664:             T=std::vector<comics::cobalt::SearchResult,std::allocator<comics::cobalt::SearchResult>>
1>D:\legalize\utahcpp\cobalt\cobalt-comics\tests\test-cobalt.cpp(238,53): error C2664:         ]
1>    D:\legalize\utahcpp\cobalt\cobalt-comics\tests\test-cobalt.cpp(238,57):

I don't understand this error because the declaration of run is:

template<typename T>
T run(task<T> t)

...which implies if the return type of run should be the same as the template argument to task.

LegalizeAdulthood commented 1 month ago

Also, in case you didn't notice, the executor via boost.asio is being created in the fixture for the test case, lines 179-184

https://github.com/LegalizeAdulthood/cobalt-comics/blob/master/tests/test-cobalt.cpp#L179

klemens-morgenstern commented 1 month ago
boost::cobalt::run(twoValues())

instead of

boost::cobalt::run(twoValues)
LegalizeAdulthood commented 1 month ago

oh right, you don't get the return type instantiated for a coroutine until you 'contruct' it by calling the function. Honestly the coroutine stuff in C++20 feels a little hacky :)

klemens-morgenstern commented 1 month ago

It's a bit, but not as much as one first thinks. Coroutines are just inherently weird.

I created the same thing for boost.test btw.: https://github.com/boostorg/cobalt/blob/develop/test/test.hpp

LegalizeAdulthood commented 1 month ago

OK, with a little shuffling around to ensure proper lifetimes of test data, I end up with this:

boost::cobalt::task<std::vector<SearchResult>> twoValues(MockDatabasePtr db)
{
    boost::cobalt::generator coro{matches(db, comics::CreditField::SCRIPT, SCRIPT_NAME)};

    const SearchResult match{co_await coro};
    const SearchResult secondMatch{co_await coro};
    co_return std::vector<SearchResult>{match,secondMatch};
}

TEST_F(TestComicsCobalt, readyFromFirstMatchOfMultiple)
{
    MockDatabasePtr db{createMockDatabase()};
    ParsedJson issues{ISSUES};
    ParsedJson sequences{SEQUENCES};
    EXPECT_CALL(*db, getSequences()).WillOnce(Return(sequences.m_document));
    EXPECT_CALL(*db, getIssues()).WillOnce(Return(issues.m_document));

    const std::vector matches{run(twoValues(db))};

    const SearchResult &match{matches[0]};
    ASSERT_TRUE(match.has_value());
    EXPECT_EQ("1", match.value().issue.at_key("issue number").get_string().value());
    EXPECT_NE(std::string::npos, match.value().sequence.at_key("script").get_string().value().find(SCRIPT_NAME));
    EXPECT_EQ("cover", match.value().sequence.at_key("type").get_string().value());
    const SearchResult &secondMatch{matches[1]};
    EXPECT_EQ("1", secondMatch.value().issue.at_key("issue number").get_string().value());
    EXPECT_NE(std::string::npos, secondMatch.value().sequence.at_key("script").get_string().value().find(SCRIPT_NAME));
    EXPECT_EQ("comic story", secondMatch.value().sequence.at_key("type").get_string().value());
}

Thanks for your assistance!