Toolingcom.zoa.testkit · v0.1.0

ZOA TestKit

Deterministic fixtures, golden-data comparison, scenario runs, and a detector for the tests that lie to you.

A test whose result depends on the seed, the wall clock or the machine it runs on reports noise. Everything in TestKit removes a source of nondeterminism or catches one that slipped in anyway.

The base fixture covers the two usual culprits, randomness and time, by handing a test a seeded random source and a clock it advances by hand. Hand-written expectations drift, so the golden runner hashes recorded output and compares. The scenario runner turns a multi-step behaviour into an ordered, named sequence with a result per step. The flaky detector watches results across runs, so a test that alternates gets scored as flaky instead of re-run until it passes.

The package depends only on com.zoa.foundation, and the core models carry no Unity dependency, so a runner or a detector can be used from a plain test assembly.

Depends on (1)

Depended on by (0)

Nothing yet. This is a leaf.

How it works

Concepts

Determinism by construction

DeterministicTestFixture is an NUnit [TestFixture] base class with a setup and teardown that seed the environment per test. Inherit from it instead of writing a raw fixture whenever a test touches randomness, time, or shared state.

DeterministicRandom is a seeded System.Random, and RandomFloat, RandomInt and RandomPick draw from it. A test that calls System.Random directly throws away the value that made it fail, and the failure cannot be reproduced from the report. FixedSeed is a virtual property a fixture overrides to pin the seed; CurrentSeed reports what was used.

The test drives the clock. TestTime starts at zero, AdvanceTime moves it by a delta, SimulateFrames advances a given number of frames at a fixed delta defaulting to one sixtieth of a second, and ResetTime returns to zero. A system tested this way behaves identically on a fast machine and a loaded CI runner.

OnSetUp and OnTearDown are the hooks a fixture overrides rather than declaring its own [SetUp], so the deterministic setup always runs first.

Golden data comparison

A golden test registers an expected JSON payload against a GoldenTestId, then compares an actual payload against it. RunTest returns a GoldenTestResult carrying pass or fail, the expected and actual hashes, a diff, and the execution time.

Comparison goes through TestHashUtility: ComputeHash is SHA256 over the UTF-8 bytes returned as hex, and CompareJson does the comparison and produces the diff. Hashing keeps the pass or fail decision cheap and exact, and the diff explains what moved.

GoldenTestId is a readonly struct with implicit conversions in both directions, so a string literal is a valid id. RunAll runs every registered test. GetRegisteredTests enumerates the registrations, and the Workbench capability lists them.

Scenarios are ordered, named steps

A ScenarioDefinition is built fluently: construct it with an id, name and description, then chain AddStep with a description, an action and an expected outcome, and AddTag for filtering. Steps and tags are exposed as read-only lists.

Ordering and execution are separate contracts. IScenarioRunner owns registration and ordering; IScenarioExecutor owns what a step actually does, with ExecuteStep returning pass or fail and GetStepOutput returning the message. One scenario definition can therefore run against a real system in one harness and a stub in another.

ScenarioRunner takes a stopOnFirstFailure flag defaulting to true. RunScenario returns the steps with Passed and ErrorMessage filled in, so a failure report says which step failed and what it expected rather than just that the scenario failed.

Flakiness is a measurement

FlakyTestDetector keeps a bounded history per test, defaulting to the last ten runs. The flakiness score is the number of pass-fail state transitions divided by the window size minus one, so a test that alternates every single run scores 1.0 and a test that fails consistently scores 0. Consistent failure is a bug; alternation is flakiness, and the two need different responses.

RecordResult is called after each execution with the test's full name, whether it passed, and optionally its duration. GetFlakyTests returns everything above the threshold, which defaults to 0.3. GetReport returns one test's FlakyTestReport with its score, pass rate, run count and average duration.

The detector reports; it does not quarantine. Skipping a flaky test is done with an NUnit category or an [Ignore] attribute, which keeps the decision visible in source rather than buried in tooling.

Coverage thresholds gate the release

TestCoveragePolicy is a static table of minimum test counts per package, tiered by how much depends on the package. Foundation requires twenty; inventory, equipment, armory and persistence require ten; workbench, weapon modding and nucleon require eight; gameplay systems require four to six; newer packages ramp from two. Anything not listed falls back to DefaultMinimumTests, which is two.

