Skip to content

Testing "Impossible" State Patterns

This guide documents patterns for testing states that "should never happen" but could occur in production. These tests catch edge cases that defensive programming must handle gracefully.

Access real implementation internals

Production code contains no test-only methods, callbacks, state capture, timing latches, or conditional execution paths. Names such as ForTest, ForTesting, ForTests, and TestHooks are prohibited in shipped implementation code and generated production source. Tests use existing operations and state through internal visibility and friend assemblies.

  • Promote the actual method or field needed by a fixture; remove forwarding wrappers and duplicate diagnostics.
  • Keep fixture setup, state snapshots, reset helpers, and deterministic inputs under Tests/.
  • Exercise real file ownership, cancellation, layout, and lifecycle operations. Do not add callbacks to pause production execution or suppress editor actions.
  • State the limits of phase tests: testing publication after staging does not by itself prove scheduling through the entire public asynchronous operation.
  • Keep timing observations in test-owned entry points. A warmed registration call cannot replace a first-registration measurement.

This removes the released test APIs AttributeMetadataCache.ForceRebuildForTests, WProtoDeclaredRootProvider.Unregister, and UnityMainThreadDispatcher.CreateTestScope. Package fixtures use internal implementation state; external consumer tests exercise public operations instead. Relational metadata changes invalidate lookups through SetMetadata, and the assigner rebuilds them on first use.

The production-hook gate checks forbidden identifiers and explicit test-only markers with positive controls. Review still checks suffix-free injection and instrumentation. Legitimate discovery of test assemblies and UI suppression during automated editor runs remain supported. The ValidationRun loader overload also remains: automatic validation uses it to resolve live prefab-stage roots before falling back to asset loading. Tests use real asset targets and implementation phases.

Preserve editor assets during fixtures

Use a unique directory for generated fixture assets. Register only paths the fixture owns, then explicitly call CleanupTrackedFoldersAndAssets. Batched fixtures must transfer their tracked paths before calling the base OneTimeTearDown, which removes the deferred paths. Check the database, directory and importer metadata after cleanup.

Create fixture roots through ExecuteWithImmediateImport before using them in a batch. Creating a directory on disk while its import is deferred can make Unity create a second folder with a numeric suffix. For a root containing a unique identifier, EditorAssetFolderAssertions.AssertUniqueRootRemoved also checks for these siblings. Keep this assertion in fixture teardown so passing test cases cannot hide a cleanup failure.

Drain deferred asset actions between independent import-heavy test cases. A fixture must not accumulate their unrelated import batches into one recursive-change window. Reset that window only after draining, so a genuine loop within one case still fails visibly.

When a fixture exercises a tool that persists a shared settings or cache asset, use the test-only EditorAssetFileScope with those exact asset paths. Capture it before the tool runs and dispose it after fixture teardown, including failure paths. It restores existing file and metadata bytes, removes newly created assets, and removes only newly created empty parents. It preserves unrelated sibling assets. Clear the tool's cached singleton reference afterward.

Inspect full NUnit results as well as leaf counts. A fixture teardown failure can leave all individual test cases passing while the run itself fails.

Identify the Unity Test Framework used by a run

The local test-project builder, CI builder and package exporter read the framework request from .github/unity-test-framework.json. Unity 2021/2022 use the compatible registry request. Unity 6 supplies a core package tied to the editor, so the request alone does not identify what ran. See Unity's core-package policy. Keep the separate NUnit 3.5 compiler floor and historical host witnesses unchanged.

Generated test projects include an editor-only collector. Persistent project paths include a compact fingerprint of the test leg, collector source, assembly definition and framework policy. Revisions with different collector inputs use separate project Libraries and retain the shared package-download cache. Explicit project-path overrides keep their requested location. The runner retires only the exact known collector files left by the initial unpublished implementation in legacy caches. It refuses unknown or linked files and active editors, retains original bytes before removal, and preserves unrelated assets, parent folders and their metadata.

CI gives each editor invocation a fresh framework/<capture-id> directory at the leg artifact root, independent of nested log paths. Each invocation retains its own log and process diagnostics before retry paths can overwrite them. Its record binds those copies to the project and process. It retains the manifest before launch, then captures the resolved manifest and lock, framework package metadata and C# source files, and the loaded editor runner, runtime runner and NUnit DLLs, plus available PDBs. Package sources use short numbered filenames; the inventory retains their original package-relative paths. This keeps generated compiler and evidence paths within Windows file API limits. Each domain snapshot records its process, project, Unity version, loaded module IDs and file hashes. The readback checks those IDs against the retained DLL bytes and rejects missing or changed evidence. Repeated domain reloads produce separate snapshots. A capture error fails the evidence check.

