Skip to content

15. E2E Testing — Verifying the Whole System with a Client

Guide Home | Previous: Reading Along: ZoneWorld | Next: 13. Key Type Usage Index

What you get from this chapter

You can verify registration, connection, push, and reply order in E2E scenarios with real clients. The verification examples in this chapter follow the sample scenarios in the language-specific example repositories.

1. Where E2E Testing Is Needed

No matter how tightly you write handler unit tests, some things stay unverified: whether registration actually took effect, whether routing between two nodes is correct, whether a push reaches other participants in the room. These can only be verified by starting real processes and checking over a real connection.

At this point, teams usually implement a separate test-only client, rewriting the code to open a socket, assemble frames, and wait for a response for every scenario. ZLink doesn't need that work. The client library your real users use is itself the verification tool. An E2E test comes down to just this much code.

host.connect().submit().toCompletableFuture().join(); guest.connect().submit().toCompletableFuture().join(); observer.connect().submit().toCompletableFuture().join(); System.out.println("observer-connected endpoint=" + game.playEndpoints().get(1));

ensure(game.roomId() != null && !game.roomId().isBlank()); ensure(options.gameName().equals(game.gameName())); ensure(game.requiredLevel() == 3);

AuthenticateRes hostAuth = host.request(new AuthenticateReq(options.xActorId())) .submit(AuthenticateRes.class) .toCompletableFuture() .join(); ensure(options.xActorId().equals(hostAuth.player().actorId())); ensure(hostAuth.player().wins() == 99);

Because the connector itself provides the wait functions verification needs, like waitFor, you don't implement a separate test harness. Every sample in this repository is verified this way.

Distinguish what E2E does and doesn't cover. E2E confirms things like registration, routing, push, and lifecycle — items that only surface when multiple processes run together. Branches or calculations inside a handler are far faster and more precise to verify with a unit test, so they don't belong in E2E.

2. The Libraries Used for Verification

The two libraries used for verification don't overlap in role.

Zlink.HttpClient Zlink.Stream.Connector
What it verifies The management/gateway HTTP API A STREAM server node
When to use it Things that finish with a single request-response exchange, like creating a room, querying, or admin commands Things that require a live connection, including confirmation of server-initiated pushes
Representative call Post(...).Body(...).Fetch<T>() connect · request · waitFor · expectNone

Most scenarios chain the two together — create a target over HTTP, then connect to STREAM using the endpoint returned in that response.

CreateGameHttpRes game; try (ZLinkHttpClient api = ZLinkHttpClient.create(options.apiUrl()).build()) { game = api.post("/games") .body(new CreateGameHttpReq(options.gameName())) .submit(CreateGameHttpRes.class) .toCompletableFuture() .join() .body(); }

When dispatchMode is Immediate, the connector handles receiving on its own, so the scenario code never runs a separate pump. Environments that must pump manually to match a frame loop, like a game engine, are covered by the Stream Connector guide.

Each library's guide covers its full usage.

  • The HTTP Client guide — request construction, body, auth/TLS, retry, and error handling, across 13 chapters
  • The Stream Connector guide — per-runtime integration (Unity, Godot). Server-side STREAM registration is covered by STREAM.

3. Verification Functions and Usage

Most scenarios are expressed with the verification functions the connector provides.

What's verified Function used
Send a request and check the response Request(req) — the response type is specified on the terminal
Confirm a push the server sends first arrives WaitFor<TNotify>()
Confirm a push does not arrive ExpectNone<TNotify>().Within(window)
Confirm pushes arrive in a fixed order WaitForSequence<TNotify>().Expect(...).Expect(...)
Confirm a request fails expectFailure(...)

The terminal call follows the language — .NET uses Async, C++ uses async, Java/Node use submit, and Kotlin uses await (Async Execution Policy).

Value comparison uses Ensure(condition, message). The message is required, and on failure the scenario ends with an exception carrying that message.

3.1 Confirming a Push Arrives

Specify a condition with where(...) to wait for the first message matching that condition. Other, nonmatching pushes may arrive without affecting the scenario.

var hostSawGuestJoin = host.waitFor(PlayerJoinedNotify.class) .where( PlayerJoinedNotify.class, message -> options.oActorId().equals(message.payload().actorId())) .submit(PlayerJoinedNotify.class);

3.2 Confirming a Push Doesn't Arrive

You can't confirm something never arrives without an observation window, so within(...) must be specified. Omitting it is an error.

CompletionStage noOtherCourierOffer = otherCourier .expectNone(Messages.OfferDeliveryNotify.class) .within(Duration.ofSeconds(1)) .submit();

3.3 Confirming Push Order

In a flow where state changes in stages, the contract isn't whether something arrives but its order.

CompletionStage<List<ZLinkStreamMessage>> statuses = customer.waitForSequence(Messages.DeliveryStatusNotify.class) .expect( Messages.DeliveryStatusNotify.class, message -> matchesStatus( message, deliveryId, Messages.DeliveryStatus.Assigned)) .expect( Messages.DeliveryStatusNotify.class, message -> matchesStatus( message, deliveryId, Messages.DeliveryStatus.Accepted))