GetThreshold resolves a package's number. The release gate reads it, so a package that drops below its threshold fails the gate rather than quietly shipping untested.

Wizard smoke tests

A SmokeTestDefinition names a wizard by type name, states how many steps it should have, and lists the fields it must expose, built up with AddRequiredField and AddTag. WizardSmokeTestRunner is a three-step editor window that selects wizards, runs the checks, and shows results including the actual step count when it does not match.

This catches the specific failure where a wizard still opens but has lost a step or a field through a refactor, which no compile check and no unit test on the underlying service would notice.

In the editor

Screens

Screenshot pending

/screenshots/testkit-golden-tests.png

The TestKit module's Golden Tests capability listing registered test ids with pass and fail state, and one failure expanded to show the expected and actual hashes and the diff.

Golden tests

Screenshot pending

/screenshots/testkit-scenario-runner.png

A scenario mid-run with its steps listed in order, the completed steps marked passed, one step failed with its error message, and the remaining steps not executed because stopOnFirstFailure is set.

Scenario runner

Screenshot pending

/screenshots/testkit-pipeline-matrix.png

The URP and HDRP validation grid: samples down one axis, pipelines across the other, pass and fail cells, and a captured screenshot preview for one entry.

Pipeline matrix

Setup

Workflow

  1. 01

    Inherit the fixture

    Derive your test class from DeterministicTestFixture rather than writing a bare [TestFixture]. Override FixedSeed to pin the seed and OnSetUp for per-test arrangement; the deterministic setup runs first either way.

  2. 02

    Drive time yourself

    Replace any wait or real-time dependency with AdvanceTime or SimulateFrames. A system tested against a controlled clock produces the same result on a developer machine and a loaded CI runner.

  3. 03

    Record a golden payload

    Serialise the system's output to JSON, register it against a GoldenTestId, and compare on each run. Regenerate the golden payload when the behaviour change is intended, and read the diff when it is not.

  4. 04

    Write scenarios for behaviour that spans steps

    Build a ScenarioDefinition with one AddStep per meaningful action and its expected outcome, then implement an IScenarioExecutor that performs each action against the real system. A failing run names the step and the outcome it expected.

  5. 05

    Feed the flaky detector from CI

    Call RecordResult after every test execution across runs and inspect GetFlakyTests. Quarantine what it names with an NUnit category or [Ignore] so the decision stays visible in source.

  6. 06

    Check the coverage gate

    TestCoveragePolicy.GetThreshold gives a package's minimum. The release gate in the Workbench reads the same table, so a package below its number fails the gate before it ships.

Surface

Key types

DeterministicTestFixture

class

The base class to inherit whenever a test touches randomness, time or shared state. Seeds the random source and gives you a clock you advance by hand.

  • protected Random DeterministicRandom { get; }
  • protected double TestTime { get; }
  • protected float DeltaTime { get; }
  • protected int CurrentSeed { get; }
  • protected virtual int FixedSeed => 0
  • protected virtual void OnSetUp()
  • protected virtual void OnTearDown()
  • protected void AdvanceTime(float delta)
  • protected void SimulateFrames(int frameCount, float fixedDelta = 1f / 60f)
  • protected void ResetTime()
  • protected float RandomFloat()
  • protected int RandomInt(int min, int max)
  • protected T RandomPick<T>(IList<T> items)

IGoldenDataTestRunner

interface

Registers expected payloads and compares actual output against them, returning a hashed result with a diff.

  • void RegisterGoldenData(GoldenTestId testId, string expectedJson)
  • GoldenTestResult RunTest(GoldenTestId testId, string actualJson)
  • IList<GoldenTestResult> RunAll()
  • IList<GoldenTestId> GetRegisteredTests()

GoldenTestResult

class

The outcome of one comparison: pass or fail, both hashes, the diff that explains a failure, and how long it took.

  • GoldenTestId TestId { get; set; }
  • bool Passed { get; set; }
  • string ExpectedHash { get; set; }
  • string ActualHash { get; set; }
  • string Diff { get; set; }
  • long ExecutionTimeMs { get; set; }

GoldenTestId

struct

Readonly id with implicit conversions to and from string, so a literal is valid at any call site.

  • string Value { get; }

TestHashUtility

class

