Getting Started with Testing in Lua
Lua ships with no built-in testing framework. That absence is felt quickly once your codebase grows beyond a few hundred lines. Because Lua is dynamically typed and often runs inside larger systems, small mistakes only surface at runtime. Automated tests catch those mistakes before they reach production, and they make refactoring less frightening.
This guide covers the Busted testing framework, the most widely used option for Lua. You will learn to write assertions, structure tests with describe and it blocks, handle setup and teardown, and use stubs and spies. By the end you will have a working test suite for a small Lua module.
Why Manual Testing Has Limits
Before reaching for a framework, it is worth understanding what manual testing costs you.
Lua’s assert() function lets you check conditions inline:
assert(1 + 1 == 2)
assert(type(x) == "string", "x must be a string")
The second argument to assert() is an optional error message that appears when the condition fails. You can also use pcall() to catch errors without crashing your program:
local success, err = pcall(function()
risky_operation()
end)
if not success then
print("Operation failed: " .. err)
end
These tools work for quick checks, but they do not scale. Print statements must be removed before shipping. There is no structured reporting. You cannot run a suite of checks automatically after every code change. That is where Busted comes in.
Installing Busted
Busted is a BDD-style unit testing framework for Lua. It installs via LuaRocks:
luarocks install busted
```lua
Busted requires Lua 5.1 or later and works with LuaJIT and Lua 5.2 through 5.4. Once installed, the `busted` command is available in your terminal.
Busted discovers test files by looking for files that match the pattern `_spec.lua`. A typical project layout puts source code in `lib/` and tests in `spec/`:
lib/ add.lua spec/ add_spec.lua
You can also keep tests in the same directory as your source code, as long as the filename ends with `_spec.lua`.
## Writing Your First Tests
A test file is a Lua module that uses Busted's global `describe` and `it` functions. Here is a simple module:
```lua
-- lib/add.lua
local M = {}
function M.add(a, b)
if type(a) ~= "number" or type(b) ~= "number" then
error("both arguments must be numbers")
end
return a + b
end
return M
And its corresponding test file:
-- spec/add_spec.lua
local add = require("lib.add")
describe("lib.add", function()
describe("add()", function()
it("adds two positive integers", function()
assert.are.equal(5, add(2, 3))
end)
it("adds negative and positive numbers", function()
assert.are.equal(-1, add(2, -3))
end)
it("adds two negative numbers", function()
assert.are.equal(-10, add(-4, -6))
end)
it("raises an error for non-numeric arguments", function()
assert.has_error(function()
add("two", 3)
end)
end)
end)
end)
Run the tests with:
busted spec/add_spec.lua
If a test fails, Busted prints the file, line number, and a diff of the expected versus actual values.
How Assertions Work in Busted
Busted uses luassert for its assertion library. The syntax differs from raw Lua because luassert adds a chainable interface.
Equality and Identity
assert.are.equal(5, add(2, 3)) -- value equality (uses ==)
assert.are.same({ a = 1 }, { a = 1 }) -- deep equality for tables
assert.are_not.equal(4, add(2, 3)) -- negation forms available
are.equal checks value equality. are.same performs a recursive deep comparison, which is useful for tables that should have identical contents.
Truthiness
assert.is_true(result) -- result must be exactly true
assert.is_false(result) -- result must be exactly false
assert.truthy(x) -- x is not nil or false
assert.falsy(x) -- x is nil or false
Note the distinction between is_true and truthy. Lua tables, non-empty strings, and non-zero numbers are truthy but are not true. is_true checks for the exact boolean value.
Nil Checks
assert.is_nil(value)
assert.is_not_nil(value)
Error Handling
assert.has_error(fn) -- fn must throw any error
assert.has_error(fn, "must be a number") -- error message must match exactly
assert.has_error(fn, "number") -- or match as a Lua pattern
String and Table Comparisons
assert.contains("hello world", "world") -- string contains or table includes
assert.matches("^hello", "hello world") -- Lua pattern match
Organizing Tests with Nested Describes
describe blocks group related tests. You can nest them to model your module’s structure:
describe("Calculator", function()
describe("add()", function()
it("returns the sum of two numbers", function()
assert.are.equal(5, add(2, 3))
end)
end)
describe("divide()", function()
it("raises an error when dividing by zero", function()
assert.has_error(function() divide(1, 0) end)
end)
end)
end)
Naming your describe blocks after your module and functions makes test output readable. When a test fails, you see exactly which function misbehaved.
Setup and Teardown
Tests often need preconditions. A database connection must exist before you can test queries. A file must be created before you test reading it. Busted provides four hooks for this:
describe("Database", function()
local db
setup(function()
-- Runs once before all tests in this block
db = open_connection("test.db")
end)
teardown(function()
-- Runs once after all tests in this block
db:close()
end)
before_each(function()
-- Runs before each individual test
db:clear()
end)
after_each(function()
-- Runs after each individual test
-- useful for resetting global state
end)
it("inserts a record", function()
db:insert("users", { name = "Alice" })
assert.is_not_nil(db:get("users", 1))
end)
end)
Use setup and teardown for expensive operations that should run once. Use before_each and after_each when each test needs a clean state.
Pending Tests
Mark tests as incomplete with pending():
it("should parse JSON configuration files")
pending("waiting for a JSON library")
it("validates email format", function()
pending("regex library not available yet")
-- test code here never runs while pending
assert.matches("[^@]+@[^@]+", email)
end)
Any it block with a description string but no function body is automatically treated as pending.
Running Tests from the Command Line
The busted CLI runs your test suite. The default behavior recursively finds all _spec.lua files:
busted -- run everything
busted spec/ -- run only files in spec/
busted spec/my_spec.lua -- run one file
```lua
Useful flags:
| Flag | What it does |
|------|-------------|
| `-v, --verbose` | Show detailed output including test names |
| `--filter=PATTERN` | Run only tests whose names match a Lua pattern |
| `--exclude-tags=TAGS` | Skip tests with given tags |
| `--shuffle` | Randomize test order |
| `--seed=SEED` | Reproducible random order |
| `--coverage` | Generate a coverage report (requires LuaCov) |
You can filter down to a single test by name:
```bash
busted --filter="adds two positive"
This is useful when debugging one failing test without running the whole suite.
Stubbing and Spying
Sometimes you need to isolate a unit of code by replacing its dependencies. Busted provides stub() for this.
Stubbing a Function
it("returns mock data", function()
local my_mock = stub():returns("mocked value")
assert.are.equal("mocked value", my_mock())
end)
Stubbing an Existing Function
it("uses stubbed os.getenv", function()
stub(os, "getenv", function() return "1" end)
assert.is_true(check_env())
end)
The original function is restored automatically after the test.
Stubbing a Module with package.preload
When a module is loaded via require, you can substitute it using package.preload:
describe("Network", function()
setup(function()
package.preload["lib.http"] = function()
return { get = function() return "fake response" end }
end
end)
teardown(function()
package.preload["lib.http"] = nil
end)
it("makes a request", function()
local http = require("lib.http")
assert.are.equal("fake response", http.get())
end)
end)
This technique is particularly useful when testing code that makes HTTP requests or reads files, because it lets you test the logic without I/O.
Using Spies
A spy records calls to a function without replacing its behavior:
it("records calls to greet", function()
local obj = { greet = function(name) return "Hi " .. name end }
spy.on(obj, "greet")
obj.greet("Alice")
obj.greet("Bob")
assert.spy(obj.greet).was.called()
assert.spy(obj.greet).was.called_with("Alice")
assert.spy(obj.greet).was.called_with("Bob")
assert.spy(obj.greet).was.called(2)
end)
Spies are useful for verifying that a function was called with the right arguments, especially in callbacks or event handlers.
Configuring Busted with .busted
A .busted file in your project root sets default options for every run:
return {
_all = {
verbose = true,
coverage = false
},
default = {
["suppress-pending"] = true
}
}
Store this file in version control so your whole team runs tests the same way.
Conclusion
You now have the essentials for writing and running tests in Lua. Busted gives you a readable structure, expressive assertions, and useful tools like stubs and spies. Start with a single _spec.lua file for one module, run it with busted, and grow your coverage from there.
Good tests pay off most when you need to change code. They tell you immediately when something breaks. Even a small suite of tests prevents the kind of subtle regressions that are hardest to find manually.
See Also
- Error Handling Basics in Lua — Learn how Lua’s error model works and why
pcallmatters for safe error recovery. - Functions in Lua — Scope, closures, and multiple return values explained.
- Tables Intro — The core data structure behind Lua’s tables, arrays, and objects.