Unit tests

The tests live in test and share their fixtures through test/test_fixtures.hpp. Two ideas carry most of the suite: every client/server test runs once per protocol, and an asynchronous test is written as two coroutines, one for the server and one for the client, which the fixture then runs to completion. How to build and run them is in CLAUDE.md.

One test, three protocols

The fixtures are parametrized on Protocol. The server accepts all protocols at runtime (HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP on the same port), so the parameter only selects what the client speaks:

Server            testing::TestWithParam<Protocol>; server on 127.0.0.2, ephemeral port
└── Client        adds a client::Client, configured with .protocol = GetParam()
    └── ClientAsync   runs the test as coroutines, see below

A test suite instantiates the fixture for the protocols it covers:

INSTANTIATE_TEST_SUITE_P(ClientAsync, ClientAsync,
                         Values(anyhttp::Protocol::h1,
                                anyhttp::Protocol::h2,
                                anyhttp::Protocol::h3),
                         NameGenerator);

NameGenerator names the instances /HTTP11, /HTTP2 and /HTTP3, so a single protocol can be selected with --gtest_filter='*/HTTP3'. A test body can branch on GetParam() where the protocols genuinely differ, typically to skip:

if (GetParam() == anyhttp::Protocol::h1)
   GTEST_SKIP(); // a chunked body cannot be cancelled correctly --> disconnects

Protocol-specific suites (for example the HTTP/2 AltSvcFrame tests) instantiate with a single value instead.

A derived fixture adjusts the configuration by overriding configure_server() or configure_client(), which are called before the server and client are created:

class HeaderLimits : public ClientAsync
{
protected:
   void configure_server(server::Config& config) override { config.max_header_size = 4_k; }
   void configure_client(client::Config& config) override { config.max_header_size = 4_k; }
};

The fixtures themselves differ by I/O runtime, in test/test_fixtures_asio.hpp and test/test_fixtures_corosio.hpp.

Asynchronous tests: requestHandler and clientSession

A ClientAsync test body does not do any I/O itself. It only assigns up to two coroutines:

  • requestHandler is the server side. The fixture’s server routes a few fixed paths to built-in handlers (/echo, /eat_request, /discard, /detach, /dump, …) and everything under /custom to requestHandler. The client’s default URL is http://127.0.0.2/custom, so a test that sets requestHandler and submits to url reaches it without further ado.

  • clientSession is the client side. It receives a connected Session and runs the test scenario against it.

TEST_P(ClientAsync, WHEN_server_discards_request_and_response_THEN_completes_anyway)
{
   requestHandler = [this](server::Request request, server::Response response) -> Task<void> {
      co_return; // drops both
   };
   clientSession = [this](Session session) -> Task<void> {
      auto [ec, request] = co_await session.submit(url);
      auto [response_ec, response] = co_await request.get_response();
      EXPECT_EQ(response_ec, boost::beast::http::error::end_of_stream);
   };
}

Tests that only need the built-in handlers leave requestHandler unset and change the path, as in session.submit(url.set_path("echo")).

How it runs

  1. SetUp() creates server and client and starts the test coroutine on the client’s executor. Nothing runs yet, as the context has not been started.

  2. The test body assigns requestHandler and clientSession.

  3. TearDown() runs the context. The test coroutine connects, calls clientSession, and the server calls requestHandler for each request under /custom.

  4. When clientSession finishes, the completion token calls on_complete(ec), destroys the server and releases the work guard, so run() returns once everything has shut down.

on_complete is a gmock method, and TearDown() expects it to be called exactly once with success. A clientSession that throws, for example because an operation with a completion token failed, therefore fails the test, as does one that never finishes (it hangs, in practice). To test for an error, use the coroutine spelling and check it with EXPECT_* inside the coroutine. By default the fixture is single-threaded, so gtest assertions work there as usual.

The helpers in include/anyhttp/request_handlers.hpp (generate, send, drain, count_response, sleep, …) keep both coroutines short, and composing them with when_both() runs the upload and the download concurrently:

auto count = co_await when_both(generate(request, bytes), count_response(request));

The guide

test/test_docs.cpp runs the code of the Programmer’s guide over all three protocols, with these fixtures where it can. It is why a snippet that changes what it does fails a test, rather than just a reader.