Unit tests are a good way of automating testing of items. Before making a PR for an item, you will need to implement unit tests for your item to ensure whoever is reviewing it can confirm it works. It is also especially useful in the future for determining if a change to the game’s logic and architecture will break the item.


Before you start

Consider making a list of different condition and results that your item may encounter. Some conditions can be as simple as having greater item stacks, while other conditions may involve a myriad of factors such as adjacent units and amount of health or shield. Try to think of every edge case possible that’s related to your item.

Example

Consider the item Explosive Plating. Things a unit test needs to test can include:

  • Dealing one instance of damage with 1 Explosive Plating to an enemy that’s burning should deal 36 additional damage.
  • Dealing one instance of damage with 2 Explosive Plating to an enemy that’s burning should deal 45 additional damage.
  • Dealing one instance of damage with 1 Explosive Plating to an enemy that’s NOT burning should deal 12 additional damage.
  • Dealing one instance of damage with 2 Explosive Plating to an enemy that’s NOT burning should deal 15 additional damage.
  • Dealing two instances of damage with 1 Explosive Plating to an enemy that’s burning should deal 36 + 36 additional damage.
  • Dealing two instances of damage with 2 Explosive Plating to an enemy that’s burning should deal 45 + 45 additional damage.
  • Dealing two instances of damage with 1 Explosive Plating to an enemy that’s NOT burning should deal 12 + 12 additional damage.
  • Dealing two instances of damage with 2 Explosive Plating to an enemy that’s NOT burning should deal 15 + 15 additional damage.

Step 1: Create the script

In Tests/Playmode Tests/Item Unit Tests/ create a new script inheriting from ItemBattleTestBase.

Your class may implement the following functions (the attribute fields are IMPORTANT):

/* Gets called when unit test starts. Initialise variables and subscribe
to events here. */
[UnitySetUp]
public IEnumerator SetUp()
{
	yield break;
}
 
/* Gets called when unit test ends. Reset variables, unsubscribe to events,
and revert stat changes here. */
[UnityTearDown]
public IEnumerator TearDown()
{
	yield break;
}
 
/* The UnityTest attribute tells unity to run a specific function to conduct
unit test. You can name these functions anything you want (try to be specific
in what the test is doing). You can also have as many functions using the
UnityTest attribute. Just don't make these function take in any parameters.*/
[UnityTest]
public IEnumerator ItemName_1Stack_Condition_Result()
{
	yield break;
}

Step 2: Getting the item

On bare minimum, you will only need your item’s GUID and it’s ItemDefinitionSO. To start:

Define a private const string ITEM_NAME_GUID = ""; and set the string to equal your item’s GUID.

To get your item’s GUID, find the ItemDefinitionSO of your item within the project window, right click the GUID in the inspector window, and select copy.

Define a private ItemDefinitionSO itemName and within the SetUp() function, type:

itemName = assetCatalogueSystem.GetCatalogue("ITEMS_CATALOGUE")
	.GetAsset(ITEM_NAME_GUID) as ItemDefinitionSO;

Effectively, this is all that you will need to work with. However your item may involve more factors such as status effects, adjacent units, and proc events. This will be covered more later within the guide.

Step 3: Writing the test

As described in Step 1, you can declare a function to be run for unit test with the [UnityTest] attribute. Ideally, you would want to name your functions to be quite specific in what they are doing, and provide comments for what conditions the test is expecting. For example:

// -------------------------------------------------------------------------
// Test 2: 2 stacks, no movement -> 35 shield
// Stack formula (Linear, a=25, b=10): 25 + 10*(2-1) = 35
// Formula: ceil(35 * 4/4) = 35
// -------------------------------------------------------------------------
[UnityTest]
public IEnumerator SplinteredLotus_2Stacks_NoMovement_Gives35Shield()
{
	...
}

The first thing you want to do in your [UnityTest] functions is to give testUnit the items:

// Give the item five times
for (int i = 0; i < 5; i++)
	testUnit.AddItem(itemName);

Then you can have your testUnit perform actions, or be subjected to actions. How we can do this will be covered later within the guide.

Finally, use the static functions from the Assert class (from NUnit.Framework) to test for your conditions. Typically, these functions will have an expectedValue and actualValue parameter, as well as an optional message parameter. For example:

Assert.Equal(expectedBurnFromProc, actualBurnFromProc,
	$"Expected {expectedBurnFromProc} burn but found {actualBurnFromProc} burn.")

