v1.4.9: Validation fixes, Profiler fix, Quest link duplicate fix, Ascension fixes
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user