Introduction to Testing Lua Applications
Testing is a cornerstone of reliable software development, and Lua applications are no exception. Whether you're building game scripts, embedded systems logic, web services with Lapis, or command-line tools, a robust testing strategy ensures your code behaves as expected and remains maintainable over time. Lua's lightweight and flexible nature makes it particularly amenable to testing, but the language's dynamic typing and metatable-based object orientation also introduce unique challenges that a thoughtful testing approach must address.
This tutorial covers everything from foundational unit testing concepts to advanced strategies like mocking, table-driven tests, and integration testing. By the end, you'll have a complete toolkit for building a testing pipeline that scales with your Lua project.
Why Testing Matters in Lua
Lua is dynamically typed, meaning many errors—such as calling a method on nil or passing the wrong number of arguments—only surface at runtime. Without tests, these bugs can lurk until they hit production. A strong test suite catches regressions early, documents expected behavior, and gives you the confidence to refactor.
Additionally, Lua is often embedded in host applications (game engines like LÖVE, Redis, or Nginx). In these contexts, testing logic in isolation before deploying into the host environment saves enormous debugging time. Tests also serve as executable documentation: a new contributor can read the test file and understand how a module is supposed to behave.
Choosing a Testing Framework
Several testing frameworks exist in the Lua ecosystem. The most popular include:
- busted — A BDD-style framework with a rich assertion library and support for mocks and stubs. Great for larger projects.
- luaunit — A lightweight xUnit-style framework that's easy to drop into any project with no external dependencies.
- lust — A minimal, single-file testing library ideal for tiny projects or embedded environments.
- busted's underlying
luassert— Can be used standalone for assertions.
For this tutorial, we'll primarily use busted because of its expressiveness, but we'll also show luaunit for comparison.
Installing busted
Install busted using LuaRocks:
luarocks install busted
Verify the installation:
busted --version
Structuring Your Project for Testability
Before writing tests, organize your project so that modules are easily testable. A common layout:
myapp/
├── src/
│ ├── calculator.lua
│ ├── string_utils.lua
│ └── init.lua
├── spec/
│ ├── calculator_spec.lua
│ └── string_utils_spec.lua
└── .busted
The spec/ directory holds your test files. busted looks here by default. Each module in src/ has a corresponding _spec.lua file. Keeping source and tests separate but mirrored makes navigation intuitive.
Writing Testable Modules
Testable Lua modules return a table of functions rather than relying on global state. Here's a simple, testable module:
-- src/calculator.lua
local calculator = {}
function calculator.add(a, b)
return a + b
end
function calculator.divide(a, b)
if b == 0 then
error("division by zero")
end
return a / b
end
function calculator.factorial(n)
if n < 0 then
error("negative input")
end
if n <= 1 then
return 1
end
return n * calculator.factorial(n - 1)
end
return calculator
Notice that all functions are pure—they take inputs and return outputs without side effects. Pure functions are the easiest to test. When your code must have side effects (file I/O, network calls), isolate those behind small interfaces so you can mock them.
Writing Your First Test with busted
Here's a basic busted spec for the calculator module:
-- spec/calculator_spec.lua
local calculator = require("src.calculator")
describe("calculator", function()
describe("add", function()
it("adds two positive numbers", function()
assert.are.equal(5, calculator.add(2, 3))
end)
it("handles negative numbers", function()
assert.are.equal(-1, calculator.add(2, -3))
end)
end)
describe("divide", function()
it("divides correctly", function()
assert.are.equal(2.5, calculator.divide(5, 2))
end)
it("raises an error on division by zero", function()
assert.has_error(function()
calculator.divide(10, 0)
end, "division by zero")
end)
end)
describe("factorial", function()
it("computes factorial of positive integers", function()
assert.are.equal(120, calculator.factorial(5))
end)
it("returns 1 for 0 and 1", function()
assert.are.equal(1, calculator.factorial(0))
assert.are.equal(1, calculator.factorial(1))
end)
it("raises an error for negative input", function()
assert.has_error(function()
calculator.factorial(-3)
end)
end)
end)
end)
Run the tests:
busted
busted will discover all _spec.lua files, run them, and print a summary. The describe blocks group related tests, while it blocks define individual test cases. The assert library (provided by luassert) offers a wide range of assertions beyond equal, including is_true, is_nil, same (deep equality), and matches (pattern matching).
Table-Driven Tests
When you have many similar test cases, table-driven tests reduce duplication. Instead of writing a separate it block for each input, iterate over a table of cases:
-- spec/calculator_spec.lua (extended)
local calculator = require("src.calculator")
describe("calculator.factorial (table-driven)", function()
local cases = {
{ input = 0, expected = 1 },
{ input = 1, expected = 1 },
{ input = 2, expected = 2 },
{ input = 3, expected = 6 },
{ input = 4, expected = 24 },
{ input = 5, expected = 120 },
{ input = 10, expected = 3628800 },
}
for _, case in ipairs(cases) do
it(("factorial(%d) == %d"):format(case.input, case.expected), function()
assert.are.equal(case.expected, calculator.factorial(case.input))
end)
end
end)
This pattern is especially powerful for edge cases. Each row in the table becomes its own test, so a failure pinpoints exactly which input broke. When you discover a new bug, add a row to the table and you've both documented and protected against it.
Testing Error Handling
Lua uses exceptions via error() and pcall for error handling. busted's assert.has_error wraps pcall to verify that a function raises an error. You can also match the error message:
describe("error handling", function()
it("matches error message exactly", function()
assert.has_error(function()
error("something specific went wrong")
end, "something specific went wrong")
end)
it("matches error message by pattern", function()
assert.has_error(function()
error("User 42 not found in database")
end, "User %d+ not found")
end)
it("asserts no error is raised", function()
assert.has_no_error(function()
local _ = calculator.add(1, 2)
end)
end)
end)
For modules that use pcall internally and return success tuples instead of raising errors, test both branches:
-- src/safe_divide.lua
local M = {}
function M.safe_divide(a, b)
if b == 0 then
return nil, "division by zero"
end
return a / b, nil
end
return M
-- spec/safe_divide_spec.lua
local safe_divide = require("src.safe_divide")
describe("safe_divide", function()
it("returns result on success", function()
local result, err = safe_divide(10, 2)
assert.are.equal(5, result)
assert.is_nil(err)
end)
it("returns nil and error message on failure", function()
local result, err = safe_divide(10, 0)
assert.is_nil(result)
assert.are.equal("division by zero", err)
end)
end)
Setup and Teardown Hooks
busted provides before_each and after_each hooks for common setup and cleanup. These run around every it block within the same describe scope. There are also setup and teardown which run once per describe block.
describe("with setup and teardown", function()
local state
setup(function()
-- Runs once before all tests in this describe block
state = {}
end)
before_each(function()
-- Runs before each test
state.value = 0
end)
after_each(function()
-- Runs after each test
state.value = nil
end)
teardown(function()
-- Runs once after all tests
state = nil
end)
it("starts at zero", function()
assert.are.equal(0, state.value)
end)
it("can be modified without affecting other tests", function()
state.value = 42
assert.are.equal(42, state.value)
end)
it("is reset before each test", function()
assert.are.equal(0, state.value)
end)
end)
Use these hooks to create fresh instances, reset global state, or initialize test databases. Keeping tests isolated from one another is critical—tests should not depend on execution order.
Mocking and Stubbing
Real applications interact with external systems: files, networks, databases, or hardware. In unit tests, you want to avoid these dependencies. busted integrates with luassert mocks and stubs to replace external calls with controlled fakes.
Stubbing a Function
A stub replaces a function with a no-op or a fixed return value. Consider a module that reads configuration from a file:
-- src/config_loader.lua
local io = io -- capture io at module load
local M = {}
function M.load(path)
local file = io.open(path, "r")
if not file then
return nil, "could not open file"
end
local content = file:read("*a")
file:close()
return content
end
return M
To test load without touching the filesystem, stub io.open:
-- spec/config_loader_spec.lua
local config_loader = require("src.config_loader")
describe("config_loader.load", function()
local stub_open, stub_file
before_each(function()
stub_file = {
read = function() return '{"key": "value"}' end,
close = function() end,
}
stub_open = stub(io, "open", function(path, mode)
return stub_file
end)
end)
after_each(function()
stub_open:revert()
end)
it("reads file content", function()
local content = config_loader.load("/fake/path/config.json")
assert.are.equal('{"key": "value"}', content)
assert.stub(stub_open).was_called_with("/fake/path/config.json", "r")
end)
it("returns error when file cannot be opened", function()
stub_open:revert()
stub(io, "open", function() return nil end)
local content, err = config_loader.load("/nonexistent")
assert.is_nil(content)
assert.are.equal("could not open file", err)
end)
end)
The stub function replaces io.open with a custom implementation. After the test, :revert() restores the original. You can also assert that the stub was called with specific arguments using assert.stub(stub_open).was_called_with(...).
Mocking a Module
For more complex scenarios, create a mock object that records interactions:
describe("mocking a logger", function()
local logger
before_each(function()
logger = mock({
info = function() end,
error = function() end,
debug = function() end,
})
end)
it("records calls to info", function()
logger.info("starting up")
logger.info("ready")
assert.spy(logger.info).was_called(2)
assert.spy(logger.info).was_called_with("starting up")
end)
end)
mock wraps a table, turning each function into a spy that records calls. This is useful when your module accepts a dependency via dependency injection.
Dependency Injection Pattern
The cleanest way to make code testable is dependency injection—pass dependencies as parameters rather than hardcoding them. Compare these two approaches:
-- BAD: hardcoded dependency, hard to test
local http = require("socket.http")
local M = {}
function M.fetch_user(id)
local body = http.request("https://api.example.com/users/" .. id)
return body
end
return M
-- GOOD: dependency injection, easy to test
local M = {}
function M.new(http_client)
local self = { http = http_client }
setmetatable(self, { __index = M })
return self
end
function M:fetch_user(id)
return self.http.request("https://api.example.com/users/" .. id)
end
return M
-- spec for the injected version
describe("fetch_user with injected client", function()
local service, fake_http
before_each(function()
fake_http = {
request = function(url) return '{"id": 1, "name": "Alice"}' end
}
service = require("src.user_service").new(fake_http)
end)
it("returns user JSON", function()
local result = service:fetch_user(1)
assert.are.equal('{"id": 1, "name": "Alice"}', result)
end)
end)
With dependency injection, no stubbing of global state is needed. You simply pass a fake object. This leads to faster, more reliable tests.
Testing Object-Oriented Lua
Lua doesn't have built-in classes, but the common pattern uses metatables. Here's a class with methods:
-- src/stack.lua
local Stack = {}
Stack.__index = Stack
function Stack.new()
return setmetatable({ items = {} }, Stack)
end
function Stack:push(item)
table.insert(self.items, item)
end
function Stack:pop()
if #self.items == 0 then
error("stack is empty")
end
return table.remove(self.items)
end
function Stack:size()
return #self.items
end
function Stack:peek()
return self.items[#self.items]
end
return Stack
-- spec/stack_spec.lua
local Stack = require("src.stack")
describe("Stack", function()
local stack
before_each(function()
stack = Stack.new()
end)
describe("push", function()
it("adds items to the stack", function()
stack:push("a")
stack:push("b")
assert.are.equal(2, stack:size())
end)
end)
describe("pop", function()
it("removes and returns the top item (LIFO)", function()
stack:push("a")
stack:push("b")
assert.are.equal("b", stack:pop())
assert.are.equal("a", stack:pop())
end)
it("raises an error when stack is empty", function()
assert.has_error(function()
stack:pop()
end, "stack is empty")
end)
end)
describe("peek", function()
it("returns the top item without removing it", function()
stack:push("x")
assert.are.equal("x", stack:peek())
assert.are.equal(1, stack:size())
end)
it("returns nil for an empty stack", function()
assert.is_nil(stack:peek())
end)
end)
end)
Using before_each to create a fresh stack ensures each test starts from a clean state, which is essential for stateful objects.
Testing Asynchronous Code
If your Lua code uses coroutines or a framework like OpenResty with asynchronous I/O, testing requires special handling. busted supports async tests via done callbacks in certain configurations, but for coroutine-based code, you can often test synchronously by driving the coroutine manually:
-- src/async_task.lua
local M = {}
function M.process_items(items)
local co = coroutine.create(function()
local results = {}
for i, item in ipairs(items) do
coroutine.yield(i, #items) -- yield progress
results[i] = item * 2
end
return results
end)
return co
end
return M
-- spec/async_task_spec.lua
local async_task = require("src.async_task")
describe("process_items coroutine", function()
it("yields progress and returns doubled results", function()
local co = async_task.process_items({1, 2, 3})
local progress_calls = {}
local results
while coroutine.status(co) ~= "dead" do
local ok, current, total = coroutine.resume(co)
if current and total then
table.insert(progress_calls, { current = current, total = total })
elseif ok then
results = current -- final return value
end
end
assert.are.equal(3, #progress_calls)
assert.are.same({2, 4, 6}, results)
end)
end)
For OpenResty and ngx.timer-based async code, consider using resty-cli with a test harness that runs within the OpenResty environment, or extract the pure logic into testable synchronous functions.
Using luaunit as an Alternative
If you prefer an xUnit-style framework or need zero external dependencies, luaunit is an excellent choice. Download the single luaunit.lua file and place it in your project:
-- spec/test_calculator_luaunit.lua
local lu = require("luaunit")
local calculator = require("src.calculator")
TestCalculator = {}
function TestCalculator:testAdd()
lu.assertEquals(calculator.add(2, 3), 5)
lu.assertEquals(calculator.add(-1, 1), 0)
end
function TestCalculator:testDivideByZero()
lu.assertErrorMsgEquals("division by zero", calculator.divide, 10, 0)
end
function TestCalculator:testFactorial()
lu.assertEquals(calculator.factorial(5), 120)
lu.assertEquals(calculator.factorial(0), 1)
end
function TestCalculator:setUp()
-- runs before each test method
end
function TestCalculator:tearDown()
-- runs after each test method
end
os.exit(lu.LuaUnit.run())
Run it with:
lua spec/test_calculator_luaunit.lua
luaunit discovers test methods by the test prefix and supports setUp/tearDown at both class and instance levels. It's ideal for embedded Lua environments where installing busted's dependency tree isn't feasible.
Integration Testing
Unit tests verify individual modules in isolation. Integration tests verify that modules work together correctly. For a Lua web application using Lapis, an integration test might start the app and make HTTP requests:
-- spec/app_integration_spec.lua
local http = require("socket.http")
local app = require("src.app")
describe("app integration", function()
local server_thread
setup(function()
-- Start the app in a background coroutine or process
server_thread = coroutine.create(function()
app.run({ port = 8080 })
end)
coroutine.resume(server_thread)
end)
teardown(function()
-- Gracefully shut down the app
app.stop()
end)
it("responds to GET /health", function()
local body, status = http.request("http://localhost:8080/health")
assert.are.equal(200, status)
assert.are.equal("ok", body)
end)
it("returns 404 for unknown routes", function()
local _, status = http.request("http://localhost:8080/nonexistent")
assert.are.equal(404, status)
end)
end)
Integration tests are slower and more brittle than unit tests, so keep them in a separate directory (e.g., spec/integration/) and run them less frequently. A common CI strategy runs unit tests on every commit and integration tests on merge to main.
Measuring Test Coverage
Coverage tells you which lines of code your tests exercise. The luacov tool is the standard coverage tool for Lua. Install it:
luarocks install luacov
Configure busted to use luacov by creating a .luacov file:
-- .luacov
return {
include = { "src/.*" },
exclude = { "spec/.*" },
runreport = true,
reportfile = "luacov.report.out",
statsfile = "luacov.stats.out",
}
Run busted with coverage:
busted --coverage
After the run, luacov generates luacov.report.out showing per-file coverage percentages and marking uncovered lines. Aim for high coverage on critical business logic, but don't chase 100% blindly—some code (like error paths that are hard to trigger) may not be worth the effort.
Best Practices
1. Write Tests First (TDD) When Practical
Test-Driven Development—writing a failing test before the implementation—helps you design clean interfaces and ensures every line of production code has a corresponding test. For Lua's dynamic nature, TDD is especially valuable because it forces you to think about types and edge cases upfront.
2. Keep Tests Fast
Slow tests discourage running them. Mock external I/O, avoid network calls in unit tests, and keep the test suite under a few seconds. If integration tests are slow, split them out.
3. Test Behavior, Not Implementation
Assert on what a function returns or what side effects it produces, not on how it achieves them. Tests that peek at internal state break every time you refactor. If you find yourself testing private helper functions, consider whether they should be extracted into their own testable module.
4. Name Tests Descriptively
Use full sentences in it blocks: it("returns an error when the input is negative"). When a test fails, the output reads like a specification of what's broken.
5. Isolate Global State
Lua's global table _G is a common source of test pollution. If your code sets globals, reset them in after_each. Better yet, avoid globals entirely—use local everywhere and pass state explicitly.
6. Use package.loaded to Reset Modules
If a module caches state at load time, tests may interfere with each other. Force a reload by clearing package.loaded:
before_each(function()
package.loaded["src.cached_module"] = nil
end)
7. Run Tests in CI
Integrate your test suite into a CI pipeline (GitHub Actions, GitLab CI). Run tests against multiple Lua versions (5.1, 5.2, 5.3, 5.4, and LuaJIT) since behavior can differ. A minimal GitHub Actions workflow:
name: Lua Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
lua_version: ["5.3", "5.4", "luajit"]
steps:
- uses: actions/checkout@v3
- name: Install Lua
uses: leafo/gh-actions-lua@v9
with:
luaVersion: ${{ matrix.lua_version }}
- name: Install LuaRocks
uses: leafo/gh-actions-luarocks@v4
- name: Install dependencies
run: luarocks install busted
- name: Run tests
run: busted
8. Don't Ignore Failing Tests
A red test is a signal, not noise. Either fix the bug or, if the test is genuinely invalid, delete it. Leaving failing tests "for later" erodes trust in the entire suite.
Common Pitfalls
- Testing through
printoutput: Capturing stdout is fragile. Return values from functions and assert on those instead. - Over-mocking: If every test mocks everything, you're testing mocks, not real code. Reserve mocking for true external boundaries (I/O, network, time).
- Ignoring floating-point precision: Use a tolerance-based assertion for floats:
assert.is_true(math.abs(result - expected) < 1e-9). - Forgetting to test
nilinputs: Lua functions often receivenilunexpectedly. Test how your functions handle missing arguments. - Not testing metatable behavior: If your module uses
__index,__newindex, or operator overloading, write tests that exercise those metamethods directly.
Conclusion
Testing Lua applications effectively requires choosing the right framework, structuring modules for testability, and applying strategies like table-driven tests, mocking, and dependency injection to handle real-world complexity. By combining busted or luaunit with luacov for coverage and integrating everything into a CI pipeline, you build a safety net that catches regressions, documents behavior, and gives you the confidence to evolve your codebase. Start small—write tests for your most critical module today—and let the practice grow organically as your project does. The investment pays dividends in fewer bugs, easier refactoring, and a more maintainable codebase for the long haul.