In this lesson we will be creating a system that will let us easily persist the entire object graph of our game.
Getting Started
Feel free to continue from where we left off, or download and use this project here. It has everything we did in the previous lesson ready to go.
Create a new folder in Assets/Scripts named Data. Next create a new folder in Assets/Tests named Data. All of the scripts we create in this lesson will be placed in one of those two folders.
Overview
In the pursuit of clean architecture, I will be making a lot of systems which are focused on a single job. For example, a system whose whole job is to manage “health” or “ability score” etc. Each system will be responsible for its own basic CRUD operations (Create, Read, Update and Delete) but I don’t want each to have to also handle things like serialization and file storage.
The goal for this system is to create the foundation for easy data persistence. I want to be able to save and load my game data – the entire object graph – and I want it to be easy to do. In addition, I want to be able to easily inspect the saved output as JSON.
Data Model
Create a new C# script in Assets/Scripts/Data named Data. Copy the following:
[System.Serializable] public partial class Data { public int version; }
This code creates a new partial class named Data with a single field named version. The version field can be used in case you make changes to your game data and need a way to flag what data has been persisted. This way you can migrate changes as needed. We won’t really need it because we won’t be making any releases, so any changes we make can be made in place. In the meantime, it at least gives us something to look at for our tests, and serves as a good practice.
Since our class is partial, we may optionally define any future data we will need in the same file as the system that will use it. The model was also marked as Serializable, so that I can easily persist my data as json, via Unity’s JsonUtility. Any additional fields we add, even in other partial definitions, will automatically be included when we save this model.
Data System
Create a new C# script in Assets/Scripts/Data named DataSystem. Copy the following:
public interface IDataSystem : IDependency<IDataSystem> { Data Data { get; } void Create(); void Delete(); bool HasFile(); void Save(); void Load(); }
This time we are starting from the beginning with the idea of testability in mind by defining our interface first. Other systems will need to be able to access the Data object, but readonly access is good enough. Naturally we will need to be able to Create, Delete, Save and Load our game data, and I added HasFile so for example, on a main menu, I could determine if there was save state to resume or not.
Next, I could go ahead and create the concrete implementation of my IDataSystem except that I can already tell there are a few hidden dependencies that will be present. Much like I relied on Unity for generating a random number, I will also be relying on Unity for the ability to serialize to json. In addition, I will be relying on System.IO for the ability to read and write to disk. Let’s take a quick detour to separate those dependencies into their own files.
Data Serializer
Create a new C# script in Assets/Scripts/Data named DataSerializer. Copy the following:
using UnityEngine; public interface IDataSerializer : IDependency<IDataSerializer> { string Serialize(Data data); Data Deserialize(string json); } public class DataSerializer : IDataSerializer { public string Serialize(Data data) { return JsonUtility.ToJson(data); } public Data Deserialize(string json) { return JsonUtility.FromJson<Data>(json); } }
Here is both an interface and implementation of a serializer that handles turning our Data model into JSON and creating a Data model from JSON. All it does is wrap functionality that Unity provides via JsonUtility, but an added bonus of keeping this separate is that it would be trivial to swap the serializer out for another library should it be desired.
While Unity’s tools get the job done, there are some features that are missed such as built-in support for serializing things like Dictionaries and HashSets.
Data Store
Create a new C# script in Assets/Scripts/Data named DataStore. Copy the following:
using System.IO; using UnityEngine; public interface IDataStore : IDependency<IDataStore> { bool HasFile(); string Read(); void Write(string json); void Delete(); } public class DataStore : IDataStore { public string FilePath { get; private set; } public DataStore(string fileName) { this.FilePath = string.Format("{0}/{1}.txt", Application.persistentDataPath, fileName); } public bool HasFile() { return File.Exists(FilePath); } public string Read() { if (File.Exists(FilePath)) return File.ReadAllText(FilePath); return ""; } public void Write(string json) { File.WriteAllText(FilePath, json); } public void Delete() { File.Delete(FilePath); } }
The purpose of this interface and concrete implementation is to wrap the access to the File class. I can check if files exist, create and write to them, read from them or delete them as needed. The primary reason for making this separate is because it is not a good practice for unit tests to be doing disk-access. As an extra bonus, it would now be much easier to modify how and where files are stored. Maybe you want encryption, or to save your data to a server instead of on the client. Keeping things separate helps make those options easier to swap in. Some of the more complex features (especially remote access) may require additional changes like async callbacks, but for now this is good enough.
Concrete Data System
Now we have what we need to finish the concrete implementation of our Data System. Head back to that file and add the following:
public class DataSystem : IDataSystem { public Data Data { get; private set; } public void Create() { Data = new Data(); } public void Delete() { IDataStore.Resolve().Delete(); } public bool HasFile() { return IDataStore.Resolve().HasFile(); } public void Save() { var json = IDataSerializer.Resolve().Serialize(Data); IDataStore.Resolve().Write(json); } public void Load() { var json = IDataStore.Resolve().Read(); Data = IDataSerializer.Resolve().Deserialize(json); } }
While our system creates and holds the Data model on its own, it coordinates with the custom data serializer and data store interfaces we provided to complete the other tasks. Since they are obtained via interface injection, we can use the real versions of each for play, or mocks as needed for testing.
Unit Testing
You could say we’re done now! All the features I wanted are in place. If you want to be thorough though, or if you want to “see” things working, it never hurts to add some unit tests.
Mock Data Store
As a general rule, it is better to avoid expensive operations in a unit-test. This means I don’t want unit tests that actually perform disk-access like creating, reading, or deleting files. Instead, we will create a mock for our data store.
Create a new C# Script in Assets/Tests/Data named MockDataStore and add the following:
public class MockDataStore : IDataStore { public bool fakeHasFile; public string fakeReadResult; public bool DidCallDelete { get; private set; } public bool DidCallHasFile { get; private set; } public bool DidCallRead { get; private set; } public bool DidCallWrite { get; private set; } public string WriteJsonParam { get; private set; } public void Delete() { DidCallDelete = true; } public bool HasFile() { DidCallHasFile = true; return fakeHasFile; } public string Read() { DidCallRead = true; return fakeReadResult; } public void Write(string json) { DidCallWrite = true; WriteJsonParam = json; } }
As part of the setup any tests that consume this mock, we may want full control over the returned result of HasFile and Read, so I created public fields: fakeHasFile and fakeReadResult. Other times, I may just wish to know that systems are integrated properly, so I merely want to keep a flag showing that the mock was triggered when I expected, and with any parameters I expected. For example, invoking the Delete method sets DidCallDelete to true. When calling Write, we keep track of the fact that the method was invoked, as well as the parameter that was passed along via DidCallWrite and WriteJsonParam.
Mock Data Serializer
Serializing and Deserializing is probably not that slow, but technically our Data class is unbounded – I have no idea how big the model could grow. I also don’t know what serializer will ultimately be used, and some could be more wasteful than others. Ultimately though, we just want 100% certainty that everything in our unit test returns the same result every time to be truly deterministic. That means we need another mock!
Create a new C# Script in Assets/Tests/Data named MockDataSerializer and add the following:
public class MockDataSerializer : IDataSerializer { public string fakeSerializeResult; public Data fakeDeserializeResult; public bool DidCallSerialize { get; private set; } public Data SerializeDataParam { get; private set; } public bool DidCallDeserialize { get; private set; } public string DeserializeJsonParam { get; private set; } public string Serialize(Data data) { DidCallSerialize = true; SerializeDataParam = data; return fakeSerializeResult; } public Data Deserialize(string json) { DidCallDeserialize = true; DeserializeJsonParam = json; return fakeDeserializeResult; } }
Following the same pattern from before, I have public fields for any fake return value I will need to use, and then Properties to track whether or not the various methods have been invoked and if so, with what parameter.
Data System Tests
Create a new C# Test Script in Assets/Tests/Data named DataSystemTests and add the following:
using NUnit.Framework; public class DataSystemTests { MockDataSerializer mockDataSerializer = new MockDataSerializer(); MockDataStore mockDataStore = new MockDataStore(); DataSystem sut = new DataSystem(); [SetUp] public void SetUp() { IDataSerializer.Register(mockDataSerializer); IDataStore.Register(mockDataStore); } [TearDown] public void TearDown() { IDataSerializer.Reset(); IDataStore.Reset(); } [Test] public void Create_InitsData() { var dataBefore = sut.Data; sut.Create(); Assert.IsNull(dataBefore); Assert.IsNotNull(sut.Data); } [Test] public void Delete_WrapsStore() { sut.Delete(); Assert.IsTrue(mockDataStore.DidCallDelete); } [Test] public void HasFile_WrapsStore() { mockDataStore.fakeHasFile = true; var result = sut.HasFile(); Assert.IsTrue(mockDataStore.DidCallHasFile); Assert.AreEqual(mockDataStore.fakeHasFile, result); } [Test] public void Save_Success() { mockDataSerializer.fakeSerializeResult = "abc123"; sut.Create(); sut.Data.version = 1; sut.Save(); Assert.IsTrue(mockDataSerializer.DidCallSerialize); Assert.AreEqual(sut.Data, mockDataSerializer.SerializeDataParam); Assert.IsTrue(mockDataStore.DidCallWrite); Assert.AreEqual(mockDataSerializer.fakeSerializeResult, mockDataStore.WriteJsonParam); } [Test] public void Load_Success() { mockDataSerializer.fakeDeserializeResult = new Data(); mockDataStore.fakeReadResult = "abc123"; sut.Load(); Assert.IsTrue(mockDataStore.DidCallRead); Assert.IsTrue(mockDataSerializer.DidCallDeserialize); Assert.AreEqual(mockDataStore.fakeReadResult, mockDataSerializer.DeserializeJsonParam); Assert.AreEqual(mockDataSerializer.fakeDeserializeResult, sut.Data); } }
Our script creates fields for each of the mocks our system will depend upon, as well as the system we will be testing (the “sut” or subject under test). In the test SetUp I register the mocks, each to their respective interfaces. Likewise, I use the TearDown to clean up those registrations.
I added a new test per method in the System. For example, the test named Create_InitsData tests what happens when we invoke the Create method. In that test, I expected that the system’s Data model had been null before invoking the method, and that it would be non-null after.
I also verify integration with other “systems” such as when we “Save”. That test verifies that we call the relevant methods on both the serializer and store, and that we pass along the values that should be passed.
Head over to Unity and look at the TestRunner panel. Select the DataSystemTests row, and then click Run Selected. Each of the tests it contains should pass!
Summary
In this lesson, we created the necessary foundation to be able to save and load game data. We created a system and its dependencies in such a way as to keep it all testable, and demonstrated that fact by adding some additional mocks and unit tests.
If you got stuck anywhere along the way, check out the end project here.
If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!
Another excellent writeup, in following this lesson and the last one I realized though that there was nothing registering the interfaces for the non-unit test implementations. To remedy this I just created an Injector monobehavior that executes before pretty much every other script and stuck it on an empty game object to use both this system and the dice rolling system from the last lesson in the Checkpoint file.
Excited for the next one!
Glad you are enjoying it! You’ve got the right idea with having a script to inject what you want, my plan was to show that a little later on when I show some demos.