Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions docs/Engine-Testing-Patterns.md
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` |
Comment thread
agent-refr marked this conversation as resolved.
Outdated

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));
Comment thread
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);
```
Comment thread
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
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");
Comment thread
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");
}
}
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++;
}
}
}
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

View check run for this annotation

Terasology Jenkins.io / CheckStyle

UnusedImportsCheck

NORMAL: Unused import - org.terasology.engine.logic.players.LocalPlayer.
Raw output
<p>Since Checkstyle 3.0</p><p> Checks for unused import statements. Checkstyle uses a simple but very reliable algorithm to report on unused import statements. An import statement is considered unused if: </p><ul><li> It is not referenced in the file. The algorithm does not support wild-card imports like <code>import java.io.*;</code>. Most IDE's provide very sophisticated checks for imports that handle wild-card imports. </li><li> It is a duplicate of another import. This is when a class is imported more than once. </li><li> The class imported is from the <code>java.lang</code> package. For example importing <code>java.lang.String</code>. </li><li> The class imported is from the same package. </li><li><b>Optionally:</b> it is referenced in Javadoc comments. This check is on by default, but it is considered bad practice to introduce a compile time dependency for documentation purposes only. As an example, the import <code>java.util.Date</code> would be considered referenced with the Javadoc comment <code>{@link Date}</code>. The alternative to avoid introducing a compile time dependency would be to write the Javadoc comment as <code>{@link java.util.Date}</code>. </li></ul><p> The main limitation of this check is handling the case where an imported type has the same name as a declaration, such as a member variable. </p><p> For example, in the following case the import <code>java.awt.Component</code> will not be flagged as unused: </p><pre><code> import java.awt.Component; class FooBar { private Object Component; // a bad practice in my opinion ... } </code></pre>
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);
}
Comment thread
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);
Comment thread
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));
}
Comment thread
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);
Comment thread
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();
Comment thread
agent-refr marked this conversation as resolved.
}
}
}
Loading