.claude/skills/testing/SKILL.md
Testing infrastructure reference — TDD workflow, NUnit + NSubstitute + UniTask patterns, MockFactory/TestFixtures APIs, coverage checklist, and forbidden patterns. Use when writing tests, modifying code that needs test updates, or reviewing test coverage.
npx skillsauth add punkfuncgames/tetris-clone testingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Location: Each package has its own Tests/ directory under Packages/com.punkfuncgames.<module>/Tests/. Template-level tests remain at Assets/Scripts/PunkFuncGames/GameTemplate/Tests/.
Guide: Tests/TESTING_GUIDE.md
Namespace: PunkFuncGames.<Module>.Tests.EditMode.* / PunkFuncGames.<Module>.Tests.PlayMode.*
100% test coverage goal. Every code change MUST include tests:
A false green is a passing test that masks a real bug by working around it instead of fixing it. False greens are worse than failing tests because they create false confidence.
When a test fails, the fix MUST target the root cause:
BigDouble, SerializableBigDouble, helper class), fix it THEREThese are signs of a false green — NEVER do these to make a test pass:
| Pattern | Why It's Wrong |
|---------|---------------|
| Adding guards in consumer code to avoid triggering a dependency bug | Masks the bug — other consumers still hit it |
| Changing test expectations to match buggy behavior | Tests now validate the bug instead of the contract |
| Adding try/catch around buggy code to swallow errors | Hides the failure, bug persists silently |
| Mocking away the layer that contains the bug | Test passes but production code still broken |
| Special-casing test data to avoid the failing code path | Reduces test coverage, bug still exists for other inputs |
Test fails
→ Trace the root cause
→ Bug is in THIS class?
→ Fix it here, test passes ✓
→ Bug is in a DEPENDENCY (utility, shared lib, lower layer)?
→ Fix the dependency FIRST
→ Write tests for the dependency fix
→ Re-run original test — should pass now ✓
→ Optionally add defensive guard in consumer as SUPPLEMENTARY protection
→ Bug is in the TEST itself (wrong expectation, bad mock setup)?
→ Fix the test, verify it tests the right contract ✓
Defensive guards in consumer code are ONLY acceptable when:
A defensive guard is NEVER a substitute for a root cause fix.
Wrong approach (false green):
// CurrencyContainer.SetCap — workaround added to avoid BigDouble operator bug
if (!BigDouble.IsPositiveInfinity(_cap.Value) && _amount.Value > _cap.Value)
// Test passes, but BigDouble.operator > is STILL broken for all other callers
Correct approach:
// 1. Fix BigDouble.operator > to handle infinity FIRST
public static bool operator >(BigDouble a, BigDouble b)
{
if (IsNaN(a) || IsNaN(b)) return false;
if (IsInfinity(a) || IsInfinity(b)) return a.Mantissa > b.Mantissa; // ROOT FIX
// ... rest of operator
}
// 2. Write BigDoubleComparisonTests proving the fix
[Test]
public void GreaterThan_FiniteVsPositiveInfinity_ReturnsFalse()
{
Assert.That(new BigDouble(500) > BigDouble.PositiveInfinity, Is.False);
}
// 3. THEN optionally keep the guard in CurrencyContainer as supplementary defense
| Library | Role |
|---------|------|
| NUnit | [TestFixture], [Test], [SetUp], [TearDown] |
| NSubstitute | Substitute.For<T>(), Arg.Any<T>(), .Received() |
| UniTask | async Task return with UniTask APIs in body (NUnit doesn't support async UniTask) |
| R3 | ReactiveProperty<T> testing, .Subscribe() |
| MessagePipe | IPublisher<T> / ISubscriber<T> mock verification |
| VContainer | ContainerBuilder for integration tests |
IEnumerator / [UnityTest] with IEnumerator return / yield return / coroutinesasync UniTask as test method return type (NUnit doesn't support it — use async Task instead)Task.Delay / Thread.Sleep (use UniTask.Delay)ValueTask in production codeAssert.AreEqual — use Assert.That constraint syntaxDebug.Log — use assertionsAll rules from the code-style skill apply to test code. Key reminders:
var — use explicit types: string name = "test"; not var name = "test";[Test] on one line, [Category("X")] on the nextif (obj) / if (!obj) not obj != nullEditMode (default): Pure C# services, models, calculations, reactive properties, commands, events PlayMode (only when needed): MonoBehaviour lifecycle, GameObjects, VContainer scene resolution, Views, Time.timeScale, Physics
using System;
using MessagePipe;
using NSubstitute;
using NUnit.Framework;
namespace PunkFuncGames.GameTemplate.Tests.EditMode.<Module>
{
[TestFixture]
public sealed class MyServiceTests
{
private IDependency _mockDep;
private IPublisher<MyEvent> _mockPublisher;
private MyService _service;
[SetUp]
public void SetUp()
{
_mockDep = Substitute.For<IDependency>();
_mockPublisher = Substitute.For<IPublisher<MyEvent>>();
_service = new MyService(_mockDep, _mockPublisher);
}
[TearDown]
public void TearDown()
{
_service?.Dispose();
}
}
}
using System.Threading;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
namespace PunkFuncGames.GameTemplate.Tests.PlayMode.<Module>
{
[TestFixture]
public sealed class MyIntegrationTests
{
private CancellationTokenSource _cts;
[SetUp]
public void SetUp()
{
_cts = new CancellationTokenSource();
}
[TearDown]
public void TearDown()
{
_cts?.Cancel();
_cts?.Dispose();
}
[Test]
public async Task Method_Condition_Expected()
{
await UniTask.NextFrame(_cts.Token);
Assert.That(result, Is.EqualTo(expected));
}
}
}
When mocks return UniTask.CompletedTask, async completes synchronously:
_mockDep.DoAsync(Arg.Any<CancellationToken>()).Returns(UniTask.CompletedTask);
_service.ProcessAsync(CancellationToken.None).Forget();
_mockDep.Received(1).DoAsync(Arg.Any<CancellationToken>());
await UniTask.NextFrame(_cts.Token); // Next frame
await UniTask.DelayFrame(10, cancellationToken: ct); // N frames
await UniTask.Delay(TimeSpan.FromSeconds(1), DelayType.Realtime, cancellationToken: ct);
await UniTask.WaitUntil(() => ready, cancellationToken: ct).Timeout(TimeSpan.FromSeconds(5));
await prop.Where(v => v == target).FirstAsync(ct); // Reactive wait
| Method | Returns | Key Parameters |
|--------|---------|----------------|
| CreateSteamService() | ISteamService | initialized, connected, loggedIn, steamId, personaName |
| CreateSteamCloudService() | ISteamCloudService | cloudEnabled, quotaTotal, quotaAvailable |
| CreateSteamAchievementService() | ISteamAchievementService | Defaults: all async → success |
| CreateSerializer() | ISaveSerializer | JSON-backed for testing |
| CreateEncryption() | ISaveEncryption | enabled (XOR when true, passthrough when false) |
| CreateLocalizationService() | ILocalizationService | currentLanguage, strings dict |
| CreatePublisher<T>() | IPublisher<T> | Generic |
| CreateSubscriber<T>() | ISubscriber<T> | Generic |
| CreateTriggeredSubscriber<T>() | (ISubscriber<T>, Action<T>) | Subscriber + manual trigger |
| CreateGameStateService() | IGameStateService | initialState (reactive, auto-updates) |
| CreateAnalyticsService() | IAnalyticsService | enabled, hasConsent |
| Method | Returns |
|--------|---------|
| CreateGameSettings() | GameSettings (ScriptableObject) |
| CreateSteamSettings() | SteamSettings (ScriptableObject) |
| CreateLocalizationSettings() | LocalizationSettings (ScriptableObject) |
| CreateSaveData() | SaveData |
| CreateSaveMetadata() | SaveMetadata |
| CreateTestBytes() | byte[] |
| CreateTestSerializableObject() | TestSerializableObject |
All ScriptableObject configs follow the Config + Data pattern in tests:
FloatingTextConfig config = ScriptableObject.CreateInstance<FloatingTextConfig>();
config.data = new FloatingTextConfigData
{
duration = 1f,
moveDistance = 2f,
defaultColor = Color.white,
prewarmCount = 5,
maxPoolSize = 20
};
.data.fieldName:Assert.That(config.data.duration, Is.EqualTo(1f));
Key rules:
public SomeData data;[Serializable] public struct with public camelCase fields, no methodsCreateRuntime() factories — use ScriptableObject.CreateInstance<T>() + struct initializerTestFixtures methods return SOs with data struct pre-initializedExamples across modules:
// LogSettings
LogSettings logSettings = ScriptableObject.CreateInstance<LogSettings>();
logSettings.data = new LogSettingsData { minimumLevel = LogLevel.Debug, historyCapacity = 256 };
// PoolConfig
PoolConfig poolConfig = ScriptableObject.CreateInstance<PoolConfig>();
poolConfig.data = new PoolConfigData { pools = new List<PoolEntryData> { new PoolEntryData { poolId = "Bullet", initialSize = 10 } } };
// CurrencyDefinition
CurrencyDefinition def = ScriptableObject.CreateInstance<CurrencyDefinition>();
def.data = new CurrencyDefinitionData { displayNameKey = "Gold", hasCap = false, showInUI = true };
// AudioSettings
AudioSettings audioSettings = ScriptableObject.CreateInstance<AudioSettings>();
audioSettings.data = new AudioSettingsData { sfxPoolSize = 16, defaultMasterVolume = 1f };
await PlayModeTestHelpers.WaitForConditionAsync(() => ready, timeout: 5f, ct);
await PlayModeTestHelpers.WaitFramesAsync(10, ct);
await PlayModeTestHelpers.WaitForRealSecondsAsync(1f, ct);
GameObject go = PlayModeTestHelpers.CreateTestObject("Name");
PlayModeTestHelpers.DestroyTestObject(go);
Class: {ClassUnderTest}Tests
Method: {MethodOrProperty}_{Condition}_{ExpectedResult}
Examples:
GetProductionRate_NoProducers_ReturnsZeroConstructor_NullWalletService_ThrowsArgumentNullExceptionCount_InitialState_ReturnsZeroAsyncMethod_Cancelled_ThrowsOperationCanceledExceptionServices:
Models: Default values, property setters, serialization round-trip, equality
Events: Factory methods, field population
Commands: ExecuteAsync, UndoAsync, Name, TryMerge
Views (PlayMode): Init, ShowAsync/HideAsync, model binding, cleanup
Configs: Default values, validation, data struct initialization (no runtime factories — use ScriptableObject.CreateInstance<T>() + struct init)
Substitute.For<IService>() // Create mock
mock.Method().Returns(value) // Return value
mock.AsyncMethod(ct).Returns(UniTask.CompletedTask) // Async return
mock.AsyncMethod(ct).Returns(UniTask.FromResult(val)) // Async with result
Arg.Any<T>() // Match any
Arg.Is<T>(v => v > 0) // Match predicate
mock.Received(1).Method() // Verify called once
mock.DidNotReceive().Method() // Verify not called
mock.ClearReceivedCalls() // Reset call tracking
mock.When(x => x.Method()).Do(ci => ...) // Capture/callback
Assert.That(actual, Is.EqualTo(expected)) // Equality
Assert.That(condition, Is.True / Is.False) // Boolean
Assert.That(obj, Is.Null / Is.Not.Null) // Null check
Assert.That(value, Is.GreaterThan(n)) // Comparison
Assert.That(list, Contains.Item(item)) // Contains
Assert.That(list, Has.Count.EqualTo(n)) // Count
Assert.That(obj, Is.InstanceOf<T>()) // Type check
Assert.Throws<T>(() => code()) // Sync throws
Assert.ThrowsAsync<T>(async () => await code()) // Async throws
Assert.DoesNotThrow(() => code()) // No throw
Assert.That(val, Is.EqualTo(exp).Within(0.001f)) // Tolerance
EditMode (Tests.EditMode.asmdef): Editor-only, NUnit + NSubstitute + Newtonsoft.Json + R3 + DOTween
PlayMode (Tests.PlayMode.asmdef): All platforms, adds MessagePipe.VContainer + ObservableCollections + VContainer
EditMode (33 files): Idle (9), Services (9), UnlockCondition (4), Serialization (3), FloatingText (2), Math (1), Pool (2), Helpers (2) PlayMode (10 files): FloatingText (3), Integration (3), Services (3), Helpers (1)
development
WalletModule reference — currency management with BigDouble support, reactive properties, caps, lifetime stats, and persistence. Use when working with currencies, wallets, or financial systems.
development
UnlockConditionModule reference — composable unlock conditions using ScriptableObjects with AND/OR/NOT logic, stat/currency/upgrade/prestige/gamestate/boolean checks, reactive service layer with progress tracking. Use when implementing unlock systems, gating, or progression requirements.
development
UndoModule reference — command pattern with undo/redo stacks, command merging, and reactive state. Use when implementing undo/redo, undoable actions, or command patterns.
tools
Unity UI Toolkit reference — UXML documents, USS styling, MVVM pattern (ViewModel + Presenter), custom VisualElements, responsive layout, animations, performance guidelines, and complete Figma-to-UI-Toolkit property mapping. Use when building or modifying UI with UI Toolkit, creating UXML/USS files, writing ViewModels or Presenters, designing screens/panels/components, or converting Figma designs to UI Toolkit.