Write xUnit tests for a C# game with onegame’s test API: drive a headless game by id and command, run scenario files, and prove determinism with RunTwice, SnapshotRestore and Replay.
RecursiveAI.OneGame.Testing runs the game headless inside the test process, through the same operations as scenarios and live control. onegame test runs these tests together with the scenarios.
The test project
Put it in tests/ inside the game’s folder. onegame test runs every project under the game folder whose name contains “Test”, with dotnet test --project.
tests/Orchard.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <OutputType>Exe</OutputType> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <ProjectReference Include="../orchard.csproj" /> <PackageReference Include="RecursiveAI.OneGame.Testing" Version="1.1.2" /> <PackageReference Include="RecursiveAI.OneGame.Live" Version="1.1.2" /> <PackageReference Include="xunit.v3" Version="4.0.2" /> <Using Include="Xunit" /> </ItemGroup> <ItemGroup> <None Include="../scenarios/**" LinkBase="scenarios" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup> </Project>
Two more files in the game’s folder. The game’s project must leave the tests out of its own compile:
<Project Sdk="RecursiveAI.OneGame.Sdk/1.1.2"> <PropertyGroup> <AssemblyName>Orchard</AssemblyName> <RootNamespace>Orchard</RootNamespace> </PropertyGroup> <ItemGroup> <!-- The tests are their own project. --> <Compile Remove="tests/**" /> </ItemGroup> </Project>
And a global.json that selects the Microsoft.Testing.Platform runner, which dotnet test --project needs on the .NET 10 SDK:
{ "test": { "runner": "Microsoft.Testing.Platform" } }
Without it, onegame test runs the scenarios, then the test project fails with MSBUILD : error MSB1001: Unknown switch. (Switch: --project), and the run exits 1.
The tests
tests/TreeTests.cs, for the apple tree from Getting started:
using OneGame.Testing; namespace Orchard.Tests; public class TreeTests { private static readonly TestOptions Options = new() { Width = 640, Height = 360, Seed = 7 }; // Pick one apple every 40 frames. private static void PickNow(TestClient client, int frame) { if (frame % 40 == 39) { client.Send("Tree", "Pick", "1"); } } [Fact] public void PickingTakesAnAppleFromTheTree() { using var cluster = TestCluster.Start(new OrchardGame(), Options); var client = cluster.Client; client.Step(300); var before = int.Parse(client.Get("Tree", "Apples")); client.Click("pick").Step(1); Assert.Equal(before - 1, int.Parse(client.Get("Tree", "Apples"))); Assert.Equal("1", client.Get("Tree", "Picked")); } [Fact] public void ThePickApplesScenarioPasses() { using var cluster = TestCluster.Start(new OrchardGame(), Options); var result = cluster.Run("scenarios/pick-apples.yaml"); Assert.True(result.Passed, result.ToString()); } [Fact] public void TheSameRunTwiceGivesTheSameHashEveryFrame() => Determinism.RunTwice(() => new OrchardGame(), PickNow, 600, Options).ThrowIfDifferent(); [Fact] public void ASnapshotRestoredHalfwayFinishesOnTheSameHashes() => Determinism.SnapshotRestore(() => new OrchardGame(), PickNow, 600, splitFrame: 300, Options).ThrowIfDifferent(); [Fact] public void ARecordingReplaysToTheSameHashes() => Determinism.Replay(() => new OrchardGame(), PickNow, 600, Options).ThrowIfDifferent(); }
onegame testPASS pick-apples (40 ms, tick 302, hash 9a447aae324c764b) 1 passed, 0 failed Running tests from /tmp/games/orchard/tests/bin/Debug/net10.0/Orchard.Tests.dll (net10.0|x64) /tmp/games/orchard/tests/bin/Debug/net10.0/Orchard.Tests.dll (net10.0|x64) passed (370ms) Test run summary: Passed! total: 5 failed: 0 succeeded: 5 skipped: 0 duration: 509ms unit tests: 5 passed, 0 failed, 0 skipped
TestCluster.Start(game, options)starts one headless node.cluster.Clientclicks by id (Click), presses keys (Press), types (Type), sends commands as a player would (Send, run on the next tick), steps frames (Step) and reads one synced property as text (Get).Call(op, json)runs any live-control operation, andCapturerenders a frame.cluster.Run(path)runs a scenario file against the game and returns its result.Determinism.RunTwiceruns the script in two fresh games and compares the state hash after every frame.SnapshotRestoretakes a snapshot atsplitFrame, restores it into a new game and checks the rest of the run lands on the same hashes.Replayrecords the run and replays it.ThrowIfDifferent()fails the test with the first frame that differs and both hashes.TestOptions.Seedfixes the world’s seed;Width,HeightandScaleset the window, andGpuset to off renders captures on the CPU.
Good to know
RecursiveAI.OneGame.Liveis the development tools package (scenario runs, captures, replays) that a Debug build of the game carries and the test API drives.onegame test --no-unitskips the C# tests;dotnet test --project tests/Orchard.Tests.csprojruns only them.- To test servers and clients as separate processes,
TestCluster.Launch(spec)starts a cluster from the same spec asonegame cluster: see Client/server sync with a dedicated server.