This document describes the test-fixture strategy for the rust-ibapi crate. Tests are stratified by which seam they exercise — pick the lightest fixture that does the job.
| Fixture | Scope | Where it lives | Use when |
|---|---|---|---|
MessageBusStub |
Domain logic | src/stubs.rs |
Testing methods on Client (e.g. realtime_bars, place_order) — verify request encoding and response decoding through the MessageBus / AsyncMessageBus trait. Skips the dispatcher and framing entirely. |
MemoryStream |
Transport / connection | src/transport/sync/memory.rs, src/transport/async_memory.rs |
Testing the dispatcher (routing, cancel coalescing, EOF handling) or the handshake (establish_connection, disconnect, reconnect). Implements the Stream / AsyncStream trait so it slots into Connection<S> / AsyncConnection<S> directly. |
spawn_handshake_listener |
Production TCP entry points | src/transport/sync/test_listener.rs, src/transport/async_test_listener.rs |
Testing Client::connect* and AsyncTcpSocket::* — the production-only seam that does TcpStream::connect(addr). One-shot listener bound to 127.0.0.1:0. |
Most per-domain tests use this. The stub records outbound request_messages and replays scripted response_messages through whatever channel kind the request expects (request/order/shared).
// src/orders/sync/tests.rs
#[test]
fn place_order() {
let message_bus = Arc::new(MessageBusStub {
request_messages: RwLock::new(vec![]),
response_messages: vec![
"5|2|637533641|ES|FUT|...".to_owned(), // OpenOrder
"3|1|Submitted|0|1|0|...".to_owned(), // OrderStatus
],
});
let client = Client::stubbed(message_bus.clone(), server_versions::SIZE_RULES);
let mut subscription = client.place_order(1, &contract, &order).expect("...");
// assert subscription yields decoded responses
// assert message_bus.request_messages records the encoded request
}Counterpart MessageBusStub::default() exists for tests that just need a Client (accessor tests, builder smoke tests).
MemoryStream is a frame-level in-memory implementation of the Stream / AsyncStream trait. Tests push_inbound(body) to script response frames, and call captured() to read back what the consumer wrote. close() signals EOF.
// src/connection/sync_tests.rs
#[test]
fn establish_connection_populates_metadata() {
let stream = MemoryStream::default();
let connection = Connection::stubbed(stream.clone(), CLIENT_ID);
push_handshake(&stream); // pre-pushes 3 frames
connection.establish_connection(None).expect("...");
assert_eq!(connection.server_version(), server_versions::PROTOBUF);
}Use it for:
- Handshake (handshake response, NextValidId, ManagedAccounts, version-error, unknown-timezone)
- Dispatcher routing (request_id correlation, order_id correlation, shared-channel fan-out, cancel coalescing)
- Disconnect / reconnect lifecycle (push the
-2shutdown sentinel for clean disconnect; close the stream for EOF/reconnect-fail paths)
Real TCP listener that binds 127.0.0.1:0, accepts once, replays scripted handshake frames, and drains further writes until the client closes. Used only at the production-TCP entry-point seam.
// src/client/sync_tests.rs
#[test]
fn connect_handshakes_against_real_socket() {
let (addr, _h) = spawn_handshake_listener(handshake_frames());
let client = Client::connect(&addr.to_string(), 100).expect("Client::connect");
assert_eq!(client.client_id(), 100);
}This is not a re-implementation of MockGateway — it has no per-API-call interaction surface. It exists only because Client::connect, Client::builder()...connect(), and the underlying TcpSocket::connect / AsyncTcpSocket::connect cannot otherwise be exercised without a real socket.
When in doubt, default to the lightest:
- Are you testing a
Clientmethod that talks to TWS via the bus? →MessageBusStub. - Are you testing the dispatcher, handshake, or disconnect? →
MemoryStream. - Are you testing
Client::connect*itself orAsyncTcpSocket? →spawn_handshake_listener.
Going heavier than necessary adds threads, ports, or framing that doesn't earn its keep.
- Tests live in their own files — never inline
#[cfg(test)] mod tests { ... }blocks alongside implementation. - Prefer flat sibling files (
foo.rs+foo_tests.rs) over a nested module directory (foo/mod.rs+foo/tests.rs). Wire from the implementation file:#[cfg(test)] #[path = "foo_tests.rs"] mod tests;
- For domain submodules, the
#[path = "..."] mod tests;declaration can live in the parentmod.rs.
Shared test tables in <domain>/common/test_tables.rs are exercised from both <domain>/sync/tests.rs and <domain>/async/tests.rs to enforce sync/async parity:
// common/test_tables.rs
pub const TEST_CASES: &[TestCase] = &[
TestCase { name: "...", input: ..., expected: ... },
// ...
];
// sync/tests.rs and async/tests.rs both iterate TEST_CASES with the same assertions.# Default (async)
cargo test
# Sync only
cargo test --no-default-features --features sync
# Both
cargo test --all-features
# Coverage
cargo llvm-cov --all-features --summary-only # text summary
just cover # HTML report, opens browser
# Specific module
cargo test --features sync client::sync::testsPer CLAUDE.md item 5, every PR should pass cargo clippy in all three configurations: default, --features sync, --all-features.
When implementing tests for a new feature, capture real protocol bytes against a paper IB Gateway with IBAPI_RECORDING_DIR=/tmp/tws-messages, then use those captured frames in MessageBusStub::response_messages or MemoryStream::push_inbound calls.