5.4 KiB
| name | description |
|---|---|
| testing-games | Test OECS-based games via unit tests, snapshots, playtests with AI agents, reactivity logs, and serialization round-trips. Use this when writing or running tests for a game built on OECS. |
Testing Games with OECS
Read docs/testing-games.md for the testing strategy overview. This skill
provides the detailed patterns and conventions.
Test Project Setup
A test project references the game DLL plus xUnit and FluentAssertions:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Game.YourGame.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Game.YourGame\YourGame.csproj" />
</ItemGroup>
</Project>
Three Test Categories
1. Unit / Game Flow Tests
Test individual game rules in isolation. Each test sets up a fresh World,
enqueues commands, runs a tick, and asserts state:
[Fact]
public void PlaceBet_AdvancesToDealing()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.PlayerTurn);
state.Chips.Should().Be(90);
}
Common test helpers:
SetupGame(seed?)— create world, register systems, set initial singletons.FindEntity<T>(world)— find first non-singleton entity with component T.CountCardsInHand(world, handEntity)orGetHandCards(world, handEntity).
Always keep helpers in the test class (or a shared base) rather than in the game DLL — they are test infrastructure.
2. Snapshot & Log Tests
Capture world state as text and R3 change logs for manual review:
[Fact]
public void Snapshot_AfterDeal()
{
var (world, group) = SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var snapshot = SnapshotWorld(world);
_output.WriteLine(snapshot); // ITestOutputHelper
snapshot.Should().Contain("Phase: PlayerTurn");
snapshot.Should().Contain("Player hand:");
snapshot.Should().Contain("Dealer hand:");
}
SnapshotWorld(world) should produce a human-readable text block containing
all singletons, entity counts, hand contents, and any other debug-relevant state.
Reactivity log test pattern:
var log = new List<string>();
world.ObserveComponentChanges<Card>().Subscribe(change =>
log.Add($"[card] {change}"));
world.ObserveComponentChanges<GameState>().Subscribe(change =>
log.Add($"[gamestate] {change}"));
// ... run game ...
log.Should().Contain(l => l.Contains("EntityAdded"));
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
3. Play Tests
AI-driven multi-round playtests. See docs/testing-games.md for the full
strategy. Key patterns:
Agents implement a simple interface:
private interface IYourGameAgent
{
Decision Decide(World world);
}
Built-in agent types:
- Greedy/Basic Strategy: score actions, pick the highest. For blackjack: hit if total < 17, stand otherwise.
- Random: evenly choose moves randomly (but never do illegal moves).
- Weighted Pool: pick from a pool of agents by weight each round.
Round runner loops until a terminal condition:
while (true)
{
int chips = world.ReadSingleton<GameState>().Chips;
if (chips < BetAmount) { /* busted */ break; }
if (chips >= StartingChips * 2) { /* doubled */ break; }
if (totalRounds >= 200) { /* safety cap */ break; }
// Place bet, deal, agent decides hit/stand, resolve, new round.
}
Play logs are saved as .playlog files to AppContext.BaseDirectory/playlogs/:
Blackjack: Basic Strategy (seed=42, agent=BasicStrategy) — BUSTED after 38 rounds | W:12 L:22 P:4
=================================================================================================
--- Round Summaries ---
Round 1: PlayerBust | Bet=10 | Chips: 100→90 (-10) | Hits: 1
...
--- Decisions ---
1. R1 Hand=12, Decision=Hit
...
--- Reactivity ---
ComponentModified GameState = ...
...
--- Final State ---
Phase: Betting
Chips: 0, Bet: 10
...
Serialization Round-Trip
Every game must have a serialization test:
[Fact]
public void Serialization_RoundTrips()
{
var (world, group) = SetupGame(seed: 42);
// ... run some game state ...
using var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
// Assert key state survived:
var state = world2.ReadSingleton<GameState>();
state.Chips.Should().Be(expected);
}
Running Tests
dotnet test "E:/projects/oecs-sharp/Game.YourGame.Tests/Game.YourGame.Tests.csproj"
To run only playtests:
dotnet test ... --filter "FullyQualifiedName~PlayTests"