For a manually launched generated project, pass -uhFrameworkEvidenceDirectory <new-path> to collect the same snapshots. These startup records identify the loaded modules and source bytes observed in that domain. They do not prove a clean rebuild, map every framework source to compiler inputs, qualify CLI result semantics, or establish native process health. Inspect complete NUnit roots and suites, raw process receipts and expected inconclusive outcomes separately. A green XML report cannot explain an abnormal native exit.

Run npm run test:unity-framework for manifest controls and compiled host collector controls. The host controls use labeled API shims; native framework qualification still requires the supported Unity matrix and controls for timeout, teardown and interrupted PlayMode behavior.

A recognized Package Manager failure before a fresh configure marker or NUnit result retains the existing single retry. The final invocation still requires complete framework evidence.

Source validation must find its subjects

Package source scans resolve the owning package from its assembly, then use the source file path for an Assets installation. Validate the manifest's exact package name and the Tests directory; a path containing the package name can identify a nested dependency instead. Naming and assembly validators share this resolver.

Fail if the expected fixture source is absent, the source set is empty, or a file cannot be read. An inconclusive scan does not prove that naming rules passed. Resolver controls cover nested dependencies, wrong manifests and the source-path fallback.

Why Test "Impossible" States

Production code encounters situations that seem impossible during development:

  • Destroyed Unity Objects: Objects destroyed by external code, scene unloading, or domain reloads
  • Null References: References that "can't be null" become null due to serialization issues, race conditions, or user error
  • Invalid Enum Values: Casting arbitrary integers to enums produces values not defined in the enum
  • Corrupted Serialization State: Save files edited by users, version mismatches, or truncated data
  • Overflow Conditions: Extreme values that exceed expected ranges

Testing these scenarios ensures code fails gracefully rather than crashing or corrupting data.

Destroyed Unity Objects

Unity objects can be destroyed at any time by external systems. Code must handle the "fake null" state where an object reference is not null in C# terms but returns true for Unity's null check.

Pattern: Test Behavior After DestroyImmediate

C#
[Test]
public void GetGameObjectHandlesDestroyedComponent()
{
    GameObject go = Track(new GameObject("Test", typeof(SpriteRenderer)));
    SpriteRenderer spriteRenderer = go.GetComponent<SpriteRenderer>();

    Object.DestroyImmediate(spriteRenderer); // UNH-SUPPRESS: Test verifies behavior after component destruction

    GameObject result = spriteRenderer.GetGameObject();

    Assert.IsTrue(result == null, "Should return null for destroyed component");
}

Real Example: UnityExtensionsBasicTests.cs

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Runtime/Extensions/UnityExtensionsBasicTests.cs:

C#
[Test]
public void GetCenterUsesCenterPointOffsetWhenAvailable()
{
    GameObject go = Track(new GameObject("CenterPointTest", typeof(CenterPointOffset)));

    go.transform.position = new Vector3(5f, 5f, 0f);
    CenterPointOffset offset = go.GetComponent<CenterPointOffset>();
    offset.offset = new Vector2(3f, 4f);

    Assert.AreEqual(offset.CenterPoint, go.GetCenter());

    Object.DestroyImmediate(offset); // UNH-SUPPRESS: Test verifies behavior after component destruction
    Assert.AreEqual((Vector2)go.transform.position, go.GetCenter());
}

This test verifies that GetCenter() falls back to the GameObject's transform position when the CenterPointOffset component is destroyed.

Real Example: ObjectHelperTests.cs

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Runtime/Helper/ObjectHelperTests.cs:

C#
[UnityTest]
public IEnumerator GetGameObject()
{
    GameObject go = Track(new GameObject("Test", typeof(SpriteRenderer)));
    SpriteRenderer spriteRenderer = go.GetComponent<SpriteRenderer>();

    GameObject result = go.GetGameObject();
    Assert.AreEqual(result, go);
    result = spriteRenderer.GetGameObject();
    Assert.AreEqual(result, go);

    Object.DestroyImmediate(spriteRenderer); // UNH-SUPPRESS: Test verifies behavior after component destruction
    result = spriteRenderer.GetGameObject();
    Assert.IsTrue(result == null);
    result = go.GetGameObject();
    Assert.AreEqual(result, go);

    Object.DestroyImmediate(go); // UNH-SUPPRESS: Test verifies behavior after GameObject destruction
    result = spriteRenderer.GetGameObject();
    Assert.IsTrue(result == null);
    result = go.GetGameObject();
    Assert.IsTrue(result == null);

    result = ((GameObject)null).GetGameObject();
    Assert.IsTrue(result == null);

    result = ((SpriteRenderer)null).GetGameObject();
    Assert.IsTrue(result == null);
    yield break;
}

This test verifies:

  1. Normal operation with valid objects
  2. Behavior after component destruction (object still valid)
  3. Behavior after GameObject destruction (both references invalid)
  4. Explicit null input handling

Pattern: SerializedObject with Destroyed Target

Editor code often works with SerializedObject and SerializedProperty. When the target object is destroyed, these become invalid but may not be null.

C#
[Test]
public void DrawerHandlesDestroyedSerializedObjectTarget()
{
    MyScriptableObject target = CreateScriptableObject<MyScriptableObject>();
    SerializedObject serializedObject = new SerializedObject(target);
    SerializedProperty property = serializedObject.FindProperty("myField");

    Object.DestroyImmediate(target); // UNH-SUPPRESS: Test verifies behavior after target destroyed

    // SerializedObject.targetObject is now null
    Assert.DoesNotThrow(() => drawer.OnGUI(rect, property, label));
}

Real Example: ScriptableSingletonSerializationTests.cs

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Editor/CustomDrawers/ScriptableSingletonSerializationTests.cs:

C#
[Test]
public void IsScriptableSingletonTypeWithDestroyedObjectReturnsFalse()
{
    RegularScriptableObject target = CreateScriptableObject<RegularScriptableObject>();
    Object.DestroyImmediate(target); // UNH-SUPPRESS: Testing destroyed object handling

    // Unity's null check should handle destroyed objects
    bool result = SerializableDictionaryPropertyDrawer.IsScriptableSingletonType(target);
    Assert.IsFalse(result, "Destroyed object should return false (Unity null check).");
}

Real Example: WButtonRenderingTests.cs

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Editor/Utils/WButton/WButtonRenderingTests.cs:

C#
[Test]
public void NullEditorTargetHandledGracefully()
{
    RenderingTargetSingleButton asset = Track(
        ScriptableObject.CreateInstance<RenderingTargetSingleButton>()
    );
    UnityEditor.Editor editor = Track(UnityEditor.Editor.CreateEditor(asset));
    Dictionary<WButtonGroupKey, WButtonPaginationState> paginationStates = new();
    Dictionary<WButtonGroupKey, bool> foldoutStates = new();

    Object.DestroyImmediate(asset); // UNH-SUPPRESS: Test verifies behavior when target is destroyed
    _trackedObjects.Remove(asset);

    bool drawn = WButtonGUI.DrawButtons(
        editor,
        WButtonPlacement.Top,
        paginationStates,
        foldoutStates,
        UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen,
        triggeredContexts: null,
        globalPlacementIsTop: true
    );

    Assert.That(drawn, Is.False, "Should return false when target is destroyed");
}

Null References Where "Shouldn't Happen"

References that "can't be null" sometimes become null due to serialization issues, race conditions, improper initialization, or user error. Correct code must handle these cases gracefully.

Pattern: Explicit Null Input Handling

Test that methods handle null inputs gracefully, even when callers are "supposed to" provide non-null values.

C#
[Test]
public void ProcessNullInputDoesNotThrow()
{
    Assert.DoesNotThrow(() => Processor.Process(null));
}

[Test]
public void ProcessNullInputReturnsDefault()
{
    var result = Processor.Process(null);
    Assert.AreEqual(default(MyType), result);
}

Real Example: ObjectHelperTests.cs

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Runtime/Helper/ObjectHelperTests.cs:

C#
[UnityTest]
public IEnumerator GetGameObject()
{
    GameObject go = Track(new GameObject("Test", typeof(SpriteRenderer)));
    SpriteRenderer spriteRenderer = go.GetComponent<SpriteRenderer>();

    // ... (normal operation tests) ...

    // Test explicit null input handling
    result = ((GameObject)null).GetGameObject();
    Assert.IsTrue(result == null);

    result = ((SpriteRenderer)null).GetGameObject();
    Assert.IsTrue(result == null);
    yield break;
}

