Testing Game Save Systems: Corruption, Migration, and Edge Cases

Testing Game Save Systems: Corruption, Migration, and Edge Cases

Game save systems are among the most critical — and most under-tested — parts of any game. A bug that corrupts save data doesn't just cause a crash. It destroys hours of player progress and triggers the most negative reviews your game will ever receive.

This guide covers testing save systems systematically, from happy path to corruption recovery.

What Can Go Wrong With Save Systems

Before writing tests, enumerate the failure modes:

  • Silent corruption — save writes partially, leaving invalid state
  • Version incompatibility — save from v1.0 breaks in v1.1 after field renames
  • Concurrent writes — two systems save simultaneously, last write wins and drops data
  • Disk full — partial write leaves corrupted file
  • Power loss during save — same as disk full
  • Cloud sync conflicts — local and cloud saves diverge
  • Missing required fields — new version expects field that old saves don't have
  • Type changes — field was int, now it's float, deserialization fails silently

A Simple Testable Save System Architecture

The save system must be designed for testability. Use an interface so you can swap implementations:

// ISaveSystem.cs
public interface ISaveSystem {
    void Save(string key, SaveData data);
    SaveData Load(string key);
    bool Exists(string key);
    void Delete(string key);
}

// SaveData.cs
[Serializable]
public class SaveData {
    public int version;
    public string playerName;
    public int level;
    public int gold;
    public Vector3 position;
    public List<string> inventory;
    public Dictionary<string, bool> questFlags;
    public long saveTimestamp;
}

// FileSaveSystem.cs — production implementation
public class FileSaveSystem : ISaveSystem {
    private readonly string savePath;

    public FileSaveSystem(string savePath) {
        this.savePath = savePath;
    }

    public void Save(string key, SaveData data) {
        data.saveTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        var json = JsonUtility.ToJson(data, prettyPrint: true);
        var tempPath = Path.Combine(savePath, key + ".tmp");
        var finalPath = Path.Combine(savePath, key + ".json");
        
        // Write to temp file first, then rename (atomic-ish on most filesystems)
        File.WriteAllText(tempPath, json);
        File.Replace(tempPath, finalPath, finalPath + ".bak");
    }

    public SaveData Load(string key) {
        var path = Path.Combine(savePath, key + ".json");
        if (!File.Exists(path)) return null;
        
        var json = File.ReadAllText(path);
        return JsonUtility.FromJson<SaveData>(json);
    }

    public bool Exists(string key) =>
        File.Exists(Path.Combine(savePath, key + ".json"));

    public void Delete(string key) {
        var path = Path.Combine(savePath, key + ".json");
        if (File.Exists(path)) File.Delete(path);
    }
}

// MemorySaveSystem.cs — test implementation
public class MemorySaveSystem : ISaveSystem {
    private readonly Dictionary<string, SaveData> store = new();

    public void Save(string key, SaveData data) {
        data.saveTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        store[key] = Clone(data);
    }

    public SaveData Load(string key) =>
        store.TryGetValue(key, out var data) ? Clone(data) : null;

    public bool Exists(string key) => store.ContainsKey(key);

    public void Delete(string key) => store.Remove(key);

    private SaveData Clone(SaveData d) =>
        JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(d));
}

Unit Tests: Core Save/Load Roundtrip

// Tests/EditMode/SaveSystemTests.cs
using NUnit.Framework;
using System.Collections.Generic;
using UnityEngine;

public class SaveSystemTests {
    private ISaveSystem saveSystem;

    [SetUp]
    public void SetUp() {
        saveSystem = new MemorySaveSystem();
    }

