Writing MUD tests
This guide is about writing tests. There is a separate page about running them.
A simple test
This is an explanation of the test (opens in a new tab) for the React template (opens in a new tab) contracts.
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.21;
import "forge-std/Test.sol";
import { MudTest } from "@latticexyz/world/test/MudTest.t.sol";
Import the general definitions required in all MUD tests.
MUD test contracts inherit from MudTest
(opens in a new tab).
import { IWorld } from "../src/codegen/world/IWorld.sol";
import { Tasks, TasksData } from "../src/codegen/index.sol";
Import the definitions required for this test, the World
we can access and the tables we'll use.
contract TasksTest is MudTest {
function testWorldExists() public {
MUD tests are Foundry tests (opens in a new tab).
Any public function that starts with test
is a test that gets executed.
uint256 codeSize;
address addr = worldAddress;
The World
address comes from the MudTest
(opens in a new tab).
MudTest
gets the World
address from the $WORLD_ADDRESS
environment variable and sets it as the Store
address.
assembly {
codeSize := extcodesize(addr)
}
assertTrue(codeSize > 0);
}
Use extcodesize
(opens in a new tab) to get the size of the World
contract.
If the deploy process failed, there wouldn't be any code there.
function testTasks() public {
// Expect task to exist that we created during PostDeploy script
TasksData memory task = Tasks.get("1");
Use the structure for a table entry's values for the entry that gets created as part of code generation.
assertEq(task.description, "Walk the dog");
assertEq(task.completedAt, 0);
Verify the information that is prepopulated by the PostDeploy.s.sol
script (opens in a new tab).
// Expect the task to be completed after calling completeTask from our TasksSystem
IWorld(worldAddress).completeTask("1");
Call a System
to modify the table data.
assertEq(Tasks.getCompletedAt("1"), block.timestamp);
}
}
Verify that the call changed the data correctly.
A more complicated example
Here is an explanation for the test (opens in a new tab) for MoveSystem
(opens in a new tab) in SkyStrife (opens in a new tab).
Note that SkyStrife uses the entities, components, and systems (ECS) model.
Each entity has a 32-byte identifier, and pieces of information are typically held in specific tables, with that entity identifier as the key.
For example, there is one table (Untraversable
) that stores whether an entity blocks the traversal of its position.
There is another table (Position
) that stores the location of each entity.
A third table, (OwnedBy
) tells us what player owns the entity, if any.
There are three externally available functions in MoveSystem
:
move
(opens in a new tab), used when the entity just moves.fight
(opens in a new tab), used when the entity just attacks an enemy.moveAndAttack
(opens in a new tab), used when the entity moves and then attacks an enemy.
The first test is testMoveOneTile
(opens in a new tab):
function testMoveOneTile() public {
setupMove();
First we use setupMove
(opens in a new tab).
This function sets up the test by creating the player entity, the environment in which a unit could move (a 4x4 grid of grassland), the match, and a player unit that could move (whose original position is (0,0)
).
PositionData[] memory path = new PositionData[](1);
path[0] = PositionData(0, 1);
Create a path for the movement.
vm.startPrank(alice);
Act as alice
, the player.
startGasReport("Move unit 1 tile");
world.move(testMatch, unit, path);
endGasReport();
Perform the move and generate a gas report (opens in a new tab).
PositionData memory position = Position.get(testMatch, unit);
assertEq(position.x, 0, "x should be 0");
assertEq(position.y, 1, "y should be 1");
}
Verify the unit is in the correct position.
function testMoveTwoTiles() public {
setupMove();
PositionData[] memory path = new PositionData[](2);
path[0] = PositionData(0, 1);
path[1] = PositionData(0, 2);
.
.
.
}
function testMoveThreeTiles() public {
setupMove();
PositionData[] memory path = new PositionData[](3);
path[0] = PositionData(0, 1);
path[1] = PositionData(0, 2);
path[2] = PositionData(0, 3);
.
.
.
}
These tests are very similar to testMoveOneTile
, except the unit moves multiple tiles (which is allowed by SkyStrife).
function testMovementSetsLastAction() public {
setupMove();
PositionData[] memory path = new PositionData[](2);
path[0] = PositionData(0, 1);
path[1] = PositionData(0, 2);
runSystem(path);
uint256 lastAction = LastAction.get(testMatch, unit);
assertEq(lastAction, block.timestamp, "last action should be the block timestamp");
}
This test checks that the time of the last action is updated correctly. SkyStrife has a cooldown counter for each unit until it can act again, so we need to know when the last move was.
function testMoveInvalidPath() public {
setupMove();
PositionData[] memory path = new PositionData[](2);
path[0] = PositionData(0, 1);
path[1] = PositionData(0, 3);
vm.expectRevert("invalid path");
runSystem(path);
}
The player may be using our client, which only sends valid paths. However, in blockchain programming there is no such guarantee. We have to assume hostile input, so we have to test not just that actions that should be successful succeed, but also that actions that should fail fail.
function testMovingTooFar() public {
setupMove();
PositionData[] memory path = new PositionData[](5);
path[0] = PositionData(0, 1);
path[1] = PositionData(1, 1);
path[2] = PositionData(2, 1);
path[3] = PositionData(2, 2);
path[4] = PositionData(2, 3);
vm.expectRevert("not enough move speed");
runSystem(path);
}
A path can be invalid because it is too long for the unit to traverse in a single turn.
function testEntityBlockingPath() public {
A path can also be invalid because it contains an entity that blocks it (enemy unit, hole in the ground, etc.).
setupMove();
prankAdmin();
We need to create an entity that makes it impossible to go through a tile , so we pretend to be the smart contract that normally creates them.
PositionData memory position = PositionData(0, 1);
bytes32 entity = createMatchEntity(testMatch);
Create an entity to serve as a blocker. Make it part of the same match as our main unit.
setPosition(testMatch, entity, position);
Untraversable.set(testMatch, entity, true);
Set the position of the new entity to where it will block the path, and make it untraversable, which means it really does block. Note that we do not set the owner of this entity, so it's owned by zero, which is not the owner of the unit we are moving.
vm.stopPrank();
And now we don't need to be the privileged admin anymore.
PositionData[] memory path = new PositionData[](2);
path[0] = PositionData(0, 1);
path[1] = PositionData(0, 2);
vm.expectRevert("cannot move through enemies");
runSystem(path);
}
This move should fail.
function testMoveThroughFriendlyUnits() public {
A unit can move through blockers that are entities belonging to the same player (units in the same army).
setupMove();
prankAdmin();
bytes32 entity = createMatchEntity(testMatch);
OwnedBy.set(testMatch, entity, player);
Here we set the blocking entity to be owned by the same player.
Untraversable.set(testMatch, entity, true);
It wouldn't be a valid test if we didn't also set the unit as untraversable so it will normally be blocking.
vm.stopPrank();
PositionData[] memory path = new PositionData[](2);
path[0] = PositionData(0, 1);
path[1] = PositionData(0, 2);
runSystem(path);
PositionData memory position = Position.get(testMatch, unit);
assertEq(position.x, 0, "x should be 0");
assertEq(position.y, 2, "y should be 2");
}
This move should be successful.
function testMoveAndAttack() public {
Test moveAndAttack
(opens in a new tab).
setupMove();
prankAdmin();
To setup a fight we also need to be the administrator.
Combat.set(
testMatch,
unit,
CombatData({
health: 100_000,
maxHealth: 100_000,
strength: 20_000,
counterStrength: 100,
minRange: 1,
maxRange: 1,
archetype: CombatArchetypes.Unknown
})
);
Set the combat attributes for our unit.
bytes32 enemy = createMatchEntity(testMatch);
setPosition(testMatch, enemy, PositionData(0, 3));
The enemy position should be one space away from our position at the end of the move.
Combat.set(
testMatch,
enemy,
CombatData({
health: 100_000,
maxHealth: 100_000,
strength: 20_000,
counterStrength: 100,
minRange: 1,
maxRange: 1,
archetype: CombatArchetypes.Unknown
})
);
vm.stopPrank();
Create the combat attributes for the enemy unit.
PositionData[] memory path = new PositionData[](2);
path[0] = PositionData(0, 1);
path[1] = PositionData(0, 2);
vm.startPrank(alice);
Act as the player.
startGasReport("Move and attack unit");
world.moveAndAttack(testMatch, unit, path, enemy);
endGasReport();
vm.stopPrank();
PositionData memory position = Position.get(testMatch, unit);
assertEq(position.x, 0, "x should be 0");
assertEq(position.y, 2, "y should be 2");
The unit should be where the path ended.
CombatData memory combat = Combat.get(testMatch, enemy);
assertEq(combat.health, 80_000, "enemy was not attacked");
}
}
The enemy's original health was 100,000. Verify it is now 80,000, because our unit's attack strength is 20,000.