Testing Harness
Almost nothing in a mod runs outside the game. A block entity - the class the game attaches to one block position so that it can hold state and tick - does nothing at all until a running world hands it a core API, a block accessor and a tick listener. So the usual way to find out whether your furnace melts iron is to build the mod, start the game, load a world, place the machine and watch. Every change costs a restart, and a bug that only shows up when a chunk unloads, a save reloads, or a pipe is cut in one particular order is close to impossible to reproduce on purpose.
ExpandedLib.Testing (exlib.testing) is a headless xUnit harness that closes that loop. It loads
the real Vintage Story assemblies into the test process and fakes the server side of a world
around them, with NSubstitute behind the game's interfaces: an in-memory store of blocks and block
entities, a block accessor, a server and a client core API, a calendar, a logger, and a live
BlockNetworkModSystem - the production one, not a copy. Your block entity cannot tell the
difference. Plain dotnet test places a block, ticks it and asserts on what it did, in
milliseconds, with no game launch, no rendering and no world save.
What you write is a separate test project. It references the harness, xUnit and the game DLLs, and
declares one module initializer that resolves the game assemblies before any test type loads. Each
test then makes a TestWorld, places what it needs, steps it and asserts. A smoke
test below is a whole one: ten lines that place a block and read it back. The
harness scales up from there - Scene draws a multi-network layout as an ASCII diagram,
StructureRig stands up a machine that spans a dozen blocks, MachineRig runs one for simulated
minutes, and the checks under Checks/ read your mod's shipped JSON, lang files and definitions off
disk without booting anything.
This page gets a test project running and then works outward through those; the Testing API Reference lists every public type and signature.
What it gives you
TestWorld- the world itself: an in-memory block and block-entity store with a live network manager, over fakedIServerWorldAccessor/IBlockAccessor/ICoreServerAPI. Most tests need nothing else.Scene+SceneDiagram- a fluent builder and an ASCII-layout parser, for a test that needs more than a handful of positions: you draw the pipe run instead of typing its coordinates.VsAssemblyResolver- finds the game DLLs at runtime, in your install or the in-repo.game/<slug>folder, so your test project never carries them.TestLang- a minimalLangso production code that formats a player-facing string still runs when no game is loaded andLang.Get()would otherwise have nothing behind it.- Test doubles (
StubNetwork,TestNetworkBlock,CapturingNode,SeverableNode) - stand-ins that exercise the network graph on its own, before your own node types exist. - Supported doubles (
TestPlayer,TestInventory,TestModLoader,WorldConfigBag,ModConfigFiles,RecordingLogger) - the game contracts a mod reaches for constantly, already wired into everyTestWorld- see Doubles. ReflectionHelpers/TestBlocks- reach private state, and configure a bare block without the asset pipeline, for the setup a test needs and your own API does not expose.
1. Ten minutes to a green test
Three ways to get from nothing to a green test, cheapest first. All three end in the same place: a
test project beside your mod, referencing it, that dotnet test runs.
The template
dotnet new install ./templates/content/exlib-tests # or, once published: dotnet new install ExpandedLib.Templates
dotnet new exlib-tests -n Demo.Tests -o Demo.Tests --ModName Demo
dotnet test Demo.Tests
--ModName names your mod project (a sibling folder, ../Demo/Demo.csproj); --GamePath overrides
the Vintage Story install baked in (defaults to the VINTAGE_STORY environment variable). The
generated project's two content checks (a golden fact, a shipped-JSON fact) pass vacuously until
your mod has definitions or an assets/ tree - see templates/content/exlib-tests/README.md in the exlib repository.
By hand
Reference the harness, xUnit, the test SDK and NSubstitute, plus the game API DLLs (with
<Private>false</Private> so you don't copy them). Inside this monorepo, mirror
tests/ExpandedLib.Tests/ExpandedLib.Tests.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <!-- 1.22; -p:Legacy=true adds net8.0/net7.0 -->
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Reference Include="VintagestoryAPI"><HintPath>$(GamePath)/VintagestoryAPI.dll</HintPath><Private>false</Private></Reference>
<Reference Include="VSSurvivalMod"><HintPath>$(GamePath)/Mods/VSSurvivalMod.dll</HintPath><Private>false</Private></Reference>
<Reference Include="VSEssentials"><HintPath>$(GamePath)/Mods/VSEssentials.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\exlib\src\ExpandedLib\ExpandedLib.csproj" />
<ProjectReference Include="..\src\YourMod.csproj" />
<ProjectReference Include="..\..\exlib\src\ExpandedLib.Testing\ExpandedLib.Testing.csproj" />
</ItemGroup>
</Project>
Provisioning the game install: why IPlayer can be mocked at all
exmod provision game (both -Kind server and -Kind client) runs Publicize-GameApi
on the provisioned VintagestoryAPI.dll after every fetch, every re-check of an existing install and
every version bump - see the function in extools' exmod/provision.ps1. It flips the accessibility bits on
IPlayer.IsInInteractionRangeOf(BlockPos, float), which the game ships as internal abstract: an
interface member no external assembly is allowed to implement, so Substitute.For<IPlayer>() (and
IServerPlayer, which inherits the same member) cannot construct a proxy at all without this patch.
The edit is applied byte-for-byte in place rather than by regenerating the assembly - a
regenerated DLL loses its CodeView debug-directory entry, which crashes the game's own logger on
startup - so it is safe to leave in .game/<slug>, the copy the exlib repository builds against and
launches from; the mods you ship never see it, since the install a player runs is never patched. Idempotent
and a no-op once upstream makes the member public.
Consuming the harness from your mod
The harness is a developer library, not a game mod - it never ships inside a Mods/mod folder.
Three ways to use it from a separate mod repo, in the order the template tries them:
A - NuGet. ExpandedLib and ExpandedLib.Testing are on NuGet.org and are what the template's
generated project references by default; pin the version you build against, as
Installing describes.
B - Reference from source (recommended if you might tweak it). Add ExpandedLib.Testing (and
ExpandedLib) as a git submodule or sibling checkout and ProjectReference the .csprojs, exactly
as above. You track upstream changes and can debug into the harness.
C - The release bundle. Each GitHub release
ships exlib-testing_<version>.zip (versioned in lockstep with exlib) containing
ExpandedLib.Testing.dll + exlib.dll (built for the current game version, 1.22 / net10.0). Drop
both into your repo and reference them with copy-local off, supplying the rest yourself:
<ItemGroup>
<!-- the bundle (exlib's assembly name is "exlib") -->
<Reference Include="exlib"><HintPath>libs/exlib.dll</HintPath><Private>false</Private></Reference>
<Reference Include="ExpandedLib.Testing"><HintPath>libs/ExpandedLib.Testing.dll</HintPath><Private>false</Private></Reference>
<!-- game assemblies from your own install -->
<Reference Include="VintagestoryAPI"><HintPath>$(GamePath)/VintagestoryAPI.dll</HintPath><Private>false</Private></Reference>
<Reference Include="VSSurvivalMod"><HintPath>$(GamePath)/Mods/VSSurvivalMod.dll</HintPath><Private>false</Private></Reference>
<Reference Include="VSEssentials"><HintPath>$(GamePath)/Mods/VSEssentials.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
<ItemGroup>
<!-- the harness's own dependency, plus the test stack, from NuGet -->
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
The bundle deliberately omits the game assemblies (proprietary - you provide them) and NSubstitute
(pull it from NuGet so its own transitive deps resolve). The module initializer below is required
either way.
Note
Only the current game version is bundled. To target 1.20 / 1.21 test runs, use option B -
the harness multi-targets net8.0/net7.0 from source under -p:Legacy=true.
The required module initializer
Your test project references the game DLLs without copying them, so at run time something has to go
and find them. That has to happen before any test type is instantiated: xUnit loads every test
class to discover its facts, and a class that mentions a game type cannot load while the assembly
behind it is still missing. A [ModuleInitializer] - a method the runtime runs once when the
assembly loads, before anything in it is touched - is the one place early enough. Register the
resolver and the stand-in Lang there:
using System.Runtime.CompilerServices;
using ExpandedLib.Testing;
internal static class ModuleInit
{
[ModuleInitializer]
internal static void Init()
{
VsAssemblyResolver.Register(); // resolve VintagestoryAPI/VSSurvivalMod/... from the install or .game/<slug>
TestLang.Init(); // echo-the-key Lang so Lang.Get(...) is safe
}
}
Every test project here (ExpandedLib.Tests and the four samples' suites) and in the family
mods has exactly this - a module initializer only
runs for the assembly that declares it. VsAssemblyResolver.Register
is idempotent and resolves the install via the [AssemblyMetadata("GameInstallEnv")] environment
variable (e.g. VINTAGE_STORY) or, failing that, by walking up to .game/<slug>.
A smoke test
The smallest thing TestWorld can prove - place a block, read it back - with no network involved:
[Fact]
public void A_placed_block_reads_back_at_its_position()
{
var world = new TestWorld();
var pos = new BlockPos(0, 0, 0);
world.Place(pos, TestBlocks.Configure(new Block(), "test:stone", 1));
Assert.Equal("test:stone", world.GetBlock(pos).Code.ToString());
}
A first test
The same shape with the network graph in play: make a world, register a network type, place three
node blocks in a row, and assert that the manager merged them into one network rather than three.
Every test on this page is that shape - arrange on a TestWorld, act through the calls production
code makes, assert on state you read back:
using ExpandedLib.Testing;
using Vintagestory.API.MathTools;
using Xunit;
public class NetworkGraphTests
{
[Fact]
public void Three_adjacent_nodes_merge_into_one_network()
{
var world = new TestWorld();
world.RegisterNetwork("test", sys => new StubNetwork(sys));
var block = TestNetworkBlock.Create("test", "ns", id: 1);
var positions = new[] { new BlockPos(0, 0, 0), new BlockPos(0, 0, 1), new BlockPos(0, 0, 2) };
foreach (var pos in positions) world.Place(pos, block);
foreach (var pos in positions) world.AddNode(pos, "test");
var net = world.NetworkAt(positions[0]);
Assert.NotNull(net);
Assert.Equal(3, net!.Nodes.Count);
Assert.Same(net, world.NetworkAt(positions[2]));
}
}
2. Stand something up
A bare TestWorld holds blocks. This section is about giving it something worth asserting on: the
game contracts your code reads through, a layout bigger than a few positions, a mega-block footprint
that has to complete itself, a machine that runs over simulated minutes, Harmony patches, and
packets crossing between the two sides.
Doubles
A double is a stand-in for something the game would normally supply: the player holding an item, the
mod config on disk, the log an error goes to. Six of them are wired into every TestWorld already,
so your first inventory, config or logging test needs no NSubstitute knowledge. Each is a real
object holding real state, not a bare Substitute.For<T>(): assert by reading state off it rather
than by asking a mock what it received with Received().
TestPlayer - a player with a real hotbar:
TestPlayer player = world.Player();
player.Hold(new ItemStack(pickaxe));
Assert.True(player.Sneaking is false); // player.Sneaking = true toggles Entity.Controls.Sneak
TestInventory - a real multi-slot inventory, for a test that needs more than one slot:
InventoryGeneric inv = TestInventory.Of(world, slots: 4);
inv[0].Itemstack = new ItemStack(ironBar);
TestModLoader (world.Mods) - IsModEnabled and the aliases some mods probe by reflection:
world.Mods.Add("iiex", "1.0.0");
Assert.True(world.Api.ModLoader.IsModEnabled("iiex"));
WorldConfigBag (world.Config) - the real ITreeAttribute behind World.Config:
world.Api.World.Config.SetString("difficulty", "hard");
Assert.Equal("hard", world.Config.Tree.GetString("difficulty"));
ModConfigFiles (world.ConfigFiles) - backs Api.LoadModConfig/StoreModConfig with real
files under a temp directory deleted when the TestWorld is disposed:
world.Api.StoreModConfig(new MyConfig { Volume = 800 }, "mymod.json");
MyConfig? loaded = world.Api.LoadModConfig<MyConfig>("mymod.json"); // round-trips through real JSON
RecordingLogger (world.Log) - every entry Api.Logger/World.Logger received, formatted:
Assert.Contains(world.Log.Errors, m => m.Contains("names no network type"));
Integration tests with Scene and SceneDiagram
A test that needs a dozen blocks in a row spends more lines on coordinates than on the thing it
proves, and nobody reading it later can see the shape it builds. Scene composes over TestWorld:
register your network types, queue blocks, nodes and machines, then Build and Step them
together. SceneDiagram places them from a drawing instead of a list of positions. For your own
real network types, build the world as a diagram, step it, then read state back:
var scene = new Scene();
scene.Network("pipe", sys => new PipeNetwork(sys));
new SceneDiagram()
.On('#', p => scene.Block(p, Rock))
.On('=', p => scene.Node(p, Pipe, new BlockEntityPipe(), "pipe"))
.Layer("#===#"); // rock caps + three pipe cells along +X
scene.Build(); // add all queued nodes to the graph
scene.Step(10); // fire BE ticks then network ticks, 10 times
var net = scene.NetworkAt<PipeNetwork>(new BlockPos(1, 0, 0));
Assert.Equal(3, net!.Nodes.Count);
SceneDiagram maps characters to placement lambdas: columns advance +X, rows advance +Z (a space is
a gap, unlike a code-first layout's grids, where a space is a spacer between cells), and
Stack(baseY, layers...) stacks layers bottom-to-top in +Y. SceneDiagram is a thin forwarder over
SceneGrid, the scene diagram's derivation of ExpandedLib.Structures.CellGrid - the same grid core
the multiblock and filler layout DSLs draw over.
To pin a multiblock's own layout against a test rather than re-typing its cells by hand, read the
authored def's emitted table with LayoutTable:
var layout = LayoutTable.From(BlockBlastFurnaceCoreHot.Definitions("siex").Single());
var rotated = LayoutTable.Rotated(def, angle: 90); // as vanilla MultiblockStructure would place it
Standing up a mega-block with StructureRig
A mega-block is one machine spread over many block positions: an anchor block entity that does the work, and a footprint of cells around it that all have to hold the right blocks before it runs. That makes it the hardest thing on this page to stand up in a test, and the easiest to fake.
A BlockEntityMultiblockStructure only runs its production tick while StructureComplete is true, and
that flag is set by the machine's own monitor tick when vanilla's InCompleteBlockCount reaches zero.
The tempting shortcut is to force it:
ReflectionHelpers.SetProperty(be, "StructureComplete", true); // don't
That asserts the conclusion. The test then passes even when the layout is wrong, the rotation is a
half-turn out, or the anchor never loaded its attributes - the machine "works" in the test and does
nothing in game. StructureRig builds the footprint instead, and lets the machine complete itself:
var rig = StructureRig.Around(world, furnace, BlockBlastFurnaceCoreHot.Definitions("siex").Single(), angle: 0);
rig.Occupy(rig.Cell(0, 1, -1), tuyereBlock, new BlockEntityTuyere()); // cells the test cares about
rig.Complete(); // fill the rest, Initialize, and wait for the machine's own monitor tick
- The layout comes from the anchor's code-first
ExBlockDef, so re-authoring a footprint moves its tests with it.Aroundalso attaches the def'sattributesto the placed block, which is what lets the productionUpdateStructureRotationfind the layout (aTestBlocks.Configureblock has none). anglemust be the angle the machine derives from its own variant (north 0, west 90, south 180, east 270, plus any per-machine offset - the Bessemer control and cowper stove useangle + 180). A wrong angle is not tolerated: the cells land where the machine isn't looking andCompletethrows with a per-cell breakdown of what each unsatisfied cell wants and what it holds.Raise()fills only empty cells. A cell you placed yourself is never replaced, even if it does not satisfy the layout - otherwise a fixture could put its tuyere one cell out and still complete, orphaning the block it goes on to assert against. Air-satisfied cells (@(air|coalpile)shafts) are left empty on purpose.AwaitCompletion()/Missinglet a test assert the transitions - that an unbuilt footprint never completes, or that breaking one cell takes a running machine back out of production.
Place the real functional blocks (tuyeres, taps, outlets) before raising, and give them the code
the layout asks for. A generic pipe behaves identically to a tuyere as a network node but does not
satisfy an iiex:tuyere* cell.
Driving one machine with MachineRig
Fixtures that stand up a machine on a TestWorld (or a Scene, through scene.World) tend to write
the same three stepping loops by hand. MachineRig(world) is the base class for that: subclass it,
call one of the three, and stop repeating the loop.
internal sealed class WaterPumpPlant : EnginePlant {
// ... construction ...
}
var plant = new WaterPumpPlant(scene, pos);
plant.RunWithSteam(3f, seconds: 10); // holds the inlet fed while the engine runs
RunUntil(Func<bool> until, ceilingSeconds, stepSeconds = 1f)steps untiluntil()holds, returning the seconds elapsed; throwsTimeoutExceptionnaming the ceiling if it never holds.RunLive(seconds, observer = null, stepSeconds = 1f)steps for the given duration, callingobserver(with the seconds just elapsed) after every step.RunWhile(beforeEachStep, seconds, stepSeconds = 1f)runs an action before every step - the "hold a source at a level and step" loop (recharge a pipe, crank a pump) so the machine sees a fed line rather than one that drains on the first tick.
Each step fires block-entity ticks then the network tick, the same order Scene.Step uses. A rig
that needs a different order - feeding a fuel source directly, invoking a production tick by
reflection to fast-forward past a multi-minute heat-up - is doing more than this base covers and
steps by hand instead; RunUntil/RunLive/Tick variants that re-feed a furnace's tuyeres each
step are like that, and stay as fixture-local methods.
Testing Harmony patches
Harmony is the runtime patching library a mod uses to change a vanilla method's behaviour without
its source. HarmonyFixture applies a mod's Harmony patches once and reverts them on dispose, so a
patch a test applies does not leak into the tests that run after it. Harmony patches are
process-wide (keyed by owner id and target method, not by test instance), so every class that
touches one joins a collection with DisableParallelization = true:
[CollectionDefinition(Name, DisableParallelization = true)]
public class MyPatchesCollection { public const string Name = "MyPatches"; }
[Collection(MyPatchesCollection.Name)]
public class MyPatchesTests {
[Fact]
public void Prefix_runs() {
using var fixture = new HarmonyFixture("mymod.patchtest", typeof(MyPatchesTests).Assembly);
// exercise the patched method here
Assert.True(fixture.IsPatched(typeof(SomeType).GetMethod(nameof(SomeType.SomeMethod))!));
}
}
A category ([HarmonyPatchCategory("...")]) applies through the third constructor argument instead
of every uncategorised class in the assembly:
using var fixture = new HarmonyFixture("mymod.patchtest", typeof(MyPatchesTests).Assembly, "myCategory");
For the explicit form - no fixture, reverting by hand - call ExHarmony.PatchOnce/UnpatchAll
directly, the same entry points the fixture is built on.
A vanilla type whose static constructor touches client or world state runs that constructor the
first time any test references the type, however indirectly - BlockEntityAnvil's builds particle
objects in its type initialiser. Patch a server-safe vanilla method (CollectibleObject .GetHeldItemName is the one this harness tests against) or a type the test assembly itself declares.
Testing packets
A packet is a message your mod sends over a named channel between the server and a client, and a real one needs two processes to exercise. The harness holds both ends, so a round trip is an assertion.
A ModSystem - your mod's entry-point class, the one the game constructs at load - that registers a
channel through Api.Network.RegisterChannel/ClientApi.Network .RegisterChannel in StartServerSide/StartClientSide needs no test-only wiring: call it against
world.Api/world.ClientApi and read the pair back off world.Channels:
var system = new MyPacketModSystem();
system.StartServerSide(world.Api);
system.StartClientSide(world.ClientApi);
var channels = world.Channels("mymod-channel"); // same pair both sides just registered against
world.Api.Event.PlayerJoin += Raise.Event<PlayerDelegate>(channels.Sender);
Assert.Contains(channels.SentToClients, p => ((MyPacket)p).Value == 42);
Building the pair directly - TestChannels.Create(world, name) - is the same object world.Channels
memoises; reach for it when there is no ModSystem in the loop, just a packet type to round-trip:
var channels = TestChannels.Create(world, "mymod-channel");
channels.Server.RegisterMessageType<MyPacket>();
channels.Client.RegisterMessageType<MyPacket>();
MyPacket? received = null;
channels.Client.SetMessageHandler<MyPacket>(p => received = p);
channels.Server.SendPacket(new MyPacket { Value = 42 }, channels.Sender);
Assert.Equal(42, received!.Value);
Every send round-trips through SerializerUtil into a fresh instance and delivers synchronously to
the registered handler on the other side - no scheduler, no wire. SentToServer/SentToClients
record every packet a channel carried, deserialised, oldest first. Sending a type neither side
registered throws InvalidOperationException naming it - the same mistake a real mismatched
RegisterMessageType order would silently corrupt in the real game.
Priming private state
Prefer a named seam when one exists: DriveProductionTick/DriveIdleTick on
BlockEntityProductionMachine/BEBehaviorProductionMachine, DriveMonitorTick/
ApplyStructureRotation on BlockEntityMultiblockStructure, and SetNetworkTypeForTest/
ApplyOrientationForTest on BlockNetworkNode all run the same code path the real listener or loader
would, and a rename breaks them at compile time rather than at run time.
ReflectionHelpers reaches non-public fields/properties/methods (walking the base hierarchy) when
you need to set up or assert internal state that has no seam yet:
var boiler = new BlockEntityBoilerCornish();
ReflectionHelpers.SetField(boiler, "_waterVolume", 300f);
var water = (float)ReflectionHelpers.GetField(boiler, "_waterVolume")!;
Assert.Equal(300f, water, 3);
3. Prove the content
Half a mod is not code. Shipped JSON, lang files, block codes, save keys and code-first definitions never reach the compiler, so nothing catches a mistake in them until a world loads - or much later, when a player's save holds a block code you renamed. The checks below read those files off disk and fail a test instead.
Pinning a block entity's save shape
A block entity saves itself into a tree attribute, the game's nested key-value bag, and reads the same keys back on load. Spell a key one way on the write side and another on the read side and the field loads as zero in every existing save, with no error anywhere.
ExpandedLib.Testing.TreeKeys golden-checks the keys a block entity writes, the same way
DefinitionGoldens (see Code-First Definitions) golden-checks a def's JSON:
TreeKeys.AssertGolden(new BlockEntityFurnaceTap(), "iiex");
TreeKeys.Of reads back a fresh instance's ToTreeAttributes, sorted and type-tagged
("temp:float"), nested trees flattened to "parent/child". AssertGolden compares that against
mods/<domain>/tests/goldens/<domain>/treekeys/<ClassName>.txt, reblessed under
EXLIB_WRITE_GOLDENS like any other golden. It is the oracle for converting a hand-written
ToTreeAttributes/FromTreeAttributes pair to [Persist]/Persisted - see
Converting a hand-written pair on Block Entities.
Content validators (Checks/)
Most types under Checks/ are thin wrappers over ExpandedLib.Checks - the same rule
the game runs at load and /exmod verify runs on demand, callable directly against a mod's own
assembly and asset tree without booting anything. DefinitionGoldens and TreeKeys (above) are the
two that compare against a committed golden rather than a live rule; DefinitionParity is the
semantic JSON comparison both DefinitionGoldens and a migration's own before/after test build on;
LayoutTable reads a code-first multiblock layout's emitted table back into a per-cell block code.
The full list, one row per type, is the Testing API Reference's "Checks"
table - this page doesn't repeat it.
Repo-wide guards, per mod
ShippedJson, LoopingAnimations, LangKeys and LangParity are the four checks a mod's own
suite runs over its own tree: every shipped JSON parses and carries no control character, and (under
patches/) declares its side; no looping shape animation unwinds a whole turn across its wrap; every
literal Lang.Get("domain:key") in the mod's source resolves in its lang tree's English file; every
locale in that lang tree carries the same key set and placeholders as English. Each returns
IReadOnlyList<string> findings (empty means clean) and pairs with a premise method
(PatchFiles/ShapeFiles/Literals/LocaleFiles) a mod's suite asserts is non-empty wherever its
tree is known to carry that kind of file - otherwise a renamed folder or a dead regex would make the
rule above it pass trivially:
[Fact]
public void Iiexs_shipped_json_carries_no_defect() {
var offenders = ShippedJson.Check(RepoPaths.Assets("iiex"));
Assert.True(offenders.Count == 0, string.Join("\n", offenders));
}
A mod whose tree carries another domain's overlay (iiex's game lang overlay) runs the same check
over both trees. RepoPaths and RepoManifest resolve which tree is whose from exmod.json (see
Testing API Reference): a mod, a sample and a plain mods/<id> fallback all
resolve the same way, so these four checks read the same whether exmod.json exists or not.
dotnet new exlib-tests scaffolds two of the four against YourModProject's own tree -
Invariants/ShippedAssetJsonTests.cs and Localization/LangParityTests.cs - trivially true (no
files found, no failure) until the scaffolded mod ships assets and a translated locale to check.
Reflection scans: RegistryLawScanner and ResourceInvariant
A law that must hold for every concrete subclass of some base type - wherever it is declared, not
just the leaves the current test assembly compiles against - is RegistryLawScanner:
RegistryLawScanner.ForEach<BlockEntityFurnaceCore>(leaf => {
// assert something about `leaf`, the concrete Type
});
ConcreteSubclasses<TBase>() walks the loaded assembly closure (references load lazily, so call
this only after the subject type is known to be loaded) and returns every non-abstract type
assignable to TBase. ForEach runs a law against each and aggregates every failure into one
message, so three broken leaves are reported together rather than one retry at a time.
ResourceInvariant<TState> checks an invariant across randomised operation sequences - useful where
a resource (a pipe network, a canal run) can be split, merged, drained or refilled in any order:
new ResourceInvariant<Pool>(
fresh: () => new Pool(),
moves: [p => p.Split(), p => p.Merge(), p => p.Drain(10)],
assert: p => Assert.True(p.Total >= 0)
).Run(sequences: 5, movesPerSequence: 50, seed: 1);
A failure names the sequence, the move index, and the full move list applied so far, so it reproduces with the same seed.
StaticStateCollection: catching an unsynchronised xUnit collection
xUnit runs test classes in parallel by default; two classes that mutate the same process-wide
static must join one [Collection("Name")], backed by a [CollectionDefinition("Name", ...)]. A
collection name with no definition still "works" - xUnit synthesises one - but it synthesises a
different one per typo, so two classes meaning to serialize against each other race instead. Call
the guard once per test assembly:
[Fact]
public void Every_collection_name_has_a_definition() =>
StaticStateCollection.EveryCollectionNameHasADefinition(Assembly.GetExecutingAssembly());
Repo paths and released-code history for a new mod
RepoPaths.Assets(domain) falls back to mods/<domain>/assets/<domain> for a domain it does not
already know, so a new mod's own domain resolves before anyone edits the harness for it.
RepoPaths.Register(domain, modFolder) is there for the case that does need an edit - a domain whose
zip ships from a different mod's folder, the way game's shared vanilla-lang overlay ships from
iiex's.
Released-code history (ReleasedCodes, ReleasedVersions, ReleasedCodeDebt) is registered per
mod, from that mod's own test ModuleInit, through ReleasedHistory.Register:
ReleasedHistory.Register(
mod: "iiex",
shipped: PpexShippedRows,
entityClasses: PpexEntityClasses,
versions: new Dictionary<string, string> { ["ppex"] = "0.6.8" },
debt: PpexKnownUnmigrated
);
ReleasedCodes.Ppex/.Smex/.Exlib, ReleasedVersions.HighestPublished and
ReleasedCodeDebt.KnownUnmigrated read ReleasedHistory rather than holding the rows themselves, so
the harness carries no mod's shipping history directly.
Regenerating a block-code table: exmod codes
{Mod}Blocks.g.cs (ExlibBlocks, IiexBlocks, SiexBlocks) is generated from the mod's own
code-first block definitions by the mod's own *BlocksCodeTests fixture (BlockCodeEmitter.CheckOrWrite)
- there is no separate emitter tool:
exmod codes iiex
builds the mod, runs its test project with EXLIB_WRITE_BLOCKCODES=1 in the environment and
--filter "exmod=codes" (the trait every *BlocksCodeTests class carries), and rebuilds so a
table change that no longer compiles is caught immediately. Run without that environment variable -
as the normal test lane does - CheckOrWrite stays read-only: it fails and names exmod codes <mod> when the table has drifted, rather than writing it.
Real assets: TestWorld.LoadAssets
Register/RegisterItem build a stand-in Block/Item by hand; LoadAssets instead drives the
game's own asset manager and object loader against a mod's real assets and compiled classes, so the
Block/Item it registers is the one the object loader itself resolved - real class, real
variants:
using var world = new TestWorld();
world.LoadAssets(Path.Combine(RepoPaths.Root, "samples", "TwinTubBlower"));
Block blower = world.World.GetBlock(new AssetLocation("twintubblower:blower-twintubblower-n"))!;
modPath is a mod's folder (modinfo.json, assets/<modid>/, a compiled dll under bin/); every
ModSystem the mod's assembly declares is Started against an isolated API before the object
loader runs, so both JSON and code-first mods resolve. Base game domain assets load too, but not
vanilla survival/creative content - those blocks need classes only VSSurvivalMod's own
ModSystems register.
4. Boot it
A green suite proves your logic. It does not prove the game will load your mod: a malformed asset, a class the game cannot resolve by name, a dependency version it will not accept - each of those passes every test above and still fails at world load. This is the lane that catches them.
The smoke lane
exmod smoke is the zero-effort rung below everything else on this page: one command boots the
real dedicated server with your mod and fails if anything goes wrong - no test project, no xUnit,
no fixtures.
exmod smoke # every built mod under mods/*/src/bin/Debug/Mods/mod
exmod smoke -Mods path/to/one/mod # a single built mod folder instead
exmod smoke -Mods path/to/several-mods # a folder holding several built mod folders
It provisions a dedicated-server install if it doesn't have one yet (exmod provision game -Kind server), assembles a scratch mods folder keyed by each mod's own modid, boots
VintagestoryServer.dll --dataPath <scratch> --addModPath <scratch> on port 42499 (unusual on
purpose, so it never collides with a game you're actually playing on the same machine), waits for
"Dedicated Server now running", sends /exmod verify then /stop on its stdin, and reports what it
found: every [exlib] notification line, every [Error]/[Fatal] log line, and the verify summary.
It exits non-zero - printing the offending lines - on a boot timeout (-Timeout, default 180s), any
[Error]/[Fatal] line, or a verify summary with errors in it; -KeepData keeps the scratch
dataPath and mods folder afterwards instead of deleting them, for chasing a failure.
A mod whose modinfo.json doesn't even parse is still copied in (under its folder name) rather than
failed locally, so the real mod loader is what reports it - the point of this lane is what the game
itself catches, not what the script can catch first.
CI runs it in its own job (see .github/workflows/tests.yml's smoke job, or extools'
templates/ci/smoke.yml for a mod outside the exlib repository), separate from the test job so a
game-loading failure and a test failure are reported distinctly.
CI templates
extools' templates/ci/tests.yml (provision + dotnet test) and templates/ci/smoke.yml (this
section's lane) are the copy-into-your-own-repo forms of .github/workflows/tests.yml's two jobs -
each carries the two edits a third-party repo needs to make, marked CHANGE inline.
5. Where things are
One namespace, ExpandedLib.Testing; the folders sort files by what you're doing, not by type.
| Folder | A test author finds... |
|---|---|
World/ | TestWorld, TestBlocks, TestLang, VsAssemblyResolver |
Scenes/ | Scene, SceneDiagram, SceneGrid |
Rigs/ | StructureRig, MachineRig, RegistryLawScanner, ResourceInvariant<TState>, StaticStateCollection, HarmonyFixture |
Doubles/ | stand-ins: StubNetwork, TestNetworkBlock, CapturingNode, SeverableNode, OrientableNode, RccFake, TestMemberBlockEntity, MechPower; supported doubles: TestPlayer, TestInventory, TestModLoader, WorldConfigBag, ModConfigFiles, RecordingLogger, TestChannels |
Checks/ | the content validators: definition parity, goldens, lang coverage, wiki parity, LayoutTable, ... - most now wrap ExpandedLib.Checks, the same rule the game runs at load and /exmod verify; see the Testing API Reference's full "Checks" table |
Repo/ | RepoPaths, ReleasedHistory, ReleasedCodes, ReleasedVersions, ReleasedCodeDebt, BlockCodeEmitter |
| (root) | ReflectionHelpers |
For the complete public surface, folder by folder, see the Testing API Reference's
own "Where things are" table - this one stays a quick map, that one is the one kept in lockstep with
the assembly by HarnessSurfaceTests.
6. Examples
Six files worth reading end to end before writing a new one, one per shape of test:
Networks/NetworkGraphTests.cs (pure graph maths over POCOs), Structures/StructureRigTests.cs
(a mega-block driven through StructureRig), Machines/ProductionMachineTests.cs (a machine driven
through MachineRig), Config/ConfigMigrationTests.cs (the load-migrate-stamp-save cycle over a fake
ICoreAPI), Definitions/ExlibDefinitionGoldenTests.cs (a golden-file comparison), and
Invariants/ShippedAssetJsonTests.cs (a repository-wide JSON guard).
Beside them, covering exlib's own public surface: Registries/ExmodCommandTests.cs (a hand-rolled fluent IChatCommand fake, since NSubstitute returns
a fresh substitute per call rather than the same one down a chain), Registries/PreferencesTests.cs,
Registries/Recipes/RecipeProfilesTests.cs, Config/ConfigAttributesTests.cs,
Blocks/ConstructionTests.cs, Migrations/BlockRemovalTests.cs, Migrations/ItemCodeMigrationTests.cs,
Migrations/ChunkSweeperTests.cs, Helpers/LegacyTests.cs (legacy-lane-only; empty on 1.22) and
Helpers/Measure/HandbookUnitPatchTests.cs.
7. Limits
What the harness does not do, so you find out here rather than from a test that proves nothing.
- Side. The harness fakes the server; tests exercise server-side simulation. Client-only
render paths, GUI and real chunk loading aren't covered -
IServerPlayerworks (see Doubles above), because the game's own object graph makes it a server-side type in every way that matters to test code. - The publicizer.
IPlayer/IServerPlayercan be substituted at all only because provisioning patches the game'sVintagestoryAPI.dllin place - see "Provisioning the game install" under Ten minutes to a green test. It never touches the copy a player runs. - Legacy targets. The harness multi-targets
net8.0/net7.0under-p:Legacy=trueand branches on#if GAME_GE_1_22for the tick-listener signature change, so the same tests run on 1.20/1.21 too. - Burst/realism. Some behaviours (e.g. pipe burst) need a real concrete block, not a stub - the doubles are for graph topology, your own network/node types bring the gameplay semantics.
Related pages
- Testing API Reference - every public type and signature.
- Block Networks - the system under test.