This test verifies that extension methods handle explicit null inputs gracefully, returning null rather than throwing NullReferenceException.

Pattern: Null Serialized Property Handling

Editor code may receive null SerializedProperty references due to timing issues or invalid property paths.

C#
[Test]
public void DrawPropertyHandlesNullProperty()
{
    Assert.DoesNotThrow(() => CustomDrawer.DrawProperty(null, GUIContent.none));
}

[Test]
public void GetValueFromNullPropertyReturnsDefault()
{
    object result = PropertyHelper.GetValue(null);
    Assert.IsNull(result);
}

Pattern: Null Collection Elements

Collections may contain null elements even when the code assumes they won't.

C#
[Test]
public void ProcessCollectionWithNullElementsSucceeds()
{
    List<string> items = new() { "A", null, "B", null, "C" };

    Assert.DoesNotThrow(() => Processor.ProcessAll(items));
}

[Test]
public void FilterHandlesNullElements()
{
    List<Component> components = new() { validComponent, null, anotherValid };

    List<Component> filtered = ComponentFilter.FilterValid(components);

    Assert.That(filtered, Has.None.Null);
    Assert.AreEqual(2, filtered.Count);
}

Invalid Enum Values

Enums can hold any integer value their underlying type supports, not just defined members. This occurs when:

  • Deserializing data from older/newer versions
  • Casting user input or external data
  • Data corruption

Pattern: Cast Invalid Integer to Enum

C#
1
2
3
4
5
6
7
8
9
[Test]
public void DisplayNameWithInvalidEnumValue()
{
    TestEnum invalidValue = (TestEnum)999;

    string displayName = invalidValue.ToDisplayName();

    Assert.IsNotEmpty(displayName, "Should return some string, not crash");
}

Pattern: Test All Enum Operations with Invalid Values

C#
[Test]
public void CachedNameWithInvalidEnumValue()
{
    TestEnum invalidValue = (TestEnum)999;

    string cachedName = invalidValue.ToCachedName();

    Assert.IsNotEmpty(cachedName);
}

[Test]
public void HasFlagNoAllocWithInvalidEnumValue()
{
    TestEnum invalidValue = (TestEnum)999;

    Assert.IsTrue(invalidValue.HasFlagNoAlloc(invalidValue));
    Assert.IsFalse(TestEnum.First.HasFlagNoAlloc(invalidValue));
}

Real Example: EnumExtensionTests.cs

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Runtime/Extensions/EnumExtensionTests.cs:

C#
[Test]
public void DisplayNameWithInvalidEnumValue()
{
    TestEnum invalidValue = (TestEnum)999;
    string displayName = invalidValue.ToDisplayName();
    Assert.IsNotEmpty(displayName);
}

[Test]
public void CachedNameWithInvalidEnumValue()
{
    TestEnum invalidValue = (TestEnum)999;
    string cachedName = invalidValue.ToCachedName();
    Assert.IsNotEmpty(cachedName);
}

[Test]
public void HasFlagNoAllocWithInvalidEnumValue()
{
    TestEnum invalidValue = (TestEnum)999;
    Assert.IsTrue(invalidValue.HasFlagNoAlloc(invalidValue));
    Assert.IsFalse(TestEnum.First.HasFlagNoAlloc(invalidValue));
}

Pattern: Invalid SerializationType

C#
[Test]
public void GenericSerializeInvalidSerializationTypeThrowsException()
{
    TestMessage msg = new() { Id = 1, Name = "Test" };

    Assert.Throws<InvalidEnumArgumentException>(() =>
        Serializer.Serialize(msg, (SerializationType)999)
    );
}

[Test]
public void GenericDeserializeInvalidSerializationTypeThrowsException()
{
    byte[] data = { 1, 2, 3 };

    Assert.Throws<InvalidEnumArgumentException>(() =>
        Serializer.Deserialize<TestMessage>(data, (SerializationType)999)
    );
}

Pattern: Flags Enum with All Bits Set

