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, Busted is the tool you reach for.
Installing Busted
You need LuaRocks, the Lua package manager. Install Busted with a single command:
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:
setup— runs once before the first test in the blockteardown— runs once after the last test in the blockbefore_each— runs before every testafter_each— runs 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.
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.
Tags for Selective Running
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)
Run only :slow tests:
busted --tags=slow
Skip :slow tests (useful in CI to keep the fast suite quick):
busted --exclude-tags=slow
You can combine multiple tags with AND logic:
busted --tags=unit,fast
: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 match module provides helpers for flexible assertions:
assert.spy(s).was.called_with(match._, "Alice") -- first arg can be anything
Spies clean themselves up automatically after the test ends.
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.
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
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.
Running Tests from the Command Line
Busted has flexible options for selecting which tests to run:
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.
Configuration with .busted
Busted reads a .busted file in the current directory. It is a Lua table:
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: ["suppress-pending"]. Options are the long CLI names, not short flags. Run a named task with:
busted --run=ci
Without --run, the default task applies. The _all block is merged into every task automatically.
Code Coverage
Install LuaCov separately:
luarocks install luacov
Then run with the --coverage flag:
busted --coverage
This produces luacov.report.out with a summary and luacov.report.json for machine consumption. For CI systems that understand Cobertura XML, add this to a luacov.lua config:
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 hard to test.
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.
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