Skip to content

Latest commit

 

History

History
135 lines (99 loc) · 6.11 KB

File metadata and controls

135 lines (99 loc) · 6.11 KB

Testing Patterns

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.

Three fixtures, three scopes

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.

Pattern 1: MessageBusStub for domain tests

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).

Pattern 2: MemoryStream for transport / connection 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 -2 shutdown sentinel for clean disconnect; close the stream for EOF/reconnect-fail paths)

Pattern 3: spawn_handshake_listener for Client::connect*

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.

Picking the right fixture

When in doubt, default to the lightest:

  1. Are you testing a Client method that talks to TWS via the bus?MessageBusStub.
  2. Are you testing the dispatcher, handshake, or disconnect?MemoryStream.
  3. Are you testing Client::connect* itself or AsyncTcpSocket?spawn_handshake_listener.

Going heavier than necessary adds threads, ports, or framing that doesn't earn its keep.

Test file layout

  • 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 parent mod.rs.

Table-driven tests

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.

Running tests

# 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::tests

Per CLAUDE.md item 5, every PR should pass cargo clippy in all three configurations: default, --features sync, --all-features.

Recording real messages

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.