C#
[Test]
public void FlagsEnumShowsWhenAllFlagsSetAndExpectedIsSubset()
{
    OdinShowIfFlagsTarget target = CreateScriptableObject<OdinShowIfFlagsTarget>();
    target.flags = (TestFlagsEnum)(-1); // All bits set

    (bool success, bool shouldShow) = EvaluateCondition(
        target,
        nameof(OdinShowIfFlagsTarget.flags),
        new WShowIfAttribute(
            nameof(OdinShowIfFlagsTarget.flags),
            expectedValues: new object[] { TestFlagsEnum.FlagA | TestFlagsEnum.FlagB }
        )
    );

    Assert.That(success, Is.True);
    Assert.That(shouldShow, Is.True, "Field should show when all flags set and expected is subset");
}

Capacity claims without large test buffers

Keep hostile serialized capacity and index claims, including int.MaxValue, in correctness regressions. Configure a small SerializationCapacityLimits.MaximumRestoredCapacity before decoding a capacity hint, assert the restored capacity exactly, and restore the setting in teardown. The same payload must clamp at a low limit and retain its claimed capacity after the test raises that limit. Default-limit values can be checked directly through Clamp and TryAccept without allocating their corresponding buffers.

This exercises rejection, clamping, growth, and limit configuration while keeping routine tests small. Reserve large real buffers for separately categorized stress or performance tests. These bounded fixtures reduce test memory pressure; passing them alone does not establish the cause of an editor termination.

Overflow Conditions

Test behavior at the boundaries of numeric types to catch overflow, underflow, and precision issues.

Pattern: Extreme Numeric Values

C#
[Test]
public void BinaryRoundTripComplexObjectAllFieldsCorrect()
{
    ComplexMessage msg = new()
    {
        Integer = int.MaxValue,
        Double = Math.PI,
        Text = "Complex test with unicode",
        Data = new byte[] { 1, 2, 3, 255, 0, 128 },
    };

    byte[] serialized = Serializer.BinarySerialize(msg);
    ComplexMessage deserialized = Serializer.BinaryDeserialize<ComplexMessage>(serialized);

    Assert.AreEqual(msg.Integer, deserialized.Integer);
    Assert.AreEqual(msg.Double, deserialized.Double);
}

Pattern: Edge Case Values via TestCaseSource

C#
private static IEnumerable<TestCaseData> EdgeCaseTestData()
{
    yield return new TestCaseData(new[] { int.MaxValue }, int.MaxValue)
        .SetName("Input.MaxValue.HandlesCorrectly");
    yield return new TestCaseData(new[] { int.MinValue }, int.MinValue)
        .SetName("Input.MinValue.HandlesCorrectly");
    yield return new TestCaseData(new[] { 0 }, 0)
        .SetName("Input.Zero.ReturnsZero");
    yield return new TestCaseData(new[] { -1 }, -1)
        .SetName("Input.Negative.HandlesCorrectly");
}

[Test]
[TestCaseSource(nameof(EdgeCaseTestData))]
public void ProcessHandlesEdgeCases(int[] input, int expected)
{
    int result = MyProcessor.Process(input);

    Assert.AreEqual(expected, result);
}

Real Example: SerializerAdditionalTests.cs

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Runtime/Serialization/SerializerAdditionalTests.cs:

C#
[Test]
public void GenericSerializeWithAllTypesEdgeCaseData()
{
    ComplexMessage msg = new()
    {
        Integer = int.MinValue,
        Double = double.MaxValue,
        Text = string.Empty,
        Data = new byte[] { 0, 255 },
        StringList = new List<string> { "", "test" },
        Dictionary = new Dictionary<string, int> { [""] = 0, ["test"] = -1 },
    };

    foreach (SerializationType type in new[]
    {
        SerializationType.SystemBinary,
        SerializationType.Protobuf,
    })
    {
        byte[] serialized = Serializer.Serialize(msg, type);
        ComplexMessage deserialized = Serializer.Deserialize<ComplexMessage>(serialized, type);

        Assert.AreEqual(msg.Integer, deserialized.Integer, $"Failed for {type}");
        Assert.AreEqual(msg.Double, deserialized.Double, $"Failed for {type}");
    }
}

Corrupted Serialization State

Test handling of malformed, truncated, or invalid serialized data.

Pattern: Empty Data

C#
[Test]
public void BinaryDeserializeEmptyArrayThrowsException()
{
    byte[] emptyData = Array.Empty<byte>();

    Assert.Throws<SerializationException>(() =>
        Serializer.BinaryDeserialize<TestMessage>(emptyData)
    );
}

[Test]
public void ProtoDeserializeEmptyArrayReturnsDefaultInstance()
{
    byte[] emptyData = Array.Empty<byte>();

    TestMessage result = Serializer.ProtoDeserialize<TestMessage>(emptyData);

    Assert.NotNull(result);
    Assert.AreEqual(0, result.Id);
}

