diff --git a/doc/modules/ROOT/nav.adoc b/doc/modules/ROOT/nav.adoc index 542e39e65..02bcc816c 100644 --- a/doc/modules/ROOT/nav.adoc +++ b/doc/modules/ROOT/nav.adoc @@ -40,6 +40,8 @@ ** xref:4.guide/4q.udp.adoc[UDP Sockets] * xref:5.testing/5.intro.adoc[Testing] ** xref:5.testing/5a.mocket.adoc[Mock Sockets] +** xref:5.testing/5b.socket-pair.adoc[Socket Pairs] +** xref:5.testing/5c.patterns.adoc[Testing Patterns] * xref:benchmark-report.adoc[Benchmarks] * xref:glossary.adoc[Glossary] * xref:quick-start.adoc[Quick Start] diff --git a/doc/modules/ROOT/pages/5.testing/5.intro.adoc b/doc/modules/ROOT/pages/5.testing/5.intro.adoc index 1f8e2b0a5..26d737fb7 100644 --- a/doc/modules/ROOT/pages/5.testing/5.intro.adoc +++ b/doc/modules/ROOT/pages/5.testing/5.intro.adoc @@ -1,5 +1,6 @@ // // Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) +// Copyright (c) 2026 Steve Gerbino // // Distributed under the Boost Software License, Version 1.0. (See accompanying // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) @@ -9,4 +10,38 @@ = Testing -Asynchronous I/O code is notoriously difficult to test. Real network operations introduce latency, non-determinism, and dependencies on external services—all of which make tests slow and fragile. Corosio provides test utilities that replace live networking with controllable, deterministic substitutes. You can stage data for reads, verify what your code writes, and inject errors at precise points—all without opening a single network connection. This section covers the tools and patterns that make thorough testing of I/O code practical and repeatable. +Asynchronous I/O code is notoriously difficult to test. Real network +operations introduce latency, non-determinism, and dependencies on +external services — all of which make tests slow and fragile. Corosio +provides test utilities that replace live networking with controllable, +deterministic substitutes. You can stage data for reads, verify what +your code writes, and force short reads or writes — all without opening +a single network connection. This section covers the tools and patterns +that make thorough testing of I/O code practical and repeatable. + +== What's in this section + +* xref:5a.mocket.adoc[Mock Sockets] — `mocket`, `make_mocket_pair`, and + the staging API for byte-level deterministic tests. +* xref:5b.socket-pair.adoc[Socket Pairs] — `make_socket_pair` for tests + that need real socket semantics (TLS, `set_option`, `shutdown`, true + EOF). +* xref:5c.patterns.adoc[Testing Patterns] — recipes that combine the two + facilities. + +== Choosing the right tool + +[cols="1,1"] +|=== +| Goal | Use + +| Byte-level expectations, staged responses, no real network +| `mocket` + +| Real socket semantics — TLS handshake, `SO_*` options, shutdown +ordering, EOF +| `socket_pair` + +| Combine the two (e.g., framing on top of TLS over a real socket) +| See xref:5c.patterns.adoc[Patterns] +|=== diff --git a/doc/modules/ROOT/pages/5.testing/5a.mocket.adoc b/doc/modules/ROOT/pages/5.testing/5a.mocket.adoc index 850633e84..c0e943b6d 100644 --- a/doc/modules/ROOT/pages/5.testing/5a.mocket.adoc +++ b/doc/modules/ROOT/pages/5.testing/5a.mocket.adoc @@ -1,5 +1,6 @@ // // Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) +// Copyright (c) 2026 Steve Gerbino // // Distributed under the Boost Software License, Version 1.0. (See accompanying // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) @@ -9,9 +10,10 @@ = Mock Sockets -The `mocket` class provides mock sockets for testing I/O code without -actual network operations. Mockets let you stage data for reading and -verify expected writes. +The `mocket` class is a testable wrapper around a `tcp_socket` that lets +you stage bytes for reads and assert on bytes written, without giving up +real socket semantics when you don't need them. Mockets are the main tool +for byte-level deterministic tests of corosio I/O code. [NOTE] ==== @@ -19,7 +21,6 @@ Code snippets assume: [source,cpp] ---- #include -#include namespace corosio = boost::corosio; namespace capy = boost::capy; @@ -28,235 +29,178 @@ namespace capy = boost::capy; == Overview -Mockets are testable socket-like objects: +`basic_mocket` holds a `Socket` member and forwards `read_some` / +`write_some` to it, with two pre-stages: -[source,cpp] ----- -// Create connected pair -capy::test::fuse f; -auto [client, server] = corosio::test::make_mockets(ioc, f); +* If the *provide* buffer has bytes, reads consume them first. +* If the *expect* buffer has bytes, writes are validated against them + before passing through. -// Stage data on server for client to read -server.provide("Hello from server"); - -// Stage expected data that client should write -client.expect("Hello from client"); - -// Now run your code that uses client/server as io_stream& ----- +Once both buffers are empty, I/O passes through to the underlying socket +unchanged. The default alias is `using mocket = basic_mocket<>;`, which +uses `tcp_socket`. == Creating Mockets -Mockets are created in connected pairs: +Mockets are created paired with a peer socket, connected via loopback: [source,cpp] ---- corosio::io_context ioc; -capy::test::fuse f; -auto [m1, m2] = corosio::test::make_mockets(ioc, f); +auto [m, peer] = corosio::test::make_mocket_pair(ioc); ---- -The pair is connected via loopback TCP sockets. Data written to one can -be read from the other, plus you can use the staging/expectation API. +`make_mocket_pair` returns `std::pair, Socket>`. The +first element is the mocket (test-instrumented). The second is the peer +(a plain `Socket`). Both are open and immediately usable. == Staging Data for Reads -Use `provide()` to stage data that the _peer_ will read: +Use `provide()` to stage bytes that the mocket itself will hand back from +`read_some`: [source,cpp] ---- -// On server: stage data for client to read -server.provide("HTTP/1.1 200 OK\r\n\r\nHello"); +m.provide("HTTP/1.1 200 OK\r\n\r\nHello"); -// Now when client reads, it gets this data -auto [ec, n] = co_await client.read_some(buffer); -// buffer contains "HTTP/1.1 200 OK\r\n\r\nHello" +auto task = [](corosio::test::mocket& m_ref) -> capy::task<> { + char buf[64] = {}; + auto [ec, n] = co_await m_ref.read_some(capy::make_buffer(buf)); + // buf[0..n] == "HTTP/1.1 200 OK\r\n\r\nHello" +}; ---- -Multiple `provide()` calls append data: - -[source,cpp] ----- -server.provide("Part 1"); -server.provide("Part 2"); -// Client sees "Part 1Part 2" ----- +Multiple `provide()` calls append. Reads consume from the front of the +buffer; once empty, subsequent reads pass through to the underlying +socket. == Setting Write Expectations -Use `expect()` to verify what the caller writes: +Use `expect()` to declare bytes that the code under test must write next: [source,cpp] ---- -// Client should send this exact data -client.expect("GET / HTTP/1.1\r\n\r\n"); - -// Now client writes -co_await corosio::write(client, request_buffer); +m.expect("GET / HTTP/1.1\r\n\r\n"); -// If written data doesn't match, fuse fails +auto task = [](corosio::test::mocket& m_ref) -> capy::task<> { + auto [ec, n] = co_await m_ref.write_some( + capy::const_buffer("GET / HTTP/1.1\r\n\r\n", 18)); + // ec is empty; n == 18 +}; ---- -=== How Matching Works - -When you write to a mocket with expectations: - -1. Written data is compared against the expect buffer -2. If it matches, the expect buffer is consumed -3. If it doesn't match, `fuse.fail()` is called - -After the expect buffer is exhausted, writes pass through to the real socket. +`write_some` matches the leading bytes of the buffer sequence against the +expect buffer. If they match, the matched prefix is consumed; if they +don't, `write_some` returns `capy::error::test_failure` and writes zero +bytes. Once the expect buffer is empty, subsequent writes pass through. -== Closing and Verification +== Chunked I/O -Use `close()` to verify all expectations were met: +`make_mocket_pair` accepts `max_read_size` and `max_write_size` to cap +the bytes a single `read_some` / `write_some` will deliver. This is the +right tool for forcing your code's read/write loops to handle short +transfers: [source,cpp] ---- -auto ec = client.close(); -if (ec) - std::cerr << "Test failed: " << ec.message() << "\n"; ----- - -The `close()` method: - -1. Closes the underlying socket -2. Checks that `provide()` buffer is empty (all data read) -3. Checks that `expect()` buffer is empty (all expected data written) -4. Returns error and calls `fuse.fail()` if verification fails +// max_read_size = 4, max_write_size = 3 force short transfers. +auto [m, peer] = corosio::test::make_mocket_pair(ioc, {}, 4, 3); -== The Fuse +m.provide("0123456789"); +m.expect("abcdef"); -Mockets work with `capy::test::fuse` for error injection: +auto task = [](corosio::test::mocket& m_ref) -> capy::task<> { + char buf[16] = {}; + auto [rec, rn] = co_await m_ref.read_some(capy::make_buffer(buf)); + // rn == 4 ("0123") -[source,cpp] + auto [wec, wn] = co_await m_ref.write_some( + capy::const_buffer("abcdef", 6)); + // wn == 3 (matched "abc") +}; ---- -capy::test::fuse f; -auto [m1, m2] = corosio::test::make_mockets(ioc, f); -// The first mocket (m1) calls f.maybe_fail() on operations -// This enables systematic error injection testing ----- +A value of `0` for either size parameter throws `std::logic_error` from +the constructor. The default is `std::size_t(-1)` (unlimited). -The second mocket (m2) doesn't call `maybe_fail()`, allowing asymmetric -testing. +== Closing and Verification -== Complete Example +`close()` shuts the underlying socket and verifies that both staging +buffers are empty: [source,cpp] ---- -#include -#include - -capy::task test_http_client() +auto ec = m.close(); +if (ec == capy::error::test_failure) { - corosio::io_context ioc; - capy::test::fuse f; - - auto [client, server] = corosio::test::make_mockets(ioc, f); - - // Client should send this request - client.expect( - "GET / HTTP/1.1\r\n" - "Host: example.com\r\n" - "\r\n"); - - // Server will respond with this - server.provide( - "HTTP/1.1 200 OK\r\n" - "Content-Length: 5\r\n" - "\r\n" - "Hello"); - - // Run the code under test - co_await my_http_get(client, "example.com", "/"); - - // Verify expectations - auto ec1 = client.close(); - auto ec2 = server.close(); - - if (ec1 || ec2) - throw std::runtime_error("Test failed"); + // Either provide() data was never read, + // or expect() data was never written. } ---- -== Testing with io_stream Reference +Always call `close()` at the end of a test that uses `provide` / `expect` +and assert that the result is empty. This is what catches "the test +passed because the code under test did nothing." -Since mocket inherits from `io_stream`, you can pass it to code expecting -streams: +== Templated over Socket + +`basic_mocket` is a template; the default alias only specializes it for +`tcp_socket`. For backend-specific tests (`native_tcp_socket`, +etc.), name the specialization explicitly: [source,cpp] ---- -// Your production code -capy::task send_message(corosio::io_stream& stream, std::string msg) -{ - co_await corosio::write( - stream, capy::const_buffer(msg.data(), msg.size())); -} - -// Test code -capy::task test_send_message() -{ - auto [client, server] = make_mockets(ioc, f); +using socket_type = corosio::native_tcp_socket; +using acceptor_type = corosio::native_tcp_acceptor; +using mocket_type = corosio::test::basic_mocket; - client.expect("Hello, World!"); +corosio::io_context ioc(backend); - co_await send_message(client, "Hello, World!"); - - auto ec = client.close(); - assert(!ec); -} +auto [m, peer] = + corosio::test::make_mocket_pair(ioc); ---- -== Thread Safety - -Mockets are NOT thread-safe: - -* Use from a single thread only -* All coroutines must be suspended when calling `expect()` or `provide()` -* Designed for single-threaded, deterministic testing +== Underlying Socket Access -== Limitations - -* Data staging is one-way (provide on one side, read on the other) -* No simulation of partial writes or network delays -* Connection errors must be injected via fuse - -== Use Cases - -=== Unit Testing Protocol Code +`socket()` returns a reference to the wrapped `Socket`. This is how you +stack other streams (TLS, framing) on top of a mocket: [source,cpp] ---- -// Test that your protocol parser handles responses correctly -server.provide("200 OK\r\nContent-Type: text/html\r\n\r\n..."); -co_await my_protocol_read(client); -// Verify parsed result +auto [m, peer] = corosio::test::make_mocket_pair(ioc); + +corosio::tcp_socket& under = m.socket(); +// Pass `under` into a TLS stream, a custom framing layer, etc. ---- -=== Verifying Request Format +See xref:5c.patterns.adoc[Testing Patterns] for a TLS-over-mocket +example. -[source,cpp] ----- -// Ensure your code sends correctly formatted requests -client.expect("POST /api/v1/users HTTP/1.1\r\n..."); -co_await my_api_call(client, user_data); ----- +== Thread Safety -=== Integration Testing Without Network +* Use a mocket from a single thread only. +* All coroutines using the mocket must be suspended when calling + `provide()` or `expect()`. +* Designed for single-threaded, deterministic testing. -[source,cpp] ----- -// Test client-server interaction without actual networking -server.provide(server_response); -client.expect(client_request); +== Limitations -co_await run_client(client); -co_await run_server(server); ----- +* `provide` / `expect` are byte sequences only; there is no out-of-band + signal for "now produce EOF" or "now fail" beyond the matching + semantics described above. +* No simulation of network delay; for that, run code under a stop_token + with a timer. +* Connection-establishment errors are not simulated — the pair always + comes back open. == Next Steps -* xref:../4.guide/4d.sockets.adoc[Sockets Guide] — The socket interface mockets implement -* xref:../4.guide/4m.error-handling.adoc[Error Handling] — Testing error paths +* xref:5b.socket-pair.adoc[Socket Pairs] — when you need real socket + semantics instead of staged bytes. +* xref:5c.patterns.adoc[Testing Patterns] — recipes that combine mocket, + socket_pair, and chunked I/O. +* xref:../4.guide/4d.sockets.adoc[Sockets Guide] — the underlying socket + interface. +* xref:../4.guide/4m.error-handling.adoc[Error Handling] — testing error + paths. diff --git a/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc b/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc new file mode 100644 index 000000000..2f994ab42 --- /dev/null +++ b/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc @@ -0,0 +1,145 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + += Socket Pairs + +`make_socket_pair` creates two `tcp_socket` objects connected via +loopback TCP. Use it when a test needs real socket semantics — TLS +handshakes, `set_option`, `shutdown` ordering, true EOF — that the +byte-level staging in xref:5a.mocket.adoc[`mocket`] cannot reproduce. + +[NOTE] +==== +Code snippets assume: +[source,cpp] +---- +#include + +namespace corosio = boost::corosio; +namespace capy = boost::capy; +---- +==== + +== Overview + +[source,cpp] +---- +template +std::pair +make_socket_pair(corosio::io_context& ctx); +---- + +The function: + +. Opens an acceptor on `ipv4_address::loopback():0` with `SO_REUSEADDR`. +. Accepts on one side while connecting on the other. +. Runs the I/O context to completion, then restarts it. +. Returns the connected pair. + +Both sockets come back `is_open()` and ready to use. + +If bind, listen, accept, or connect fails, `make_socket_pair` throws +`std::runtime_error` with the underlying `error_code::message()` +appended. + +== Round Trip + +[source,cpp] +---- +corosio::io_context ioc; + +auto [s1, s2] = corosio::test::make_socket_pair(ioc); + +auto task = [](corosio::tcp_socket& a, corosio::tcp_socket& b) + -> capy::task<> { + co_await a.write_some(capy::const_buffer("ping", 4)); + + char buf[8] = {}; + auto [ec, n] = co_await b.read_some(capy::make_buffer(buf)); + // buf[0..n] == "ping" +}; +capy::run_async(ioc.get_executor())(task(s1, s2)); +ioc.run(); +---- + +== The `Linger` Template Parameter + +When `Linger` is `true` (the default), `make_socket_pair` sets +`SO_LINGER(true, 0)` on both ends after the connection is established. +This makes `close()` send `RST` instead of going through the normal +`FIN`/`ACK` shutdown, so test sockets release immediately and stress +runs don't accumulate `TIME_WAIT` entries. + +Set `Linger` to `false` only when a test specifically exercises graceful +shutdown: + +[source,cpp] +---- +auto [s1, s2] = corosio::test::make_socket_pair< + corosio::tcp_socket, + corosio::tcp_acceptor, + /*Linger=*/false>(ioc); +---- + +== When to Use vs. `mocket` + +[cols="1,1,1"] +|=== +| Need | mocket | socket_pair + +| Byte-level expectations on writes +| ✓ +| + +| Staged canned bytes for reads +| ✓ +| + +| Forced short reads / short writes +| ✓ (`max_read_size` / `max_write_size`) +| + +| Real `set_option`, `shutdown`, EOF +| +| ✓ + +| TLS handshake on a real socket +| +| ✓ + +| Stress / soak tests +| +| ✓ +|=== + +For tests that need byte-level determinism *and* a real socket +underneath (e.g., framing on top of TLS), see the layering recipe in +xref:5c.patterns.adoc[Testing Patterns]. + +== Caveats + +* Opens real loopback file descriptors. Visible to local network tools + and subject to the host's TCP stack. +* Slower than `mocket` for byte-level assertions. +* Timing is not deterministic; use stop_tokens or timers for cancellation + paths. +* On Linux, `SO_LINGER(true, 0)` causes `close()` to send `RST`, which + may surface as `cond::connection_reset` on the other end depending on + the order of operations. If a test asserts on a clean shutdown + sequence, set `Linger=false`. + +== Next Steps + +* xref:5a.mocket.adoc[Mock Sockets] — for byte-level deterministic + tests. +* xref:5c.patterns.adoc[Testing Patterns] — recipes that combine both. +* xref:../4.guide/4l.tls.adoc[TLS Encryption] — common consumer of + `make_socket_pair`. diff --git a/doc/modules/ROOT/pages/5.testing/5c.patterns.adoc b/doc/modules/ROOT/pages/5.testing/5c.patterns.adoc new file mode 100644 index 000000000..3eb986da1 --- /dev/null +++ b/doc/modules/ROOT/pages/5.testing/5c.patterns.adoc @@ -0,0 +1,156 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + += Testing Patterns + +This page collects recipes that combine xref:5a.mocket.adoc[`mocket`] +and xref:5b.socket-pair.adoc[`make_socket_pair`] in realistic test +scenarios. Each recipe is a small standalone block; copy and adapt. + +[NOTE] +==== +Code snippets assume: +[source,cpp] +---- +#include +#include + +namespace corosio = boost::corosio; +namespace capy = boost::capy; +---- +==== + +== Verifying Request Format + +When a function under test must emit an exact byte sequence, stage it +with `expect()` and assert on `close()` at the end: + +[source,cpp] +---- +corosio::io_context ioc; +auto [m, peer] = corosio::test::make_mocket_pair(ioc); + +m.expect("GET /api/v1/users HTTP/1.1\r\n\r\n"); + +auto task = [](corosio::test::mocket& m_ref) -> capy::task<> { + co_await my_http_get(m_ref, "/api/v1/users"); +}; +capy::run_async(ioc.get_executor())(task(m)); +ioc.run(); + +auto ec = m.close(); // !ec means everything was written +---- + +== Staging Server Responses + +When a function under test must consume canned bytes, stage them with +`provide()` and run the consumer: + +[source,cpp] +---- +m.provide( + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "\r\n" + "Hello"); + +auto task = [](corosio::test::mocket& m_ref) -> capy::task<> { + auto response = co_await my_http_read(m_ref); + // assert on parsed response +}; +---- + +== Simulating Chunked / Partial I/O + +Most read loops are wrong because they assume `read_some` delivers as +much as the kernel had. Force short reads to flush out that bug: + +[source,cpp] +---- +auto [m, peer] = corosio::test::make_mocket_pair(ioc, {}, /*max_read_size=*/4); + +m.provide("ABCDEFGH"); + +auto task = [](corosio::test::mocket& m_ref) -> capy::task<> { + std::string acc; + char buf[16]; + for (int i = 0; i < 2; ++i) + { + auto [ec, n] = co_await m_ref.read_some(capy::make_buffer(buf)); + acc.append(buf, n); // n == 4 each time + } +}; +---- + +The same applies to `max_write_size` for write-loop testing. + +== Layering Streams on a Mocket + +`m.socket()` returns the underlying `tcp_socket`. Stack any stream that +wraps a TCP socket on top of it; the staging buffers still apply at the +TCP layer, with the higher-level stream's wire format flowing through: + +[source,cpp] +---- +auto [m, peer] = corosio::test::make_mocket_pair(ioc); + +// Pass m.socket() into a TLS stream or other layer in production code: +corosio::tcp_socket& under = m.socket(); +// e.g., openssl_stream tls(&under, tls_ctx); +---- + +This is the right tool when the bytes you want to stage are below a +higher-level protocol. + +== End-to-End with Real Sockets + +When fidelity matters more than determinism — for example, exercising +`shutdown()` ordering, real `set_option` paths, or TLS handshakes — use +`make_socket_pair`: + +[source,cpp] +---- +corosio::io_context ioc; +auto [s1, s2] = corosio::test::make_socket_pair(ioc); + +auto task = [](corosio::tcp_socket& a, corosio::tcp_socket& b) + -> capy::task<> { + auto [wec, wn] = + co_await a.write_some(capy::const_buffer("payload", 7)); + + char buf[16] = {}; + auto [ec, n] = co_await b.read_some(capy::make_buffer(buf)); + // buf[0..n] == "payload" +}; +capy::run_async(ioc.get_executor())(task(s1, s2)); +ioc.run(); +---- + +== Deterministic Close-Verification + +Always call `close()` on the mocket at the end of a test that uses +`provide` / `expect`, and assert the result is empty: + +[source,cpp] +---- +auto ec = m.close(); +// ec == capy::error::test_failure means leftover provide() data was +// never read, or expect() data was never written. Either way, the test +// would have passed silently without this check. +---- + +This is the line that catches "the test passed because the code under +test silently did nothing." Treat it as a test-suite convention. + +== See Also + +* xref:5a.mocket.adoc[Mock Sockets] +* xref:5b.socket-pair.adoc[Socket Pairs] +* xref:../4.guide/4d.sockets.adoc[Sockets Guide] +* xref:../4.guide/4l.tls.adoc[TLS Encryption]