Table of Contents

Testing Guide

The KeenEyes.Testing package provides mock implementations and utilities for testing ECS code without requiring full World instances.

Why KeenEyes.Testing?

Testing ECS code often requires complex setup:

// Without KeenEyes.Testing - heavy setup
using var world = new World();
world.InstallPlugin<PhysicsPlugin>();
world.InstallPlugin<RenderingPlugin>();

var entity = world.Spawn()
    .With(new Position { X = 0, Y = 0 })
    .Build();

// Run your system...
mySystem.Update(0.016f);

With KeenEyes.Testing, you can test in isolation:

// With KeenEyes.Testing - focused, fast tests
var mockContext = new MockPluginContext("Test");

var plugin = new MyPlugin();
plugin.Install(mockContext);

// Verify behavior without heavy infrastructure
Assert.Single(mockContext.RegisteredSystems);

Installation

Reference KeenEyes.Testing in your test project:

<ItemGroup>
  <ProjectReference Include="..\..\src\KeenEyes.Testing\KeenEyes.Testing.csproj" />
</ItemGroup>

Mock Capabilities

Capability mocks let you test plugins without a real World.

MockHierarchyCapability

Tests entity parent-child relationships:

var hierarchy = new MockHierarchyCapability();

// Set up test data
var parent = new Entity(1, 1);
var child1 = new Entity(2, 1);
var child2 = new Entity(3, 1);

hierarchy.SetParent(child1, parent);
hierarchy.SetParent(child2, parent);

// Query relationships
var children = hierarchy.GetChildren(parent).ToList();
Assert.Equal(2, children.Count);

// Check operation log
Assert.Equal(2, hierarchy.OperationLog.Count);
Assert.Contains(("SetParent", child1, parent), hierarchy.OperationLog);

MockValidationCapability

Tests component validation configuration:

var validation = new MockValidationCapability();

// Register a validator
validation.RegisterValidator<Health>((world, entity, health) =>
    health.Current >= 0 && health.Current <= health.Max);

// Verify registration
Assert.True(validation.HasValidator<Health>());
Assert.Single(validation.RegisteredValidators);

MockTagCapability

Tests string-based entity tagging:

var tags = new MockTagCapability();
var entity = new Entity(1, 1);

tags.AddTag(entity, "Enemy");
tags.AddTag(entity, "Boss");

Assert.True(tags.HasTag(entity, "Enemy"));
Assert.True(tags.HasTag(entity, "Boss"));

var entityTags = tags.GetTags(entity);
Assert.Equal(2, entityTags.Count);

MockStatisticsCapability

Tests memory profiling with configurable stats:

var stats = new MockStatisticsCapability
{
    TotalAllocatedBytes = 1024 * 1024,
    EntityCount = 500,
    ArchetypeCount = 10,
    ComponentTypeCount = 25
};

var memStats = stats.GetMemoryStats();
Assert.Equal(1024 * 1024, memStats.TotalAllocatedBytes);
Assert.Equal(500, memStats.EntityCount);

MockInspectionCapability

Tests entity inspection for debugging tools:

var inspection = new MockInspectionCapability();
var entity = new Entity(1, 1);

// Configure mock inspection results
inspection.SetEntityInfo(entity, new EntityInfo
{
    Name = "Player",
    Components = new List<ComponentInfo>
    {
        new() { TypeName = "Position", Value = new Position { X = 10, Y = 20 } }
    }
});

// Inspect entity
var info = inspection.Inspect(entity);
Assert.Equal("Player", info.Name);
Assert.Single(info.Components);

MockSystemHookCapability

Tests system execution hooks for profiling and debugging:

var hooks = new MockSystemHookCapability();

// Register hooks via plugin
plugin.Install(context.SetCapability<ISystemHookCapability>(hooks));

// Verify hooks were registered
Assert.True(hooks.WasHookAdded);
Assert.Equal(2, hooks.HookCount);

// Simulate system execution to test hook behavior
hooks.SimulateSystemExecution(mockSystem, 0.016f);

Testing Plugins

Basic Plugin Test

