Files
Questie-X/.agent/rules/lua-testing.md
T

11 KiB

paths
paths
**/tests/**/*.lua
**/*_spec.lua
**/*_test.lua

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.lua suffix (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)

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