Most WoW addons have no automated tests. The dominant testing strategy is "I logged in and it didn't error." This works until it doesn't — usually right when Blizzard ships a patch.
Blizz uses headless Lua testing with hand-mocked WoW globals. The full test suite runs in under a second from the command line, doesn't require WoW to be installed, and catches the kind of regression that takes hours to find via /reload + manual play.
| Tool | Role | Required? |
|---|---|---|
| LuaJIT 2.1 | Test runner that matches WoW's Lua 5.1 runtime + bit library |
Yes — LuaJIT, not Lua 5.4 |
| stylua | Formatter | Yes |
| lua-language-server | IDE intelligence + type-checking | Recommended |
| WoW API annotations | LLS workspace library for autocomplete | Recommended |
| No external test framework | We use plain assert() + a small tests/run.lua |
Intentional |
The decision to skip busted and luaunit is deliberate. Plain assert + a self-rolled runner is 70 lines total and has no LuaRocks dependency. The cost is fewer features (no spy/stub built-in, no BDD syntax); the benefit is a single-file dependency that runs from any Lua interpreter.
WoW's runtime is LuaJIT 2.0/2.1 with Lua 5.1 semantics. If you write tests against Lua 5.4 (or even 5.3), you'll occasionally hit syntax that WoW's runtime doesn't accept:
- No integer division
// - No bitwise operators
&|~(use thebitlibrary instead) - No goto-continue idiom in some 5.1 compilers
- Different
os.timebehavior - No
gotolabel scoping changes from 5.2+
Running tests with luajit (2.1) avoids all of these surprises. If your test passes locally, it almost certainly works in WoW.
Install (Arch / Steam Deck):
pacman -S luajit
luajit -v
# → LuaJIT 2.1.x ... Lua 5.1The runner discovers tests/test_*.lua files, runs each in a fresh _G.Blizz scope, and prints a summary:
package.path = "./?.lua;./?/init.lua;" .. package.path
local function list_test_files()
local files = {}
local p = io.popen("ls tests/test_*.lua 2>/dev/null")
if p then
for line in p:lines() do
local name = line:gsub("^tests/", ""):gsub("%.lua$", "")
table.insert(files, "tests." .. name)
end
p:close()
end
table.sort(files)
return files
end
local files = list_test_files()
local passed, failed = 0, 0
for _, modname in ipairs(files) do
-- fresh state per test file: clear globals + cached modules
_G.Blizz = nil
for k in pairs(package.loaded) do
if k:match("^core%.") or k:match("^ui%.") or k:match("^config%.")
or k:match("^modules%.") or k:match("^data%.")
or k == modname or k == "tests.mocks.wow_api" or k == "Blizz" then
package.loaded[k] = nil
end
end
io.write(string.format("\n=== %s ===\n", modname))
local ok, err = pcall(require, modname)
if ok then
passed = passed + 1
else
failed = failed + 1
print("✗ FAIL:", err)
end
end
print(string.format("\n--- %d passed, %d failed ---", passed, failed))
if failed > 0 then os.exit(1) endRun with luajit tests/run.lua — output looks like:
=== tests.test_eventbus ===
✓ subscribe/dispatch works
✓ multi-subscriber works
✓ pcall containment works
✓ unsubscribe by token works
✓ args passthrough works
...
--- 20 passed, 0 failed ---
A test file looks like:
-- tests/test_eventbus.lua
require("tests.mocks.wow_api")
local EventBus = require("core.eventbus")
local count = 0
EventBus:subscribe("PLAYER_LOGIN", function() count = count + 1 end)
EventBus:dispatch("PLAYER_LOGIN")
EventBus:dispatch("PLAYER_LOGIN")
assert(count == 2, "subscriber should fire on each dispatch")
print("✓ subscribe/dispatch works")That's it. No describe/it blocks, no test discovery beyond filename. The assert either passes silently or throws; print on pass for visibility.
This is where you stub the WoW API so module code can run outside WoW. The structure:
local Mock = {
units = {}, -- [unit] = { health, maxHealth, absorb, ... }
cooldowns = {}, -- [spellID] = { start, duration, charges, ... }
cleu_listener = nil,
time = 1000,
frames = {}, -- tracks created frames for event broadcast
mythicplus = { active = false, mapID = 0, timeLimit = 1800 },
forces = { total = 100, current = 0 },
}
local function make_frame(frameType, name, parent)
local f = {
__type = frameType, __name = name, __parent = parent,
__events = {}, __scripts = {}, __points = {}, __size = { 0, 0 },
__shown = true,
}
function f:SetSize(w, h) self.__size = { w, h } end
function f:SetPoint(...) table.insert(self.__points, { ... }) end
function f:RegisterEvent(ev) self.__events[ev] = true end
function f:SetScript(name, fn) self.__scripts[name] = fn end
function f:Show() self.__shown = true end
function f:Hide() self.__shown = false end
function f:IsShown() return self.__shown end
function f:SetBackdropColor(_, _, _, _) end
function f:CreateTexture(_, _) return make_frame("Texture") end
function f:CreateFontString(_, _, _) return make_frame("FontString") end
-- ... catch-all for the long tail:
setmetatable(f, { __index = function(_, k)
if type(k) == "string" and (k:match("^Set") or k:match("^Get")) then
return function() end
end
return nil
end })
table.insert(Mock.frames, f)
return f
end
_G.UIParent = make_frame("Frame", "UIParent")
_G.CreateFrame = function(frameType, name, parent, template)
return make_frame(frameType, name, parent, template)
end
_G.GetTime = function() return Mock.time end
_G.UnitHealth = function(unit) return (Mock.units[unit] or {}).health or 0 end
-- ... etc.
-- Mock control API for tests:
function MockSetUnit(unit, props) Mock.units[unit] = props end
function MockSetCooldown(spellID, start, duration) ... end
function MockSetTime(t) Mock.time = t end
function MockFireFrameEvent(eventName, ...) ... end -- simulate a WoW eventThe pattern:
- Each Mock-controlled value lives in the
Mocktable _G.UnitFooreads fromMock.units[unit].fooMockSetUnit("player", { health = 50000 })lets tests set up state- The catch-all metatable returns no-ops for methods you forgot to stub — your tests won't crash when a module calls
frame:SetWidth(100)even if you never added an explicitSetWidthstub
This is intentionally minimal. Cell, ElvUI, BigWigs all maintain mock layers an order of magnitude larger; ours covers exactly the API surface Blizz uses (~30 functions). When a module starts calling a new API, add a stub.
The module files use this load pattern:
local _, addon = ...
if not addon then
addon = _G.Blizz or {}
_G.Blizz = addon
endThe first line handles WoW's TOC load (which passes addonName, addonTable as varargs). The fallback if not addon then covers headless tests where the file is loaded via require() (which passes ("module_name",) and addon is nil).
This dual-load pattern means every module file works identically in WoW and in tests. No conditional code, no separate test entry points.
Config in stylua.toml:
column_width = 100
line_endings = "Unix"
indent_type = "Tabs"
indent_width = 4
quote_style = "AutoPreferDouble"
call_parentheses = "Always"
collapse_simple_statement = "Never"Run on every new file before commit:
stylua path/to/file.lua && stylua --check path/to/file.luaThe collapse_simple_statement = "Never" is non-default. Without it, stylua collapses small if cond then statement end blocks onto one line, which makes diffs noisier and conflicts with the lua-language-server's understanding of branch coverage.
Install (Arch):
curl -fsSL -o stylua.zip https://github.com/JohnnyMorganz/StyLua/releases/download/v2.4.1/stylua-linux-x86_64.zip
unzip stylua.zip -d ~/.local/bin/
chmod +x ~/.local/bin/styluaConfig in .luarc.json:
{
"runtime.version": "Lua 5.1",
"runtime.special": {
"C_Timer.After": "setTimeout"
},
"workspace.library": [
"/home/<user>/.local/share/wow-api/Annotations"
],
"workspace.checkThirdParty": false,
"diagnostics.globals": [
"LibStub",
"SLASH_BLIZZ1",
"SlashCmdList",
"BlizzDB",
"MockSetUnit",
"MockSetCooldown",
"MockSetThreat",
"MockSetTime",
"MockSetCLEUListener",
"MockFireCLEU",
"MockFireFrameEvent",
"MockSetMythicPlus",
"MockSetForces",
"MockSetTimer",
"MockSetGroup",
"MockSetUnitDead",
"MockReset"
]
}Key parts:
runtime.versionisLua 5.1because WoW runs LuaJIT 5.1-compat semanticsworkspace.librarypoints at a WoW-API-Annotations bundle (e.g. Ketho/vscode-wow-api emits these). LLS uses them for autocomplete and type-checking on WoW APIsdiagnostics.globalsdeclares globals LLS should ignore — slash commands, saved-variable global, and allMock*test helpers
Load order matters in WoW. Files are concatenated and executed top-to-bottom. Reference dependencies must load first.
Blizz's Blizz.toc load order (excerpt):
## Interface: 120005
## Title: Blizz
## Version: 0.1.1
## SavedVariables: BlizzDB
# Theme (UI color/font tokens — depended on by widgets)
ui/theme.lua
# Core (event bus + API wrappers, no UI dependencies)
core/eventbus.lua
core/secrets.lua
core/cooldowns.lua
core/unitstate.lua
core/combatlog.lua
core/wowevents.lua
# Config (saved-vars)
config/savedvars.lua
# Static data (no dependencies)
data/spells_prot_warrior.lua
data/reflect_spells.lua
data/affixes_s1.lua
data/npcs_midnight_s1.lua
data/party_interrupts.lua
data/party_cds.lua
# Widget toolkit (depends on theme)
ui/widgets/frame.lua
ui/widgets/text.lua
ui/widgets/icon.lua
ui/widgets/bar.lua
ui/widgets/alert.lua
# Main entry (provides addon.registerModule)
Blizz.lua
# Modules (call addon.registerModule on load; depend on Blizz.lua being loaded)
modules/mitigation/init.lua
modules/cooldowns/init.lua
modules/threat/init.lua
modules/reflect/init.lua
modules/mplus_frame/init.lua
modules/affix_s1/init.lua
modules/nameplates/init.lua
modules/kickrota/init.lua
modules/party_cds/init.lua
The rule: anything Blizz.lua references via addon.X must load before Blizz.lua. Modules load after Blizz.lua because they call addon.registerModule at file end.
Symlink the repo into the WoW AddOns folder:
./scripts/install.sh
# verifies all TOC-listed files exist
# creates symlink: <WoW>/Interface/AddOns/Blizz → /path/to/blizzAfter any edit, in-game /reload picks up the changes immediately. No copy step needed.
Blizz doesn't currently run CI but is set up to. A minimal GitHub Actions job would be:
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: sudo apt-get install -y luajit
- run: luajit tests/run.lua
- run: curl -L https://github.com/JohnnyMorganz/StyLua/releases/download/v2.4.1/stylua-linux-x86_64.zip -o stylua.zip
- run: unzip stylua.zip -d /tmp/
- run: /tmp/stylua --check $(find . -name "*.lua" -not -path "./.claude/*")20 tests run in 130ms. The lint sweep is sub-second.
When you write a module that needs a new WoW API, add a stub. The pattern:
-- in tests/mocks/wow_api.lua, alongside other unit stubs:
_G.UnitFoo = function(unit)
return (Mock.units[unit] or {}).foo or 0
end
-- and a control helper:
function MockSetUnitFoo(unit, foo)
Mock.units[unit] = Mock.units[unit] or {}
Mock.units[unit].foo = foo
endThen declare the mock helper in .luarc.json's diagnostics.globals so LLS doesn't complain. Then your test:
MockSetUnitFoo("player", 42)
assert(UnitState:getFoo("player") == 42)Three lines of mock code per API. Compounds slowly. After 6 months of development, our mock is 300 lines covering ~30 WoW APIs.
Honest accounting:
- No coverage reports. If you need line coverage, add
luacov(LuaRocks dependency). - No spy/mock framework. If you want
spy.on(module, "method")syntax, switch tobusted. Thesetfenv-per-test pattern in Adirelle/wowmock is a middle-ground worth studying. - No async test support. WoW's
C_Timer.Afteris stubbed as a no-op; if your module relies on delayed execution, your tests will need explicit time-advance helpers (seeMockSetTime). - No visual regression. Anything UI-related is tested at the call-pattern level (e.g. "did
setReady()change__stateto'ready'?") — not pixel-perfect.
For Blizz's scope, this is enough. For an addon 10x larger, consider migrating to busted and adding coverage.
- Install
luajitandstylua - Copy
tests/run.luaas your runner - Copy
tests/mocks/wow_api.luaand trim it to the APIs you actually call - Write your first module with the dual-load pattern (
local _, addon = ...) - Write a test that
require()s the module and asserts behavior - Run
luajit tests/run.lua— verify it passes - Add stylua + LLS config
- From here, each new module = a test file + an implementation file + a commit
You'll have a tighter feedback loop than 99% of WoW addons within a day.