Skip to content

Latest commit

 

History

History
244 lines (184 loc) · 18.3 KB

testing.md

File metadata and controls

244 lines (184 loc) · 18.3 KB

Testing resources

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 test

It runs the tests of the project in the directory it runs in:

  • A resource, when the directory has an fxmanifest.lua or a __resource.lua. Servers of tests have it as resources/<name of the directory> and ensure it, so a resource in bank/ is the resource bank.
  • A server, when the directory has a resources directory. Servers of tests scan it for resources, then run the commands of server.cfg, if there is one, as +exec server.cfg would.

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/players

Writing tests

describe('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, which test adds too. A test fails if it does not end within its timeout: 5000 milliseconds, unless it gives its own.
  • it.skip and describe.skip skip tests. it.only and describe.only run tests alone: once a file marks a test with only, its other tests are skipped.
  • beforeEach(fn) and afterEach(fn) add hooks to the describe they are in, or to the file. A test runs after the beforeEach hooks of its describes, from the outermost in, and before their afterEach hooks, from the innermost out, the last added first. When a beforeEach hook fails, the test does not run, but the afterEach hooks do.

Tests run in threads, so they may Wait. Errors fail tests, with the line of the test file they came through.

expect

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).

Players

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)

Helpers

  • fiverunner.output(from) gives what the server printed, line by line, as [channel] text, without colors: from its fromth 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 that RegisterNetEvent marked are the ones that clients can trigger.
  • fiverunner.waitFor(check, timeout) calls check at each tick until it returns something truthy, and returns that. With a timeout, in milliseconds, it fails the test once that much time went by.
fiverunner.waitFor(function() return #ann:received('bank:balance') > 0 end)

Mocks

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)

Time

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.

The servers of tests

  • 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 .sql files of the project, so the next test does not see what a test wrote. A resource whose manifest loads @oxmysql/lib/MySQL.lua gets oxmysql started before it. docs/differences.md tells what the database runs.
  • PerformHttpRequest sends no requests: each fails as if no server took it, with status 0 and the error FiveRunner 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.

Frameworks

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:Login creates a character and logs the player in with it.
  • QBCore: the page of qb-multicharacter creates characters, which a test reaches with player:nuiCallback once 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
end

The 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.

Output

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.

GitHub Actions

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@v1

The 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.