    [Test]
    public void Save_ThenLoad_ReturnsIdenticalData() {
        var original = new SaveData {
            version = 2,
            playerName = "TestPlayer",
            level = 15,
            gold = 9999,
            position = new Vector3(10f, 0f, -5f),
            inventory = new List<string> { "sword", "potion", "key" },
            questFlags = new Dictionary<string, bool> {
                { "defeated_boss", true },
                { "found_treasure", false }
            }
        };

        saveSystem.Save("slot1", original);
        var loaded = saveSystem.Load("slot1");

        Assert.AreEqual(original.playerName, loaded.playerName);
        Assert.AreEqual(original.level, loaded.level);
        Assert.AreEqual(original.gold, loaded.gold);
        Assert.AreEqual(original.position, loaded.position);
        Assert.AreEqual(original.inventory.Count, loaded.inventory.Count);
        CollectionAssert.AreEqual(original.inventory, loaded.inventory);
    }

    [Test]
    public void Load_NonExistentKey_ReturnsNull() {
        var result = saveSystem.Load("nonexistent");
        Assert.IsNull(result);
    }

    [Test]
    public void Exists_AfterSave_ReturnsTrue() {
        saveSystem.Save("slot2", new SaveData { playerName = "Test" });
        Assert.IsTrue(saveSystem.Exists("slot2"));
    }

    [Test]
    public void Exists_BeforeSave_ReturnsFalse() {
        Assert.IsFalse(saveSystem.Exists("slot99"));
    }

    [Test]
    public void Delete_RemovesSaveFile() {
        saveSystem.Save("slot3", new SaveData { playerName = "Delete Me" });
        saveSystem.Delete("slot3");
        Assert.IsFalse(saveSystem.Exists("slot3"));
    }

    [Test]
    public void Save_OverwritesExistingData() {
        saveSystem.Save("slot1", new SaveData { gold = 100 });
        saveSystem.Save("slot1", new SaveData { gold = 500 });

        var loaded = saveSystem.Load("slot1");
        Assert.AreEqual(500, loaded.gold);
    }

    [Test]
    public void Save_SetsSaveTimestamp() {
        var beforeSave = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        saveSystem.Save("slot1", new SaveData { playerName = "Timer Test" });
        var afterSave = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

        var loaded = saveSystem.Load("slot1");
        Assert.GreaterOrEqual(loaded.saveTimestamp, beforeSave);
        Assert.LessOrEqual(loaded.saveTimestamp, afterSave);
    }
}

Testing Save Migration

Version migration is critical when you change your save format between game updates:

// SaveMigration.cs
public static class SaveMigration {
    public const int CURRENT_VERSION = 3;

    public static SaveData Migrate(SaveData data) {
        while (data.version < CURRENT_VERSION) {
            data = data.version switch {
                1 => MigrateV1ToV2(data),
                2 => MigrateV2ToV3(data),
                _ => throw new InvalidOperationException($"Unknown save version: {data.version}")
            };
        }
        return data;
    }

    private static SaveData MigrateV1ToV2(SaveData data) {
        // V2: added inventory list (didn't exist in V1)
        data.inventory ??= new List<string>();
        data.version = 2;
        return data;
    }

    private static SaveData MigrateV2ToV3(SaveData data) {
        // V3: added questFlags, gold was renamed from "coins"
        data.questFlags ??= new Dictionary<string, bool>();
        // Note: gold field name change requires special JSON handling
        data.version = 3;
        return data;
    }
}
// Tests/EditMode/SaveMigrationTests.cs
public class SaveMigrationTests {
    [Test]
    public void MigrateV1_AddsEmptyInventory() {
        var v1Save = new SaveData {
            version = 1,
            playerName = "OldPlayer",
            level = 5,
            inventory = null // V1 saves don't have inventory
        };

        var migrated = SaveMigration.Migrate(v1Save);

        Assert.AreEqual(3, migrated.version);
        Assert.IsNotNull(migrated.inventory);
        Assert.AreEqual(0, migrated.inventory.Count);
        Assert.AreEqual("OldPlayer", migrated.playerName); // Original data preserved
        Assert.AreEqual(5, migrated.level);
    }

