11 KiB
11 KiB
paths
| paths | |||
|---|---|---|---|
|
Lua Testing
This file extends common/testing.md with Lua specific content.
Test Frameworks
| Framework | Best for | Install |
|---|---|---|
| Busted | BDD-style unit/integration testing (most popular) | luarocks install busted |
| LuaUnit | xUnit-style, zero dependencies, minimal footprint | luarocks install luaunit |
| Telescope | Flexible, extensible, custom reporters | luarocks install telescope |
| WoW mocks | Game addon testing with C_API stubs | Custom tests/mocks/ |
Default choice: Use Busted unless the project has an existing convention.
Test Organization
project/
├── src/ # Production code
│ ├── Database/
│ │ └── QuestieDB.lua
│ └── Modules/
│ └── QuestieInit.lua
├── tests/
│ ├── mocks/ # Environment stubs
│ │ ├── wow_api.lua # CreateFrame, C_Timer, GetTime stubs
│ │ ├── questie_env.lua # QuestieLoader, Questie object stubs
│ │ └── saved_vars.lua # SavedVariables mock
│ ├── unit/ # Unit tests (one per module)
│ │ ├── QuestieDB_spec.lua
│ │ ├── ZoneDB_spec.lua
│ │ └── compiler_spec.lua
│ ├── integration/ # Multi-module interaction tests
│ │ └── init_flow_spec.lua
│ └── helpers/ # Shared test utilities
│ ├── assertions.lua # Custom assert functions
│ └── fixtures.lua # Reusable test data
├── .busted # Busted configuration
└── .luacov # Coverage configuration
- Name spec files with
_spec.luasuffix (Busted convention) or_test.lua - Mirror the source directory structure in
tests/unit/ - One spec file per production module
Busted Configuration (.busted)
return {
_all = {
coverage = false,
lpath = "src/?.lua;src/?/init.lua",
},
default = {
verbose = true,
output = "utfTerminal",
ROOT = { "tests/" },
},
ci = {
ROOT = { "tests/" },
output = "TAP",
coverage = true,
},
}
Unit Test Patterns
Basic describe / it / before / after
describe("QuestieDB", function()
local QuestieDB
before_each(function()
-- Fresh module instance per test (isolation)
package.loaded["Database.QuestieDB"] = nil
QuestieDB = require("Database.QuestieDB")
QuestieDB.npcData = {
[1] = { "Hogger", 11, 0 },
[26680] = { "Grizzly Hills NPC", 74, 1 },
}
end)
after_each(function()
package.loaded["Database.QuestieDB"] = nil
end)
describe(":GetNPC", function()
it("returns NPC data for a valid id", function()
local data = QuestieDB:GetNPC(1)
assert.is_not_nil(data)
assert.are.equal("Hogger", data.name)
end)
it("returns nil for an unknown id", function()
assert.is_nil(QuestieDB:GetNPC(99999))
end)
it("returns nil for nil input", function()
assert.is_nil(QuestieDB:GetNPC(nil))
end)
it("returns nil for non-numeric input", function()
assert.is_nil(QuestieDB:GetNPC("abc"))
end)
end)
end)
Pending Tests (Work-in-Progress)
Mark incomplete tests with pending — they appear in reports but do not fail:
pending("respects NPC blacklist during query")
pending("handles WotLK-only NPCs when plugin is absent")
Test Naming Conventions
Use descriptive names that explain the scenario and expected outcome:
-- GOOD: Explains what + when + expected outcome
it("returns nil when NPC id does not exist in the database", ...)
it("triggers recompile when WotLK plugin is newly installed", ...)
it("skips cleanup when database has not been compiled yet", ...)
-- BAD: Vague
it("works", ...)
it("test 1", ...)
Assertions Reference (Busted)
| Assertion | Meaning |
|---|---|
assert.are.equal(expected, actual) |
Strict equality (==) |
assert.are.same(t1, t2) |
Deep table value equality |
assert.is_nil(v) |
v == nil |
assert.is_not_nil(v) |
v ~= nil |
assert.is_true(v) |
v == true (strict, not truthy) |
assert.is_false(v) |
v == false (strict, not falsy) |
assert.truthy(v) |
v is truthy (not nil/false) |
assert.falsy(v) |
v is falsy (nil or false) |
assert.has_error(fn) |
fn() throws any error |
assert.has_error(fn, "msg") |
fn() throws with specific message |
assert.has_no_error(fn) |
fn() does not throw |
assert.are.near(expected, actual, tolerance) |
Float comparison within epsilon |
Mocking and Spies
spy.on — Observe without replacing
local s = spy.on(Questie, "Debug")
QuestieDB:GetNPC(0)
assert.spy(s).was.called()
assert.spy(s).was.called_with(
match._, -- self (Questie)
Questie.DEBUG_CRITICAL, -- severity
match._ -- message string
)
s:revert() -- Restore original (automatic in after_each)
stub — Replace with controlled implementation
stub(QuestieDB, "QueryNPCSingle").returns(nil)
-- Verify it was called with specific args
assert.stub(QuestieDB.QueryNPCSingle).was.called_with(
match._, 26680, match._
)
QuestieDB.QueryNPCSingle:revert()
mock — Full module replacement
local mockDB = mock({
GetNPC = function(_, id) return { name = "Mock NPC " .. id } end,
GetQuest = function() return nil end,
})
Rule: Always restore stubs and spies. Busted auto-reverts stubs created with stub() at the end of each it() block, but manually-created stubs need explicit :revert().
WoW API Mock Environment
Create a tests/mocks/wow_api.lua that defines the WoW protected API surface. Require it BEFORE any addon file:
-- tests/mocks/wow_api.lua
-- Frame system
_G.CreateFrame = function(frameType, name, parent, template)
local frame = {
_events = {},
_scripts = {},
RegisterEvent = function(self, event) self._events[event] = true end,
UnregisterEvent = function(self, event) self._events[event] = nil end,
SetScript = function(self, handler, fn) self._scripts[handler] = fn end,
GetScript = function(self, handler) return self._scripts[handler] end,
Show = function() end,
Hide = function() end,
IsShown = function() return true end,
}
return frame
end
-- Timer system
_G.C_Timer = {
After = function(delay, fn) fn() end, -- Execute immediately in tests
NewTicker = function(interval, fn, iterations)
for i = 1, (iterations or 1) do fn() end
return { Cancel = function() end }
end,
}
-- Time
_G.GetTime = function() return os.clock() end
_G.time = os.time
-- Unit info
_G.UnitGUID = function(unit) return "Player-1234-ABCDEF" end
_G.UnitName = function(unit) return "TestPlayer" end
_G.UnitLevel = function(unit) return 80 end
_G.UnitFactionGroup = function(unit) return "Alliance", "Alliance" end
-- Map API
_G.C_Map = {
GetMapInfo = function(mapID) return { mapID = mapID, name = "Test Zone" } end,
GetBestMapForUnit = function(unit) return 1 end,
}
-- Enum system
_G.Enum = {
UIMapType = { Cosmic = 0, World = 1, Continent = 2, Zone = 3, Dungeon = 4 },
}
-- Misc
_G.IsAddOnLoaded = function(name) return true end
_G.GetAddOnInfo = function(name) return name, "Test Addon", "", true, "INSECURE" end
_G.InCombatLockdown = function() return false end
_G.debugstack = function(level) return "mock stack trace" end
_G.geterrorhandler = function() return print end
_G.hooksecurefunc = function(table, name, fn) end
_G.print = print
_G.wipe = function(t) for k in pairs(t) do t[k] = nil end return t end
_G.select = select
_G.format = string.format
_G.strsplit = function(sep, str)
local parts = {}
local pattern = "([^" .. sep .. "]+)"
for match in str:gmatch(pattern) do
table.insert(parts, match)
end
return unpack(parts)
end
-- SavedVariables
_G.QuestieSV = {}
Firing Events in Tests
-- Helper to fire a WoW event on a frame mock
local function fireEvent(frame, event, ...)
if frame._events[event] then
local onEvent = frame._scripts["OnEvent"]
if onEvent then
onEvent(frame, event, ...)
end
end
end
-- Usage in test
it("handles PLAYER_LOGIN event", function()
local frame = CreateFrame("Frame")
frame:RegisterEvent("PLAYER_LOGIN")
frame:SetScript("OnEvent", myHandler)
fireEvent(frame, "PLAYER_LOGIN")
-- assert expected side effects
end)
Error Path Testing
Always test error paths, not just happy paths:
describe("error handling", function()
it("handles nil rawdata gracefully", function()
QuestieDB.npcData = {}
local result = QuestieDB:GetNPC(99999)
assert.is_nil(result)
end)
it("logs critical error for nil rawdata", function()
local s = spy.on(Questie, "Debug")
QuestieDB.npcData = {}
QuestieDB:GetNPC(99999)
assert.spy(s).was.called_with(
match._, Questie.DEBUG_CRITICAL, match.is_string()
)
end)
it("survives pcall on corrupted data", function()
QuestieDB.npcData = { [1] = "not a table" }
assert.has_no_error(function()
QuestieDB:GetNPC(1)
end)
end)
end)
Coverage
Use Luacov for line coverage. Target ≥ 80%.
Configuration (.luacov)
return {
statsfile = "luacov.stats.out",
reportfile = "luacov.report.out",
exclude = {
"tests/", -- Exclude test files from coverage
"Database/Data/.*Data", -- Exclude large static data tables
"Localization/Translations/", -- Exclude translation strings
},
include = {
"Database/",
"Modules/",
},
}
Commands
# Run tests with coverage
busted --coverage
# Generate report
luacov
cat luacov.report.out
# Fail CI if below threshold
awk '/^Total/ { if ($4+0 < 80) { print "Coverage below 80%: " $4 "%"; exit 1 } }' luacov.report.out
Test-Driven Development Workflow (Lua)
- RED — Write the test first. It must FAIL.
- GREEN — Write the minimum production code to make it pass.
- REFACTOR — Clean up while keeping tests green.
- COVERAGE — Verify ≥ 80% with
busted --coverage && luacov.
# TDD cycle
busted tests/unit/QuestieDB_spec.lua # RED: expect failures
# ... write implementation ...
busted tests/unit/QuestieDB_spec.lua # GREEN: expect passes
# ... refactor ...
busted --coverage && luacov # COVERAGE: verify 80%+
Testing Commands
busted # Run all tests
busted --verbose # Verbose output
busted tests/unit/ # Run only unit tests
busted --filter="GetNPC" # Run tests matching pattern
busted --tags="wotlk" # Run tagged tests only
busted --coverage # With coverage collection
busted --output=TAP # TAP format for CI
busted --shuffle # Randomize test order (detect coupling)
Agent Support
- tdd-guide — Use proactively for new features; enforces write-tests-first workflow
- code-reviewer — Review test quality after writing tests