Getting Started with Testing in Lua
Lua ships with no built-in testing framework, so getting started with testing means choosing a tool first. 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.
Prerequisites
You need a Lua interpreter (5.1 or later, or LuaJIT) and LuaRocks to install Busted. No prior testing experience is required, but you should be comfortable writing basic Lua modules with require and function return values. If you are new to error handling in Lua, the Error Handling Basics tutorial covers pcall and error patterns that appear throughout testing code.
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. While assert() halts execution on failure, Lua also provides pcall() (protected call) to catch errors without crashing your program. The key difference is that assert is for validating invariants during development, while pcall lets you handle expected runtime failures gracefully in production code:
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
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. The convention that makes Busted easy to adopt is its automatic test discovery: any file named _spec.lua is treated as a test file. This means you can add tests without maintaining a separate test registry or configuration file. 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. This flexibility lets you colocate tests with the modules they cover, which some teams prefer because it keeps related files together. Regardless of which layout you pick, the workflow is the same: write your module, create a corresponding _spec.lua file, and run busted to verify everything works.
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:
-- 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
The add function is deliberately simple: it validates its inputs and returns a sum. Type checking with type() is a common Lua pattern because the language does not enforce parameter types at call sites. Always validate inputs in functions that will be called from multiple places; the error message you include here becomes the failure message in your test output, so make it descriptive.
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)
The test file follows a pattern you will use for every module: require your module at the top, then use nested describe blocks to mirror the module’s structure. Each it block tests one specific behavior with a descriptive name. Covering both normal inputs and error cases gives you confidence that the module handles edge conditions correctly.
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. On success, you see a green summary showing the number of assertions that passed. This immediate feedback loop is the whole point: change some code, run busted, and know within seconds whether you broke anything.
How assertions work in Busted
Busted uses luassert for its assertion library. The syntax differs from raw Lua because luassert adds a chainable interface. Instead of calling standalone functions, you build assertion chains that read like sentences: assert.are.equal(expected, actual) tells you exactly what is being checked at a glance.
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 with the == operator, which works for numbers, strings, and booleans. are.same performs a recursive deep comparison that walks through every key and value in a table. When two tables have identical structures but are different objects in memory, are.equal fails while are.same passes. Use are.same whenever you need to verify that a function returned the right table contents rather than the exact table reference.
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 — it trips up newcomers regularly. Lua tables, non-empty strings, and non-zero numbers are truthy but are not true. If a function returns a table on success and nil on failure, use truthy to verify the success case. Use is_true only when the function is explicitly documented to return the boolean true.
Nil Checks
assert.is_nil(value)
assert.is_not_nil(value)
Nil checks are surprisingly common in Lua testing because so many Lua functions return nil to signal absence: failed lookups, missing keys, empty search results. A function that returns a value or nil depending on whether something exists should have tests for both paths.
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
Testing error paths is just as important as testing happy paths. The has_error assertion wraps your function call and catches the error. Passing a second argument checks the error message: an exact string match verifies the full message, while a Lua pattern lets you check for a partial match. The pattern variant is useful when error messages include dynamic content like filenames.
String and table comparisons
assert.contains("hello world", "world") -- string contains or table includes
assert.matches("^hello", "hello world") -- Lua pattern match
These two assertions serve different needs. contains checks for simple substring presence in strings or value membership in tables. It answers “is this thing in there?” matches evaluates a Lua pattern against a string, giving you the same expressive power as string.match. Use matches when you need to verify string formats, like checking that an error message starts with a specific prefix.
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, Busted prints the full describe → describe → it chain so you see exactly which function misbehaved without opening the test file. Deep nesting works best when your module has a clear hierarchy — for flat utility modules, a single describe level is perfectly fine.
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 per describe block, like opening a database connection or creating a temporary directory. Use before_each and after_each when each individual test needs a completely clean slate — for example, clearing a table before inserting test rows so no test’s data leaks into the next one. Getting this distinction right prevents the most common source of flaky tests: shared mutable 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. Busted reports pending tests in the summary output so they are visible reminders of unfinished work; your suite passes, but you can see exactly which tests still need to be written.
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
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:
busted --filter="adds two positive"
This is useful when debugging one failing test without running the whole suite. The filter accepts Lua patterns, so --filter="add" matches any test whose full describe chain contains “add” anywhere. Combined with --shuffle and --seed, you can reproduce random test-ordering bugs deterministically, which is essential when tracking down tests that only fail in a specific sequence.
Stubbing and Spying
Sometimes you need to isolate a unit of code by replacing its dependencies. Busted provides stub() for this.
Stubbing a Function
Creating a standalone stub with stub():returns() gives you a function you control completely. This is the simplest form of stubbing: you create a fake function that returns exactly what you tell it to, then pass it into the code under test instead of the real dependency.
it("returns mock data", function()
local my_mock = stub():returns("mocked value")
assert.are.equal("mocked value", my_mock())
end)
Stubbing an existing function
Stubs are most valuable when applied to existing functions that your code depends on but you do not want to call during tests. Replacing os.getenv with a stub lets you control what environment variables the code sees without touching the real environment, which is essential for deterministic test runs in CI pipelines.
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. Busted tracks every stub you create and reverts them during teardown, so you never need to manually save and restore function references. This cleanup happens even if the test fails, which prevents stubs from leaking into other tests and causing confusing cascading failures.
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. By preloading a fake module before the code under test calls require, you intercept the dependency at the loading stage. The key detail is resetting package.preload in teardown — otherwise subsequent tests that load the same module get your fake instead of the real one.
Using Spies
A spy records calls to a function without replacing its behavior. Unlike stubs, spies let the original function run normally while tracking every call made to it: the arguments passed, the number of invocations, and the calling order. Spies are the right tool when you need to verify that a callback was triggered, that an event handler fired the expected number of times, or that a function was called with specific parameters without changing what that function does:
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. They are also valuable for confirming that a function was called a specific number of times, for instance, ensuring a rate limiter only allows three requests before rejecting the fourth.
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.
Next steps
Now that you can write and run tests, put them to work on a real project. The Testing with Busted Framework tutorial covers advanced Busted features like custom matchers, test tagging, and integrating with LuaCov for coverage reports. For testing code with external dependencies, Testing Mocks and Stubs goes deeper into the stubbing patterns introduced here.
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.