398 lines
11 KiB
Markdown
398 lines
11 KiB
Markdown
---
|
|
paths:
|
|
- "**/tests/**/*.lua"
|
|
- "**/*_spec.lua"
|
|
- "**/*_test.lua"
|
|
---
|
|
# Lua Testing
|
|
|
|
> This file extends [common/testing.md](../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
|
|
|
|
```text
|
|
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.lua` suffix (Busted convention) or `_test.lua`
|
|
- Mirror the source directory structure in `tests/unit/`
|
|
- One spec file per production module
|
|
|
|
## Busted Configuration (`.busted`)
|
|
|
|
```lua
|
|
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
|
|
|
|
```lua
|
|
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:
|
|
|
|
```lua
|
|
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:
|
|
|
|
```lua
|
|
-- 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
|
|
|
|
```lua
|
|
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
|
|
|
|
```lua
|
|
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
|
|
|
|
```lua
|
|
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:
|
|
|
|
```lua
|
|
-- 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
|
|
|
|
```lua
|
|
-- 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:**
|
|
|
|
```lua
|
|
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`)
|
|
|
|
```lua
|
|
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
|
|
|
|
```bash
|
|
# 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)
|
|
|
|
1. **RED** — Write the test first. It must FAIL.
|
|
2. **GREEN** — Write the minimum production code to make it pass.
|
|
3. **REFACTOR** — Clean up while keeping tests green.
|
|
4. **COVERAGE** — Verify ≥ 80% with `busted --coverage && luacov`.
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
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
|