Skip to content

Testing Standards

The original Applejack has zero automated tests. Every rule below exists to make sure this one does not repeat that.


1. The binding constraint

Game rules and arithmetic live in engine-agnostic types.

This is the single most important architectural constraint in the project, and it is a testing requirement that shapes the entire design. It means:

  • Capacity and stacking maths
  • Pricing, tax, salary and refund arithmetic
  • Permission and access resolution
  • Save migration
  • Warrant and sentence timing
  • Need progression curves
  • Crafting requirement evaluation

…all live in plain C# classes that take plain data and return plain data. No GameObject, no Component, no Scene, no Connection, no static engine access. They are compiled into the test project and exercised under dotnet test with nothing running.

Components are then thin: they hold state, they call the rules, they replicate the result.

If you find yourself needing to launch the game to test a rule, the rule is in the wrong place. Move it.


2. Harness

S&box generates the test project automatically: add a UnitTests/ directory at the project root and restart the editor. The generated project uses MSTest.

using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class InventoryCapacityTests
{
    [TestMethod]
    public void NegativeSizeItemsIncreaseCapacity()
    {
        // Legacy: items/misc/small_pocket.lua has Size = -5, so two pockets take the
        // budget from 40 to 50. See Documentation/Legacy/03_INVENTORY_AND_ITEMS.md §1.2.
        var inventory = new InventoryState(baseCapacity: 40);
        inventory.Add(Pocket, count: 2);

        Assert.AreEqual(50, inventory.MaximumSpace);
    }
}

Run with dotnet test, or through the Test Explorer in Visual Studio / Rider.


3. What must be tested

Required — a subsystem is not done without these

Area Must cover
Inventory Capacity maths, negative-size items, the over-encumbrance refusal on pocket removal, stack limits, transfers, atomic failure
Items Definition loading, inheritance resolution, unset vs set-to-falsy, unresolvable base is a hard failure
Economy Money floor at zero, refund arithmetic, tax, repossession, affordability, batch cost
Permissions Access resolution across player / team / gang, hierarchical group;0, denial by default
Persistence Round-trip every schema, every migration path, forward-compat on unknown fields, corrupt-file handling
Needs Progression curves, thresholds, hysteresis, the suicide/respawn anti-exploit
Crime Warrant expiry, sentence timing across a reconnect, confiscation vs disarming
Crafting Requirement evaluation, category monopoly enforcement

Regression tests are mandatory for the legacy defect list

Every defect in Legacy/12_DEFECTS_AND_LESSONS.md that is expressible as a unit test gets one, named for it:

[TestMethod]
public void D03_PerItemStackLimitIsEnforced() {  }

[TestMethod]
public void D04_ItemWithUnresolvableBaseFailsLoudly() {  }

[TestMethod]
public void D10_TwoInstancesOfTheSameDefinitionAreDistinguishable() {  }

These are the project's promises. If one fails, we have reintroduced a fifteen-year-old bug.


4. Test quality

Name tests as statements of behaviour. TransferIsRefusedWhenDestinationIsFull, not TestTransfer2.

Arrange / Act / Assert, visually separated. One logical assertion per test — several Assert calls checking one outcome is fine; testing two behaviours is not.

No shared mutable state between tests. Every test constructs what it needs. Tests must pass in any order and in parallel.

Test behaviour, not implementation. A test that breaks when you rename a private field is a liability. Drive through the public API.

Test the boundaries. Zero, one, maximum, maximum + 1, negative, empty, null. Most real bugs live there — including D-03, which is a boundary check that never executed.

Cover the failure paths. "You cannot afford this" is behaviour and needs a test as much as the success case does.


5. Beyond unit tests

Unit tests cannot cover everything. These are checklist items on the Definition of Done and are performed manually until automated.

Kind What When
Integration Two or more modules through their real interfaces, no engine Per subsystem
Multiplayer Two or more connected clients; every networked feature Before merge
Persistence Save → restart → load; and a migration from the previous version Before merge
Reconnect Disconnect mid-action, reconnect, verify state Per subsystem touching persistence
Exploit Deliberately send invalid, out-of-range and out-of-authority requests Per networked feature
Stress Many players, many entities, many items Per milestone
Performance Allocation, network bytes per second, tick cost Per milestone

The exploit checklist

Run against every client→server entry point:

  • [ ] Call it without the required permission
  • [ ] Call it targeting an entity you do not own
  • [ ] Call it from across the map
  • [ ] Call it with negative, zero, and absurdly large quantities
  • [ ] Call it with a null or non-existent target
  • [ ] Call it faster than the rate limit
  • [ ] Call it while dead, arrested, tied, or not yet initialised
  • [ ] Call it twice concurrently

The legacy dispatcher checked argument count and an access flag and nothing else (D-02). This list is the replacement for that gap.


6. Coverage

No coverage percentage target — they reward testing the easy code.

The rule instead: every rule type has tests, and every defect fixed gains a regression test. A pull request that changes a rule type without touching a test file is questioned at review.


7. When a bug is found

  1. Write a failing test that reproduces it. First.
  2. Fix the bug.
  3. Confirm the test passes.
  4. Keep the test.

If the bug cannot be reproduced in a test, that is information: it means the logic is not in an engine-agnostic type, and it usually should be.