3.4 Confirming a Request Fails

Whether a request with no permission or an out-of-order request gets rejected is also part of the contract. Verifying only the success path leaves this path unverified.

ConversationClient closedRoom2 = customerRoom2; ZLinkStreamAssert.expectFailure(() -> closedRoom2.close("again"), null);

4. How to Handle Waiting for a Message

Most E2E flakiness has the same cause. You act first, then start waiting, and miss a push that arrived in between.

Reverse the order. Register the wait first, then run the action that triggers that push. The waitForSequence registration in Confirming Push Order is exactly this order — the wait is built first, and the request is sent afterward.

If multiple clients need to confirm the same event, register a wait for each and receive them together with Task.WhenAll.

var client1Started = client1.waitFor(SampleNames.GameStartedPacket) .submit(Messages.BingoGameStartedNotify.class); var client2Started = client2.waitFor(SampleNames.GameStartedPacket) .submit(Messages.BingoGameStartedNotify.class);

Don't use Sleep to line up timing. Express every wait through the timeout on waitFor/expectNone/waitForSequence. Sleep fails on slow hardware and wastes time on fast hardware.

5. A Complete Scenario Example

The TicTacToe sample is the shortest. Create a room over HTTP → both players connect and authenticate → confirm the join push → make a move → confirm the opponent observes that move, in that order.

var hostJoinCompletion = host.waitFor(JoinGameNotify.class).submit(JoinGameNotify.class); host.send(new JoinGameMsg(game.roomId())).submit().toCompletableFuture().join(); JoinGameNotify hostJoin = hostJoinCompletion.toCompletableFuture().join().payload(); ensure(hostJoin.state().roomId().equals(game.roomId())); ensure("WaitingForPlayers".equals(hostJoin.state().status())); ensure(options.xActorId().equals(hostJoin.state().xActorId()));

Choose verification points by this rule. Don't just check a request's own response — also check whether another client observes the same fact. Making the result that actually reaches the user, not server-internal state, the contract, is the point of E2E.

6. Verifying with Multiple Clients

A single scenario can create several clients. Splitting roles verifies contracts a single client can't confirm.

  • Two players — whether one's action reaches the other, and conversely, that it doesn't reach themselves
  • A spectator — whether a notification reaches a non-participant connection, and conversely, that a participant-only notification doesn't
  • Two connected to different nodes — whether routing and location resolution between nodes actually work

ZLinkStreamConnector host = playerConnector(game.playEndpoints().get(0), "host"); ZLinkStreamConnector guest = playerConnector(game.playEndpoints().get(1), "guest"); ZLinkStreamConnector observer = playerConnector(game.playEndpoints().get(1), "observer");

The Bingo sample uses this composition as-is — it brings together two players and one spectator, and even confirms the win notification is delivered only to the spectator.

7. Run Scripts and Success Criteria

The run script is responsible for starting the server, running the client, and cleaning up afterward.

# gradle builds a runnable distribution, and the script launches that executable.
gradle_run :Server:installDist :Client:installDist

start_server play-a "$(app_bin Server Server)" --config "${CONFIG_DIR}/play-a.json"
start_server play-b "$(app_bin Server Server)" --config "${CONFIG_DIR}/play-b.json"
start_server api-a  "$(app_bin Server Server)" --config "${CONFIG_DIR}/api-a.json"

# Wait until the port opens. Doesn't use sleep.
wait_port "${PLAY_A_ROUTE_ENDPOINT}"

"$(app_bin Client Client)" --api-url "http://127.0.0.1:${api_a_http_port}" \
  >"${log_dir}/client.log" 2>&1

RUN_SUCCEEDED=1

The script follows these rules.

  • The client's exit code is the success criterion. Under set -e, if the client exits with an exception, the script fails at that point too. No separate judgment logic is implemented.
  • Wait on a condition, not sleep. Server startup is confirmed by whether the port is open; async post-processing, by whether a specific line appeared in the log.
  • Clean up with trap so that a scenario failing partway through doesn't leave started processes, temp directories, or containers behind to affect the next run.

Even if the client passes, also check that the server logs have no errors. Sometimes the client observes normal behavior while the server records a dispatch error.

if grep -R -q "dispatch-error" "${LOG_DIR}"; then
  echo "Unexpected dispatch-error in sample logs." >&2
  exit 1
fi

8. Common Problems

  • A push isn't received, causing intermittent failure → check that the wait was registered before the action (How to Handle Waiting for a Message). Starting the wait afterward misses a push that arrived in between.
  • expectNone ends in an error → within(...) wasn't specified. You can't confirm something never arrives without an observation window, so the window is required explicitly.
  • waitFor returns a different message → you waited on type alone, with no condition. Narrow it with where(...) to the event this scenario is actually waiting for.
  • It passes locally but fails only in CI → check for remaining timing dependencies implemented with sleep. Express every wait through a wait function with an explicit timeout.
  • The client passes but the server log has an error → the script doesn't check server logs for errors (Run Scripts and Success Criteria).
  • It connects but the push never arrives → in an environment that needs manual pumping, like engine integration, dispatch wasn't run (see the Stream Connector guide).