fiverunner test runs Lua tests against your resources in Node, whether they are Lua or JavaScript: a FiveM server, and the clients of the players that tests connect, on FiveRunner's engine. No GTA V, no FXServer.
npx fiverunner testIt runs the tests of the project in the directory it runs in:
- A resource, when the directory has an
fxmanifest.luaor a__resource.lua. Servers of tests have it asresources/<name of the directory>and ensure it, so a resource inbank/is the resourcebank. - A server, when the directory has a
resourcesdirectory. Servers of tests scan it for resources, then run the commands ofserver.cfg, if there is one, as+exec server.cfgwould.
Test files are the files whose names end in .test.lua, anywhere in the project but in node_modules and hidden directories. They are not part of your resources: their resources do not see them, so a manifest that globs **/*.lua does not load them as scripts.
fiverunner init adds a first test file to the project, tests/<name of the resource>.test.lua or tests/server.test.lua, whose test checks that the project starts without script errors, on the server and on the client of a player, and that every resource it starts does start, so a server whose resource needs one that it lacks fails. On a server of Qbox, of QBCore, or of ESX without esx_multicharacter, a second test logs the player in with a character, as Frameworks shows, and checks that no script breaks as the character loads. It also adds .fiverunner/fiverunner.lua and .luarc.json, which tell the Lua language server of your editor the functions of tests, and, at the root of a git repository, the workflow of GitHub Actions as .github/workflows/fiverunner.yml. It writes over nothing that is there.
With --watch, it runs them again as files of the project change, until Ctrl+C.
With --junit <file>, it also writes a JUnit report of the tests to the file, which the test summaries of CI read.
With --coverage, it counts the lines of Lua of your resources that the tests ran, on the server and on clients, and writes them to coverage/lcov.info, which editors and services such as Codecov read. JavaScript is not counted, and tests take longer.
To run only some of them, give their paths, or the directories they are in:
npx fiverunner test tests/bank.test.lua tests/playersdescribe('bank', function()
it('starts players with 500', function()
local ann = fiverunner.connect({ name = 'Ann' })
expect(exports.bank:balance(ann.id)).toBe(500)
end)
it('deposits', function()
local ann = fiverunner.connect({ name = 'Ann' })
ann:emit('bank:deposit', 100)
expect(exports.bank:balance(ann.id)).toBe(600)
end)
end)A test file runs as a server script of a resource of its own, fiverunner, which starts after the resources of the project. It reaches them as any resource would: with exports, events, state bags, commands and natives.
Each test runs in a server of its own, which starts the project anew: what a test does, the next test does not see. A first server loads the file to collect its tests, then each test that runs gets a server, so the code of the file outside tests runs each time.
describe(name, fn)groups tests, and may hold other describes.it(name, fn, timeout)adds a test, whichtestadds too. A test fails if it does not end within its timeout: 5000 milliseconds, unless it gives its own.it.skipanddescribe.skipskip tests.it.onlyanddescribe.onlyrun tests alone: once a file marks a test with only, its other tests are skipped.beforeEach(fn)andafterEach(fn)add hooks to the describe they are in, or to the file. A test runs after thebeforeEachhooks of its describes, from the outermost in, and before theirafterEachhooks, from the innermost out, the last added first. When abeforeEachhook fails, the test does not run, but theafterEachhooks do.
Tests run in threads, so they may Wait. Errors fail tests, with the line of the test file they came through.
expect(value) checks value with a matcher. Call matchers with a dot:
expect(balance).toBe(600)
expect(player).never.toBeNil()| Matcher | Passes when the value |
|---|---|
toBe(expected) |
is expected: the same table, or the same string, number or boolean, NaN included |
toEqual(expected) |
equals expected: tables with the same keys and equal values, whatever their metatables |
toBeTruthy() |
is neither nil nor false |
toBeFalsy() |
is nil or false |
toBeNil() |
is nil |
toBeGreaterThan(n) |
is a number greater than n, and so for toBeGreaterThanOrEqual, toBeLessThan and toBeLessThanOrEqual |
toBeCloseTo(n, digits) |
is a number that differs from n by less than half of 10 to the power of minus digits: 2 unless given |
toContain(item) |
is a string that holds the string item, or a table with a value that is item |
toContainEqual(item) |
is a table with a value that equals item |
toHaveLength(n) |
is a string or a table whose length is n |
toMatch(pattern) |
is a string that matches the Lua pattern pattern |
toThrow(text) |
is a function that raises an error when called, whose message holds text, if given |
never negates them: expect(value).never.toBe(nil).
fiverunner.connect(identity) connects a player, and returns it once its client started the resources of the server. If the server refuses the player, it returns nil and why.
local ann = fiverunner.connect({
name = 'Ann',
identifiers = { 'license:abc', 'discord:123' },
tokens = { '3:abc' },
endpoint = '127.0.0.2',
guid = '1234',
card = { name = 'Ann Lee', rules = 'true' },
})Every field is optional. Players without identifiers get a license: of their name, as frameworks turn away those without one. card is what the player answers the adaptive cards of deferrals with, which presentCard calls back with. Each player runs a client of its own, with the client scripts of your resources.
| Field or method | |
|---|---|
player.id |
The id of the player on the server, its source |
player.name |
Its name |
player:run(code, ...) |
Runs the Lua code code in the client of the player, with ... as its arguments, and returns what it returns. Raises what it raises |
player:emit(event, ...) |
Triggers event on the server from the client, with ..., and returns once the server got it |
player:received(event) |
The arguments of each event that the client got from the server, in the order they came |
player:output(from) |
What the client printed, line by line, as [channel] text, without colors, from its fromth line on, when given |
player:disconnect(reason) |
Disconnects the player, as players quit, and returns once the server dropped it |
player:nuiMessages(resource) |
What the client sent the NUI frame of resource with SendNUIMessage, as tables, in the order it sent them |
player:nuiCallback(resource, name, data) |
Calls the NUI callback name of resource, as its page does, with data as JSON, and returns what it answered, read as JSON, and the status |
player:nuiFocus() |
Whether NUI has the focus on the client, and whether it shows the cursor |
player:keyDown(key), player:keyUp(key) |
Has a key of the keyboard of the client, such as E or LMENU, go down or up before the next frame, which presses the controls of GTA V that it presses and runs the commands that resources bound to it. W, A, S and D move the player while they are held |
player:press(key) |
Has the key go down, and up a frame later |
player:mock(name, fn) |
Mocks a native on the client, as fiverunner.mock does on the server |
player:run runs code given as a string, as Lua cannot send functions with their upvalues to another state: give the code what it needs as arguments.
local x, y = ann:run('local a, b = ... return a + b, GetCurrentResourceName()', 1, 2)fiverunner.output(from)gives what the server printed, line by line, as[channel] text, without colors: from itsfromth line on, when given, so a test can read only what came after it last read.fiverunner.events()gives the events that the resources of the server handle, as{ name, resources }, in the order they first registered for them, without those of tests. Those thatRegisterNetEventmarked are the ones that clients can trigger.fiverunner.waitFor(check, timeout)callscheckat each tick until it returns something truthy, and returns that. With atimeout, in milliseconds, it fails the test once that much time went by.
fiverunner.waitFor(function() return #ann:received('bank:balance') > 0 end)fiverunner.mock(name, fn) has fn run in place of the native that the Lua global name stands for, on the server, for every resource, for the rest of the test, and player:mock(name, fn) on the client of a player. fn gets the arguments of the native, and the first of its results is what the native returns, as the type of its result has it: a number, a boolean, a string, a vector3, or anything that msgpack packs for natives that return objects. Natives that FiveRunner does not implement can be mocked too, which gives them what the game would.
fiverunner.mock('GetEntityCoords', function(entity) return vector3(100.0, 200.0, 30.0) end)
ann:mock('IsControlPressed', function(pad, control) return control == 38 end)The clock of the server of a test moves only as FiveRunner moves it, frame by frame, as fast as it can: Wait(60000) takes no time. Timeouts are by that clock. GetGameTimer starts at 0, and the wall clock of each server starts at 2026-01-01 00:00:00 UTC, in UTC, so os.time and os.date give the same results on every run.
- They run the resources of the project, with their files as they are on the disk. What tests write, such as with
SaveResourceFile, stays in memory, and the next test does not see it. - KVP starts empty.
- Resources that use oxmysql get a database of their own, in memory, which starts with the tables of the
.sqlfiles of the project, so the next test does not see what a test wrote. A resource whose manifest loads@oxmysql/lib/MySQL.luagets oxmysql started before it. docs/differences.md tells what the database runs. PerformHttpRequestsends no requests: each fails as if no server took it, with status 0 and the errorFiveRunner sends no HTTP requests in tests - CURL error code 7 (Couldn't connect to server).- Tests may run any command with
ExecuteCommand, as the console may. - A script that runs for 5 seconds without returning, such as a loop without
Wait, gets an error.
The rest is FiveRunner's engine, which follows FiveM's server and clients as docs/differences.md says. Natives that FiveRunner does not implement do nothing and return zero values, and tests say so in their output when they fail.
Test resources that need a framework in the folder of a server that has it, as the recipe of txAdmin lays it out. Players join as they join servers, so a framework knows them, but they have a character only once its character menu, or the test, logs them in. Connect them once the server started, as players join started servers, then:
- Qbox:
exports.qbx_core:Logincreates a character and logs the player in with it. - QBCore: the page of qb-multicharacter creates characters, which a test reaches with
player:nuiCallbackonce its menu shows. Without qb-multicharacter,QBCore.Player.Login(player.id, false, { charinfo = ... })creates one and logs the player in with it. - ESX: without esx_multicharacter, ESX logs players in as they join, and marks them as spawned.
-- Qbox
local function login(name)
while GetGameTimer() < 1000 do Wait(100) end
local player = fiverunner.connect({ name = name })
expect(exports.qbx_core:Login(player.id, nil, {
charinfo = { firstname = 'Ann', lastname = 'Lee', birthdate = '1990-01-01', gender = 0, nationality = 'Romania' },
})).toBe(true)
return player
end
-- QBCore, with qb-multicharacter
local function login(name)
while GetGameTimer() < 3000 do Wait(100) end
local player = fiverunner.connect({ name = name })
fiverunner.waitFor(function()
for _, message in ipairs(player:nuiMessages('qb-multicharacter')) do
if message.action == 'ui' then return true end
end
end, 10000)
player:nuiCallback('qb-multicharacter', 'createNewCharacter', {
firstname = 'Ann', lastname = 'Lee', nationality = 'Romania', birthdate = '1990-01-01', gender = 'Female', cid = 1,
})
fiverunner.waitFor(function() return exports['qb-core']:GetCoreObject().Functions.GetPlayer(player.id) end, 10000)
return player
end
-- ESX
local function login(name)
local player = fiverunner.connect({ name = name })
fiverunner.waitFor(function()
local xPlayer = exports.es_extended:getSharedObject().GetPlayerFromId(player.id)
return xPlayer and xPlayer.spawned
end, 3000)
return player
endThe suites of compat run these on the recipes of Qbox, QBCore and ESX on every change of FiveRunner, with tests of money, items, jobs and saves to read.
fiverunner test writes a line for each test file, then each failure, with where it failed and what the server and the clients printed, but for the lines that tell that resources start and stop:
✓ tests/bank.test.lua (2 tests)
× tests/withdraw.test.lua (1 test, 1 failed)
× withdraws
Failed: tests/withdraw.test.lua > withdraws
expected 500 to be 400
at tests/withdraw.test.lua:4
Output:
[script:fiverunner] withdrawing
Test files: 1 failed, 1 passed (2)
Tests: 1 failed, 2 passed (3)
Duration: 0.15 s
It exits with 0 when the tests passed, 1 when any failed, a test file did not load or had no tests, or there were no test files, and 2 when its arguments make no sense or there is no project.
On GitHub Actions, fiverunner test also annotates each failure at its line. A workflow that runs the tests of a resource on each push, with FiveRunner's action, which fiverunner init writes:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: DariusHQ/fiverunner-action@v1The action takes the folder of the project as path, and what fiverunner test gets as args, such as --junit report.xml. Elsewhere, run npx fiverunner test with Node 22 or later.