[Fact]
public void MyPlugin_RegistersSystems()
{
    // Arrange
    var mockContext = new MockPluginContext("TestWorld");

    // Act
    var plugin = new MyPlugin();
    plugin.Install(mockContext);

    // Assert
    Assert.Equal(3, mockContext.RegisteredSystems.Count);
    Assert.Contains(mockContext.RegisteredSystems,
        s => s.SystemType == typeof(MovementSystem));
}

Testing with Capabilities

[Fact]
public void CombatPlugin_TagsEntities()
{
    // Arrange
    var mockTags = new MockTagCapability();
    var mockContext = new MockPluginContext("TestWorld")
        .WithCapability<ITagCapability>(mockTags);

    // Act
    var plugin = new CombatPlugin();
    plugin.Install(mockContext);

    // Assert
    Assert.Contains(mockTags.OperationLog, e => e.Tag == "Sword");
    Assert.Contains(mockTags.OperationLog, e => e.Tag == "Arrow");
}

Testing Plugin Cleanup

[Fact]
public void MyPlugin_CleansUpOnUninstall()
{
    // Arrange
    var mockContext = new MockPluginContext("TestWorld");
    var plugin = new MyPlugin();
    plugin.Install(mockContext);

    // Act
    plugin.Uninstall(mockContext);

    // Assert
    Assert.Empty(mockContext.Extensions);
}

Testing Systems

For system testing, you typically want a real World for integration tests, but can use mocks for unit tests:

Integration Test with Real World

[Fact]
public void MovementSystem_UpdatesPositions()
{
    using var world = new World();
    var system = new MovementSystem();
    world.AddSystem(system);

    var entity = world.Spawn()
        .With(new Position { X = 0, Y = 0 })
        .With(new Velocity { X = 10, Y = 0 })
        .Build();

    world.Update(1.0f);

    ref var pos = ref world.Get<Position>(entity);
    Assert.Equal(10, pos.X);
}

Unit Test with TestWorld

Build an isolated world with TestWorldBuilder, opting into only the mocks the test needs. TestWorld.Step() advances the manual clock and runs one update; the mock renderer records every draw call it received:

[Fact]
public void RenderSystem_RecordsDrawCommands()
{
    using var test = new TestWorldBuilder()
        .WithManualTime()
        .WithMock2DRenderer()
        .Build();

    // Arrange entities in test.World and register your render system here...

    test.Step(); // advance one frame at the manual clock's fps

    Assert.NotEmpty(test.Mock2DRenderer!.Commands);
}

Mock Graphics Infrastructure

For rendering tests:

var mock2D = new Mock2DRenderer();
var mockText = new MockTextRenderer();
var mockFont = new MockFontManager();

// Use in tests
mock2D.FillRect(bounds, color);
Assert.Single(mock2D.DrawnRects);

Mock Platform Infrastructure

For input and platform tests:

var mockInput = new MockInputState();
mockInput.SetKeyDown(Key.Space);
mockInput.SetMousePosition(100, 200);

Assert.True(mockInput.IsKeyDown(Key.Space));
Assert.Equal(100, mockInput.MouseX);

Best Practices

Do

  • Use MockPluginContext for plugin installation tests
  • Use capability mocks for testing specific World features
  • Use real World for integration tests
  • Reset mock state between tests with .Clear() methods

Don't

  • Over-mock - sometimes a real World is simpler
  • Test implementation details - test behavior
  • Share mock state between tests

Test Naming Convention

// Pattern: MethodName_Scenario_ExpectedResult
[Fact]
public void SetParent_WithValidEntities_UpdatesHierarchy() { }

[Fact]
public void AddTag_WithDuplicateTag_ReturnsFalse() { }

[Fact]
public void Update_WhenDisabled_SkipsExecution() { }

Assertion Helpers

KeenEyes.Testing provides FluentAssertions-style extension methods that throw AssertionException with a descriptive message on failure. They live in the KeenEyes.Testing namespace and work against Entity, World/IWorld, TestWorld, and component values directly - no assertion library dependency required.

Entity Assertions

EntityAssertions checks alive/dead state, component presence, tags, and predicate matches:

var entity = world.Spawn().With(new Position { X = 10 }).Build();

entity.ShouldBeAlive(world);
entity.ShouldHaveComponent<Position>(world);
entity.ShouldNotHaveComponent<Velocity>(world);
entity.ShouldHaveTag<EnemyTag>(world);
entity.ShouldHaveComponentMatching<Position>(world, p => p.X > 0);

