Testing Lua Code with Busted
The Busted tutorial walks through the full DSL and all the built-in tools. This guide assumes you have read that and want to use Busted effectively in a real project. Here we focus on the workflows that actually come up once you have a test suite running: selecting which tests to run during development, keeping tests properly isolated from each other, integrating with CI, and the mistakes you will hit the first time you try to test something realistic.
Running Specific Tests During Development
When you are iterating on a single feature, running the full suite every time is slow. Busted has several ways to narrow the run.
By file or directory — pass a path and Busted recursively finds all *_spec.lua files under it:
busted spec/unit/ # all tests in spec/unit/
busted spec/integration/ # integration tests only
By file name pattern — use --pattern to match files:
busted --pattern="user" # runs *_spec.lua files with "user" in the name
By test name — --filter matches against the full test name (describe + it labels concatenated):
busted --filter="add" # runs any test with "add" in its name
busted --filter="Calculator" # runs all tests in the Calculator group
For more precise targeting, combine --filter with a file path:
busted spec/unit/calculator_spec.lua --filter="add"
By tag — tag tests to create logical groups, then select by tag:
busted --tags=unit # only tests tagged :unit
busted --tags=fast,unit # tests with both :fast AND :unit
busted --exclude-tags=slow # run everything except :slow
The :focus tag is particularly useful during development. Tag a test you are working on and run with --tags=focus to isolate it:
it("handles edge case in sort", {":focus"}, function()
-- your test here
end)
Test Isolation and the State Problem
Busted runs tests in the same Lua interpreter, which means state leaks between tests if you are not careful. The most common mistake is initializing mutable state at the describe level:
-- WRONG: state is shared across all tests in this describe block
describe("Cache", function()
local cache = {}
it("stores a value", function()
cache["key"] = "value"
assert.is_not_nil(cache["key"])
end)
it("retrieves the stored value", function()
-- this test runs second, but the cache from the previous test may persist
assert.is_not_nil(cache["key"]) -- depends on the test above running first
end)
end)
Use before_each to reset state before every test:
-- RIGHT: each test gets a fresh cache
describe("Cache", function()
before_each(function()
cache = {}
end)
it("stores a value", function()
cache["key"] = "value"
assert.is_not_nil(cache["key"])
end)
it("retrieves the stored value", function()
assert.is_nil(cache["key"]) -- clean slate each time
end)
end)
This is especially important when testing code that relies on the package table or module-level globals. The before_each pattern works correctly regardless of test order.
When to use before_each versus setup
setup runs once before the first test in a describe block. before_each runs before every test. If your setup is cheap and the state might have been modified, before_each is safer.
A typical pattern for tests against a real (non-mocked) module:
describe("UserService", function()
local service
setup(function()
service = require("services.user")
end)
before_each(function()
-- reset the database to a known state before each test
test_db:reset()
end)
it("creates a user", function()
local user = service:create({ name = "Alice" })
assert.is_table(user)
assert.equals("Alice", user.name)
end)
end)
Structuring a Large Test Suite
As your project grows, a flat spec/ directory stops scaling. A layout that works well:
spec/
unit/
calculator_spec.lua
validator_spec.lua
integration/
api_spec.lua
helpers/
test_db.lua
factories.lua
Then run specific subdirectories in CI:
# Fast unit tests for every commit
busted spec/unit/
# Full suite on merge
busted spec/
Environment-Specific Testing
Neovim
If you are testing Neovim configuration code, load the plenary library’s busted helper which provides the describe and it globals:
-- spec/my_plugin_spec.lua (in your nvim plugin repo)
require('plenary.busted')
describe("my plugin", function()
it("formats buffer", function()
-- test code here with access to nvim API
vim.api.nvim_buf_set_lines(0, 0, -1, false, {"hello"})
-- ...assert on buffer state
end)
end)
Run with nvim -l spec/my_plugin_spec.lua or configure plenary to discover specs automatically.
OpenResty and Nginx
Testing code that uses ngx.* APIs requires an nginx context. The busted CLI does not give you that — you need busted running inside the nginx worker process. There are two approaches:
End-to-end test via http — make HTTP requests to a running nginx instance and assert on the response. This is the most reliable approach for OpenResty APIs.
Direct ngx stubbing — if you only need to test pure Lua functions that happen to reference ngx at the top level, stub the ngx global before requiring your module:
before_each(function()
-- stub ngx globals so pure Lua business logic can be tested standalone
_G.ngx = {
say = function() end,
log = function() end,
var = {},
NULL = {}
}
end)
The real answer for complex ngx code is end-to-end tests with a real nginx process.
Integrating with CI
Busted exits with code 0 on success and a non-zero code on failure, which works fine with most CI systems.
For GitHub Actions with Lua, the simplest setup:
- name: Run tests
run: |
luarocks install busted
busted --output=TAP --verbose spec/
The --output=TAP option emits Test Anything Protocol format, which most CI servers understand natively. For JSON output:
busted --output=json --verbose spec/
Exit codes
Busted returns different exit codes for different situations:
0— all tests passed1— one or more tests failed2— test execution error (syntax error in a spec file, for example)
Your CI should treat any non-zero as a failure.
Custom Assertions
Busted’s built-in assertions cover most cases, but you can add your own. An assertion is a function that throws if the condition is not met:
local function assert_positive(value)
if value <= 0 then
error("Expected positive number, got " .. tostring(value), 2)
end
end
describe("Counter", function()
it("increments by a positive amount", function()
local counter = Counter.new()
counter:add(5)
assert_positive(counter:value())
end)
end)
For more complex assertions with readable failure messages, return a table with an __unm metamethod (the unary minus metamethod):
local function assert_valid_user(user)
local mt = {
__unm = function()
return "Invalid user: expected non-empty name and positive id"
end
}
return setmetatable({}, mt)
end
This pattern lets you write assert.has_error with a descriptive message when the assertion fails.
Common Mistakes
Stubbing at module level — if you stub a function at the describe level (outside an it() block), the stub persists for all subsequent tests in that file. Declare stubs inside it() blocks so they clean up automatically:
-- WRONG
describe("Network", function()
stub(network, "fetch") -- lives for entire describe block
it("calls the API", function()
-- ...
end)
end)
-- RIGHT
describe("Network", function()
it("calls the API", function()
stub(network, "fetch"):returns({ body = "ok" })
-- ...
end)
end)
Confusing pending with skipped tests — pending means the test is written but intentionally not implemented yet. It shows up in test output as PENDING. There is no built-in “skip this test” in standard Busted, but you can achieve it with tags and --exclude-tags.
Relying on test order — tests that depend on running after a specific other test will eventually break. Busted can shuffle tests with --shuffle, and --repeat runs them multiple times in random order to surface these dependencies. Use before_each to ensure each test is self-contained.
Not checking auto-insulate behavior — file insulation is on by default, which means each test file runs in a fresh Lua state. If your tests share a fixture file or database, this can cause confusion when the file isolation does not match your expectations of shared state.
See Also
- testing-busted-framework — the full Busted DSL walkthrough: describe blocks, lifecycle hooks, spies, stubs, mocks, and LuaCov setup
- testing-ci-github-actions — setting up automated CI runs for Lua projects with GitHub Actions
- testing-coverage — understanding LuaCov output and configuring coverage thresholds