Valheim.Testing.Doubles
Release
0.1.0from source2204f554da12; this package on NuGet.org.
This release's API reference. This page is the package's README; the versioned source commit is in the package metadata.
Valheim.Testing.Doubles lets a unit-test project compile and run a mod's pure-logic source files without Unity, Valheim, BepInEx or Jotunn. It is a source package: its files compile into your test project and stand in for the game's types under their real names. Your mod's source, linked unchanged, calls them as it would call the game. Use it for decisions, persistence formats, RPC handlers, registrations and anything else a mod computes from game types. For rendering, physics, timing and real networking, run a native check with Valheim.Testing.Game.
Example: ModWithTests links real mod source and runs five tests. The step-by-step guide is Test mod logic that uses game types.
Install
Add the package to the unit-test project, not the mod project. It needs C# 10 and works on net48 and modern .NET. Reference it with PrivateAssets="all", and do not also reference the game's assemblies: the doubles use the same names.
<PropertyGroup><LangVersion>10</LangVersion></PropertyGroup> <!-- or newer; net48 test legs default to C# 7.3 -->
<ItemGroup>
<PackageReference Include="Valheim.Testing.Doubles" Version="[0.1.0-rc.1]" PrivateAssets="all" />
<Compile Include="../MyMod/Src/RoadMath.cs" /> <!-- the mod's pure-logic sources -->
</ItemGroup>
The doubles are process-wide statics, as the game's singletons are. Add this once per test project:
[assembly: CollectionBehavior(DisableTestParallelization = true)]
The released version to pin is in the package table.
Your first test
The mod's own code, compiled against Valheim in the plugin:
// MyMod/DrySiteRule.cs
internal static bool CanPlace(Vector3 position, float waterLevel, float clearance)
=> WorldGenerator.instance is { } world
&& world.GetHeight(position.x, position.z) >= waterLevel + clearance;
The test installs a world with ValheimWorldScope and calls the real method:
// MyMod.Tests/DrySiteTests.cs
using MyMod;
using UnityEngine;
using Valheim.Testing;
using Valheim.Testing.Doubles;
using Xunit;
public class DrySiteTests
{
[Theory]
[InlineData(29f, false)] // Under water.
[InlineData(31.5f, true)] // Exactly the promised minimum clearance.
[InlineData(40f, true)] // Safely above it.
public void RequiresGroundAtLeastClearanceAboveWater(float ground, bool expected)
{
using var world = new ValheimWorldScope().WithTerrain(new PlaneTerrain(ground));
Assert.Equal(expected, DrySiteRule.CanPlace(new Vector3(0, 100, 0), 30f, 1.5f));
}
}
Run it from your mod checkout:
dotnet test MyMod.Tests/MyMod.Tests.csproj -c Release
A failed assertion returns a nonzero exit code.
What is in the package
Every type is partial. If your mod calls a member the doubles lack, add it in your own file and keep only mod-specific behaviour there. Two Heightmap hooks carry a mod's terrain logic into the rebuild: ModBaseHeight and ModTerrainPass.
The member index, Valheim.Testing.Doubles.members.txt, lists every type and member, one per line. It marks the members the game does not have ([TestOnly] in source): test switches, recorders and fixture shorthands such as ZNet.Peers or Heightmap.Registered. Mod code must not call those; it compiles against the doubles and fails against the game. Members marked internal are the doubles' own plumbing. A release's diff of the index is the list of names that may collide with your own partials.
| Member kind | Who calls it |
|---|---|
| Unmarked | Mod code and tests; the game has it |
[TestOnly] |
Tests only |
internal |
The doubles themselves |
Read-only singletons
A member that is read-only in the game is read-only in the doubles. Signatures ending in { get; } cannot be assigned by mod code. To set ZNet.instance, WorldGenerator.instance, HeightmapBuilder.instance, UnityEngine.Time or another game read-only member, use a ValheimWorldScope builder and dispose the scope. WithClock(120f) sets game time in seconds without running behaviours. If a test needs a frame, call the [TestOnly] Object.RunFrame.
One world per test: ValheimWorldScope
ValheimWorldScope gives each test its own world. It records every static the doubles keep and puts them back on dispose, also when the test throws. A second dispose does nothing. Disposing also ends the frame (UnityEngine.Object.EndOfFrame()), so an object the test destroyed is gone before the next test.
It records: the singletons (WorldGenerator.instance, ZDOMan.instance, ZoneSystem.instance, ZNetScene.instance, ZNet.instance, ZRoutedRpc.instance, ObjectDB.instance, Jotunn's NetworkManager.Instance); the loaded heightmaps and terrain modifiers; the scene's Unity objects and pending destroys; the log capture, Terminal.commands, the cheat gates, Player.m_localPlayer and the player list; localization, input, preferences, the config disk, the clock and the random generator Random.InitState installed; plus the server and dedicated flags of the ZNet it found. It restores the contents of the game's readonly heightmap, player and terrain-modifier lists without replacing those list objects.
Chain builders to install fresh objects:
using var world = new ValheimWorldScope().WithTerrain(new PlaneTerrain(30f)).WithZdos().WithNetwork(server: true);
| Builder | What the test gets |
|---|---|
WithWorld, WithTerrain |
A fresh world |
WithZdos |
A fresh ZDOMan |
WithZoneSystem |
A fresh ZoneSystem |
WithScene |
An empty scene: no prefabs, Unity objects or pending destroys |
WithNetwork(server) |
A ZNet with no peers, a new ZRoutedRpc, a new Jotunn NetworkManager |
WithCommands |
An empty console-command table |
WithCheats() |
Both cheat gates open; restored on dispose |
WithTerrainModifiers() |
A fresh live terrain-modifier list |
WithConfigFiles() |
An empty in-memory config disk |
WithClock(120f) |
Game time in seconds, without running behaviours |
WithUnityOrder(UnityOrder.Reversed) |
Unity's unordered phases run backwards; restored on dispose |
AsServer |
The server role flag |
WithLocalPlayer(position) |
A Player (none by default, as on a dedicated server) |
RegisterHeightmap |
A loaded zone; zones add up, loading a zone again replaces it |
UnloadHeightmap |
The zone unloaded |
CaptureLog |
The captured log |
The scope restores references, not contents. Nothing is deep-copied. If you change an object the scope did not install, such as adding a peer to the ZNet that was already there, the change outlives the test. Install a fresh one with a builder instead.
The scope covers the game's statics, not your mod's. Scope those with Valheim.Testing.StaticOverride:
using var plain = StaticOverride.Set(() => MyMod.Enabled, false);
Roles: server, host, client and main menu
Presets set the role flags mod code branches on and install a fresh world for it:
| Preset | What the test gets |
|---|---|
AsDedicatedServer() |
Server and dedicated, no local player |
AsHost(position) |
Server, not dedicated, a local player owned by this session |
AsClient(position) |
A client with a local player |
AtMainMenu() |
No ZNet, ZDOMan, ZNetScene or world; a preview Player whose view has no ZDO |
AddRemotePlayer(peerId, position, name) |
Another peer's player; on a server also a connected peer |
Player.GetAllPlayers() lists the main-menu preview player as the game's does.
A plugin loads as BepInEx's chainloader loads it: add every plugin to one manager object with manager.AddComponent<T>(), where manager = new GameObject("BepInEx_Manager"). Its Awake runs at once and a plugin finds its siblings with GetComponent.
PlatformPrefs under AsDedicatedServer() is on the Unity page.
Where each behaviour is documented
| Topic | Page |
|---|---|
ZDOs, ZNetView, ZNetScene, saves and restarts |
World state |
Heightmaps, terrain rebuild, TerrainSnapshot, TerrainAssert |
World state |
| Global keys, terrain modifier order, location placement | World state |
ObjectDB, ZNetScene registries, Utils, HeightmapBuilder, DropTable, Inventory |
World state |
ZPackage bytes, routed and per-object RPCs, Jotunn RPCs |
Network |
Terminal console commands and the cheat gates |
Network |
| Peer connections, the join handshake, Steam and crossplay identities | Network |
| Unity objects, their order, destroyed objects | Unity, config and Harmony |
Localization, BepInEx ConfigFile, config sync |
Unity, config and Harmony |
Harmony attributes, Traverse, AccessTools, the ModuleInitializer polyfill |
Unity, config and Harmony |
Limits
The doubles model only the behaviour those pages list and the members mod logic has needed so far. Anything else is a plain field or a no-op, not the game. Unity objects have no physics simulation or rendering: components keep their settings and answer queries, nothing more. Terrain is the rebuild and the compiler, not the game's mesh. Networking is in-process: peers connect only through linked socket doubles, and RPCs follow the delivery and routing rules without real timing, loss or reordering. Test what the game does natively with Valheim.Testing.Game against a real server.