This is all that’s needed for a unit test on bare minimum. However, not all unit tests are that simple.

Step 4 (Optional): Adding complexities to the test

This section of the guide is still work in progress

The step will be divided into multiple sections. Each section will cover a specific complexity your tests may involve.

Status Effects

On your class level, define the GUID of a status effect, similar to how we defined an item’s GUID in Step 2. For example: private const string SHOCK_GUID = "f23f33ce-f037-466a-aea6-5b7e2c75d301";

Define a nullable OnStatusEffectGained class variable. For example: private OnStatusEffectGained? shockGainedEvent;

Implement a function to subscribe to OnStatusEffectGained events. For example:

/* This function is for when testUnit gains a status effect. Modify it based
on your needs. */
private void OnStatusEffectGained_Callback(OnStatusEffectGained statusProc)
{
	if (statusProc.targetId == testUnit.GetId() && 
		statusProc.statusGUID.Equals(SHOCK_GUID))
	{
		// Store the proc within a class variable
		shockGainedEvent = statusProc;
	}
}

Within SetUp() and TearDown() subscribe to OnStatusEffectGained events. For example:

[UnitySetUp]
public IEnumerator SetUp()
{
	...
	
	shockGainedEvent = null;
	EventBus.Subscribe<OnStatusEffectGained>(OnStatusEffectGained_Callback);
}
 
[UnityTearDown]
public IEnumerator TearDown()
{
	...
	
	shockGainedEvent = null;
	EventBus.Unsubscribe<OnStatusEffectGained>(OnStatusEffectGained_Callback);
}

Then within our UnityTest function, after our unit triggers their item, we can check if our nullable OnStatusEffectGained is not null and the number of stacks gained from a single proc.

/* Wait a few seconds to ensure the item or action is fully performed before
check if the test condition is fulfilled */
yield return new WaitForSeconds(PlaymodeTestHelpers.PROC_WAIT_SECONDS);
 
/* Check if shockGainedEvent is not null */
Assert.IsNotNull(shockGainedEvent,
	"Expected a OnStatusEffectGained proc on testUnit but none was received.");
 
/* Check if the number of status stacks from a single OnStatusEffectGained proc
matches the expected amount */
uint shockAmount = shockGainedEvent.Value.stackAmount
Assert.AreEqual(expectedValue, shockAmount,
	$"Expected {expectedValue} shock from a single proc but found {shockAmount}.");

TODO: write on how to gather total number of status stacks instead of amount from a single proc

Unit Stats

TODO: write on how to gather stats and compare the before and after differences

OnHit Proc

TODO: write on how to gather OnHit procs, number of hits, damage dealt, include critical hits in checks, etc.

Spawning Units

TODO: write on how to spawn units and fetching units from the field

Moving Units

TODO: write on how to control units to move and how to allow units to move again when they have already acted

Step 5: Running the test

Using Test Runner

TODO: add images

To run your unit test, we use the Test Runner window which can be found at Window > General > Test Runner.

From the Test Runner window, you can locate the unit test for your item by expanding FREAKINCYBERPUNKROGUELIKE > PDT.PlaymodeTests.dll > PDT > Tests > ItemNameTest.

Then finally you can select your item’s unit tests and click on Run Selected to start automatic unit testing. Optionally, if you want to test a specific unit test of your item, you can expend the item’s tests and only select the test you want.

My test has failed

If a unit test has failed, select the failed test and read the error message carefully, it might not be your fault. Unity’s Test Runner will consider a test to fail when an error message appears, even when the error is not related to your item or the unit test.

In the case where the error message is unrelated to your item or test, check if you can solve/prevent the error message from appearing and try testing again. Be sure to NOT include the fixes within your PR and instead let the reviewer and programming team know about the issue. If the error can’t be solved by you, send a message in Programming chat.

However, if your unit test has failed due to an issue within the item or test, It will be up to you to solve. If you know the item is functioning correctly but the test is still failing, it may be due to your test checking for conditions before the item has activated. In this case, be sure to use yield return new WaitForSeconds(PlaymodeTestHelpers.PROC_WAIT_SECONDS);.


Tips and Tricks

Coding Practices

TODO: write about atomising functions when multiple tests cover similar conditions

What if this guide doesn’t cover what I want?

TODO: write about using EventBus to subscribe to proc events, and looking at other unit tests to see how they handle a specific condition. Also write about asking programming chat.