    [Test]
    public void MigrateV2_AddsEmptyQuestFlags() {
        var v2Save = new SaveData {
            version = 2,
            playerName = "V2Player",
            inventory = new List<string> { "sword" },
            questFlags = null
        };

        var migrated = SaveMigration.Migrate(v2Save);

        Assert.AreEqual(3, migrated.version);
        Assert.IsNotNull(migrated.questFlags);
        Assert.AreEqual(1, migrated.inventory.Count); // Preserved
    }

    [Test]
    public void CurrentVersionSave_ReturnedUnchanged() {
        var currentSave = new SaveData {
            version = SaveMigration.CURRENT_VERSION,
            playerName = "CurrentPlayer",
            gold = 1000
        };

        var migrated = SaveMigration.Migrate(currentSave);

        Assert.AreSame(currentSave, migrated); // No copy needed for current version
        Assert.AreEqual(1000, migrated.gold);
    }

    [Test]
    public void UnknownVersion_ThrowsException() {
        var futureSave = new SaveData { version = 99 };

        Assert.Throws<InvalidOperationException>(() => SaveMigration.Migrate(futureSave));
    }
}

Testing Corruption Recovery

// FileSaveSystemTests.cs (Integration tests using temp directory)
using System.IO;
using NUnit.Framework;

public class FileSaveSystemTests {
    private string tempDir;
    private FileSaveSystem saveSystem;

    [SetUp]
    public void SetUp() {
        tempDir = Path.Combine(Path.GetTempPath(), "unity-save-tests-" + System.Guid.NewGuid());
        Directory.CreateDirectory(tempDir);
        saveSystem = new FileSaveSystem(tempDir);
    }

    [TearDown]
    public void TearDown() {
        if (Directory.Exists(tempDir))
            Directory.Delete(tempDir, recursive: true);
    }

    [Test]
    public void Load_CorruptedFile_ReturnsNullOrFallback() {
        // Write a corrupt JSON file
        File.WriteAllText(Path.Combine(tempDir, "slot1.json"), "{ corrupted: json }}}");

        SaveData result = null;
        Assert.DoesNotThrow(() => {
            result = saveSystem.Load("slot1");
        }, "Loading corrupted save should not throw");

        Assert.IsNull(result, "Corrupt save should return null (not crash)");
    }

    [Test]
    public void Load_EmptyFile_ReturnsNullGracefully() {
        File.WriteAllText(Path.Combine(tempDir, "slot1.json"), "");

        SaveData result = null;
        Assert.DoesNotThrow(() => result = saveSystem.Load("slot1"));
        Assert.IsNull(result);
    }

    [Test]
    public void Save_CreatesBackupFile() {
        var data = new SaveData { playerName = "BackupTest", gold = 100 };
        
        saveSystem.Save("slot1", data); // First save
        saveSystem.Save("slot1", new SaveData { playerName = "NewSave", gold = 200 }); // Overwrite

        Assert.IsTrue(File.Exists(Path.Combine(tempDir, "slot1.json.bak")),
            "Backup file should exist after overwrite");

        // Backup should contain the previous save
        var backup = File.ReadAllText(Path.Combine(tempDir, "slot1.json.bak"));
        Assert.IsTrue(backup.Contains("BackupTest"), "Backup should contain previous save data");
    }

    [Test]
    public void Load_AfterDiskSimulatedFailure_FallsBackToBackup() {
        // Save valid data
        saveSystem.Save("slot1", new SaveData { playerName = "GoodSave", gold = 500 });

        // Simulate corruption of the main file
        File.WriteAllText(Path.Combine(tempDir, "slot1.json"), "CORRUPTED");

        // The save system should fall back to backup
        var result = saveSystem.LoadWithFallback("slot1");

        Assert.IsNotNull(result, "Should recover from backup");
        Assert.AreEqual("GoodSave", result.playerName);
    }
}

Testing Concurrent Save Prevention

