-
Notifications
You must be signed in to change notification settings - Fork 890
Testing
xi_test embeds the equivalent of a fully functional xi_map server and executes various tests defined in the scripts/tests folder.
Helpers are available to simulate client interactions as if they had come from an actual FFXI client.
- Tests run one at a time, top to bottom when reading a file.
- Each test gets a fresh PRNG seed. The seed is reset between test cases, so a case cannot rely on another case's random draws.
- State is not automatically reset between test cases. A player, mob or world value you mutate in one
itblock is still mutated in the next, unless you rebuild it (seebefore_each).- The one exception is test doubles (
spy/stub/mock), which revert automatically at the end of each case.
- The one exception is test doubles (
- Multi-process tests are not yet supported. It is not possible to spawn players in a way that emulates them being on different
xi_mapprocesses. - World messages are not yet processed
Follow the Quick Start Guide to prepare all dependencies and build the binaries.
xi_test has the same requirements as a fully functioning xi_map, namely a working database with tables imported.
In a terminal/shell/your IDE debug configuration, target the xi_test binary/launch target.
# See next sections for arguments
# Run everything, stops on first failure
./xi_test
# Run everything, but don't stop
./xi_test --keep-going
# Run ONLY tests in filepaths matching 'magic'
# Note: This supports regex
./xi_test --keep-going --file 'magic'
# Run ALL tests except in filenames matching 'magic'
# Note: This supports regex
./xi_test --keep-going --no-file 'magic'
# Run ONLY test cases (in all files) matching 'Chains of Promathia'
./xi_test --keep-going --filter 'Chains of Promathia'
# Run ALL test cases EXCEPT those matching 'Chains of Promathia'
./xi_test --keep-going --no-filter 'Chains of Promathia'
# Run all tests except those tagged with '#long'
./xi_test --keep-going --no-tag 'long'
# Mix and match arguments as desired
# Run tests tagged with '#magic' in filepaths matching 'systems'
./xi_test --keep-going --tag 'magic' --file 'systems'| Arg | Description | Example |
|---|---|---|
| --keep-going | Don't stop on test failure. | --keep-going |
| --verbose | Always print logs. | --verbose |
| --file | Only execute files matching regex pattern. | --file 'smoke_test' |
| --no-file | Execute every files but the ones matching regex pattern. | --no-file 'war' |
| --filter | Only execute test cases matching regex pattern. | --filter 'Spell' |
| --no-filter | Execute every test cases but the ones matching regex pattern. | --no-filter 'Framework' |
| --tag | Only execute test cases containing tag. | --tag 'ci' |
| --no-tag | Execute every test cases but the ones containing tag. | --no-tag 'long' |
| --output | Test report path (*.json) | --output report.ctrf.json |
By default, xi_test will produce a colorized gtest style output.
This reporter cannot be disabled.
[ RUN ] missions::Rise of the Zilart::ZM9 to ZM13::should complete ZM13 - The Gate of the Gods
[ OK ] missions::Rise of the Zilart::ZM9 to ZM13::should complete ZM13 - The Gate of the Gods
[==========] 108 tests ran. (37.567 s total)
[ PASSED ] 108 tests.Note that logs are only printed on test failures.
[ RUN ] missions::Chains of Promathia::4-3 The Secrets of Worship::should complete the mission successfully
[ FAILED ] missions::Chains of Promathia::4-3 The Secrets of Worship::should complete the mission successfully (369 ms)
Error: scripts/tests/missions/cop.lua:542: Expected player to have key item RELIQUIARIUM_KEY
stack traceback:
[C]: in function 'hasKI'
scripts/tests/missions/cop.lua:542: in function <scripts/tests/missions/cop.lua:517>
Test logs:
[20:30:23][info][ spawnPlayer:401] Spawning player in zone: 210
[20:30:23][debu][ setThreadpoolSize:109] Setting async thread pool size to 15
[20:30:23][info][ spawnPlayer:413] Created test character ID: 20000016 in zone: 210
[20:30:23][debu][ createSession:40] Creating session for 16.45.49.1
[20:30:23][debu][ InsertPC:223] CZone:: GM_Home IncreaseZoneCounter <1> T20000016
[20:30:23][info][ skipTime:120] Skipping 3 seconds
[20:30:23][warn][ addMission:7955] Lua::addMission: player has a current mission (6)
[20:30:23][info][ gotoZone:99] Player T20000016 zoning to zone ID: 26
[20:30:23][debu][ DecreaseZoneCounter:586] CZone:: GM_Home DecreaseZoneCounter <0> T20000016
[20:30:23][info][ sendZonePackets:115] Reloading character 20000016 for zone change
[20:30:23][debu][ InsertPC:223] CZone:: Tavnazian_Safehold IncreaseZoneCounter <6> T20000016
[20:30:23][info][ skipTime:120] Skipping 3 seconds
[20:30:23][info][ finish:87] Sending event finish packet for event ID: 111
[20:30:23][info][ gotoZone:99] Player T20000016 zoning to zone ID: 25
[20:30:23][debu][ DecreaseZoneCounter:586] CZone:: Tavnazian_Safehold DecreaseZoneCounter <5> T20000016
[20:30:23][info][ sendZonePackets:115] Reloading character 20000016 for zone change
[20:30:23][debu][ InsertPC:223] CZone:: Misareaux_Coast IncreaseZoneCounter <2> T20000016
[20:30:23][info][ skipTime:120] Skipping 3 seconds
[20:30:23][info][ finish:87] Sending event finish packet for event ID: 9
[20:30:23][info][ skipTime:120] Skipping 1 seconds
[20:30:23][debu][ DecreaseZoneCounter:586] CZone:: Misareaux_Coast DecreaseZoneCounter <1> T20000016
[20:30:23][info][ sendZonePackets:115] Reloading character 20000016 for zone change
[20:30:23][warn][ OnZoneIn:1989] Attempt to Zone In player to invalid/disabled zone 28.
[20:30:23][warn][ process:82] GP_CLI_COMMAND_LOGIN: player tried to enter zone that was invalid or out of range
[20:30:23][warn][ process:83] GP_CLI_COMMAND_LOGIN: dumping player `T20000016` to homepoint!
[20:30:23][info][ skipTime:120] Skipping 3 seconds
[20:30:23][info][ sendZonePackets:115] Reloading character 20000016 for zone change
[20:30:23][debu][ InsertPC:223] CZone:: unknown IncreaseZoneCounter <2> T20000016
[20:30:23][info][ skipTime:120] Skipping 3 seconds
[20:30:23][info][ gotoZone:99] Player T20000016 zoning to zone ID: 28
[20:30:23][debu][ setThreadpoolSize:109] Setting async thread pool size to 15
[20:30:23][info][ LoadZones:753] Loading 1 zones
[20:30:23][debu][ wait:86] Waiting for async 2 tasks to complete on 15 workers
[20:30:23][debu][ wait:95] All async tasks completed
[20:30:23][info][ LoadNPCList:270] Loading NPCs
[20:30:23][debu][ wait:86] Waiting for async 1 tasks to complete on 15 workers
[20:30:23][debu][ wait:95] All async tasks completed
[20:30:23][info][ LoadNPCList:362] Loading NPC scripts
[20:30:23][info][ LoadMOBList:392] Loading Mobs
[20:30:23][debu][ wait:86] Waiting for async 1 tasks to complete on 15 workers
[20:30:23][debu][ wait:95] All async tasks completed
[20:30:23][info][ LoadMOBList:642] Loading Mob scripts
[20:30:23][debu][ setThreadpoolSize:109] Setting async thread pool size to 1
[20:30:23][debu][ DecreaseZoneCounter:586] CZone:: unknown DecreaseZoneCounter <1> T20000016
[20:30:23][info][ sendZonePackets:115] Reloading character 20000016 for zone change
[20:30:23][debu][ InsertPC:223] CZone:: Sacrarium IncreaseZoneCounter <1> T20000016
[20:30:23][info][ skipTime:120] Skipping 3 seconds
[20:30:23][info][ finish:87] Sending event finish packet for event ID: 6
[20:30:23][info][ skipTime:120] Skipping 1 seconds
[==========] 108 tests ran. (34.866 s total)
[ PASSED ] 107 tests.
[ FAILED ] 1 test, listed below:
[ FAILED ] missions::Chains of Promathia::4-3 The Secrets of Worship::should complete the mission successfully--output xxx.json will produce a CTRF JSON file.
The file will be created at the end of the run, at the desired path.
Tests must be located in scripts/tests, if possible nested in the appropriate folders pertaining to their kind.
Create subfolders as needed so that individual test files remain relatively short.
Examples:
scripts/tests/missions/cop.luascripts/tests/jobs/brd.luascripts/tests/systems/magic/immunobreak.luascripts/tests/systems/magic/magicburst.lua
A test suite must contain an arbitrary number of nested describe blocks, and at least one it block.
-- Minimally viable test
describe('True', function()
it('is true', function()
assert(true)
end)
end)describe blocks can be nested to provide a human-readable delineation between different tests, or to support different setup/teardowns for each blocks.
Test names are a concatenation of the file path and of each describe block. Care should be taken to ensure the resulting fully qualified name is an intelligible English sentence that conveys the right amount of context.
-- scripts/tests/magic/magic_burst.lua
describe('Magic burst', function()
it('should increase damage', function()
-- magic::Magic burst::should increase damage
-- Actual code to cast a magic burst and assert
end)
end)It is also acceptable to describe tests using the naming convention of the system under test
-- scripts/tests/bindings/CBaseEntity.lua
describe('CBaseEntity', function()
describe('getItems', function()
it('should find owned items', function()
-- bindings::CBaseEntity::getItems::should find owned items
-- Test code goes here
end)
it('should not find unowned items', function()
-- bindings::CBaseEntity::getItems::should not find unowned items
-- Test code goes here
end)
end)
end)You may optionally specify one or more tags in the test name.
These can be used to selectively include or exclude a group of loosely related tests when running xi_test.
describe('Tagged tests', function()
describe('', function()
it('is very #long #ci', function()
end)
it('magic related #magic', function()
end)
end)
end)# Will run every tests except those tagged with #long
xi_test --no-tag long
# Will run only tests tagged with #magic
xi_test --tag magicArguments can be mixed in different ways, described in the "Arguments" section of this document.
Before writing any code, think about what you are attempting to test and how you will go about it.
The framework is flexible enough to allow testing in various ways:
- By simulating a FFXI client emitting packets to the server
- By making use of existing Lua bindings
As an example if you wanted to test the magic system, you could either test against the Lua functions or emulate a client casting Stone.
In general, testing using client emulation is going to be the preferred way but intricate systems will benefit from also testing directly the inner functions.
Reusing the magic system example, given the amount of calculations and parameters that go into calculating the final value, it would be saner to test directly each function than to rely on a simulated Stone cast.
It is possible to define several functions that will execute at different time in the test lifecycle.
Lifecycle functions are always defined at the describe level.
Only one of each kind can be specified at any given level!
- Create nested describe blocks if you need finer test initialization.
Use setup() to prepare an environment where each test case either does not strictly care about sharing state, or when designing test cases that build on the previous cases.
Assertions can be performed in the block.
Examples:
- Testing a mob properties, where the mob entity is not mutated during the test
- Testing several successive steps for a Quest
-- Retrieve Fafnir once and check some stats
describe('Fafnir', function()
local fafnir
local player
setup(function()
player = xi.test.world:spawnPlayer()
-- Only retrieve Fafnir once
fafnir = player.entities:get(zones[xi.zone.DRAGONS_AERY].mob.FAFNIR)
assert(fafnir)
fafnir:spawn()
end)
it('is spawned', function()
assert(fafnir:isSpawned())
end)
it('has a lot of HP', function()
assert(fafnir:getHP() > 10000)
end)
end)
-- Another example with nested setup()
describe('Fafnir', function()
local fafnir
local player
setup(function()
player = xi.test.world:spawnPlayer()
-- Only retrieve Fafnir once
fafnir = player.entities:get(zones[xi.zone.DRAGONS_AERY].mob.FAFNIR)
assert(fafnir)
fafnir:spawn()
end)
describe('Test Fafnir drops', function()
setup(function()
-- Any setup code specific to this nested suite
end)
end)
describe('Test Fafnir resistance', function()
setup(function()
-- Different setup code specific to this nested suite
end)
end)
end)See setup()
Use to prepare a clean isolated environment. This is the preferred way to ensure complete isolation between test cases.
Note that when building tests in this way, any step executed in a previous case may need to be repeated to get to the correct testable state.
describe('Treasure Hunter', function()
local player
before_each(function()
player = xi.test.world:spawnPlayer({ zone = xi.zone.KUFTAL_TUNNEL, job = xi.job.THF, level = 75 })
end)
it('equals TH2 for a naked THF75', function()
player.assert:hasModifier(xi.mod.TREASURE_HUNTER, 2)
end)
it('equals TH3 with Thiefs Knife', function()
player:addItem(xi.item.THIEFS_KNIFE)
player:equipItem(xi.item.THIEFS_KNIFE, nil, xi.slot.MAIN)
player.assert:hasModifier(xi.mod.TREASURE_HUNTER, 3)
end)
it('equals TH4 with Thiefs Knife and Assassins Armlets', function()
-- You have to give a Thief's Knife again, since player is now a brand new naked THF75
player:addItem(xi.item.THIEFS_KNIFE)
player:equipItem(xi.item.THIEFS_KNIFE, nil, xi.slot.MAIN)
player:addItem(xi.item.ASSASSINS_ARMLETS)
player:equipItem(xi.item.ASSASSINS_ARMLETS)
player.assert:hasModifier(xi.mod.TREASURE_HUNTER, 4)
end)
end)See before_each()
It is possible to mix any of the functions. Per test case, they execute in the following order:
- setup() (once per suite, before the first case)
- before_each() (before every case)
- after_each() (after every case)
- teardown() (once per suite, after the last case)
before_each and after_each inherit into nested describe blocks and stack: the outer block's before_each runs before the inner block's.
This is the idiomatic way to layer setup: put "everyone needs a player" in an outer before_each and "this sub-group also needs a Thief's Knife" in an inner one.
See scripts/tests/framework/lifecycle.lua for the exact ordering and inheritance behaviour expressed as runnable tests.
describe('THF', function()
local player
-- Create a THF75 player once.
setup(function()
player = xi.test.world:spawnPlayer({ zone = xi.zone.KUFTAL_TUNNEL, job = xi.job.THF, level = 75 })
end)
-- But clear the inventory and reequip a Thief's Knife before every case
before_each(function()
player:delContainerItems(xi.inv.INVENTORY)
player:addItem(xi.item.THIEFS_KNIFE)
player:equipItem(xi.item.THIEFS_KNIFE, nil, xi.slot.MAIN)
end)
it('can make lots of gils', function()
-- Kill mobs, track drops, idk
end)
end)The global xi.test.world exposes a handful of methods to manipulate the state of the world.
See the LLS documentation for a complete list, along with expected parameters
Creates a new player character. All parameters are optional; refer to the LLS documentation for the authoritative, current list. At time of writing it accepts zone, job, level, race and new (spawn as a fresh character with zero playtime).
Prefer passing the initial job/level as parameters over mutating the player afterwards.
describe('xi_test', function()
local player
before_each(function()
-- One-liner setup; no need to changeJob/setLevel by hand afterwards
player = xi.test.world:spawnPlayer({ zone = xi.zone.KUFTAL_TUNNEL, job = xi.job.THF, level = 75 })
end)
-- The returned object also behaves like a CBaseEntity, so bindings work directly:
-- player:changeJob(xi.job.WAR), player:addItem(...), etc.
end)tick() moves the steady clock forward and executes the requested tasks. It is variadic and accepts one or more xi.tick task-boundary types. If no argument is provided, it will default to ticking each zone once.
Note that depending on the provided arguments, different tasks may execute.
tickEntity() will tick an entity AI independently without moving any clock or executing any task.
describe('xi_test', function()
local player
setup(function()
player = xi.test.world:spawnPlayer()
end)
it('executes ticks', function()
local mob = player.entities:get('Some_Mob')
xi.test.world:tick() -- tick zone once
xi.test.world:tickEntity(mob) -- Only tick the mob
end)
end)Explicitly ask the map server to load a given zone. In most cases it is not necessary as it is handled behind the scene when the player is moved across zones.
However during certain events teleporting the player to a different zone, the zone must be pre-loaded.
describe('xi_test', function()
local player
setup(function()
player = xi.test.world:spawnPlayer()
end)
it('teleports player to Hall of the Gods', function()
-- Upcoming CS will dump us in Hall of the Gods.
xi.test.world:loadZone(xi.zone.HALL_OF_THE_GODS)
player.bcnm:enter("_513", xi.battlefield.id.CELESTIAL_NEXUS)
player.bcnm:expectWin()
-- After the final cutscene, you appear in Hall of the Gods.
player.assert:inZone(xi.zone.HALL_OF_THE_GODS)
end)
end)Moves the steady clock forward by the given amount of seconds.
Depending on the desired outcome, you may still need to call tick() after skipTime()
Note: This has NO impact on the Vana'Diel clock!
describe('xi_test', function()
local player
setup(function()
player = xi.test.world:spawnPlayer()
end)
it('can reuse items', function()
-- Some arbitrary charged item
local item = player:getItem(xxx)
player.actions:useItem(player, item)
xi.test.world:tick()
-- Do some assertions here
-- Skip time forward by 30 seconds and try again
xi.test.world:skipTime(30)
player.actions:useItem(player, item)
xi.test.world:tick()
-- Do some more assertions here
end)
end)Sets the Vana'Diel clock directly to some specific value.
This does not impact the steady clock.
xi.test.world:setVanaDay(xi.day.FIRESDAY)
xi.test.world:setVanaTime(6, 0)
xi.test.world:skipToNextVanaDay()Sets the PRNG seed to a provided value, allowing deterministic random values for testing. With a fixed seed, math.random produces the same sequence on every run, which is what makes tests involving randomness (damage rolls, drop rates, proc chances) reproducible.
The seed is forcibly reset between test cases. Seed in before_each if a whole suite needs determinism; see scripts/tests/packets/s2c/0x028_battle2/base.lua.
xi.test.world:setSeed(1)
local a = math.random(1000) -- deterministic given the seed
xi.test.world:setSeed(1)
local b = math.random(1000)
assert(a == b) -- same seed, same sequence
xi.test.world:seed() -- assign a fresh, non-deterministic seed
math.random(1000) -- who knows!The player object returned by CSimulation:spawnPlayer() contains a handful of helpers to simulate interactions as if they had originated from a legitimate game client.
Sections below will only present a handful of possible usage, refer to the LLS documentation for an up-to-date listing.
The helper namespaces below have grown well beyond what this page can enumerate. Treat the LLS specification files under scripts/specs/test/ as the authoritative, always-current list of methods; the examples here are only representative of each category.
Pre-canned packets for the things a client can do. This namespace covers a broad and growing surface (combat, spells, abilities, trading, guild shops, crafting, item management, party/alliance), so consult the LLS file for the full list. A representative sample:
-- Combat
player.actions:engage(mob)
player.actions:useSpell(mob, xi.magic.spell.STONE)
player.actions:useWeaponskill(mob, xi.weaponskill.TACHI_FUDO)
player.actions:useAbility(mob, xi.jobAbility.PROVOKE)
player.actions:skillchain(mob, ws1, ws2) -- two or more weaponskills
-- Trading to an NPC (see the Events section for the expectedEvent argument)
player:addItem(xi.item.ORCISH_AXE)
player.actions:tradeNpc('Ambrotien', { xi.item.ORCISH_AXE }, { eventId = 2020 })
-- Guild shops
player.actions:guildBuy(xi.item.CHUNK_OF_TIN_ORE, 1)
player.actions:guildSell(xi.item.CHUNK_OF_TIN_ORE, 1)
local buyList = player.actions:guildBuyList() -- decoded, keyed by item ID
-- Crafting (inventory slots are resolved for you)
player.actions:craft(xi.item.FIRE_CRYSTAL, { xi.item.BRONZE_INGOT, xi.item.BRONZE_INGOT })
-- Item management
player.actions:moveItem(xi.inv.INVENTORY, srcSlot, xi.inv.MOGSATCHEL, quantity)Retrieval of and interaction with non-PC entities around the player. Entities can be queried by name, ID, or object, and the returned entity is wired with assertion helpers.
local mob = player.entities:get('Some_Mob') -- by name, ID, or entity object; raises if not found
mob.assert:isAlive()
-- Walk to an NPC and trigger it, expecting a particular event to start
player.entities:gotoAndTrigger('Ambrotien', { eventId = 2000, finishOption = 0 })
-- Combat convenience: claim and kill, optionally waiting for despawn
player:claimAndKillMob(mob, { waitForDespawn = true })
player.entities:killMobs() -- clear the whole zone
player.entities:killMobs({ 'Orcish Grunt' }) -- or a filtered subsetDrive the event (cutscene) engine and assert on it. Most action helpers that can start an event (trigger, gotoAndTrigger, tradeNpc) accept an ExpectedEvent table describing what should happen:
---@class ExpectedEvent
---@field eventId? integer -- event that should start
---@field updates? integer[] -- option values to send as event updates
---@field finishOption? integer -- option value used to finish the event
-- Expect event 2000 to start, then finish it with option 0
player.entities:gotoAndTrigger('Ambrotien', { eventId = 2000, finishOption = 0 })
-- Or drive the current event manually
player.events:update(nil, 1) -- send an update to the in-progress event
player.events:finish() -- finish the current event
player.events:expectNotInEvent() -- assert we are no longer in an eventThe eventId, updates and finishOption values must come from a real event dump; never invent them. See the Interaction Framework Migration guide for how to obtain them.
Enter and resolve battlefields.
-- Some battlefields dump the player into a different zone on win; pre-load it first.
xi.test.world:loadZone(xi.zone.HALL_OF_THE_GODS)
player.bcnm:enter('_513', xi.battlefield.id.CELESTIAL_NEXUS)
player.bcnm:killMobs()
player.bcnm:expectWin()
player.assert:inZone(xi.zone.HALL_OF_THE_GODS)Beyond emitting packets, this namespace lets you inspect what the server sent back, which is how you assert on server behaviour at the wire level:
player.packets:clear() -- drop any queued packets first
player.actions:guildSell(itemId, 1)
for _, pkt in pairs(player.packets:getIncoming()) do -- raw incoming packets as Lua tables
if pkt.type == 0x084 then
-- inspect pkt.data bytes...
end
end
-- Higher-level helper: 0x028 BATTLE2 action packets unpacked into structured tables
local actions = player.packets:actionPackets()The BATTLE2 action suite (scripts/tests/packets/s2c/0x028_battle2/base.lua) is the reference example: it captures a real action packet with actionparse, stores the expected fields, and diffs them against actionPackets(). Its header documents the full capture-to-test workflow; follow it whenever asserting on captured packet data.
You can also emit specially crafted packets via FFI definitions.
This is not intended to be a replacement for commonly re-used packets who should live in the actions namespace.
local ffi = require('ffi')
ffi.cdef [[
typedef struct {
uint16_t id : 9;
uint16_t size : 7;
uint16_t sync;
} GP_CLI_HEADER;
// 0x0A1 - Random Request packet
typedef struct {
GP_CLI_HEADER header;
uint32_t unknown00; // Always 0
} GP_CLI_COMMAND_DICE;
]]
describe('Packets', function()
it('can emit arbitrary packets and parse responses', function()
local p = ffi.new('GP_CLI_COMMAND_DICE')
p.unknown00 = 0
player.packets:send(0x0A2, p, ffi.sizeof(p))
end)
end)
Beyond the built-in Lua assert, CLuaBaseEntity are pre-wired for easy human readable assertions.
Exposed assertions can be negated by prepending with no.
Prefer exposed helpers over the built-in assert() as they take care of formatting error messages.
Assertions return themselves (until one of them forcefully emits an error, that is) and can be chained up.
Not all assertions are valid on all entity types. Mobs will generally have a much reduced set of valid assertions.
The available helpers cover zone, status effects/animations, modifiers, local variables, inventory items, gil, key items, missions, quests, nation rank and liveness. See the LLS documentation for the current, authoritative list rather than relying on this page. Chaining reads naturally:
-- from scripts/tests/missions/sandoria.lua
player.assert
:hasMission(xi.mission.log_id.SANDORIA, xi.mission.id.sandoria.NONE)
:hasCompletedMission(xi.mission.log_id.SANDORIA, xi.mission.id.sandoria.SMASH_THE_ORCISH_SCOUTS)describe('xi_test', function()
local player
setup(function()
player = xi.test.world:spawnPlayer()
end)
it('asserts all the things', function()
-- Basic Lua assert, perfectly serviceable
-- Only downside: It will only print 'assertion failed!' if not passing a string
assert(true)
assert(not true, 'Expected condition to be false')
-- Custom CLuaBaseEntity assertions
player.assert:hasKI(xi.ki.BLUE_ACIDITY_TESTER)
player.assert.no:hasKI(xi.ki.RED_ACIDITY_TESTER)
-- Different syntax to chain assertions
player.assert
:hasKI(xi.ki.BLUE_ACIDITY_TESTER)
.no:hasKI(xi.ki.RED_ACIDITY_TESTER)
end)
end) Error: scripts/tests\missions\bastok.lua:64: Expected player NOT to have key item RED_ACIDITY_TESTERA variety of test doubles is available to test and assert nested systems.
Test doubles are automatically reverted on test case end.
Spies allow the observation of specified functions, in order to assert if they were called during a test case, with the expected arguments and that they returned the expected values.
-- Boilerplate left out
describe('xi.spells.damage.calculateDayAndWeather', function()
local player = ...
local mob = ...
it('increases damage with matching weather', function()
-- Create a new spy and temporarily replace the global
-- The global is automatically reverted when the case ends
local s = spy('xi.spells.damage.calculateDayAndWeather')
player:setMod(xi.mod.FORCE_EARTH_DWBONUS, 1)
player:setWeather(xi.weather.SAND_STORM)
-- This sends a packet.
player.actions:useSpell(mob, xi.magic.spell.STONE)
-- Assert that the function was called. Optional parameter specify count.
s:called()
-- Assert that the function was called with the following parameters
s:calledWith(player, xi.magic.spell.STONE)
-- Individual calls can be inspected
assert(s.calls[1].returned == 1.25, 'Expected return to be 1.25')
end)
end)Stubs possess the same feature set as Spies but also allow specifying:
- A new implementation for the function (passed to
stub(path, fn)) - A direct result to be returned (passed to
stub(path, value), or set later withs:returnValue(value)) - A side-effect function to be executed (
s:sideEffect(fn))
mock(path, impl) is an alias of stub(path, impl); use whichever reads better.
-- from scripts/tests/systems/combat/knockback.lua
local s = stub('xi.combat.knockback.calculate')
mob:useMobAbility(xi.mobSkill.AQUA_BLAST, player, 0)
xi.test.world:skipTime(1)
s:called(1) -- assert the mob ability actually invoked knockback calculation
s:returnValue(xi.action.knockback.LEVEL5)describe('Test Doubles', function()
---@type CClientEntityPair
local player
before_each(function()
player = xi.test.world:spawnPlayer()
end)
-- Stubs replace global functions with your own implementation
-- They do not need to match params
-- They can be instantiated in the following fashion
describe('stub()', function()
it('can replace global functions with a new implementation', function()
player:setSkillLevel(xi.skill.BONECRAFT, 300)
local s = stub('xi.crafting.getRealSkill', function()
return 100
end)
assert(s)
local level = xi.crafting.getRealSkill(player, xi.skill.BONECRAFT)
assert(level == 100, string.format('Expected stubbed level of 100 but got %s', tostring(level)))
end)
it('does not leak across tests', function()
player:setSkillLevel(xi.skill.BONECRAFT, 300)
local level = xi.crafting.getRealSkill(player, xi.skill.BONECRAFT)
assert(level == 30, 'Expected level of 30')
end)
it('can replace global functions with a direct result', function()
player:setSkillLevel(xi.skill.BONECRAFT, 300)
local s = stub('xi.crafting.getRealSkill', 100)
assert(s)
local level = xi.crafting.getRealSkill(player, xi.skill.BONECRAFT)
assert(level == 100, string.format('Expected stubbed level of 100 but got %s', tostring(level)))
end)
it('can execute side-effects', function()
player:setSkillLevel(xi.skill.BONECRAFT, 300)
local s = stub('xi.crafting.getRealSkill')
s:sideEffect(function()
player:changeJob(xi.job.DRK)
end)
local level = xi.crafting.getRealSkill(player, xi.skill.BONECRAFT)
assert(level == 30, string.format('Expected original level of 30 but got %s', tostring(level)))
assert(player:getMainJob() == xi.job.DRK, 'Expected job to be DRK')
end)
end)
end)