Overloads accepting a TestWorld instead of World/IWorld are also available, so assertions can chain directly off TestWorldBuilder.Build() results.

Component Assertions

ComponentAssertions operates on component struct values directly - useful after pulling a component out with world.Get<T>():

var position = world.Get<Position>(entity);

position.ShouldEqual(new Position { X = 10, Y = 20 });
position.ShouldMatch(p => p.X >= 0 && p.Y >= 0, "position should be in positive quadrant");
position.ShouldHaveField(p => p.X, 10);
position.ShouldHaveFieldInRange(p => p.X, 0f, 100f);
position.ShouldBeDefault();

ShouldHaveField and ShouldHaveFieldMatching take an Expression<Func<T, TField>> field selector and are reflection-free (AOT-compatible) - the expression tree is compiled once and the field name is only used for the failure message.

World Assertions

WorldAssertions checks entity counts, installed plugins, and query results:

world.ShouldHaveEntityCount(2);
world.ShouldNotBeEmpty();
world.ShouldHavePlugin<PhysicsPlugin>();
world.ShouldNotHavePlugin<RenderingPlugin>();
world.ShouldContainEntitiesWith<Position, Velocity>();
world.ShouldContainExactlyWith<EnemyTag>(5);

Every assertion accepts an optional because string that is appended to the failure message.

Snapshot Testing

KeenEyes.Testing.Snapshots captures the full state of a world (or a subset of entities) as plain data, then diffs two captures to verify that an operation produced exactly the changes you expect - or none at all.

Capturing Snapshots

WorldSnapshot.Create(world) walks every live entity and records an EntitySnapshot per entity (ID, version, name, and a Dictionary<string, Dictionary<string, object?>> of component field values, keyed by component type name):

using KeenEyes.Testing.Snapshots;

var before = WorldSnapshot.Create(world);
world.Update(1.0f);
var after = WorldSnapshot.Create(world);

An overload, WorldSnapshot.Create(world, entities), captures only the specified entities. WorldSnapshot exposes EntityCount, EntityIds, GetEntity(id), EntitiesWithComponent<T>(), and AllComponentTypes for inspecting a capture directly.

Comparing Snapshots

SnapshotComparer.Compare(expected, actual) returns a SnapshotComparison describing every difference - added/removed entities, version or name changes, added/removed components, and per-field value changes - each as a SnapshotDifference with a DifferenceType (EntityAdded, ComponentRemoved, FieldChanged, etc.):

var comparison = SnapshotComparer.Compare(before, after);

if (!comparison.AreEqual)
{
    Console.WriteLine(comparison.GetReport());
}

SnapshotComparer.CompareEntities(expected, actual) compares two EntitySnapshot instances directly when you only care about one entity.

Snapshot Assertions

SnapshotAssertions wraps the comparer in fluent, AssertionException-throwing checks:

after.ShouldEqual(before);
after.ShouldHaveEntityCount(before.EntityCount);
after.ShouldContainEntity(entity.Id);
after.ShouldHaveEntitiesWithComponent<Health>();

comparison.ShouldBeEqual();
comparison.ShouldHaveDifferenceCount(1);

EntitySnapshot has its own ShouldHaveComponent<T>(), ShouldNotHaveComponent<T>(), and ShouldEqual() for entity-level checks.

Recording and Playback

Two recorders capture activity during a test run for later inspection or replay.

InputRecorder / InputPlayer

KeenEyes.Testing.Input.InputRecorder subscribes to an IInputContext (keyboard, mouse, gamepad events) and records each event as a timestamped RecordedInputEvent (e.g. RecordedKeyDownEvent, RecordedMouseMoveEvent, RecordedGamepadAxisEvent). Pairing it with a TestClock synchronizes timestamps to simulation time:

using KeenEyes.Testing.Input;

using var testWorld = new TestWorldBuilder()
    .WithManualTime()
    .WithMockInput()
    .Build();

var recorder = new InputRecorder(testWorld.MockInput!, testWorld.Clock);

recorder.StartRecording(name: "jump-sequence");
testWorld.MockInput!.SimulateKeyDown(Key.Space);
testWorld.Step();
testWorld.MockInput.SimulateKeyUp(Key.Space);