// Tests/EditMode/ConcurrentSaveTests.cs
using System.Threading.Tasks;

public class ConcurrentSaveTests {
    [Test]
    public void ConcurrentSaves_DoNotCorruptData() {
        var saveSystem = new ThreadSafeMemorySaveSystem();
        var data = new SaveData { gold = 0 };

        // Simulate 10 concurrent save operations
        var tasks = new Task[10];
        for (int i = 0; i < 10; i++) {
            int goldAmount = i * 100;
            tasks[i] = Task.Run(() => {
                var d = new SaveData { gold = goldAmount };
                saveSystem.Save("slot1", d);
            });
        }
        Task.WaitAll(tasks);

        // Data should be consistent (one of the 10 values, not a mix)
        var result = saveSystem.Load("slot1");
        Assert.IsNotNull(result);
        Assert.IsTrue(result.gold % 100 == 0, "Gold should be a clean multiple of 100, not corrupted");
    }
}

Play Mode Tests: Save/Load During Gameplay

// Tests/PlayMode/SaveLoadGameplayTests.cs
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

public class SaveLoadGameplayTests {
    private GameManager gameManager;
    private ISaveSystem saveSystem;

    [UnitySetUp]
    public IEnumerator SetUp() {
        saveSystem = new MemorySaveSystem();
        
        var go = new GameObject("GameManager");
        gameManager = go.AddComponent<GameManager>();
        gameManager.Initialize(saveSystem);
        
        yield return null;
    }

    [UnityTest]
    public IEnumerator SaveAndLoad_ResumesFromSamePosition() {
        // Move player to a specific position
        var player = GameObject.FindWithTag("Player");
        player.transform.position = new Vector3(15f, 0f, 7f);
        
        gameManager.SaveGame("test-slot");
        
        yield return null;
        
        // Move player elsewhere
        player.transform.position = Vector3.zero;
        
        gameManager.LoadGame("test-slot");
        
        yield return new WaitForSeconds(0.1f);
        
        var distanceFromSaved = Vector3.Distance(
            player.transform.position,
            new Vector3(15f, 0f, 7f)
        );
        Assert.Less(distanceFromSaved, 0.1f, "Player should be at saved position after load");
    }

    [UnityTest]
    public IEnumerator LoadGame_RestoresInventoryItems() {
        var inventory = gameManager.PlayerInventory;
        inventory.AddItem("health_potion");
        inventory.AddItem("sword_of_testing");
        
        gameManager.SaveGame("inventory-slot");
        inventory.Clear();
        
        yield return null;
        
        gameManager.LoadGame("inventory-slot");
        
        yield return new WaitForSeconds(0.1f);
        
        Assert.IsTrue(inventory.Contains("health_potion"), "Inventory should be restored");
        Assert.IsTrue(inventory.Contains("sword_of_testing"), "All items should be restored");
        Assert.AreEqual(2, inventory.Count);
    }

    [UnityTearDown]
    public IEnumerator TearDown() {
        Object.Destroy(gameManager.gameObject);
        yield return null;
    }
}

Save System Testing Checklist

  • Basic save/load roundtrip preserves all field types (int, float, string, Vector3, List, Dictionary)
  • Save overwrites existing data correctly
  • Load returns null for non-existent key (no exception)
  • Load handles corrupt JSON without throwing
  • Load handles empty file without throwing
  • Backup file created on overwrite
  • Migration runs for each version step (V1→V2, V2→V3, etc.)
  • Migration preserves existing valid data
  • Current-version saves pass through migration unchanged
  • Unknown future version throws clear exception
  • Concurrent saves don't corrupt data
  • Save timestamp is set and accurate
  • Save slot isolation (slot1 and slot2 don't interfere)
  • Player position restored correctly after load
  • Inventory/items restored after load
  • Quest flags restored after load

Save system bugs are the ones players remember forever. Testing them thoroughly is one of the highest-ROI investments in game QA.

Read more