SHA256 over UTF-8 bytes returned as hex, plus the JSON comparison that produces the diff a failing golden test reports.

  • static string ComputeHash(string data)
  • static bool CompareJson(string expected, string actual, out string diff)

IScenarioRunner

interface

Owns registration and ordering. Execution is delegated to an IScenarioExecutor so the same scenario can run against different harnesses.

  • void RegisterScenario(ScenarioDefinition scenario)
  • IList<ScenarioStep> RunScenario(string scenarioId, IScenarioExecutor executor)
  • IList<ScenarioDefinition> GetRegisteredScenarios()

IScenarioExecutor

interface

What a step actually does. Return false to fail the step and expose the reason through GetStepOutput.

  • bool ExecuteStep(ScenarioStep step)
  • string GetStepOutput()

ScenarioDefinition

class

A named, ordered sequence of steps built fluently. Tags make a subset selectable.

  • ScenarioDefinition(string id, string name, string description)
  • ScenarioDefinition AddStep(string description, string action, string expectedOutcome)
  • ScenarioDefinition AddTag(string tag)
  • IReadOnlyList<ScenarioStep> Steps { get; }
  • IReadOnlyList<string> Tags { get; }

ScenarioStep

class

One step and its result. Passed and ErrorMessage are filled in by the run, so a report names the step that failed and what it expected.

  • int StepIndex { get; set; }
  • string Description { get; set; }
  • string Action { get; set; }
  • string ExpectedOutcome { get; set; }
  • bool Passed { get; set; }
  • string ErrorMessage { get; set; }

ScenarioRunner

class

The shipped runner. Constructed with stopOnFirstFailure, defaulting to true, so a scenario stops at the first broken step rather than cascading.

  • ScenarioRunner(bool stopOnFirstFailure = true)

FlakyTestDetector

class

Scores each test on how often it flips between pass and fail across a rolling window. Alternation is flakiness; consistent failure is a bug, and the score separates them.

  • FlakyTestDetector(int windowSize = 10, double flakinessThreshold = 0.3)
  • void RecordResult(string testFullName, bool passed, double durationMs = 0)
  • IReadOnlyList<FlakyTestReport> GetFlakyTests()
  • FlakyTestReport GetReport(string testFullName)
  • void Reset()

FlakyTestReport

class

One test's record: flakiness score, pass rate, how many runs are in the window, and average duration.

  • string TestFullName { get; }
  • double FlakinessScore { get; }
  • double PassRate { get; }
  • int RunCount { get; }
  • double AverageDurationMs { get; }

TestCoveragePolicy

class

Per-package minimum test counts, tiered by how much depends on the package. The release gate reads this, so falling below the threshold fails the gate.

  • static IReadOnlyDictionary<string, int> PackageThresholds { get; }
  • const int DefaultMinimumTests = 2
  • static int GetThreshold(string packageName)

SmokeTestDefinition

class

What a wizard should look like: its type name, its expected step count, and the fields it must expose. Catches a wizard that still opens but has lost a step.

  • SmokeTestDefinition(string wizardTypeName, int expectedStepCount)
  • SmokeTestDefinition AddRequiredField(string fieldName)
  • SmokeTestDefinition AddTag(string tag)
  • IReadOnlyList<string> RequiredFields { get; }
  • IReadOnlyList<string> Tags { get; }

TestMatrixEntry

class

One cell of the render-pipeline validation matrix: which pipeline, which sample, which test, whether it passed, and the screenshot captured for it.

  • string Pipeline { get; set; }
  • string SampleName { get; set; }
  • string TestName { get; set; }
  • bool Passed { get; set; }
  • string ErrorMessage { get; set; }
  • string ScreenshotPath { get; set; }

Usage

Examples

A deterministic fixturecsharp
using NUnit.Framework;
using ZOA.TestKit.Core;

public sealed class LootRollTests : DeterministicTestFixture
{
    // Pin the seed so a failure is reproducible from the test name alone.
    protected override int FixedSeed => 20260904;

    private LootTable _table;

    // Override OnSetUp rather than declaring [SetUp], so the deterministic
    // setup always runs first.
    protected override void OnSetUp()
    {
        _table = LootTable.Create();
    }