Pattern: Corrupted Data

C#
[Test]
public void BinaryDeserializeCorruptedDataThrowsException()
{
    byte[] corruptedData = { 0xFF, 0xFF, 0xFF, 0xFF };

    Assert.Throws<SerializationException>(() =>
        Serializer.BinaryDeserialize<TestMessage>(corruptedData)
    );
}

[Test]
public void FileIOReadFromInvalidJsonThrowsException()
{
    string filePath = Path.Combine(_tempDirectory, "invalid.json");
    File.WriteAllText(filePath, "{ invalid json content }");

    Assert.Throws<JsonException>(() =>
        Serializer.ReadFromJsonFile<TestMessage>(filePath)
    );
}

Pattern: Null Data

C#
[Test]
public void ProtoDeserializeNullDataThrowsException()
{
    Assert.Throws<ProtoException>(() =>
        Serializer.ProtoDeserialize<TestMessage>(null)
    );
}

[Test]
public void ProtoDeserializeWithTypeNullDataThrowsException()
{
    Assert.Throws<ArgumentException>(() =>
        Serializer.ProtoDeserialize<object>(null, typeof(TestMessage))
    );
}

Concurrent Access Edge Cases

Multi-threaded code can encounter states that are impossible in single-threaded execution. Unity Helpers uses #if !SINGLE_THREADED conditionals to wrap concurrent tests.

Pattern: Concurrent Operations Do Not Corrupt State

C#
#if !SINGLE_THREADED
[Test]
public void ConcurrentSetsDoNotCorruptCache()
{
    using Cache<int, int> cache = CacheBuilder<int, int>
        .NewBuilder()
        .MaximumSize(1000)
        .Build();

    int threadCount = 4;
    int operationsPerThread = 250;
    CountdownEvent countdownEvent = new(threadCount);
    Exception capturedException = null;

    for (int t = 0; t < threadCount; t++)
    {
        int threadIndex = t;
        ThreadPool.QueueUserWorkItem(_ =>
        {
            try
            {
                for (int i = 0; i < operationsPerThread; i++)
                {
                    int key = threadIndex * operationsPerThread + i;
                    cache.Set(key, key);
                }
            }
            catch (Exception ex)
            {
                capturedException = ex;
            }
            finally
            {
                countdownEvent.Signal();
            }
        });
    }

    countdownEvent.Wait(TimeSpan.FromSeconds(10));

    Assert.IsTrue(capturedException == null, $"Exception during concurrent sets: {capturedException}");
    Assert.AreEqual(threadCount * operationsPerThread, cache.Count);
}
#endif

Pattern: Mixed Read/Write Operations

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Runtime/DataStructures/CacheTests.cs:

C#
#if !SINGLE_THREADED
[Test]
public void ConcurrentSetsAndGetsDoNotCorruptCache()
{
    using Cache<int, int> cache = CacheBuilder<int, int>
        .NewBuilder()
        .MaximumSize(500)
        .Build();

    int threadCount = 4;
    int operationsPerThread = 500;
    CountdownEvent countdownEvent = new(threadCount);
    Exception capturedException = null;

    for (int t = 0; t < threadCount; t++)
    {
        int threadIndex = t;
        ThreadPool.QueueUserWorkItem(_ =>
        {
            try
            {
                for (int i = 0; i < operationsPerThread; i++)
                {
                    if (i % 2 == 0)
                    {
                        int key = threadIndex * 100 + (i % 100);
                        cache.Set(key, key);
                    }
                    else
                    {
                        int key = (threadIndex + 1) % threadCount * 100 + (i % 100);
                        cache.TryGet(key, out _);
                    }
                }
            }
            catch (Exception ex)
            {
                capturedException = ex;
            }
            finally
            {
                countdownEvent.Signal();
            }
        });
    }

    countdownEvent.Wait(TimeSpan.FromSeconds(10));

    Assert.IsNull(capturedException, $"Exception during concurrent operations: {capturedException}");
}
#endif

Pattern: Rapid Allocation/Deallocation

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Runtime/Utils/BuffersTests.cs:

