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)
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.
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.
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.
Setup
Workflow
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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}");
}
}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}");
}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;
}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