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¶
- Write a failing test that reproduces it. First.
- Fix the bug.
- Confirm the test passes.
- 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.