Basic Test Structure
Tests use a simple API similar to Jest:Your First Test
Createsrc/test/e2e/tests/first.spec.ts — specs live in tests, in whatever subdirectories you like (Project Layout):
Common Test Patterns
Testing Commands
Testing Economy
Testing Inventory
Server Operations
API Reference
Test Context
Each test receives a context object with:player- The PlayerWrapper instance managing the botserver- Server control interface
Player Actions
Assertions
Utilities
The framework provides several exported utilities for advanced waiting and polling:Tests that share a player: describe.serial
Every test gets its own bot, connected before it starts and disconnected after it ends. That is the right default, and most tests want nothing else.
Some scenarios aren’t one test, though. “Claim a kit, see it on cooldown, wait, see it claimable again” is three assertions about the same player in a fixed order, and three independent bots can’t express it however you schedule them. describe.serial is for exactly that:
The block is the unit
Filters apply to the block, not to the tests inside it. Ifrequires, environments, tests.names or tests.exclude rules out any test in the block, the whole block is skipped and every test in it is reported skipped with that reason. A chain missing a link is worse than no chain: the steps after it would assert against state that nothing produced.
The same logic covers failure. When a test fails or times out, the rest of its block is reported skipped, not failed — those tests never ran, and reporting them as failures would bury the one real cause under a pile of noise. A test can also say so itself:
invalidatePlayer does nothing, since the bot is disconnected at the end of the test anyway.
Hooks inside a block
PluginbeforeEach/afterEach run once around the whole block — before its first test and after its last. A plugin whose job is to reset an account between tests would otherwise undo exactly what the block is built on. Spec-level beforeEach/afterEach still run for every test, and describe nesting works inside a block the way it does anywhere else:
describe.serial — nesting one ordered chain inside another is a scheduling question the runner deliberately doesn’t answer.
Cleanup that must run no matter what
beforeEach/afterEach skip a test entirely once the block has stopped — that’s the point, the rest of the chain never ran. But some cleanup (revoking a permission you granted mid-block, releasing something outside the game world) needs to happen once regardless of which test failed or got skipped. That’s what afterAll is for:
afterEach. The one case it skips is a block that never got its player — nothing happened yet, so there’s nothing to clean up. A hook that throws gets logged and otherwise ignored; it won’t turn a passing block into a failure.
Naming an account
On a stand where one specific account carries the state a scenario needs — a permission group, a starting balance, a whitelist entry — name it:accounts { } pool and be free. If it is already leased, or the environment has no pool at all (LocalMode invents a name per bot and has none), the block fails with that message rather than quietly running as somebody else.
A second bot for the block
ctx.createPlayer() connects an extra bot that lives as long as the test does. Inside a block, as gives it a name so a later test gets the same bot back:
Racing bots against each other: concurrency
One bot can’t produce a race. Two players opening the same chest at once, three players buying the last item in stock — bugs like that only exist when several bots hit the same feature at the same time, on the same server. concurrency runs a test as N independent instances at once, each with its own bot:
describe.serial blocks take the same option, running N independent copies of the whole ordered chain at once:
The report still has one row per test
concurrency multiplies how many bots run a test, not how many rows it produces in the summary. That row’s duration is the slowest instance, and it carries an instances array with every instance’s own outcome — bot username, pass or fail, duration — so a failure tells you which bot lost, not just that somebody did.
How many you can ask for
concurrency: N needs N free accounts. On an environment with an account pool, this is checked before any test in the run starts: ask for concurrency: 10 against a 4-account pool and it fails immediately with a clear error, instead of the 5th bot hanging on a lease nobody’s going to release. LocalMode has no pool — it mints a throwaway account per bot — so there’s nothing to check there; its only real ceiling is the server’s own max-players.
The server log is still one shared log
expect(server) reads the console output the whole session shares, so one instance’s commands sit in that log right next to every other instance’s. Nothing filters that for you, and that’s deliberate — asserting the server log never produced something no bot should have caused (expect(server).not.toHaveReceivedMessage('NullPointerException')) is exactly what a concurrency test is for. If you need one bot’s output specifically, either put its username in the pattern or check expect(player) instead, since a player’s own messages never mix with another bot’s.
Best Practices
- Keep tests isolated - Each test gets a fresh bot unless it is in a
describe.serialblock - Use descriptive names - Make test failures easy to understand
- Wait for conditions - Use assertions that auto-retry
- Test one thing - Each test should verify one behavior
- Check server logs - They appear in console during test runs
- Add delays when needed - Use the built-in
sleep()utility for timing-dependent tests
Tips
- Tests run sequentially by default — a test that needs bots racing each other can opt into
concurrency - Server starts fresh for each test run
- Bot automatically connects to the server
- Server logs are visible in the console output