-
Notifications
You must be signed in to change notification settings - Fork 1.4k
test: add MTE network and multiplayer integration tests #5325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
agent-refr
wants to merge
7
commits into
MovingBlocks:develop
Choose a base branch
from
SiliconSaga:test/salvage-mte-network-tests
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
2b136f8
test: add MTE network and multiplayer integration tests
Cervator ab1ad01
docs: add singleton state testing pattern to Engine-Testing-Patterns
Cervator e5e096e
fix: remove unused LocalPlayer import in TwoClientChatTest
Cervator 3ed1b86
test: tag client-creating MTE tests as flaky
Cervator 5841573
fix: address CodeRabbit review — correct event type, remove duplicate…
Cervator 57ce510
fix: address review — match sender to client1, bump timeout, add timi…
Cervator 1692754
fix: address Copilot review — simplify test, add TestReporter diagnos…
Cervator File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| # Engine Testing Patterns | ||
|
|
||
| Patterns and gotchas for writing tests against the Terasology engine, | ||
| especially integration tests using the ModuleTestingEnvironment (MTE). | ||
|
|
||
| For module-level testing basics, see [Testing-Modules.md](Testing-Modules.md). | ||
|
|
||
| ## Test Hierarchy | ||
|
|
||
| Tests form a natural progression from fast/isolated to slow/integrated: | ||
|
|
||
| | Level | Runner | Speed | Example | | ||
| |-------|--------|-------|---------| | ||
| | Unit (no engine) | Plain JUnit, mocks | Fast | `PojoEventSystemTests` | | ||
| | Unit (engine libs) | `ModuleManagerFactory`, manual Context | Medium | `ContextImplTest` | | ||
| | Integration (MTE, single player) | `@IntegrationEnvironment` | Slow | `ComponentSystemTest` | | ||
| | Integration (MTE, multiplayer) | `@IntegrationEnvironment(networkMode = LISTEN_SERVER)` | Very slow | `ClientConnectionTest` | | ||
|
|
||
| Prefer higher levels only when the thing you're testing genuinely requires | ||
| the engine or network stack. | ||
|
|
||
| ## MTE Basics | ||
|
|
||
| The `@IntegrationEnvironment` annotation starts a headless engine instance. | ||
| Use `@In` (not `@javax.inject.Inject`) for field injection in test classes — | ||
| the MTE harness uses `InjectionHelper` which looks for `@In`. | ||
|
|
||
| ```java | ||
| @IntegrationEnvironment | ||
| public class MyTest { | ||
| @In | ||
| private EntityManager entityManager; // injected by MTE | ||
|
|
||
| @Test | ||
| public void testSomething(MainLoop mainLoop) { | ||
| // MainLoop injected via JUnit parameter resolution | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| For multiplayer tests, use `NetworkMode.LISTEN_SERVER` and create clients | ||
| via `ModuleTestingHelper`: | ||
|
|
||
| ```java | ||
| @IntegrationEnvironment(networkMode = NetworkMode.LISTEN_SERVER) | ||
| public class MyNetworkTest { | ||
| @In | ||
| private ModuleTestingHelper helper; | ||
|
|
||
| @Test | ||
| public void testWithClient() throws IOException { | ||
| Context clientContext = helper.createClient(); | ||
| // client has its own EntityManager, NetworkSystem, etc. | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Network Event Testing | ||
|
|
||
| ### The Event Registration Problem | ||
|
|
||
| Network events (`@BroadcastEvent`, `@OwnerEvent`, `@ServerEvent`) require | ||
| two things to replicate over the network: | ||
|
|
||
| 1. Registration in the `EventSystem` (for local dispatch) | ||
| 2. Registration in the `EventLibrary` (for network metadata — direction, serialization) | ||
|
|
||
| The `NetworkEventSystemDecorator` handles both: its `registerEvent()` method | ||
| registers the event and, if the event has a network annotation, adds it to | ||
| the `EventLibrary`. | ||
|
|
||
| **Inner-class events defined in test files** get registered in the `EventSystem` | ||
| by the module classpath scan, but may NOT get their network metadata populated | ||
| in the `EventLibrary`. This means they fire locally but never replicate across | ||
| the network. | ||
|
|
||
| ### Working Patterns | ||
|
|
||
| **Use existing engine events for network propagation tests.** Events like | ||
| `ChatMessageEvent` (`@BroadcastEvent`) are already fully registered and | ||
| will replicate correctly: | ||
|
|
||
| ```java | ||
| // This works — ChatMessageEvent is engine-registered with full metadata | ||
| clientEntity.send(new ChatMessageEvent("hello", senderInfo)); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ``` | ||
|
|
||
| **Use `TestEventReceiver` for local event testing.** MTE provides this | ||
| helper to listen for events without defining a full ComponentSystem: | ||
|
|
||
| ```java | ||
| try (TestEventReceiver<MyEvent> receiver = new TestEventReceiver<>(context, MyEvent.class)) { | ||
| entity.send(new MyEvent()); | ||
| assertTrue(receiver.hasReceived()); | ||
| } | ||
| ``` | ||
|
|
||
| **Register a probe system for multiplayer event verification.** When you | ||
| need to verify an event arrived on a specific context (host or client), | ||
| register a `BaseComponentSystem` as a probe and also register it with the | ||
| `EventSystem` directly (needed for post-initialization registration): | ||
|
|
||
| ```java | ||
| MyProbe probe = new MyProbe(); | ||
| context.get(ComponentSystemManager.class).register(probe); | ||
| context.get(EventSystem.class).registerEventHandler(probe); | ||
| ``` | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| ### What Does NOT Work | ||
|
|
||
| Defining `@BroadcastEvent`/`@OwnerEvent`/`@ServerEvent` inner classes in | ||
| test files and expecting them to replicate over the network. They register | ||
| locally but the `EventLibrary` doesn't pick up their network metadata, | ||
| so `NetworkEventSystemDecorator.networkReplicate()` silently skips them. | ||
|
|
||
| ## Context and Registry Patterns | ||
|
|
||
| ### CoreRegistry Isolation | ||
|
|
||
| `CoreRegistry` is a deprecated static singleton. Tests sharing a JVM must | ||
| save and restore it: | ||
|
|
||
| ```java | ||
| @BeforeEach | ||
| void setUp() { | ||
| originalContext = CoreRegistry.get(Context.class); | ||
| CoreRegistry.setContext(new ContextImpl()); | ||
| } | ||
|
|
||
| @AfterEach | ||
| void tearDown() { | ||
| CoreRegistry.setContext(originalContext); | ||
| } | ||
| ``` | ||
|
|
||
| MTE tests handle this automatically — don't manually set `CoreRegistry` | ||
| in `@IntegrationEnvironment` tests. | ||
|
|
||
| ### Service vs System | ||
|
|
||
| Distinguish between: | ||
| - **Services** (in `Context`/`CoreRegistry`): always available via `@In`, | ||
| e.g. `EntityManager`, `NetworkSystem` | ||
| - **ComponentSystems** (in `ComponentSystemManager`): event-driven, process | ||
| events via `@ReceiveEvent`, e.g. `ChatSystem` | ||
|
|
||
| Some functionality (like chat) requires an ECS system to be registered and | ||
| processing events — just having the service in the context isn't enough. | ||
|
|
||
| ## Gradle Test Execution | ||
|
|
||
| ```bash | ||
| # Run all tests | ||
| ./gradlew test | ||
|
|
||
| # Run a specific test class (subproject is required) | ||
| ./gradlew :engine-tests:test --tests "*.ClientNetworkStateTest" | ||
|
|
||
| # Force fresh run (clear cached results) | ||
| ./gradlew :engine-tests:cleanTest :engine-tests:test --tests "*.MyTest" | ||
|
|
||
| # Via ws CLI (auto-discovers subproject and clears cache) | ||
| ws test terasology ClientNetworkStateTest | ||
| ``` | ||
|
|
||
| Always use `cleanTest` for targeted test runs — Gradle's UP-TO-DATE cache | ||
| can serve stale results from a previous failure. | ||
|
|
||
| ## See Also | ||
|
|
||
| - [Testing-Modules.md](Testing-Modules.md) — module-level testing basics | ||
| - `engine-tests/src/main/java/org/terasology/engine/integrationenvironment/` — MTE source | ||
| - `engine-tests/src/test/java/org/terasology/engine/integrationenvironment/` — existing MTE tests | ||
59 changes: 59 additions & 0 deletions
59
...ts/src/test/java/org/terasology/engine/integrationenvironment/ClientNetworkStateTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| // Copyright 2026 The Terasology Foundation | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| package org.terasology.engine.integrationenvironment; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
| import org.terasology.engine.context.Context; | ||
| import org.terasology.engine.core.GameEngine; | ||
| import org.terasology.engine.core.TerasologyEngine; | ||
| import org.terasology.engine.core.subsystem.EngineSubsystem; | ||
| import org.terasology.engine.core.subsystem.common.NetworkSubsystem; | ||
| import org.terasology.engine.integrationenvironment.jupiter.IntegrationEnvironment; | ||
| import org.terasology.engine.network.NetworkMode; | ||
| import org.terasology.engine.network.NetworkSystem; | ||
| import org.terasology.engine.registry.In; | ||
|
|
||
| import java.io.IOException; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertNotNull; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| /** | ||
| * Verifies that a client created via MTE has the correct network state: | ||
| * NetworkSystem in CLIENT mode and a NetworkSubsystem registered. | ||
| */ | ||
| @IntegrationEnvironment(networkMode = NetworkMode.LISTEN_SERVER) | ||
| public class ClientNetworkStateTest { | ||
|
|
||
| @In | ||
| private ModuleTestingHelper helper; | ||
|
|
||
| @Test | ||
| public void testClientNetworkMode() throws IOException { | ||
| Context clientContext = helper.createClient(); | ||
|
|
||
| NetworkSystem networkSystem = clientContext.get(NetworkSystem.class); | ||
| assertNotNull(networkSystem, "NetworkSystem should exist in client context"); | ||
| assertEquals(NetworkMode.CLIENT, networkSystem.getMode(), | ||
| "Client NetworkSystem should be in CLIENT mode"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testClientHasNetworkSubsystem() throws IOException { | ||
| Context clientContext = helper.createClient(); | ||
|
|
||
| TerasologyEngine engine = (TerasologyEngine) clientContext.get(GameEngine.class); | ||
| assertNotNull(engine, "Client should have a GameEngine in context"); | ||
|
agent-refr marked this conversation as resolved.
Outdated
|
||
|
|
||
| boolean hasNetworkSubsystem = false; | ||
| for (EngineSubsystem subsystem : engine.getSubsystems()) { | ||
| if (subsystem instanceof NetworkSubsystem) { | ||
| hasNetworkSubsystem = true; | ||
| break; | ||
| } | ||
| } | ||
| assertTrue(hasNetworkSubsystem, | ||
| "NetworkSubsystem should be registered in the client engine"); | ||
| } | ||
| } | ||
56 changes: 56 additions & 0 deletions
56
...ts/src/test/java/org/terasology/engine/integrationenvironment/ClientSystemUpdateTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| // Copyright 2026 The Terasology Foundation | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| package org.terasology.engine.integrationenvironment; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
| import org.terasology.engine.context.Context; | ||
| import org.terasology.engine.core.ComponentSystemManager; | ||
| import org.terasology.engine.entitySystem.systems.BaseComponentSystem; | ||
| import org.terasology.engine.entitySystem.systems.UpdateSubscriberSystem; | ||
| import org.terasology.engine.integrationenvironment.jupiter.IntegrationEnvironment; | ||
| import org.terasology.engine.network.NetworkMode; | ||
| import org.terasology.engine.registry.In; | ||
|
|
||
| import java.io.IOException; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertNotNull; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| /** | ||
| * Verifies that a system registered on a client receives update ticks | ||
| * through the MTE game loop. | ||
| */ | ||
| @IntegrationEnvironment(networkMode = NetworkMode.LISTEN_SERVER) | ||
| public class ClientSystemUpdateTest { | ||
|
|
||
| @In | ||
| private ModuleTestingHelper helper; | ||
|
|
||
| @Test | ||
| public void testClientSystemReceivesUpdates() throws IOException { | ||
| Context clientContext = helper.createClient(); | ||
|
|
||
| ComponentSystemManager csm = clientContext.get(ComponentSystemManager.class); | ||
| assertNotNull(csm, "Client should have a ComponentSystemManager"); | ||
| assertTrue(csm.isActive(), "Client ComponentSystemManager should be active"); | ||
|
|
||
| TickCountingSystem tickCounter = new TickCountingSystem(); | ||
| csm.register(tickCounter); | ||
|
|
||
| int targetTicks = 5; | ||
| helper.runUntil(() -> tickCounter.updateCount >= targetTicks); | ||
|
|
||
| assertTrue(tickCounter.updateCount >= targetTicks, | ||
| "Client system should have received at least " + targetTicks | ||
| + " updates, got " + tickCounter.updateCount); | ||
| } | ||
|
|
||
| public static class TickCountingSystem extends BaseComponentSystem implements UpdateSubscriberSystem { | ||
| int updateCount; | ||
|
|
||
| @Override | ||
| public void update(float delta) { | ||
| updateCount++; | ||
| } | ||
| } | ||
| } |
99 changes: 99 additions & 0 deletions
99
...e-tests/src/test/java/org/terasology/engine/integrationenvironment/TwoClientChatTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| // Copyright 2026 The Terasology Foundation | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| package org.terasology.engine.integrationenvironment; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
| import org.terasology.engine.context.Context; | ||
| import org.terasology.engine.core.ComponentSystemManager; | ||
| import org.terasology.engine.entitySystem.entity.EntityRef; | ||
| import org.terasology.engine.entitySystem.event.internal.EventSystem; | ||
| import org.terasology.engine.entitySystem.systems.BaseComponentSystem; | ||
| import org.terasology.engine.integrationenvironment.jupiter.IntegrationEnvironment; | ||
| import org.terasology.engine.logic.chat.ChatMessageEvent; | ||
| import org.terasology.engine.logic.permission.PermissionManager; | ||
| import org.terasology.engine.logic.players.LocalPlayer; | ||
|
Check warning on line 14 in engine-tests/src/test/java/org/terasology/engine/integrationenvironment/TwoClientChatTest.java
|
||
| import org.terasology.engine.network.Client; | ||
| import org.terasology.engine.network.ClientComponent; | ||
| import org.terasology.engine.network.NetworkMode; | ||
| import org.terasology.engine.network.NetworkSystem; | ||
| import org.terasology.engine.registry.In; | ||
| import org.terasology.gestalt.entitysystem.event.ReceiveEvent; | ||
|
|
||
| import static com.google.common.truth.Truth.assertThat; | ||
| import static org.junit.jupiter.api.Assertions.assertNotNull; | ||
|
|
||
| /** | ||
| * Verifies that a chat message sent from the host on behalf of one client | ||
| * is received by another client via network event propagation. | ||
| * <p> | ||
| * This exercises the full ChatMessageEvent broadcast path through the | ||
| * LISTEN_SERVER network stack with two connected clients. | ||
| */ | ||
| @IntegrationEnvironment(networkMode = NetworkMode.LISTEN_SERVER) | ||
| public class TwoClientChatTest { | ||
|
|
||
| @In | ||
| private ModuleTestingHelper helper; | ||
|
|
||
| @Test | ||
| public void testChatPropagatesBetweenClients() throws Exception { | ||
| // Set up two clients | ||
| Context client1Ctx = helper.createClient(); | ||
| Context client2Ctx = helper.createClient(); | ||
| assertNotNull(client1Ctx, "Client 1 context should be created"); | ||
| assertNotNull(client2Ctx, "Client 2 context should be created"); | ||
|
|
||
| // Wait for both clients to register on the host | ||
| NetworkSystem hostNetwork = helper.getHostContext().get(NetworkSystem.class); | ||
| helper.runUntil(() -> { | ||
| int count = 0; | ||
| for (Client ignored : hostNetwork.getPlayers()) { | ||
| count++; | ||
| } | ||
| return count >= 2; | ||
| }); | ||
| assertThat(hostNetwork.getPlayers()).hasSize(2); | ||
|
|
||
| // Grant chat permission to all connected clients | ||
| PermissionManager hostPerms = helper.getHostContext().get(PermissionManager.class); | ||
| for (Client client : hostNetwork.getPlayers()) { | ||
| EntityRef clientInfo = client.getEntity().getComponent(ClientComponent.class).clientInfo; | ||
| hostPerms.addPermission(clientInfo, PermissionManager.CHAT_PERMISSION); | ||
| } | ||
|
agent-refr marked this conversation as resolved.
Outdated
|
||
|
|
||
| // Register a ChatMessageEvent probe on client 2 | ||
| ChatProbe probe = new ChatProbe(); | ||
| ComponentSystemManager client2Csm = client2Ctx.get(ComponentSystemManager.class); | ||
| client2Csm.register(probe); | ||
| // Also register with EventSystem for post-init registration | ||
| client2Ctx.get(EventSystem.class).registerEventHandler(probe); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| // Send a ChatMessageEvent from the host targeting all connected clients | ||
| // (mimics what ChatSystem does when a player sends a message) | ||
| Client senderClient = hostNetwork.getPlayers().iterator().next(); | ||
| EntityRef senderInfo = senderClient.getEntity().getComponent(ClientComponent.class).clientInfo; | ||
| String testMessage = "hello from client 1"; | ||
|
|
||
| for (Client client : hostNetwork.getPlayers()) { | ||
| client.getEntity().send(new ChatMessageEvent(testMessage, senderInfo)); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| // Wait for client 2's probe to receive the message | ||
| helper.setSafetyTimeoutMs(10000); | ||
| helper.runUntil(() -> probe.received); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| assertThat(probe.received).isTrue(); | ||
| assertThat(probe.lastMessage).contains(testMessage); | ||
| } | ||
|
|
||
| public static class ChatProbe extends BaseComponentSystem { | ||
| boolean received; | ||
| String lastMessage = ""; | ||
|
|
||
| @ReceiveEvent(components = ClientComponent.class) | ||
| public void onChatMessage(ChatMessageEvent event, EntityRef entity) { | ||
| received = true; | ||
| lastMessage = event.getMessage(); | ||
|
agent-refr marked this conversation as resolved.
|
||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.