C#
#if !SINGLE_THREADED
[Test]
public void WallstopFastArrayPoolConcurrentAccessRapidAllocationDeallocation()
{
    const int iterations = 1000;
    const int threadCount = 4;

    CountdownEvent countdownEvent = new(threadCount);
    Exception capturedException = null;

    for (int t = 0; t < threadCount; t++)
    {
        ThreadPool.QueueUserWorkItem(_ =>
        {
            try
            {
                for (int i = 0; i < iterations; i++)
                {
                    using (PooledArray<int> pooled = WallstopFastArrayPool<int>.Get(64, out _))
                    {
                        // Rapid acquire/release cycle
                    }
                }
            }
            catch (Exception ex)
            {
                capturedException = ex;
            }
            finally
            {
                countdownEvent.Signal();
            }
        });
    }

    countdownEvent.Wait(TimeSpan.FromSeconds(30));
    Assert.IsNull(capturedException, $"Exception during rapid allocation: {capturedException}");
}
#endif

Key Practices for Concurrent Tests

  1. Use CountdownEvent to synchronize thread completion
  2. Capture exceptions in threads since NUnit cannot catch them directly
  3. Use reasonable timeouts (10-30 seconds) to prevent test hangs
  4. Wrap in #if !SINGLE_THREADED for WebGL/IL2CPP compatibility
  5. Test both success and exception cases for thread safety

Invalid State Combinations

Some states are logically impossible during normal execution but can occur due to reflection, serialization bugs, or corrupted data.

Pattern: Empty Collections Where Non-Empty Expected

C#
[Test]
public void ProcessEmptyArrayGracefully()
{
    int[] emptyArray = Array.Empty<int>();

    // Methods that "shouldn't" receive empty arrays should handle them
    int result = collection.Min(emptyArray);

    Assert.AreEqual(default(int), result);
}

[Test]
public void SortEmptyCollection()
{
    List<int> emptyList = new();

    Assert.DoesNotThrow(() => emptyList.Sort(SortAlgorithm.Tim));
    Assert.AreEqual(0, emptyList.Count);
}

Real Example: Spatial Tree with Zero Elements

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Runtime/DataStructures/QuadTree2DTests.cs:

C#
[Test]
public void ConstructorWithEmptyCollectionSucceeds()
{
    List<Vector2> points = new();
    QuadTree2D<Vector2> tree = CreateTree(points);
    Assert.IsNotNull(tree);

    List<Vector2> results = new();
    tree.GetElementsInRange(Vector2.zero, 10000f, results);
    Assert.AreEqual(0, results.Count);
}

[Test]
public void GetApproximateNearestNeighborsWithEmptyTreeReturnsEmpty()
{
    List<Vector2> points = new();
    QuadTree2D<Vector2> tree = CreateTree(points);
    List<Vector2> results = new();

    tree.GetApproximateNearestNeighbors(Vector2.zero, 5, results);
    Assert.AreEqual(0, results.Count);
}

Pattern: Invalid Index/Key Access

C#
[Test]
public void IndexerThrowsOnInvalidIndex()
{
    CyclicBuffer<int> buffer = new(5) { 1, 2 };

    Assert.Throws<IndexOutOfRangeException>(() => { _ = buffer[-1]; });
    Assert.Throws<IndexOutOfRangeException>(() => { _ = buffer[2]; });
    Assert.Throws<IndexOutOfRangeException>(() => { _ = buffer[int.MaxValue]; });
    Assert.Throws<IndexOutOfRangeException>(() => { _ = buffer[int.MinValue]; });
}

Real Example: CyclicBufferTests.cs

From /workspaces/com.wallstop-studios.unity-helpers/Tests/Runtime/DataStructures/CyclicBufferTests.cs:

C#
[Test]
public void IndexerGetOutOfBounds()
{
    CyclicBuffer<int> buffer = new(5) { 1, 2 };

    Assert.Throws<IndexOutOfRangeException>(() =>
    {
        _ = buffer[-1];
    });
    Assert.Throws<IndexOutOfRangeException>(() =>
    {
        _ = buffer[2];
    });
    Assert.Throws<IndexOutOfRangeException>(() =>
    {
        _ = buffer[5];
    });
    Assert.Throws<IndexOutOfRangeException>(() =>
    {
        _ = buffer[int.MaxValue];
    });
    Assert.Throws<IndexOutOfRangeException>(() =>
    {
        _ = buffer[int.MinValue];
    });
}

Pattern: Disposed Object Access