var recording = recorder.StopRecording();
var json = recording.ToJson(); // or recording.ToBinary()

InputRecording can round-trip through ToJson()/FromJson() or ToBinary()/FromBinary(), making recorded sequences reusable as fixtures across test runs.

InputPlayer plays a loaded InputRecording back through a MockInputContext, firing each event once the TestClock reaches its timestamp:

var player = new InputPlayer(testWorld.MockInput!, testWorld.Clock!);

player.LoadRecording(InputRecording.FromJson(json));
player.Play();

while (player.IsPlaying)
{
    player.Update();
    testWorld.Step();
}

InputPlayer also supports Pause(), Stop(), Seek(positionMs), and an OnPlaybackComplete event.

SystemRecorder

KeenEyes.Testing.Systems.SystemRecorder attaches to a world's system hooks (AttachTo(world, phase)) and records every system execution as a SystemCall (system type, name, delta time, and UTC timestamp). TestWorldBuilder.WithSystemRecording() wires one up automatically and exposes it via TestWorld.SystemRecorder:

using var testWorld = new TestWorldBuilder()
    .WithSystemRecording()
    .WithSystem<MovementSystem>()
    .WithManualTime()
    .Build();

testWorld.Step();

var recorder = testWorld.SystemRecorder!;
recorder
    .ShouldHaveCalledSystem<MovementSystem>()
    .ShouldHaveCalledSystemTimes<MovementSystem>(1);

Assert.Equal(1, recorder.GetCallCount<MovementSystem>());

SystemRecorderAssertions adds ShouldHaveCalledSystemAtLeast<T>(), ShouldNotHaveCalledSystem<T>(), ShouldHaveTotalCallCount(), ShouldHaveNoCalls(), and ShouldHaveAccumulatedDeltaTime<T>(). SystemRecorder itself also exposes GetCalls<T>(), GetLastCall<T>(), GetTotalDeltaTime<T>(), and Clear() for direct inspection.

Test Fixtures

KeenEyes.Testing.Fixtures provides ready-made components and entity builders so tests don't need to declare throwaway component types.

CommonComponents

A set of [Component]-generated structs prefixed Test (TestPosition, TestPosition3D, TestVelocity, TestHealth, TestDamage, TestSpeed, TestRotation, TestScale, TestLifetime, TestTeam, TestCounter) plus [TagComponent] tags (PlayerTag, EnemyTag, ProjectileTag, PickupTag, DeadTag, ActiveTag, DisabledTag, InvulnerableTag). Each component has a Create(...) factory and most have convenience properties like TestHealth.Full(max), TestPosition.Zero, or TestHealth.Percentage:

using KeenEyes.Testing.Fixtures;

var entity = world.Spawn()
    .With(TestPosition.Create(0, 0))
    .With(TestHealth.Full(100))
    .WithTag<EnemyTag>()
    .Build();

EntityPresets / EntityPresetBuilder

EntityPresets provides factory methods - Player(world), Enemy(world), Projectile(world), Pickup(world), MovingEntity(world) - that return a fluent EntityPresetBuilder pre-populated with sensible defaults built from the CommonComponents above:

using var world = new World();

var player = EntityPresets.Player(world)
    .WithName("Player1")
    .AtPosition(100, 50)
    .WithHealth(100)
    .Build();

EntityPresetBuilder supports WithName, AtPosition, WithVelocity, WithHealth (single value or current/max pair), WithDamage, WithSpeed, WithLifetime, OnTeam, and WithTag<T>(), finished with Build().

For batches, EntityPresets.CreatePlayers(world, count), CreateEnemies(world, count), and CreateProjectiles(world, count) return a BatchEntityBuilder that applies the same modifiers - WithHealth, WithDamage, WithSpeed, InGrid(columns, spacing), InLine(spacing, horizontal), WithSequentialTeams(), WithAlternatingTeams(teamA, teamB), or a custom WithModifier((builder, index) => ...) - to every entity before calling Build(), which returns an Entity[]:

var enemies = EntityPresets.CreateEnemies(world, count: 5)
    .WithHealth(50)
    .InGrid(columns: 5, spacing: 32f)
    .Build();

Next Steps