This week, I wrote some test code on my game using Unity Test Framework. There’s a purpose for doing this.
Purpose and Thoughts on writing test code
I want to test for various cases without actually playing the game whenever I want on the key part. I have experience writing test codes on web server application so I know it takes some time to setup test and write the code. So I tried to balance writing test code with developing my game for the next updates. This makes my mind not to write every detail case in the game, but find the most important and complicated part of the game.
Tests on ‘Crafting Item’
So I decided to write test about ‘Crafting Item’ which is key feature of my game. The one I want to show here is checking whether materials is enough to craft item: HasEnoughMaterials()
components
represents required items to make the item andmaterials
is a list of items that the player puts to craft item.- So
materials
must have same type of item ofcomponents
and its amount must be larger than thecomponents
. For example, for crafting ‘Blunt LongSword’, we need 1 ‘Iron Ingot’ and 1 ‘Cloth’. And that (1 ‘Iron Ingot’, 1 ‘Cloth’) iscomponents
. To craft ‘Blunt LongSword’, player should put at least (1 ‘Iron Ingot’, 1 ‘Cloth’). - But there’s one exception. One of the
components
can be replaced with ‘Demonic Mind Fragment’ item. So if player has ‘Demonic Mind Fragment’, then ‘Blunt LongSword’ can be crafted by (1 ‘Demonic Mind Fragment’, 1 ‘Cloth’) or (1 ‘Iron Ingot’, 1 ‘Demonic Mind Fragment’)
// CraftManagerSO.cs
public bool HasEnoughMaterials()
{
...
return this.HasEnoughMaterials(this._currentCrafting.Components, this._materials);
}
public bool HasEnoughMaterials(List<ItemStack> components, List<ItemStack> materials)
{
bool demonicMindFragmentReplaced = false;
foreach (ItemStack component in components)
{
ItemStack materialFind = materials.Find(m => m.Item.Spec.Equals(component.Item.Spec));
if (materialFind == null)
{
// We can replace one type of material with 'Demonic Mind Fragment'
// And its amount must be large or equal than the replaced one
if (!demonicMindFragmentReplaced && this.ContainsDemonicMindFragmentOnMaterials(materials))
{
demonicMindFragmentReplaced = true;
ItemStack demonicMindFragment = this.GetDemonicMindFragmentFromMaterials(materials);
if (component.Amount > demonicMindFragment.Amount)
{
return false;
}
continue;
}
return false;
}
if (component.Amount > materialFind.Amount)
{
return false;
}
}
return true;
}
Write first version of test code
Because most of the game logic is in the ScriptableObjects, I think it's enough to test with ‘Edit mode’.
// CraftManagerSOTests.cs
namespace Tests.Craft.ScriptableObjects
{
public class CraftManagerSOTests
{
private CraftManagerSO craftManager;
[SetUp]
public void SetUp()
{
this.craftManager = AssetDatabase.LoadAssetAtPath<CraftManagerSO>(
AssetPath.ScriptableObjects.Craft.Path("CraftManager"));
}
[Test]
public void testHasEnoughMaterials()
{
// TODO: writing test code :)
}
}
}
And as you may noticed that to test HasEnoughMaterials()
we need to make components
and materials
, list of ItemStack
and in the Item
, and Item
references two other ScriptableObjects: ItemSO
, ItemPrefixSO
. So we need to load other ScriptableObjects which need by testing the ScriptableObject.
Then add assertion on several detail cases.
- If there’s no items in
materials
, test method must returnfalse
- If there’s an item but not enough, test method must return
false
[Serializable]
public class ItemStack
{
[SerializeField] private Item _item;
public Item Item => this._item;
public int Amount;
}
[Serializable]
public class Item
{
[SerializeField] private ItemSO _specs;
[SerializeField] private ItemPrefixSO _prefix;
}
// CraftManagerSOTests.cs
namespace Tests.Craft.ScriptableObjects
{
public class CraftManagerSOTests
{
private CraftManagerSO craftManager;
private ItemSO ironIngotItem;
private ItemSO clothItem;
private ItemPrefixSO noneItemPrefix;
[SetUp]
public void SetUp()
{
this.craftManager = AssetDatabase.LoadAssetAtPath<CraftManagerSO>(
AssetPath.ScriptableObjects.Craft.Path("CraftManager"));
this.ironIngotItem = AssetDatabase.LoadAssetAtPath<ItemSO>(
AssetPath.ScriptableObjects.Storage.Path("Items/Item_IronIngot"));
this.clothItem = AssetDatabase.LoadAssetAtPath<ItemSO>(
AssetPath.ScriptableObjects.Storage.Path("Items/Item_Cloth"));
this.noneItemPrefix = AssetDatabase.LoadAssetAtPath<ItemPrefixSO>(
AssetPath.ScriptableObjects.Craft.Path("Product Prefixes/ItemPrefix_None"));
}
[Test]
public void testHasEnoughMaterials()
{
// in the case there's no materials
Assert.False(this.craftManager.HasEnoughMaterials());
ItemStack ironIngotStack = new ItemStack(new Item(this.ironIngotItem, this.noneItemPrefix), 2);
ItemStack clothStack = new ItemStack(new Item(this.clothItem, this.noneItemPrefix), 1);
// in the case there's only iron ingot
this.craftManager.AddMaterial(ironIngotStack);
Assert.False(this.craftManager.HasEnoughMaterials());
// in the case there's iron ingot and cloth with sufficient amount
this.craftManager.AddMaterial(clothStack);
Assert.True(this.craftManager.HasEnoughMaterials());
}
}
}
Let’s refactor redundant code
But soon I realized that many ScriptableObjects like ItemSO
, ItemPrefixSO
can be used in other tests so I centralized these ScriptableObects loading logics in the test util class.
So I created Assets
class and categorized with nested classes. And inside each class load its resources.
With this refactoring, first it increases readability in the test code. And second we don’t need to write loading ScriptableObjects logic in other test cases.
// Assets.cs
namespace Tests.Utils
{
public class Assets
{
...
public static class ItemPrefixes
{
public static ItemPrefixSO None;
public static void Init()
{
None = AssetDatabase.LoadAssetAtPath<ItemPrefixSO>(
AssetPath.ScriptableObjects.Craft.Path("Product Prefixes/ItemPrefix_None"));
LongSword_Blunt = AssetDatabase.LoadAssetAtPath<ItemPrefixSO>(
AssetPath.ScriptableObjects.Craft.Path("Product Prefixes/ItemPrefix_LongSword_Blunt"));
}
}
public static class Items
{
public static ItemSO IronIngot;
public static ItemSO Cloth;
public static void Init()
{
IronIngot = AssetDatabase.LoadAssetAtPath<ItemSO>(
AssetPath.ScriptableObjects.Storage.Path("Items/Item_IronIngot"));
Cloth = AssetDatabase.LoadAssetAtPath<ItemSO>(
AssetPath.ScriptableObjects.Storage.Path("Items/Item_Cloth"));
}
}
public static void Init()
{
ItemPrefixes.Init();
Items.Init();
}
}
}
// CraftManagerSOTests.cs
namespace Tests.Craft.ScriptableObjects
{
public class CraftManagerSOTests
{
[SetUp]
public void SetUp()
{
Assets.Init();
}
[Test]
public void testHasEnoughMaterials()
{
// in the case there's no materials
Assert.False(Assets.Managers.CraftManager.HasEnoughMaterials());
ItemStack ironIngotStack = new ItemStack(new Item(Assets.Items.IronIngot, Assets.ItemPrefixes.None), 2);
ItemStack clothStack = new ItemStack(new Item(Assets.Items.Cloth, Assets.ItemPrefixes.None), 1);
// in the case there's only iron ingot
Assets.Managers.CraftManager.AddMaterial(ironIngotStack);
Assert.False(Assets.Managers.CraftManager.HasEnoughMaterials());
// in the case there's iron ingot and cloth with sufficient amount
Assets.Managers.CraftManager.AddMaterial(clothStack);
Assert.True(Assets.Managers.CraftManager.HasEnoughMaterials());
}
}
}
Things to keep in mind when writing ScriptableObject test
One thing that is hard when writing test is that ScriptableObject has its own state and it is persistent. So on writing about ScriptableObject we need to keep in mind that we should clear its state every time the test finished.
So there’s a fix on the test code above.
namespace Tests.Craft.ScriptableObjects
{
public class CraftManagerSOTests
{
[SetUp]
public void SetUp()
{
Assets.Init();
Assets.Managers.CraftManager.OnPostCraft(); // clear its state
}
[TearDown]
public void TearDown()
{
Assets.Managers.CraftManager.OnPostCraft();
}
[Test]
public void testHasEnoughMaterials()
{
// same as above
}
}
}
Before and after running test case, we need to clear its state by calling Assets.Managers.CraftManager.OnPostCraft()
What’s Next?
Now I’m getting used to writing Unity test code a bit. As much time as not interrupted by developing new features on my game, I will going to add my test cases. And there’s plan to add e2e test like simulating clicking buttons or interacting with characters.