Testing Lua Code with Busted: A Practical Guide
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 testing Lua code in a realistic scenario.
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 — the --pattern flag matches against the file name, not the directory path. This is useful when you have a naming convention that groups related specs together. For example, all files containing “user” in the name will be picked up regardless of which subdirectory they sit in. Unlike the path-based approach above, pattern matching gives you cross-directory selection without needing to move files around or restructure your spec layout. It also means you can add new spec files that match the pattern later and they will be included automatically without updating CI scripts.
busted --pattern="user" # runs *_spec.lua files with "user" in the name
By test name — --filter operates at a finer granularity than --pattern: it matches against the concatenated string of the describe and it labels, not the file name. This matters when one spec file contains tests for multiple behaviours and you only want to exercise a subset. If you have a file called user_spec.lua that contains describe("User", ...) with tests like it("validates email", ...) and it("hashes password", ...), you can run just the email test without touching the password logic. The filter is applied after Busted has already discovered and loaded spec files, so it narrows an already-selected set rather than restricting file discovery.
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. This is the most surgical option available: Busted first limits file discovery to the given path, then applies the filter to tests found in those files. The combination is particularly handy when you have test files that share similar test names across different modules—a describe("add", ...) in calculator_spec.lua versus one in shopping_cart_spec.lua. Specifying both a file path and a filter eliminates the ambiguity and guarantees you are running exactly the tests you intend to iterate on. During active development, this two-level narrowing is often the fastest feedback loop.
busted spec/unit/calculator_spec.lua --filter="add"
By tag: tag tests to create logical groups independent of file names or test descriptions. Tags offer a different kind of filtering that works orthogonally to the path, pattern, and filter mechanisms. You define tags inside the test definitions themselves—often as a second argument to describe or it—and then select which tags to include or exclude at the command line. This separation means you can reorganise your spec files without touching the filtering logic. A common setup uses :unit and :integration tags to distinguish fast in-memory tests from slower tests that require external services or databases. The --exclude-tags flag is especially useful in CI when you want to skip tests marked with :flaky or :slow without modifying the test files.
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. By tagging just the test you are currently working on and running busted --tags=focus, you get single-test isolation without having to remember the exact filter string or file path. This approach also works well in teams: multiple developers can each have their own :focus tests active simultaneously without stepping on each other’s filter selections. Just remember to remove the :focus tag before committing, or your CI pipeline will run only that one test and miss everything else. Some teams add a pre-commit hook that greps for :focus to prevent this mistake.
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. Unlike test frameworks that spawn a fresh process per file, Busted keeps everything in one Lua VM. This design gives you fast startup times but puts the burden of isolation squarely on you. 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. This is the standard Busted pattern for test isolation. The function you pass to before_each runs immediately before each it block inside the same describe scope, in the order they are declared. Crucially, any variable you assign inside before_each (like cache above) is visible to the test body because both run in the same closure scope. This scoping behaviour is a Lua property, not a Busted invention: the before_each callback and the it callback share the same upvalues. Whenever you see mutable state in a describe block, ask yourself whether that state should survive between tests. If the answer is no, move the initialisation into before_each.
-- 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 because Busted guarantees the callback runs before every individual test, even if you run tests in a different sequence or with --shuffle enabled. Getting the isolation right early saves hours of debugging flaky tests later. A single test that depends on its predecessor’s side effects can pass locally a hundred times and then fail in CI when the runner decides on a different execution order.
When to use before_each versus setup
setup runs once before the first test in a describe block, while before_each resets state before every single test. When your setup is cheap and the state might have been modified by a previous test, before_each is the safer default. Reserve setup for expensive one-time operations like loading a module or establishing a database connection—work you want to do exactly once and then reuse across all tests in that describe scope. The two hooks compose naturally, which leads to a common pattern for tests that interact with real, non-mocked modules.
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. When you have twenty or thirty spec files all sitting at the same level, it becomes hard to know which tests are fast unit checks and which ones require external setup. A layered directory structure solves this by grouping tests by their role rather than by the module they exercise. Each subdirectory tells you something about the test’s runtime characteristics and dependencies: unit tests run in milliseconds with no external services, integration tests may need a database or network, and helper modules provide shared fixtures without being picked up as tests themselves. A layout that works well for most projects:
spec/
unit/
calculator_spec.lua
validator_spec.lua
integration/
api_spec.lua
helpers/
test_db.lua
factories.lua
Then run specific subdirectories in CI. This directory structure maps directly to your CI pipeline stages. The spec/unit/ suite should be fast enough to run on every push—typically a few seconds for a hundred or so pure-Lua tests. The full spec/ run including integration tests belongs on pull requests and merges, where a longer runtime is acceptable. Keeping your helpers and factories in a separate helpers/ directory rather than scattering them across spec files makes it clear which code is test infrastructure and which is test logic. Busted will not pick up helper files automatically since they do not match the *_spec.lua pattern, so you must require them explicitly from your spec files.
# Fast unit tests for every commit
busted spec/unit/
# Full suite on merge
busted spec/
Environment-Specific Testing
Not all Lua code runs in the same environment. The standard Busted setup assumes a plain Lua interpreter with access to the filesystem and standard libraries, but real-world Lua projects often target embedded runtimes like Neovim or OpenResty where the environment is fundamentally different. Testing code in these contexts requires understanding what APIs are available and how to either provide them or work around their absence.
Neovim
If you are testing Neovim configuration code, load the plenary library’s busted helper which provides the describe and it globals. Plenary bridges the gap between Neovim’s Lua runtime and Busted’s test framework, giving you access to the full vim.api surface inside your test callbacks. This means you can manipulate buffers, set options, and call Neovim-specific functions directly in your assertions, just as you would in plugin code.
-- 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. TAP is a line-oriented format that has been the standard for Perl testing since the late 1990s and is supported by virtually every CI platform, including GitHub Actions, GitLab CI, Jenkins, and TeamCity. Each test result appears as either ok N (pass) or not ok N (fail), with optional diagnostic lines prefixed by #. This simplicity makes TAP output easy to parse with standard Unix tools like grep and awk if you ever need to post-process results outside of a CI environment. For JSON output, which is more convenient when you want to feed results into a dashboard or notification system, Busted provides a structured alternative:
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. Exit code 2 is worth paying attention to separately: a syntax error in a spec file means your tests never ran at all, which is a different category of problem from a test that ran and failed. Setting up your CI to surface this distinction—perhaps by parsing the TAP output for the specific error count versus failure count—helps you triage build results faster.
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. Writing custom assertions keeps your test bodies readable by moving verbose condition checks into named functions that communicate intent. When a custom assertion throws, Busted catches the error and reports it as a test failure with the error message you provided. This approach works with any Lua function—no special Busted API required—as long as the function raises an error on failure.
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 that need to produce readable failure messages, Busted provides a convention based on the __unm metamethod (the unary minus operator in Lua). When you return a table with an __unm metamethod, Busted calls that metamethod to generate the failure description if the assertion does not pass. This pattern separates the assertion logic from the error message formatting, keeping both concerns clean. The metamethod approach is especially useful when you have assertions that validate complex data structures—you can describe exactly which field failed and why, rather than dumping a generic “assertion failed” message.
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
- Error Handling Patterns: using
pcallandxpcallto catch and report errors, complementary to test assertions - Dependency Management: managing LuaRocks dependencies and project structure for testable codebases