Quick answer: Store representative save files from every released version of your game as test fixtures. Write automated tests that load each fixture in the current build and verify that player progression, inventory, settings, and quest state survive the migration. Run these tests in CI on every commit so that any change that breaks backward compatibility is caught immediately.
Nothing destroys player trust faster than a lost save file. A player who has invested 80 hours into your RPG updates to the latest version and finds their progress gone — or worse, corrupted into an unplayable state. The reviews write themselves: “Update deleted my save. Do not buy.” Save file migration is one of the most critical and most under-tested aspects of game development. Automated testing ensures that every past version’s saves continue to work in every future version, without relying on someone remembering to test it manually before each release.
Why Save Files Break
Save files are serialized snapshots of game state. When the game’s data structures change between versions, the serialization format changes with them. A field that was an integer in version 1.0 becomes a float in version 1.1. An array that held item IDs now holds item objects. A struct gains three new fields. An enum value is renamed. Each of these changes is a potential save file incompatibility.
The most insidious failures are silent: the save file loads without crashing, but the data is wrong. An item ID that mapped to “iron sword” in version 1.0 now maps to “health potion” in version 1.2 because someone reordered the enum. The player’s inventory looks fine at first glance, but every item is wrong. Or a quest flag that was stored as a boolean is now stored as an integer state machine, and the migration sets every in-progress quest to “not started” because the old true value does not map to the new state values.
These bugs are nearly impossible to catch without automated testing, because they require loading actual save files from previous versions and inspecting the results. No amount of manual code review will reliably catch every migration edge case. Tests catch them because tests do exactly what the player will do: load an old save and check if the data is correct.
Creating Save File Fixtures
A fixture is a representative save file generated from a specific version of your game. For each released version, create at least three fixtures: an early-game save (just past the tutorial), a mid-game save (roughly halfway through the main content), and a late-game or post-game save (with most content completed and a complex inventory/quest state). These three progression points exercise different parts of your save schema and catch different classes of migration bugs.
Generate fixtures by playing the game in that version, not by constructing them manually. A hand-crafted JSON file might be syntactically correct but miss quirks of the actual serialization code — byte order, compression, encryption, or custom encoding that differs between versions. Save fixtures as binary files in your test directory, organized by version number:
# Directory structure for save file fixtures
tests/
fixtures/
saves/
v1.0.0/
early_game.sav
mid_game.sav
late_game.sav
v1.1.0/
early_game.sav
mid_game.sav
late_game.sav
v1.2.0/
early_game.sav
mid_game.sav
late_game.sav
v2.0.0/
early_game.sav
mid_game.sav
late_game.sav
# v2.0 added New Game+, so include it
new_game_plus.sav
Check fixtures into version control. They are small files (save data is typically kilobytes, rarely megabytes) and they must travel with the codebase so that CI can run the tests without external dependencies. When you release a new version, add its fixtures to the directory as part of the release process. Never delete old fixtures — a player who has not updated since version 1.0 must still be able to load their save in the latest version.
Writing Migration Tests
Each migration test loads a fixture from a previous version and verifies that the migrated data matches expected values. The test calls the same deserialization code path that the game uses at runtime, so it catches real-world failures, not just theoretical ones.
// save_migration_test.cs — Unity Test Framework example
using NUnit.Framework;
using System.IO;
[TestFixture]
public class SaveMigrationTests
{
[TestCase("v1.0.0/mid_game.sav", "Forest Village", 47, 12)]
[TestCase("v1.1.0/mid_game.sav", "Forest Village", 47, 15)]
[TestCase("v2.0.0/late_game.sav", "Dragon Peak", 89, 42)]
public void LoadLegacySave_PreservesProgress(
string fixturePath,
string expectedLocation,
int expectedLevel,
int expectedInventoryCount)
{
var fullPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
"fixtures/saves", fixturePath);
var bytes = File.ReadAllBytes(fullPath);
var saveData = SaveSerializer.Deserialize(bytes);
Assert.AreEqual(expectedLocation, saveData.PlayerLocation);
Assert.AreEqual(expectedLevel, saveData.PlayerLevel);
Assert.AreEqual(expectedInventoryCount, saveData.Inventory.Count);
Assert.IsTrue(saveData.Inventory.All(
item => item.Id != null && item.Quantity > 0),
"All inventory items must have valid IDs and positive quantities");
}
}
Do not just test that the save loads without crashing. Test specific values: the player’s location, level, inventory contents, quest progress, settings, and any other data that matters to the player. A save that loads without crashing but puts the player at the wrong location or deletes their inventory is still a broken migration. Be explicit about expected values in your test assertions.
The Version Matrix
The version matrix defines which migrations are tested. At minimum, test loading a save from every released version into the current build. If your migration system works by chaining incremental migration functions (v1-to-v2, v2-to-v3, etc.), also test the full chain by loading the oldest supported version to ensure that the composition of all migrations produces correct results.
If you support multiple save slots or multiple save types (autosave, manual save, quicksave), include fixtures for each type. Save types may have different schemas or different serialization paths, and a migration that works for manual saves may not work for autosaves if they store different fields.
Document the version matrix alongside the fixtures. A simple table listing each fixture file, its source version, the progression point it represents, and the key expected values makes it easy for anyone on the team to understand what is being tested and to add new fixtures when new versions are released.
Schema Evolution Strategies
The best migration testing is paired with a disciplined schema evolution strategy. Store a version number in every save file, in a fixed location that will never change (the first four bytes, a header field, a magic number). When loading, read the version first and dispatch to the appropriate migration path.
Write migration functions that transform save data from one version to the next, never skipping versions. A migration from v1 to v3 should call the v1-to-v2 migration followed by the v2-to-v3 migration, not a direct v1-to-v3 transformation. This keeps each migration function small, testable, and focused on the changes introduced in a single version. It also means you never have to write O(n²) migration functions for n versions — you only ever write one new migration function per release.
Never delete migration code. The v1-to-v2 migration function must remain in your codebase for as long as you support players who might have v1 save files. If you deprecate a version (by clearly communicating to players that saves from before version X are no longer supported), you can remove its migration code and its fixtures, but do so explicitly and with adequate warning.
“A save file migration test is a promise to your players: your progress from last year will still be there next year. Break that promise once and you lose trust that takes years to rebuild.”
Running Migration Tests in CI
Add migration tests to your CI pipeline as a required check. They should run on every commit to the main branch and on every pull request. Migration tests are fast — loading a save file and checking values takes milliseconds — so they add negligible time to your CI run. But they catch one of the most devastating classes of bugs: the kind that deletes a player’s progress.
When a migration test fails, treat it as a critical regression. Do not merge the pull request until the test passes. The fix is usually straightforward: update the migration function to handle the new schema change, or revert the schema change if the migration is too complex. The test failure is the signal that a migration is needed; the fix is writing that migration.
Related Issues
For tracking save file bugs reported by players across versions, see how to track save file bugs across game versions. For broader regression testing strategies, read how to write regression tests for game bugs.
Every released version of your game creates a contract with players: their saves will still work tomorrow. Automated tests enforce that contract.