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

Fixture Location Covers
RevSaveCoordinatorTests Tests/EditMode/Save/ Routing, isolation, carry-over, displacement, restore ordering, bad input
RevSaveManagerTests Tests/EditMode/Save/ Registration, slots, carry-over across loads, store failure, and FileSaveStore against a real directory
StableIdTests Tests/EditMode/Save/ Identity, duplication, prefab instancing, and the shared scene-scan scope
SaveRestoreClassificationTests Tests/EditMode/Core/Save/ RevSaveRestore.MarkMutated — which list a failure lands in
Five participant fixtures Tests/PlayMode/Integrations/Save*/ Health, Currency, Inventory, Crafting, Status Effects against the real systems

Split by what they need, not by style.

No totals on this page, deliberately

A count here is a number that rots the moment someone adds a test, and it has rotted before. The authoritative figures are derived from the run itself — see TESTING.md. What is worth writing down is which fixture pins which contract, because that is the thing a reader is actually looking for when they arrive here.


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 manager and the store sit there too, for the same reason: RevSaveManager is a MonoBehaviour, but an AddComponent on a bare GameObject is all it needs, and FileSaveStore needs a directory rather than a scene.

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's own assembly, RevFramework.Core.Tests.Save, is not split. Only SaveRestoreClassificationTests sits outside it, in the general RevFramework.Core.Tests.EditMode assembly with the rest of Core.

That is not an omission. Every type in Runtime/Core/Save is public — the coordinator, the manager, the store and its interface, the participant interfaces, the report and its parts, the envelope. So is every member a consumer could want.

The internal members that do exist are the writing half of that surface: the Add*, SetFatal and SetSavedAt methods on RevSaveReport, and the scope that RevSaveRestore opens around each participant call. They are not a hidden model to protect — they are how the coordinator fills in a report a consumer only ever reads. Every one is already pinned through the observable result: which list a section lands in, and what the report says about it. Reaching for them directly is what an InternalTruth fixture would have done, and it would assert less.

If the save layer ever grows internal machinery whose behaviour is not visible from outside, 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_WithJsonThatIsNotASave_ReportsFatallyRatherThanSucceeding
Restore_WithNoParticipants_ReportsEverySectionAsUnrecognised

Capture_ReportsTheSameStampItWroteIntoTheEnvelope
Restore_ReadsTheEnvelopeStampOntoTheReport
Restore_ParsesTheStampAsUtc_NotTheMachinesLocalKind
Restore_WithNoStamp_ReportsNullRatherThanADefaultDate
Restore_WithAnUnreadableStamp_ReportsNullAndStillRestores

The last five are the envelope stamp. The third of them is the one worth reading twice: a bare parse returns DateTimeKind.Unspecified, which subtracts from UtcNow without complaint and is wrong by the machine's offset — so it passes in London and hands out an extra hour in Berlin. A name is the only place that decision is visible.

Restore ordering has its own block, because IRevSaveOrdered is the one thing here that reorders a caller's list and the promise that makes it safe is not reordering anything else:

Restore_MovesALateParticipant_AfterOnesSuppliedBeforeIt
Restore_MovesAnEarlyParticipant_BeforeOnesSuppliedBeforeIt
Restore_LeavesUndeclaredParticipants_InTheOrderTheyWereSupplied
Restore_KeepsSuppliedOrder_BetweenParticipantsDeclaringTheSameOrder
Restore_KeepsSuppliedOrder_AcrossManyParticipantsDeclaringTheSameOrder
Restore_ReportsAThrowingRestoreOrder_AndStillRestoresTheSection
Restore_AcceptsASingleEnumerationSequence

The two tie tests are not a duplicate

List.Sort falls back to insertion sort below sixteen elements, which is stable — so a small fixture asserting a tie-break passes against a comparer that has none. The second test uses eighteen participants with the two declared orders interleaved, deliberately, and the count must not be tidied down. At that size nothing preserves the caller's order by accident.

This is the shape a test has to take to pin a guarantee the runtime might satisfy for the wrong reason. The negative control is the point of it.

And the classification block, in SaveRestoreClassificationTests, which decides whether a caller may keep the data:

AnUnexpectedException_AfterMarkMutated_IsPartiallyApplied
AnUnexpectedException_WithoutMarkMutated_IsUnapplied_Control
RevSavePartialRestoreException_IsStillPartiallyApplied
TheMark_DoesNotLeakToTheNextParticipant
MarkMutated_OutsideARestore_IsHarmless

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 the manager and store tests prove

RevSaveManagerTests covers the layer above the coordinator, and it does write real files — into a fresh temporary directory outside the project, deleted in TearDown.

Two halves. The first drives RevSaveManager against an in-memory IRevSaveStore that can be told to fail, because the interesting behaviour there is what the manager does, not what a disk does:

SaveThenLoad_RestoresParticipantState
Register_RefusesTheSameInstanceTwice
Load_KeepsUnclaimedSections_AndTheNextSaveWritesThemBack
Load_CarriesForwardASectionAParticipantRefused
Load_ReplacesCarriedOverSections_RatherThanAccumulating
DiscardCarriedOver_DropsThem
Load_OfAMissingSlot_ReportsFatalRatherThanThrowing
Save_WhenTheStoreCannotWrite_ReportsFatal
LoadCompleted_FiresEvenWhenTheLoadFailed
AThrowingSubscriber_DoesNotStopTheOthers_OrReachTheCaller

The second is FileSaveStore against a real directory — a round trip, an overwrite, a listing that ignores files that are not slots, a delete that reports whether there was anything to delete, and the name validation on both slots and folders:

FileSaveStore_RejectsASlotNameThatIsNotAPlainName
FileSaveStore_AcceptsOrdinarySlotNames
FileSaveStore_DoesNotWriteOutsideItsRoot_ForATraversingSlot
FileSaveStore_RefusesAFolderNameThatIsNotAPlainName_AndStaysInPersistentDataPath
FileSaveStore_RefusesAnAbsolutePathPassedAsAFolderName
FileSaveStore_RoundTripsAndLeavesNoTemporaryFile
FileSaveStore_OverwritesAnExistingSlot
FileSaveStore_ListsOnlyItsOwnSlots_AndAnswersForAnEmptyDirectory
FileSaveStore_DeleteReportsWhetherThereWasAnythingToDelete

Those are not System.IO tests. Every one of them pins a decision: that a traversing slot is refused rather than sanitised, that an absolute path passed as a folder name does not silently relocate every save, that a successful write leaves no .tmp behind, and that an absent directory answers "no saves" rather than failing.


What is deliberately not tested

The interruption windows. FileSaveStore writes through a temporary file and then swaps it in. The swap is a window a failure can land in, and no test kills a process inside it — that would need a child process and a race, and the result would be a flake rather than a guarantee. What is tested is the reachable half: that a store reporting failure produces a fatal report, and that the report does not claim the previous save survived, because neither the manager nor the shipped store can promise that. See Public API → Write failure for exactly where the guarantee stops.

Platform storage. No test runs against WebGL's absent synchronous filesystem, a console's write budget, or a cloud store's conflict policy. IRevSaveStore exists so those are yours to implement and yours to test.

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, manager, store and identity 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 the whole set passes 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.