Skip to content

Save — Testing Philosophy

What the tests are for

They pin the behaviours the Guarantees Matrix claims. Where the matrix says "a participant that throws does not stop the others", there is a test that makes a participant throw and asserts the others ran.


Where the tests live

Suite Location Count Covers
Coordinator + identity Tests/EditMode/Save/ 19 RevSaveCoordinator, StableId
Participants Tests/PlayMode/Integrations/Save*/ 42 The five framework participants

61 tests in total, split by what they need rather than by style.


Why the coordinator is EditMode and the participants are PlayMode

The coordinator has no Unity dependencies beyond JsonUtility. It takes participants, returns a string, and reports. That is testable with fake participants and no scene at all — so it is EditMode, where tests run in a fraction of a second.

The participants are the opposite. HealthSaveParticipant reads live HealthSystem components; StatusEffectsSaveParticipant needs real effects ticking; CraftingSaveParticipant needs a service with jobs actually in flight. Faking all of that would test the fake. So they run in PlayMode against the real systems.


Why there is no Hostile / InternalTruth split here

Every other system splits its EditMode tests into Hostile (public API only, no reflection) and InternalTruth (refactor protection, not part of the contract). Save has a single assembly, RevFramework.Core.Tests.Save.

That is not an omission. The coordinator's entire surface is public — four types, no internals worth protecting separately, and nothing a hostile consumer could not reach. A split would produce an InternalTruth assembly with nothing to put in it.

If the coordinator ever grows internal machinery, the split should follow. Until then it would be structure for its own sake.


What the coordinator tests prove

Read the names and you have the contract:

CaptureThenRestore_DeliversTheSamePayloadToTheSameParticipant
Restore_ReportsTheVersionThePayloadWasWrittenWith_NotTheCurrentOne
Capture_WhenAParticipantHasNothingToSave_RecordsItAsSkipped
Capture_WhenOneParticipantThrows_StillCapturesTheRest
Restore_WhenOneParticipantThrows_StillRestoresTheRest
Restore_WhenNoParticipantClaimsASection_ReportsItWithoutFailing
Capture_WithCarryOver_PreservesDataForSystemsThisBuildDoesNotHave
Capture_WhenCarryOverCollidesWithALiveParticipant_TheLiveOneWins
Capture_WithDuplicateKeys_KeepsTheFirstAndReportsTheClash
Restore_WithDuplicateKeys_RoutesToTheFirstAndReportsTheClash
Capture_WithANullParticipant_IgnoresItAndCapturesTheRest
Restore_WithEmptyPayload_ReportsFatallyWithoutThrowing
Restore_WithCorruptPayload_ReportsFatallyWithoutThrowing
Restore_WithANewerEnvelopeVersion_StillRestoresRecognisedSections
Restore_WithNoParticipants_ReportsEverySectionAsUnrecognised

Plus the identity tests, which pin the trap rather than the happy path:

AssignId_SetsTheIdTheComponentReports
AssignId_ReplacesAnExistingValue
AssignId_RejectsBlankValues
DuplicatingAnObject_CopiesItsId_WhichIsWhyCollisionsHappen

That last one is a characterization test. It does not assert that duplication is correct — it asserts that it happens, so the day someone "fixes" it by auto-regenerating ids, the test fails and the conversation happens before saves break.


What the participant tests prove

Each of the five covers the same shape: round trip, nothing-to-save, through-the-coordinator, and its own failure modes. Three of them pin the trades the changelog and the Integrations README call out, which is what stops those trades from quietly becoming defects:

Trade Pinned by
A renamed recipe invalidates saves referencing it — the job is dropped, the load still succeeds Restore_WhenTheRecipeIsUnknown_DropsTheJobWithoutFailingTheLoad
Restoring a status runs its Apply, so a status owning state another participant restores can apply it twice Restore_RunsTheEffectsSideEffects
An owner without a StableId is invisible Restore_WhenTheOwnerHasNoStableId_DropsTheJob

Refusing a newer payload version is pinned too (Restore_WithANewerPayloadVersion_IsRefused), because "refuse rather than guess" is a decision, not an accident.


What is deliberately not tested

Storage. There is no test that writes a file, because the coordinator does not write files. Where the string goes is the caller's, so testing it here would be testing System.IO.

Cross-participant consistency. Sections capture in sequence, and the system does not promise a single instant. There is no test asserting one, because there is no guarantee to pin.

Every payload format in detail. Participant tests assert the round trip preserves what matters — balances, contents, remaining time — not the exact JSON. Asserting on the bytes would freeze a format the design explicitly leaves free to change.

Performance. No benchmarks. Save volume is dominated by how much state a game has, not by the routing.


If you write your own participant

The round trip is the test worth writing first, and often the only one you need:

[Test]
public void CaptureThenRestore_BringsBackTheState()
{
    var participant = new QuestSaveParticipant();
    SetUpSomeState();

    string json = RevSaveCoordinator.Capture(new[] { participant });

    ClearAllState();
    var report = RevSaveCoordinator.Restore(json, new[] { participant });

    Assert.IsTrue(report.Success, report.ToString());
    AssertStateCameBack();
}

Two things worth adding after that:

  1. A nothing-to-save case — assert Capture returns null and the section records as Skipped.
  2. A negative control — before asserting state came back, assert that ClearAllState() genuinely cleared it. Without that, a restore that does nothing passes a test that looks thorough.

That second point is the failure mode that has caught this repo before: an assertion that passes because the fixture could never have produced the opposite result.


Running them

Coordinator tests run under the normal EditMode runner.

Participant tests must not run under -nographics

They are PlayMode tests against live components, so they need a graphics device — but they do not need a human. -batchmode on its own is fine, and all 69 of them pass that way; it is -nographics that stops the things they depend on from firing, and a test that never fires passes having tested nothing. The Editor Test Runner works too, and is the easier place to read a failure.