Using the Busted Test Framework
Busted is a behaviour-driven development (BDD) testing framework for Lua. It gives you a readable DSL for writing tests, automatic test discovery, mocking and spying utilities, and built-in support for code coverage. If you are writing Lua code that matters, the Busted test framework is the tool you reach for.
Prerequisites
You need LuaRocks, the Lua package manager, and a working Lua interpreter (5.1 or later). Familiarity with basic Lua concepts — tables, functions, modules, and error handling — is assumed. No prior testing experience is required; this tutorial starts from first principles and builds up to advanced features like mocks and custom configuration. Install busted before proceeding:
luarocks install busted
Busted supports Lua >= 5.1, Moonscript, LuaJIT >= 2.0, and Terra. After installation, run busted --version to confirm it is available.
Writing your first spec
Busted discovers test files by looking for files matching the pattern *_spec.lua (configurable). Create a file named calculator_spec.lua:
describe("Calculator", function()
describe("add", function()
it("adds two positive numbers", function()
local result = add(2, 3)
assert.are.equal(5, result)
end)
it("returns the first argument when the second is zero", function()
assert.are.equal(7, add(7, 0))
end)
end)
end)
Run it with busted. Busted recursively finds every *_spec.lua in the current directory by default.
The describe() function groups related tests. It takes a label and a function that contains it() blocks. The it() function defines an individual test case. You can nest describe() blocks as deeply as you need, which is useful for mirroring the structure of your application.
Lifecycle hooks
Real tests need setup and teardown. Busted provides four hooks:
setupruns once before the first test in the blockteardownruns once after the last test in the blockbefore_eachruns before every testafter_eachruns after every test
describe("Database", function()
setup(function()
db = connect_to_test_database()
end)
teardown(function()
db:close()
end)
before_each(function()
db:clear_all_tables()
end)
describe("Users", function()
it("inserts a new user", function()
db:insert("users", { name = "Alice" })
assert.are.equal(1, db:count("users"))
end)
end)
end)
before_each and after_each bubble down into nested describe() blocks automatically. If you declare a before_each at an outer level and another at an inner level, both run before each inner test, in order from outer to inner. This cascading behaviour lets you layer common setup at higher levels while keeping test-specific preparation close to the cases that need it.
Pending tests
Mark a test as work-in-progress by omitting its body, or call pending() inside:
it("handles concurrent inserts")
it("recovers from a corrupted table", function()
pending("storage layer not ready yet")
end)
Busted shows these as PENDING rather than passing or failing. This lets you commit skeleton tests without breaking your suite, which is especially useful when planning out a feature before the implementation exists. Tags let you mark tests by category. The convention is to prefix tags with a colon:
describe("Slow integration tests", {":slow"}, function()
it("runs the full pipeline", function()
-- ...
end)
end)
it("quick validation", {":focus"}, function()
-- ...
end)
Tags are declared as the second argument to describe() or it(), and you can attach multiple tags to a single block by listing them all in the table. This tagging system is the primary way to organise tests by category — unit, integration, slow, fast, and so on — without needing separate directories or naming conventions. Once your tests are tagged, busted’s CLI options let you select exactly which subset to execute for a given test run. The --tags flag accepts a comma-separated list and runs any test carrying at least one matching tag.
Run only :slow tests:
busted --tags=slow
The --exclude-tags flag does the opposite, which is particularly useful for CI pipelines where you want to run the fast suite on every commit but reserve the slow integration tests for a nightly build or a dedicated workflow trigger:
busted --exclude-tags=slow
Combining multiple tags with AND logic means a test must carry every listed tag to be selected. This composability is what makes tags more flexible than file-based filtering alone. You can layer :focus on a specific test during development and run busted --tags=focus to isolate it without touching any other test files or configuration.
busted --tags=unit,fast
The AND logic means only tests that carry both :unit and :fast tags will run. This composability is what makes tags more flexible than file-based filtering alone. You can layer :focus on a specific test during development and run busted --tags=focus to isolate it without touching any other test files or configuration.
:focus is handy during development to isolate a single test. Just tag it and run with --tags=focus.
Spying on functions with spy.on()
A spy wraps an existing function and records every call without changing what the function does. Use spy.on():
it("calls greet with the right name", function()
local s = spy.on(greeter, "greet")
greeter.greet("Lua")
greeter.greet("Busted")
assert.spy(s).was.called()
assert.spy(s).was.called_with("Lua")
assert.spy(s).call_count(2)
end)
The spy.on() call returns a handle that you can pass to assert.spy() for verification. The original function continues to execute normally — only the call metadata is recorded. This makes spies ideal for checking that callbacks fire, that logging functions receive the expected arguments, or that a particular code path is reached during a test run. Spies are the right tool when you want to observe behaviour without altering it.
The match module provides helpers for flexible assertions that go beyond exact-value matching. You can validate argument types, patterns, or wildcards instead of literal strings and numbers:
assert.spy(s).was.called_with(match._, "Alice") -- first arg can be anything
The match._ wildcard accepts any value, which is useful when you care about some arguments but not others. Busted also provides match.is_number(), match.is_table(), match.is_not(), and other matchers that let you write assertions against the shape of arguments rather than exact values. This flexibility is especially valuable when testing functions that receive tables or callbacks as parameters.
Spies clean themselves up automatically after the test ends, so you never need to worry about manual restoration.
Replacing functions with stub()
A stub goes further than a spy: it replaces the function entirely. Use stub() when you need to control the return value or simulate an error:
it("returns the configured value", function()
stub(network, "fetch"):returns({ body = "fake response" })
local result = network.fetch("http://example.com")
assert.are.equal("fake response", result.body)
end)
it("raises an error when the network is down", function()
stub(network, "fetch"):raises("connection refused")
assert.has_error(function() network.fetch("http://example.com") end, "connection refused")
end)
Stubs are scoped to the test that creates them and unwrap automatically after the test ends. Declare stubs inside it() blocks, not at module level, or they will leak into other tests. Each stub is registered in the test runner’s cleanup queue, so even if your test fails partway through, the stubbed function gets restored before the next test begins.
Conditional stubs let you return different values for different arguments:
stub(config, "get")
:with("debug"):returns(true)
:with("timeout"):returns(30)
:with(match._):returns(nil) -- default
The .with() method chains a specific argument value to a return value, and the final match._ acts as a catch-all fallback. This is the same idea as pattern matching in a function — you define specific cases first, then a default at the end. Conditional stubs are especially useful for configuration objects and service locators where the same function call should behave differently depending on the key or parameter passed in.
Mocking modules with package.preload
When the code under test require()s a dependency, you can replace it using package.preload. This is the idiomatic way to mock modules in Lua:
it("fetches data from the HTTP module", function()
local mock_http = {
fetch = spy.new(function(url) return { body = "ok" } end)
}
package.preload["http"] = function() return mock_http end
local service = require("service") -- gets our mock
local result = service.fetch_data("http://example.com")
assert.are.equal("ok", result.body)
end)
Because package.preload affects subsequent require() calls, this pattern works cleanly when each test loads the module afresh. The mock is delivered to the code under test through Lua’s normal module resolution, so the tested code doesn’t need any special dependency injection support. Just be aware that modules already cached in package.loaded won’t pick up the preload override — you may need to clear the cache key first if the module was loaded earlier in the suite.
Running tests from the command line
Busted has flexible options for selecting which tests to run. Here is a quick reference of the most commonly used flags:
busted -- all *_spec.lua recursively
busted spec/unit/ -- only files in spec/unit/
busted --pattern="user" -- files matching "user" in the filename
busted --filter="add" -- tests whose full name contains "add"
busted --filter-out="slow" -- skip tests whose name contains "slow"
busted --exclude-tags=integration -- skip tests tagged :integration
busted --shuffle -- randomize test order
busted --seed=12345 -- reproducible random order
busted --repeat=5 -- run the suite 5 times
busted --verbose -- detailed output
busted --coverage -- enable LuaCov coverage
busted --lua=/usr/local/bin/lua5.4 -- use a specific interpreter
The --filter option matches against the full concatenated name, which looks like "Calculator add adds two positive numbers". This lets you isolate tests precisely without modifying any test files. Combining --filter with --shuffle and --seed is a useful technique for catching order-dependent test failures that only surface intermittently.
Configuration with .busted
Busted reads a .busted file in the current directory. It is a Lua table that defines named task profiles, each with its own set of options. Think of it as saved command-line presets that your team can share through version control.
return {
_all = {
coverage = false
},
default = {
verbose = true,
["suppress-pending"] = true,
pattern = "_spec"
},
ci = {
coverage = true,
verbose = true,
["exclude-tags"] = "slow"
},
unit = {
tags = "unit",
ROOT = { "spec/unit" }
}
}
Note that keys with hyphens must use bracket notation, as in ["suppress-pending"], because Lua table keys with hyphens cannot be accessed with dot syntax. Options are the long CLI names, not short flags. You can run a named task profile with:
busted --run=ci
Without --run, the default task applies. The _all block is merged into every task automatically, so you can set project-wide defaults there while keeping environment-specific overrides in the named profiles. The ROOT key lets you scope a profile to specific directories, which is handy when you have separate unit, integration, and end-to-end test folders all in the same repository.
Code coverage
Busted integrates with LuaCov to give you line-level coverage data without a separate test harness. Install LuaCov first using LuaRocks:
luarocks install luacov
Once LuaCov is installed, the --coverage flag becomes available in busted. Running your suite with this flag instruments every loaded Lua file and records which lines were hit during test execution. The coverage data is collected transparently while your tests run, so there’s no separate instrumentation step to remember:
busted --coverage
This produces luacov.report.out with a textual summary and luacov.report.json for programmatic consumption. If you need a machine-readable format that integrates with common CI dashboards, add a Cobertura reporter to a luacov.lua config file in your project root. For CI systems that understand Cobertura XML — Jenkins, GitLab CI, and most coverage dashboards — add this:
return {
["Cobertura"] = "./luacov-cobertura.xml"
}
Lines inside blocks marked with --luacov-disable or coverage = false are excluded from coverage, which is useful for boilerplate that is genuinely hard to test. Use these escapes sparingly — they’re for module-level configuration tables and one-time setup scripts, not for skipping code you haven’t gotten around to writing tests for.
Conclusion
Busted gives Lua developers a proper testing framework with a clean DSL, powerful mocking utilities, and flexible filtering. Start with describe and it blocks, add lifecycle hooks for setup and teardown, reach for spy.on() when you want to observe behaviour without changing it, and use stub() when you need to replace a function’s implementation. Configure your CI run with a .busted file and add --coverage to track line-level testing completeness over time.
Next steps
- Measuring code coverage in Lua — use LuaCov with busted to track which lines your tests exercise
- Test-driven development patterns in Lua — apply the red-green-refactor cycle using busted assertions
- Mocks and stubs in Lua — deeper patterns for isolating code with test doubles
See also
- Getting started with testing in Lua — introduction to testing concepts before diving into Busted
- Error handling basics — how Lua errors interact with assertion functions and test failure
- Functions in Lua — closures and higher-order functions that underpin hooks and the BDD DSL