Lua has a testing problem that nobody talks about: it’s one of the most widely deployed languages on Earth — embedded in Nginx and OpenResty, HAProxy, Redis modules, game engines, and Neovim — yet its test ecosystem is a fraction the size of Python’s or JavaScript’s. When your Lua code does crash, it’s usually inside someone else’s host process, which makes testing more important, not less. The good news: the ecosystem has consolidated around two real frameworks and one essential assertion layer — Busted (1,635 stars, the BDD standard), LuaUnit (633 stars, the single-file xUnit workhorse, now on version 3.5 with Lua 5.5 support), and luassert (250 stars, the extensible assertion and spy engine that powers Busted and works standalone).
Here’s what most comparisons get wrong: Busted and LuaUnit are not actually direct competitors for most teams, because they serve different deployment realities. LuaUnit ships as one dependency-free .lua file you can vendor into a game engine or a Slurm plugin — it’s literally used by Slurm for plugin validation and by CERN’s MAD accelerator design code. Busted is a full toolchain — runner, BDD syntax, spies, mocks, TAP output, Docker image, CI action — that assumes you control your environment. And luassert is the layer you reach for when you want Busted-style assertions and spies without adopting Busted’s test runner.
TL;DR: New standalone Lua projects and CI-driven repos → Busted (v2.3.0, actively maintained, richest feature set: describe/it, chained asserts, spies, tags, TAP). Embedded or vendored contexts — game mods, OpenResty phases, scientific computing, single-file constraints → LuaUnit (v3.5, March 2026, one file, zero deps, JUnit/TAP output, runs on Lua 5.1 through 5.5 and LuaJIT). Building your own harness, or extending assertions with domain-specific matchers → luassert (v1.9.0), which registers custom assertions and spies into any Lua environment. If in doubt, start with Busted and drop to LuaUnit when deployment demands it — they coexist peacefully, and plenty of projects run both.
The Three Tools Compared
| Dimension | Busted | LuaUnit | luassert |
|---|---|---|---|
| GitHub repo | lunarmodules/busted | bluebird75/luaunit | lunarmodules/luassert |
| Stars | 1,635 | 633 | 250 |
| Latest version | v2.3.0 | v3.5 (2026-03-26) | v1.9.0 |
| Last repo push | Active (2026) | Active (2026) | 2026-09-04 |
| License | MIT | BSD | MIT |
| Style | BDD (describe/it) | xUnit (TestXxx methods) | Assertion library |
| Test runner | Built-in CLI | Built-in CLI | None (embed in any runner) |
| Distribution | LuaRocks + Docker + CI action | LuaRocks + single luaunit.lua file | LuaRocks (dep of busted) |
| Runtime support | Lua ≥ 5.1, LuaJIT ≥ 2.0, MoonScript | Lua 5.1–5.5, LuaJIT 2.0 | Lua ≥ 5.1, LuaJIT |
| Spies & mocks | Yes (spy.on, stub.new) | No (use luassert separately) | Yes (luassert.spy) |
| Custom assertions | Via luassert registration | Built-in set only | First-class (assert:register) |
| Output formats | Pretty, plain, JSON, TAP | Text, TAP, JUnit XML | n/a |
| Dependency footprint | luassert, say, etc. | Zero (single file) | say (i18n) |
| Noteworthy users | LuaRocks ecosystem, Neovim plugins | Slurm (HPC), CERN MAD | Busted itself |
Use Case → Decision Matrix
| Use Case | Recommended Tool | Reason |
|---|---|---|
| New Lua library published to LuaRocks | Busted | De facto standard for rocks; contributors already know it; TAP/JSON output plays with CI |
| Testing code that ships inside another product | LuaUnit | Single self-contained file you vendor in; no external deps to fight with |
| OpenResty / Nginx / HAProxy embedded Lua | LuaUnit (or luassert alone) | Host processes hate surprise dependencies; one-file LuaUnit drops in cleanly |
| Game engine / mod / plugin code (LÖVE, Garry’s Mod, WoW) | LuaUnit | Same vendoring logic; xUnit style maps to engine test events |
| You need spies and mock objects | Busted (bundled) or luassert | assert.spy(x).was.called_with(...) works in both |
| Custom domain assertions (“this table is a valid tile map”) | luassert | assert:register("assertion", ...) with i18n messages via say |
| HPC / scientific Lua with float-heavy math | LuaUnit | assertAlmostEquals, assertNan, assertInf, machine-epsilon defaults |
| CI on GitHub Actions | Busted | Official lunarmodules/busted@v0 action or ghcr.io/lunarmodules/busted container |
Busted: The BDD Standard
Busted’s pitch is that test specs should read naturally — nested describe blocks with contextual descriptions, and assertions you can chain and negate in plain English:
| |
That last block is Busted’s killer feature: spy.on wraps an existing table method, records calls, and lets you assert on them — no separate mocking library required. Under the hood, Busted’s assertions and spies are luassert, which is why the two projects share the lunarmodules org and release rhythm.
Busted runs on Lua ≥ 5.1, LuaJIT ≥ 2.0, and MoonScript. Its output layer is modular: human-friendly pretty and plain terminal output, JSON (with or without streaming), and TAP-compatible output for CI servers. Test blocks can carry tags so you can run arbitrary groups (--tags=MYTAGS), and the runner exits with proper codes for CI.
For containerized and CI workflows, Busted is the only one of the three with first-party infrastructure: a Docker image at ghcr.io/lunarmodules/busted:latest and an official GitHub Action:
| |
Or as a Docker alias for local runs:
| |
The tradeoff: Busted is a dependency tree. Installing it pulls luassert, say, and friends. In a hermetic build environment — a game engine toolchain, an embedded Linux rootfs — that tree is exactly what you don’t want, which is where LuaUnit’s single-file design wins.
LuaUnit: One File, Zero Dependencies, Proven at Planet Scale
LuaUnit is the oldest of the three (first public release 2005) and has quietly become the testing backbone of serious numerical software. Its résumé is unusual: Slurm — the HPC workload manager running on most of the world’s top 500 supercomputers — uses LuaUnit to validate its Lua plugins, and CERN’s MAD accelerator-design code (the de facto standard for simulating particle accelerators) uses a fork of LuaUnit for all framework validation. Version 3.4 alone was downloaded more than 270,000 times from LuaRocks (700k+ badge total), and the project claims 99.5% test coverage with the suite run on every Lua from 5.1 to 5.5 plus LuaJIT on Windows, macOS, and Ubuntu.
The interface is classic xUnit — test methods, setUp/tearDown, and a CLI that selects by name or pattern:
| |
Where LuaUnit genuinely shines is failure diagnostics. Since 3.3, comparing two lists that differ produces a full diff analysis instead of a wall of numbers:
| |
For scientific and numerical code, LuaUnit has dedicated support: lu.EPS exposes the machine epsilon, and assertions cover the IEEE-754 edge cases most test frameworks ignore — assertNan(), assertInf(), assertPlusInf(), assertMinusInf(), assertPlusZero(), assertMinusZero(), plus assertAlmostEquals() with a default margin of the machine epsilon rather than a magic constant.
The 2026 news: version 3.5 (26 March 2026) added Lua 5.5 support, configurable test/method naming conventions via --test-prefix, --test-suffix, and --method-prefix, and better error handling in setup/teardown hooks. The whole library remains one file with no external dependency — you can clone the repo and copy luaunit.lua into your project or Lua path:
| |
Installation via LuaRocks works too. The CLI supports --shuffle and --repeat NUM (useful for shaking out JIT-dependent bugs), JUnit XML output for Jenkins, TAP output, and pattern-based test selection. The main weakness: no built-in spies or mocks. For that you pair it with luassert — which is exactly the modularity the ecosystem intends.
luassert: The Assertion and Spy Engine
Luassert is the layer most people have already used without knowing it — it’s the assertion library inside Busted. Standing alone, it extends Lua’s built-in assertions with chainable, composable checks and a first-class system for registering your own:
| |
Because Lua keywords (true, false, nil, function, not) can’t follow a dot, luassert provides underscore and capitalized alternates — assert.is_not_true(false) and assert.is.Not.True(false) both work.
The extension API is where luassert becomes a platform. Custom assertions register with a name, a predicate function, and human-readable positive/negative messages (via the say i18n library) — so your domain failures read like product requirements, not stack traces:
| |
Luassert also ships spies and matchers usable outside Busted entirely:
| |
Custom matchers register the same way as assertions, and composite matchers compose with not. Modifiers extend the grammar further — assert.array(arr).has.no.holes() checks for array holes with an optional explicit length. The one caveat: luassert is a library, not a runner. You still need busted, luaunit, or your own harness to collect and execute tests — which is precisely its niche as the embeddable assertion layer for hosts that can’t adopt a full framework.
Pitfalls That Trip Up Real Lua Test Suites
- Global namespace pollution is a legacy trap. LuaUnit 3.1+ no longer exports assertions to globals by default (opt in with
EXPORT_ASSERT_TO_GLOBALS). Old tutorials and 2.x forks that call bareassertEquals(...)will fail confusingly on modern versions — always prefix withlu.. - Lua keyword chaining breaks dot syntax.
assert.is_not.nil(x)is a syntax error —nilis reserved. Useassert.is_not_nil(x)orassert.is.Nil(x). The same applies totrue,false, andfunction. - Assertion argument order differs by library. LuaUnit’s
assertEquals(expected, actual)(configurable viaUSE_EXPECTED_ACTUAL_IN_ASSERT_EQUALS) and luassert’sare.equal(expected, actual)are the same — but some forks and legacy code invert it. When migrating between frameworks, flip the arguments mentally and let one failing test confirm before bulk-converting. assertTruestrictness changed. LuaUnit 3.3 madeassertTrue()/assertFalse()succeed only with actual booleans; the old coercing behavior moved toassertEvalToTrue()/assertEvalToFalse(). Ported suites that usedassertTrue(1)as “truthy” will silently fail.- Your Lua version matrix matters more than framework choice. Lua 5.1 vs 5.4 vs LuaJIT differ in integer division,
#on tables, andgoto. LuaUnit tests against the full 5.1–5.5 + LuaJIT matrix; Busted targets ≥ 5.1 + LuaJIT. If your deployment Lua differs from your dev Lua, CI must run both — this is the single most common source of “works locally, fails in production” for Lua. - Embedded hosts need vendored, dependency-free tests. If your code runs inside OpenResty or HAProxy’s Lua runtime, a LuaRocks-installed Busted tree may not even be loadable in that environment. Copy
luaunit.luainto your module tree — one file, no surprises. This is the same constraint that pushed Slurm and CERN to LuaUnit. - Spies can’t wrap what doesn’t exist.
spy.on(module, 'fn')requires the function to already be present on the table. For dependency injection,stub.new()(Busted) or luassert’sspy.newcreates fresh stubs — design your modules as injectable tables or you’ll fight your own test doubles. - Don’t test implementation order. Lua table iteration order is unspecified for non-array keys.
assert.same(deep equality, order-insensitive) is almost always what you want;assertEqualson tables with mixed keys can produce misleading diffs. - CI output format matters. Busted’s TAP output and LuaUnit’s JUnit XML integrate with different CI ecosystems. If you’re moving between GitHub Actions, Jenkins, or GitLab, verify the parser handles your chosen format’s edge cases (LuaUnit’s XML escaping of invalid characters was only fixed in 3.5).
For a broader look at how other language ecosystems solved the same testing problems, see our Haskell testing frameworks guide, the Elixir testing shootout, and the Dart/Flutter testing comparison. And if you’re wondering where embedded Lua shows up in production infrastructure, our HAProxy Lua scripting deep dive walks through real host-embedded Lua in the wild.
FAQ
Is Busted better than LuaUnit? Not universally — they target different deployment realities. Busted is richer (BDD syntax, spies, mocks, tags, Docker, CI action) but carries a dependency tree. LuaUnit is a single dependency-free file with xUnit semantics, JUnit/TAP output, and numerical-computing assertions. For standalone projects with normal CI, Busted is the stronger default; for embedded or vendored contexts, LuaUnit wins.
Does LuaUnit support mocking?
No built-in spies or mocks — pair it with luassert (require("luassert") + luassert.spy) for that. Busted bundles the same spy system natively.
Which Lua versions are supported in 2026? Busted supports Lua ≥ 5.1, LuaJIT ≥ 2.0, and MoonScript. LuaUnit 3.5 (March 2026) added Lua 5.5 support and tests across Lua 5.1–5.5 plus LuaJIT on Windows, macOS, and Ubuntu. Always run CI on the Lua version you deploy with — behavior differs meaningfully across 5.1, 5.4, and LuaJIT.
Can I use luassert without Busted? Yes — luassert is a standalone library. You register assertions, create spies, and use matchers from any runner or from production code. You just need something else to collect and execute tests (LuaUnit, a custom harness, or your host application’s own event loop).
What do Slurm and CERN use Lua for, and why LuaUnit? Slurm (HPC workload manager) validates its Lua plugins with LuaUnit; CERN’s MAD particle-accelerator design code uses a LuaUnit fork for framework validation. Both embed Lua in large native systems where a dependency-free single-file test library is the only practical option.
How do I run Lua tests in GitHub Actions?
Busted offers the official lunarmodules/busted@v0 action and a ghcr.io/lunarmodules/busted Docker image. LuaUnit runs anywhere Lua does — install via LuaRocks (luarocks install luaunit) or vendor the single file and invoke lua test_suite.lua with --output=junit for XML reports.
Is Busted still actively maintained?
Yes — the lunarmodules org released v2.3.0 and luassert v1.9.0 with pushes as recent as September 2026. The org also maintains the companion say i18n library and the LuaRocks packaging for the whole stack.
💰 想测试你的市场判断力?我用 Polymarket 做预测市场交易——这是全球最大的预测市场平台,从大选结果到技术监管时间线,什么都可以押注。和赌博不同,这是真正的信息市场:你懂的信息越多,胜率越高。我靠预测技术相关事件的走向已经赚了不少。用我的邀请链接注册:Polymarket.com