    [Test]
    public void RollsAreStableForASeed()
    {
        var first = _table.Roll(RandomFloat());
        var second = _table.Roll(RandomFloat());

        Assert.AreNotSame(first, second);
        Assert.AreEqual("scrap", first.Id, $"seed {CurrentSeed} produced {first.Id}");
    }

    [Test]
    public void CooldownExpiresAfterTenSeconds()
    {
        _table.BeginCooldown(seconds: 10f);

        // Time is a value the test moves, not something it waits for.
        SimulateFrames(frameCount: 600);

        Assert.IsFalse(_table.IsOnCooldown, $"t={TestTime:F2}");
    }
}
A golden data testcsharp
using NUnit.Framework;
using ZOA.TestKit.Core.Contracts;
using ZOA.TestKit.Core.Models;
using ZOA.TestKit.Core.Runtime;

[Test]
public void StatBlockEvaluationMatchesGolden()
{
    IGoldenDataTestRunner runner = new GoldenDataTestRunner();

    // GoldenTestId converts implicitly from string.
    runner.RegisterGoldenData(
        "stats.rifleman.evaluated",
        System.IO.File.ReadAllText("Golden/stats_rifleman.json"));

    var actual = BuildRiflemanStats().ToJson();
    GoldenTestResult result = runner.RunTest("stats.rifleman.evaluated", actual);

    // The hashes decide pass or fail; the diff is what makes it actionable.
    Assert.IsTrue(result.Passed,
        $"expected {result.ExpectedHash}, got {result.ActualHash}\n{result.Diff}");
}
A scenario and its executorcsharp
using ZOA.TestKit.Core.Contracts;
using ZOA.TestKit.Core.Models;
using ZOA.TestKit.Core.Runtime;

var scenario = new ScenarioDefinition(
        id: "pickup-equip-fire",
        name: "Pick up, equip and fire",
        description: "The core weapon loop end to end.")
    .AddStep("Approach the rack", "MoveTo(rack)", "Interaction prompt is shown")
    .AddStep("Take the rifle", "Interact()", "Rifle enters the inventory")
    .AddStep("Equip it", "EquipSlot(Primary)", "Rifle is the active weapon")
    .AddStep("Fire", "Attack()", "Round count decreases by one")
    .AddTag("weapons")
    .AddTag("smoke");

IScenarioRunner runner = new ScenarioRunner(stopOnFirstFailure: true);
runner.RegisterScenario(scenario);

foreach (var step in runner.RunScenario("pickup-equip-fire", new HarnessExecutor()))
{
    if (!step.Passed)
        UnityEngine.Debug.LogError(
            $"Step {step.StepIndex} '{step.Description}' failed: {step.ErrorMessage}");
}

// The executor is the seam: swap it to run the same scenario against a
// stub, a live scene, or a headless harness.
internal sealed class HarnessExecutor : IScenarioExecutor
{
    private string _output = string.Empty;

    public bool ExecuteStep(ScenarioStep step)
    {
        _output = string.Empty;
        return true;
    }

    public string GetStepOutput() => _output;
}
Detecting flakiness across runscsharp
using ZOA.TestKit.Core;

// Window of ten runs; a score above 0.3 is reported as flaky.
var detector = new FlakyTestDetector(windowSize: 10, flakinessThreshold: 0.3);

foreach (var run in ciHistory)
    detector.RecordResult(run.TestFullName, run.Passed, run.DurationMs);

foreach (var report in detector.GetFlakyTests())
{
    // A score near 1.0 means the test alternates almost every run.
    // A test that fails consistently scores 0 and is a bug, not flakiness.
    UnityEngine.Debug.LogWarning(
        $"{report.TestFullName}: flakiness {report.FlakinessScore:F2}, " +
        $"pass rate {report.PassRate:P0} over {report.RunCount} runs, " +
        $"avg {report.AverageDurationMs:F1}ms");
}

Tooling

Editor tools

TestKit (Workbench)

Workbench > System & Configuration > TestKit

Four capabilities: Golden Tests for the registered comparison set, Scenario Runner for executing and validating scenarios, Pipeline Matrix for the URP and HDRP sample validation grid, and an Overview covering architecture and usage.

Wizard Smoke Test Runner

Workbench > System & Configuration > TestKit

A three-step editor window: select wizards, run the smoke tests, review results. Reports the actual step count when it does not match the SmokeTestDefinition's expectation.

Read this

Notes and caveats

See also