Testing
This guide covers unit testing patterns for Reactive SO components using Unity Test Framework (NUnit).
Purpose
This guide explains how to write unit tests for Reactive SO components. You will learn testing patterns for Event Channels, Variables, Runtime Sets, and Reactive Entity Sets, along with dependency injection techniques for isolating external dependencies.
Prerequisites
Unity Test Framework
Unity Test Framework is included with Unity. Access the Test Runner via:
Window > General > Test Runner
Edit Mode vs Play Mode
| Mode | Use For | Speed |
|---|---|---|
| Edit Mode | ScriptableObject logic, pure C# classes | Fast |
| Play Mode | MonoBehaviour lifecycle, scene integration | Slower |
Most Reactive SO tests run in Edit Mode because ScriptableObjects can be instantiated without a scene.
Testing Event Channels
Basic pattern
Create ScriptableObject instances directly in tests:
using NUnit.Framework;
using UnityEngine;
using Tang3cko.ReactiveSO;
public class IntEventChannelSOTests
{
private IntEventChannelSO channel;
[SetUp]
public void Setup()
{
// Create instance without asset file
channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
}
[TearDown]
public void Teardown()
{
// Must use DestroyImmediate in Edit Mode
Object.DestroyImmediate(channel);
}
[Test]
public void RaiseEvent_WithSubscriber_NotifiesSubscriberWithValue()
{
// Arrange
int receivedValue = 0;
channel.OnEventRaised += (value) => receivedValue = value;
// Act
channel.RaiseEvent(42);
// Assert
Assert.That(receivedValue, Is.EqualTo(42));
}
}
Testing multiple subscribers
[Test]
public void RaiseEvent_WithMultipleSubscribers_NotifiesAll()
{
// Arrange
int notificationCount = 0;
channel.OnEventRaised += (value) => notificationCount++;
channel.OnEventRaised += (value) => notificationCount++;
channel.OnEventRaised += (value) => notificationCount++;
// Act
channel.RaiseEvent(10);
// Assert
Assert.That(notificationCount, Is.EqualTo(3));
}
Testing unsubscription
[Test]
public void Unsubscribe_AfterRaiseEvent_DoesNotNotify()
{
// Arrange
bool wasNotified = false;
System.Action<int> handler = (value) => wasNotified = true;
channel.OnEventRaised += handler;
channel.OnEventRaised -= handler;
// Act
channel.RaiseEvent(42);
// Assert
Assert.That(wasNotified, Is.False);
}
Testing Variables
Basic pattern
public class IntVariableSOTests
{
private IntVariableSO variable;
private IntEventChannelSO eventChannel;
[SetUp]
public void Setup()
{
variable = ScriptableObject.CreateInstance<IntVariableSO>();
eventChannel = ScriptableObject.CreateInstance<IntEventChannelSO>();
}
[TearDown]
public void Teardown()
{
Object.DestroyImmediate(variable);
Object.DestroyImmediate(eventChannel);
}
[Test]
public void Value_Set_ChangesValue()
{
// Arrange
variable.Value = 10;
// Act
variable.Value = 20;
// Assert
Assert.That(variable.Value, Is.EqualTo(20));
}
}
Testing change detection
Variables only fire events when the value actually changes:
[Test]
public void Value_SetSameValue_DoesNotRaiseEvent()
{
// Arrange
variable.Value = 42;
bool eventRaised = false;
// Set up event channel using reflection (internal field)
var field = typeof(VariableSO<int>).GetField("onValueChanged",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
field.SetValue(variable, eventChannel);
eventChannel.OnEventRaised += (value) => eventRaised = true;
// Act
variable.Value = 42; // Same value
// Assert
Assert.That(eventRaised, Is.False);
}
Testing Runtime Sets
Basic pattern
public class GameObjectRuntimeSetSOTests
{
private GameObjectRuntimeSetSO runtimeSet;
private GameObject testObject;
[SetUp]
public void Setup()
{
runtimeSet = ScriptableObject.CreateInstance<GameObjectRuntimeSetSO>();
testObject = new GameObject("TestObject");
}
[TearDown]
public void Teardown()
{
Object.DestroyImmediate(testObject);
Object.DestroyImmediate(runtimeSet);
}
[Test]
public void Add_ValidObject_IncreasesCount()
{
// Arrange
Assert.That(runtimeSet.Count, Is.EqualTo(0));
// Act
runtimeSet.Add(testObject);
// Assert
Assert.That(runtimeSet.Count, Is.EqualTo(1));
}
}
Testing Reactive Entity Sets
Test-specific subclass pattern
Create a concrete implementation for testing generic classes:
public class ReactiveEntitySetSOTests
{
// Test data structure
public struct TestEntityData
{
public int Health;
public float Speed;
}
// Test implementation
private class TestReactiveEntitySetSO : ReactiveEntitySetSO<TestEntityData>
{
}
// Test owner component
private class TestOwner : MonoBehaviour { }
private TestReactiveEntitySetSO entitySet;
private GameObject ownerObject;
private TestOwner owner;
[SetUp]
public void Setup()
{
entitySet = ScriptableObject.CreateInstance<TestReactiveEntitySetSO>();
ownerObject = new GameObject("Owner");
owner = ownerObject.AddComponent<TestOwner>();
}
[TearDown]
public void Teardown()
{
Object.DestroyImmediate(ownerObject);
Object.DestroyImmediate(entitySet);
}
[Test]
public void Register_ValidOwner_AddsToSet()
{
// Arrange
var data = new TestEntityData { Health = 100, Speed = 5f };
// Act
entitySet.Register(owner, data);
// Assert
Assert.That(entitySet.Count, Is.EqualTo(1));
Assert.That(entitySet.Contains(owner), Is.True);
}
[Test]
public void UpdateData_ModifiesState()
{
// Arrange
entitySet.Register(owner, new TestEntityData { Health = 100 });
// Act
entitySet.UpdateData(owner, state => {
state.Health -= 30;
return state;
});
// Assert
var data = entitySet.GetData(owner);
Assert.That(data.Health, Is.EqualTo(70));
}
}
Advanced: Snapshot-based testing
Using the Snapshot API, you can load a pre-saved state and verify logic behavior. This is highly effective for reproducing complex bugs.
[Test]
public void Snapshot_Logic_RegressionTest()
{
// 1. Arrange: Load captured snapshot or construct manually
// Allocate arrays for the snapshot
int count = 1;
var data = new NativeArray<EnemyState>(count, Allocator.Temp);
var ids = new NativeArray<int>(count, Allocator.Temp);
// Set "buggy" state
data[0] = enragedBossState;
ids[0] = bossId;
// Create snapshot struct
var snapshot = new EntitySetSnapshot<EnemyState>(data, ids, count);
// 2. Act: Restore state to a fresh set
var set = ScriptableObject.CreateInstance<EnemyEntitySetSO>();
set.RestoreSnapshot(snapshot);
// 3. Act: Run specific logic
battleSystem.ProcessTick(set);
// 4. Assert: Verify outcome
Assert.IsTrue(set[bossId].HasAttacked);
// Dispose snapshot (which disposes the arrays)
snapshot.Dispose();
}
Integration Testing pattern
Because Reactive SO components are isolated, you can chain multiple systems together in an Edit Mode test to verify complex workflows.
public class BattleWorkflowTests
{
[Test]
public void FullBattleTick_IntegrationTest()
{
// Setup multiple systems
var enemies = ScriptableObject.CreateInstance<EnemySetSO>();
var player = ScriptableObject.CreateInstance<PlayerVariableSO>();
var log = ScriptableObject.CreateInstance<LogChannelSO>();
// Inject dependencies manually
var strategySystem = new StrategySystem(enemies);
var combatSystem = new CombatSystem(enemies, player, log);
// 1. Arrange: Initial state
enemies.Register(1, new EnemyState { HP = 10, Pos = Vector3.forward });
player.Value = new PlayerState { HP = 100 };
// 2. Act: Run a sequence of logic
strategySystem.Update();
combatSystem.Update();
// 3. Assert: Multi-system outcome
Assert.Less(player.Value.HP, 100, "Player should take damage");
// Verify via event channel observability
// (Assuming LogChannelSO tracks calls in a list for testing)
Assert.IsTrue(log.Contains("Player was hit!"));
}
}
Dependency Injection pattern
For classes with external dependencies (file I/O, dialogs), use interfaces and manual mocks.
Define an interface
public interface IFileService
{
string SaveFilePanel(string title, string directory,
string defaultName, string extension);
void WriteAllText(string path, string content, Encoding encoding);
void RevealInFinder(string path);
}
Create a mock implementation
public class MockFileService : IFileService
{
public string PathToReturn { get; set; }
public bool WriteAllTextCalled { get; private set; }
public string LastWrittenPath { get; private set; }
public string LastWrittenContent { get; private set; }
public string SaveFilePanel(string title, string directory,
string defaultName, string extension)
{
return PathToReturn;
}
public void WriteAllText(string path, string content, Encoding encoding)
{
WriteAllTextCalled = true;
LastWrittenPath = path;
LastWrittenContent = content;
}
public void RevealInFinder(string path) { }
}
Use in tests
public class ExporterTests
{
private MockFileService mockFileService;
private MockDialogService mockDialogService;
private Exporter exporter;
[SetUp]
public void Setup()
{
mockFileService = new MockFileService();
mockDialogService = new MockDialogService();
exporter = new Exporter(mockDialogService, mockFileService);
}
[Test]
public void Export_WithValidPath_WritesFile()
{
// Arrange
mockFileService.PathToReturn = "/path/to/file.csv";
// Act
exporter.Export(testData);
// Assert
Assert.That(mockFileService.WriteAllTextCalled, Is.True);
Assert.That(mockFileService.LastWrittenPath,
Is.EqualTo("/path/to/file.csv"));
}
[Test]
public void Export_UserCancels_DoesNotWriteFile()
{
// Arrange
mockFileService.PathToReturn = ""; // User cancelled
// Act
exporter.Export(testData);
// Assert
Assert.That(mockFileService.WriteAllTextCalled, Is.False);
}
}
Best practices
Follow AAA pattern
Structure tests with clear sections:
[Test]
public void MethodName_Scenario_ExpectedBehavior()
{
// Arrange - Set up test data and conditions
// Act - Execute the code under test
// Assert - Verify the results
}
Always clean up
Use DestroyImmediate in Edit Mode tests:
[TearDown]
public void Teardown()
{
// Destroy does not work immediately in Edit Mode
Object.DestroyImmediate(channel);
}
Test edge cases
[Test]
public void RaiseEvent_WithoutSubscribers_DoesNotThrow()
{
Assert.DoesNotThrow(() => channel.RaiseEvent(42));
}
[Test]
public void RaiseEvent_WithMinValue_TransmitsMinValue()
{
int receivedValue = 0;
channel.OnEventRaised += (value) => receivedValue = value;
channel.RaiseEvent(int.MinValue);
Assert.That(receivedValue, Is.EqualTo(int.MinValue));
}
Keep tests independent
Each test should be able to run in isolation:
// BAD: Tests share state
private static IntEventChannelSO sharedChannel;
// GOOD: Each test creates its own instance
[SetUp]
public void Setup()
{
channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
}
Common pitfalls
Forgetting to destroy ScriptableObjects
ScriptableObjects created with CreateInstance persist until destroyed. Always clean up in TearDown.
Using new instead of CreateInstance
// BAD: Will throw error
var channel = new IntEventChannelSO();
// GOOD: Correct way to create ScriptableObjects
var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
Testing in Play Mode unnecessarily
Most Reactive SO logic can be tested in Edit Mode, which is much faster.
Summary
| Component | Key Pattern |
|---|---|
| Event Channels | CreateInstance, subscribe, verify callback |
| Variables | CreateInstance, set value, check change detection |
| Runtime Sets | Create GameObject, add/remove, verify count |
| Reactive Entity Sets | Test subclass, Register/UpdateData, verify state |
| External Dependencies | Interface + Mock pattern |
References
- Testability - Why Reactive SO is naturally testable
- Unity Test Framework - Official documentation
- NUnit Documentation - Test framework reference