C#
[Test]
public void AccessAfterDisposeThrows()
{
    Cache<int, int> cache = CacheBuilder<int, int>
        .NewBuilder()
        .MaximumSize(100)
        .Build();

    cache.Dispose();

    Assert.Throws<ObjectDisposedException>(() => cache.Set(1, 1));
    Assert.Throws<ObjectDisposedException>(() => cache.TryGet(1, out _));
}

Pattern: Extreme Capacity Values

C#
[Test]
public void IntMaxCapacityOk()
{
    CyclicBuffer<int> buffer = new(int.MaxValue);
    CollectionAssert.AreEquivalent(Array.Empty<int>(), buffer);

    const int tries = 50;
    List<int> expected = new(tries);
    for (int i = 0; i < tries; ++i)
    {
        buffer.Add(i);
        expected.Add(i);
        CollectionAssert.AreEquivalent(expected, buffer);
    }
}

Best Practices

Identifying "Impossible" States to Test

  1. Review defensive code paths: Any if (x == null) or try-catch suggests a potential "impossible" state
  2. Examine switch statements: Missing default cases indicate unhandled enum values
  3. Check serialization boundaries: Data crossing process/version boundaries can be corrupted
  4. Consider Unity lifecycle: Objects can be destroyed at any frame
  5. Look for race conditions: Multi-threaded code has timing-dependent states

Test Structure

Always include these categories in your tests:

Category Examples
Normal cases Typical usage, common inputs
Edge cases Empty, single element, boundary values
Negative cases Invalid inputs, error conditions
Extreme cases Maximum values, large collections
"The Impossible" Destroyed objects, invalid enums, corrupted data

UNH-SUPPRESS Usage

When testing destroyed object behavior, use the // UNH-SUPPRESS comment:

C#
// UNH-SUPPRESS tells the test linter this DestroyImmediate is intentional
Object.DestroyImmediate(target); // UNH-SUPPRESS: Test verifies behavior after destruction

Only use this for intentional destruction testing, not for cleanup. Use Track() for normal test cleanup.

Assertions for "Impossible" States

Choose assertions based on expected behavior:

C#
// When graceful handling is expected
Assert.DoesNotThrow(() => Process(invalidInput));
Assert.IsNotEmpty(invalidValue.ToDisplayName());
Assert.IsTrue(result == null);

// When exceptions are expected
Assert.Throws<InvalidEnumArgumentException>(() => Serialize(msg, (SerializationType)999));
Assert.Throws<SerializationException>(() => Deserialize(corruptedData));

// When default values are expected
Assert.AreEqual(default(T), result);
Assert.AreEqual(0, deserializedFromEmpty.Id);

Data-Driven Testing for Edge Cases

Use [TestCaseSource] to systematically cover impossible states:

C#
private static IEnumerable<TestCaseData> ImpossibleStateTestCases()
{
    // Destroyed references
    yield return new TestCaseData(CreateDestroyedObject())
        .SetName("State.DestroyedObject.HandledGracefully");

    // Invalid enums
    yield return new TestCaseData((MyEnum)(-1))
        .SetName("State.NegativeEnumValue.HandledGracefully");
    yield return new TestCaseData((MyEnum)999)
        .SetName("State.LargeEnumValue.HandledGracefully");
    yield return new TestCaseData((MyEnum)int.MaxValue)
        .SetName("State.MaxIntEnumValue.HandledGracefully");

    // Overflow values
    yield return new TestCaseData(int.MaxValue)
        .SetName("State.IntMaxValue.HandledGracefully");
    yield return new TestCaseData(int.MinValue)
        .SetName("State.IntMinValue.HandledGracefully");

    // Corrupted strings
    yield return new TestCaseData("\0\0\0")
        .SetName("State.NullChars.HandledGracefully");
    yield return new TestCaseData(new string('\uD800', 1000))
        .SetName("State.InvalidSurrogates.HandledGracefully");
}

[Test]
[TestCaseSource(nameof(ImpossibleStateTestCases))]
public void ProcessHandlesImpossibleStates(object input)
{
    Assert.DoesNotThrow(() => Process(input));
}

Summary

Testing "impossible" states is essential for dependable production code. These tests:

  1. Catch silent failures before they reach users
  2. Document expected behavior for edge cases
  3. Prevent regressions when code is refactored
  4. Build confidence that defensive code works

When adding new features, always ask: "What happens if this input is destroyed, null, invalid, or corrupted?" Then write tests to answer that question.

For more information on contributing to Unity Helpers, see the Contributing guide.