Skip to main content

Basic Test Structure

Tests use a simple API similar to Jest:

Your First Test

Create src/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 bot
  • server - 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:
One player, one connection, one leased account for the whole block. The tests run in the order they are written, and the account goes back to the pool when the block ends.

The block is the unit

Filters apply to the block, not to the tests inside it. If requires, 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:
Outside a serial block invalidatePlayer does nothing, since the bot is disconnected at the end of the test anyway.

Hooks inside a block

Plugin beforeEach/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:
Each test starts with a clear message buffer, so a message from an earlier step can’t satisfy an assertion about this one. What carries over is server state, which is the point. A block can’t contain another 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:
It runs once per instance of the block, after the last test that actually executed, right before the plugins’ own 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:
The account must exist in the environment’s 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:
Each instance leases its own account and runs the full test body on its own bot. The test only passes if every instance does — one instance losing the race it wasn’t supposed to lose is the bug you’re trying to catch, not noise to average away. 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.
Console and Server Log Assertions under Concurrency:
  • Player chat is isolated: expect(player).toHaveReceivedMessage(...) checks that specific bot’s private message buffer. It never collides between concurrent instances.
  • Server console is shared: expect(server).toHaveReceivedMessage(...) inspects the server’s single shared log. When multiple bots run concurrently, distinguish log entries by incorporating the bot’s unique username:
  • Global commands: Tests asserting on unparameterized global server events (like /reload or global broadcasts without player names) should run as standard single tests without the concurrency option.

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

  1. Keep tests isolated - Each test gets a fresh bot unless it is in a describe.serial block
  2. Use descriptive names - Make test failures easy to understand
  3. Wait for conditions - Use assertions that auto-retry
  4. Test one thing - Each test should verify one behavior
  5. Check server logs - They appear in console during test runs
  6. 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