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.
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.
[Test]publicvoidGetGameObjectHandlesDestroyedComponent(){GameObjectgo=Track(newGameObject("Test",typeof(SpriteRenderer)));SpriteRendererspriteRenderer=go.GetComponent<SpriteRenderer>();Object.DestroyImmediate(spriteRenderer);// UNH-SUPPRESS: Test verifies behavior after component destructionGameObjectresult=spriteRenderer.GetGameObject();Assert.IsTrue(result==null,"Should return null for destroyed component");}
[Test]publicvoidGetCenterUsesCenterPointOffsetWhenAvailable(){GameObjectgo=Track(newGameObject("CenterPointTest",typeof(CenterPointOffset)));go.transform.position=newVector3(5f,5f,0f);CenterPointOffsetoffset=go.GetComponent<CenterPointOffset>();offset.offset=newVector2(3f,4f);Assert.AreEqual(offset.CenterPoint,go.GetCenter());Object.DestroyImmediate(offset);// UNH-SUPPRESS: Test verifies behavior after component destructionAssert.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.
[UnityTest]publicIEnumeratorGetGameObject(){GameObjectgo=Track(newGameObject("Test",typeof(SpriteRenderer)));SpriteRendererspriteRenderer=go.GetComponent<SpriteRenderer>();GameObjectresult=go.GetGameObject();Assert.AreEqual(result,go);result=spriteRenderer.GetGameObject();Assert.AreEqual(result,go);Object.DestroyImmediate(spriteRenderer);// UNH-SUPPRESS: Test verifies behavior after component destructionresult=spriteRenderer.GetGameObject();Assert.IsTrue(result==null);result=go.GetGameObject();Assert.AreEqual(result,go);Object.DestroyImmediate(go);// UNH-SUPPRESS: Test verifies behavior after GameObject destructionresult=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);yieldbreak;}
This test verifies:
Normal operation with valid objects
Behavior after component destruction (object still valid)
Behavior after GameObject destruction (both references invalid)
[Test]publicvoidDrawerHandlesDestroyedSerializedObjectTarget(){MyScriptableObjecttarget=CreateScriptableObject<MyScriptableObject>();SerializedObjectserializedObject=newSerializedObject(target);SerializedPropertyproperty=serializedObject.FindProperty("myField");Object.DestroyImmediate(target);// UNH-SUPPRESS: Test verifies behavior after target destroyed// SerializedObject.targetObject is now nullAssert.DoesNotThrow(()=>drawer.OnGUI(rect,property,label));}
Real Example: ScriptableSingletonSerializationTests.cs¶
From /workspaces/com.wallstop-studios.unity-helpers/Tests/Editor/CustomDrawers/ScriptableSingletonSerializationTests.cs:
[Test]publicvoidNullEditorTargetHandledGracefully(){RenderingTargetSingleButtonasset=Track(ScriptableObject.CreateInstance<RenderingTargetSingleButton>());UnityEditor.Editoreditor=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);booldrawn=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");}
References that "can't be null" sometimes become null due to serialization issues, race conditions, improper initialization, or user error. Robust code must handle these cases gracefully.
[Test]publicvoidDisplayNameWithInvalidEnumValue(){TestEnuminvalidValue=(TestEnum)999;stringdisplayName=invalidValue.ToDisplayName();Assert.IsNotEmpty(displayName,"Should return some string, not crash");}
Pattern: Test All Enum Operations with Invalid Values¶
[Test]publicvoidFlagsEnumShowsWhenAllFlagsSetAndExpectedIsSubset(){OdinShowIfFlagsTargettarget=CreateScriptableObject<OdinShowIfFlagsTarget>();target.flags=(TestFlagsEnum)(-1);// All bits set(boolsuccess,boolshouldShow)=EvaluateCondition(target,nameof(OdinShowIfFlagsTarget.flags),newWShowIfAttribute(nameof(OdinShowIfFlagsTarget.flags),expectedValues:newobject[]{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");}
[Test]publicvoidBinaryRoundTripComplexObjectAllFieldsCorrect(){ComplexMessagemsg=new(){Integer=int.MaxValue,Double=Math.PI,Text="Complex test with unicode",Data=newbyte[]{1,2,3,255,0,128},};byte[]serialized=Serializer.BinarySerialize(msg);ComplexMessagedeserialized=Serializer.BinaryDeserialize<ComplexMessage>(serialized);Assert.AreEqual(msg.Integer,deserialized.Integer);Assert.AreEqual(msg.Double,deserialized.Double);}
[Test]publicvoidGenericSerializeWithAllTypesEdgeCaseData(){ComplexMessagemsg=new(){Integer=int.MinValue,Double=double.MaxValue,Text=string.Empty,Data=newbyte[]{0,255},StringList=newList<string>{"","test"},Dictionary=newDictionary<string,int>{[""]=0,["test"]=-1},};foreach(SerializationTypetypeinnew[]{SerializationType.SystemBinary,SerializationType.Protobuf,}){byte[]serialized=Serializer.Serialize(msg,type);ComplexMessagedeserialized=Serializer.Deserialize<ComplexMessage>(serialized,type);Assert.AreEqual(msg.Integer,deserialized.Integer,$"Failed for {type}");Assert.AreEqual(msg.Double,deserialized.Double,$"Failed for {type}");}}
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¶
[Test]publicvoidProcessEmptyArrayGracefully(){int[]emptyArray=Array.Empty<int>();// Methods that "shouldn't" receive empty arrays should handle themintresult=collection.Min(emptyArray);Assert.AreEqual(default(int),result);}[Test]publicvoidSortEmptyCollection(){List<int>emptyList=new();Assert.DoesNotThrow(()=>emptyList.Sort(SortAlgorithm.Tim));Assert.AreEqual(0,emptyList.Count);}
// UNH-SUPPRESS tells the test linter this DestroyImmediate is intentionalObject.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.
// When graceful handling is expectedAssert.DoesNotThrow(()=>Process(invalidInput));Assert.IsNotEmpty(invalidValue.ToDisplayName());Assert.IsTrue(result==null);// When exceptions are expectedAssert.Throws<InvalidEnumArgumentException>(()=>Serialize(msg,(SerializationType)999));Assert.Throws<SerializationException>(()=>Deserialize(corruptedData));// When default values are expectedAssert.AreEqual(default(T),result);Assert.AreEqual(0,deserializedFromEmpty.Id);
Testing "impossible" states is essential for robust production code. These tests:
Catch silent failures before they reach users
Document expected behavior for edge cases
Prevent regressions when code is refactored
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.