Test-Driven Development Patterns in Lua
Test-driven development is a discipline that flips the usual approach to writing code. Instead of writing the code first and adding tests afterward, you let the tests guide what you build. You write a failing test, you write just enough code to make it pass, and then you clean up without breaking anything. The tests become a safety net that makes refactoring safe.
This article is part five of the lua-testing series. By now you should be comfortable with busted’s describe and it blocks, basic assertions, and running tests from the command line. If any of that is unfamiliar, the earlier articles in this series cover those foundations. Here we’re moving from “how to test” to “how to develop through testing.”
The Red-Green-Refactor Cycle
TDD lives and dies by a three-step cycle:
- Red — Write a test that describes the behaviour you want. The test must fail. If it passes immediately, you are not doing TDD; you are just writing tests after the fact.
- Green — Write the smallest possible amount of code that makes the test pass. Resist the urge to add features while you are in this phase. The goal is to get to green as fast as possible.
- Refactor — Clean up the code. Extract duplication, rename things, improve structure. The tests keep you honest because they catch any behaviour you accidentally change.
This cycle repeats for every single piece of functionality you add. The loop is short — often a matter of minutes — and it forces you to stay focused on exactly what the code needs to do right now, rather than what you imagine it might need later.
The discipline sounds simple, but it rewires how you think about design. When you cannot write a test for a behaviour, that is usually a sign that the design needs work, not that testing is hard.
Structuring Tests with Arrange-Act-Assert
Every test you write has three logical phases, and keeping them separate makes tests easier to read and debug.
Arrange sets up everything the test needs — creating objects, seeding data, configuring state. Act is the single function call you are testing. Assert checks the outcome.
describe("Invoice", function()
describe("total", function()
it("sums line items", function()
-- Arrange
local invoice = Invoice.new()
invoice:add_item("Widget", 10.00, 2)
-- Act
local total = invoice:total()
-- Assert
assert.are.equal(20.00, total)
end)
end)
end)
Busted puts the expected value second in assert.are.equal(expected, actual), which reads naturally in English — “assert that total equals 20.” Keep the Act phase to a single function call. If you find yourself asserting on multiple separate calls, consider whether the test is doing too much or whether the module has too many responsibilities.
Use before_each to handle the Arrange phase automatically for every test in a describe block. This keeps individual tests clean and ensures each one runs with fresh state.
Managing Test Fixtures
before_each and after_each are the standard hooks for test fixtures in busted. Use before_each to create fresh instances before each test, and after_each for cleanup that must run regardless of whether the test passed or failed.
describe("TaskList", function()
local task_list
before_each(function()
task_list = TaskList.new()
end)
it("is empty initially", function()
assert.are.equal(0, task_list:count())
end)
it("contains added tasks", function()
task_list:add("Write tests")
assert.are.equal(1, task_list:count())
end)
end)
Each test gets its own task_list instance. If one test modifies the list, the next test is unaffected.
Busted also provides setup and teardown, which run once per describe block rather than once per test. Use them for expensive setup — opening a database connection, loading a config file — that you want to share across a group of related tests. The rule of thumb: before_each for cheap per-test state, setup for expensive once-per-group setup.
Dependency Injection in Lua
Testing is much easier when your code is honest about what it depends on. Instead of reaching for global modules with require(), pass dependencies explicitly as function arguments or constructor parameters.
-- Hard to test: hides the db dependency behind require()
local function find_user(id)
local db = require("db")
return db:query("SELECT * FROM users WHERE id = ?", id)
end
-- Easy to test: dependency is explicit
local function find_user(id, db)
return db:query("SELECT * FROM users WHERE id = ?", id)
end
-- Pass a fake db in the test
local fake_db = {
query = function(self, sql, id)
return { id = id, name = "Alice" }
end
}
local user = find_user(1, fake_db)
assert.are.equal("Alice", user.name)
Constructor injection — passing the database to new() — is idiomatic in Lua when you are building objects. The TaskList.new(db) pattern from the fixture section above is the same idea.
For modules that you cannot refactor to accept injected dependencies, the mocks article in this series covers how to override package.loaded to swap in test doubles.
Stubbing File I/O
File operations are among the most common things to stub in Lua. The key is overriding io.open for the duration of the test and restoring it afterward.
describe("config loader", function()
local original_open = io.open
after_each(function()
io.open = original_open
end)
it("reads a config file", function()
io.open = function(path, mode)
if path == "config.json" then
local fake_file = {
read = function(self, fmt) return '{"port": 8080}' end,
close = function(self) end
}
return fake_file
end
return original_open(path, mode)
end
local config = load_config("config.json")
assert.are.equal(8080, config.port)
end)
end)
Using after_each for restoration means the stub never leaks into other tests, even if a test fails partway through. For simpler cases where you just need the call to raise an error, busted’s stub() interface is cleaner:
it("raises when the file is missing", function()
stub(io, "open"):raises("No such file or directory")
assert.has_error(function()
load_config("missing.json")
end)
end)
Testing Error Conditions
Assert that your code raises errors when given bad input. Busted provides assert.has_error() and assert.has_no.error() for this purpose.
it("raises on invalid input", function()
assert.has_error(function()
validate_email("not-an-email")
end)
end)
it("raises with the correct message", function()
assert.has_error(function()
divide(10, 0)
end, "division by zero")
end)
The optional second argument to assert.has_error checks the exact error message. If you need more control over the error, use pcall directly and inspect the returned values:
it("returns nil and error message on failure", function()
local ok, err = pcall(divide, 10, 0)
assert.is_false(ok)
assert.matches("division", err)
end)
TDD Patterns: Triangulation, Mocks, and Fakes
Triangulation
Triangulation is the practice of adding a second test case to force a real implementation. You start with the simplest possible code that satisfies the first test, then add a second test that the current implementation cannot satisfy. Only then do you write the actual logic.
-- Step 1: First test drives the initial implementation
it("adds two positive numbers", function()
assert.are.equal(5, add(2, 3))
end)
-- Implementation: return 5 hard-coded
-- Step 2: Second test forces real addition
it("adds different numbers", function()
assert.are.equal(7, add(3, 4))
end)
-- Now you have no choice but to implement real addition
local function add(a, b)
return a + b
end
The hard-coded return value passes the first test but cannot pass the second. You are forced to implement the real behaviour. Triangulation prevents over-engineering — you only add complexity when the tests demand it.
Mock Objects
A mock is a stub with expectations — it verifies that specific calls happened in a specific way. Use mocks when you care about the interaction between objects, not just the end result.
it("sends a welcome email after registration", function()
local s = stub(mailer, "send")
register("alice@example.com")
assert.stub(s).was.called()
assert.stub(s).was.called_with("alice@example.com", "Welcome!")
end)
If register never calls mailer.send, the mock assertion fails. Mocks are about contract verification.
Fake Objects
A fake is a lightweight implementation of a dependency that behaves like the real thing but is not suitable for production. In-memory databases are the classic example.
local FakeDB = {}
function FakeDB:new()
return setmetatable({ rows = {} }, { __index = self })
end
function FakeDB:query(sql) return self.rows end
function FakeDB:insert(_, data) table.insert(self.rows, data) end
it("persists a user across lookups", function()
local db = FakeDB:new()
local repo = UserRepo.new(db)
repo:save({ name = "Bob" })
local user = repo:find_by_name("Bob")
assert.are.equal("Bob", user.name)
end)
Fakes are simpler than mocks — they do not verify that calls happened, they just provide convenient test data. They are ideal for testing business logic without any infrastructure.
Table-Driven Tests
When you have many similar test cases for one behaviour, a table-driven approach eliminates repetition and makes it easy to add new cases.
describe("string.trim", function()
local cases = {
{ input = " hello ", expected = "hello", desc = "strips leading and trailing spaces" },
{ input = "\t\nfoo\t\n", expected = "foo", desc = "strips tabs and newlines" },
{ input = "no-change", expected = "no-change", desc = "leaves string unchanged" },
{ input = "", expected = "", desc = "empty string stays empty" },
}
for _, case in ipairs(cases) do
it(case.desc, function()
assert.are.equal(case.expected, string.trim(case.input))
end)
end
end)
Busted discovers each iteration as a separate test. Adding a new test case means adding a row to the table, not a new it() block.
A Complete TDD Session
Here is the full Red-Green-Refactor cycle applied to building a Stack module.
Step 1 — Red. Write the test first. It fails because Stack does not exist yet.
describe("Stack", function()
describe("push", function()
it("adds an element to the top", function()
local s = Stack.new()
s:push("first")
assert.are.equal("first", s:top())
end)
end)
end)
Running busted stack_spec.lua gives you a failure — Stack is nil.
Step 2 — Green. Write the minimum code to pass.
local Stack = {}
function Stack.new() return setmetatable({ _ = {} }, { __index = Stack }) end
function Stack.push(self, v) table.insert(self._, v) end
function Stack.top(self) return self._[#self._] end
Run the tests again and they pass. Do not add pop or is_empty yet.
Step 3 — Refactor. The storage key _ is a bit ugly. Refactor to _items and verify the tests still pass. This is refactoring — behaviour unchanged, only structure improved.
Now repeat the cycle for pop and is_empty. Each small behaviour gets its own failing test first, then the minimum code to pass, then a quick refactor. The final module is small, focused, and tested.
The key discipline: never add a feature without a failing test driving it. Even something as simple as is_empty starts as a test.
Conclusion
TDD is a skill that takes practice. The Red-Green-Refactor cycle, Arrange-Act-Assert structure, and patterns like triangulation, mocks, and fakes are the tools that make test-driven development practical in Lua. The discipline is not about writing more tests — it is about letting the tests do design work.
Start small. Pick one module and commit to writing the test before the implementation, even if it feels slow at first. After a few cycles the rhythm becomes natural, and you will find that the resulting code tends to be smaller, more honest about its dependencies, and easier to change.
See Also
- /tutorials/testing-busted-framework/ — Foundations of busted: describe blocks, assertions, and lifecycle hooks
- /tutorials/testing-mocks-and-stubs/ — Deeper dive into stubs, mocks, and overriding
package.loaded - /tutorials/testing-coverage/ — Measuring how much of your code the tests actually exercise
Written
- File:
sites/luaguides/src/content/tutorials/testing-tdd-patterns.md - Words: ~1100
- Read time: 5 min
- Topics covered: Red-Green-Refactor, Arrange-Act-Assert, fixtures, dependency injection, stubbing I/O, error testing, triangulation, mocks, fakes, table-driven tests, full TDD session
- Verified via: lunarmodules/busted GitHub, Deepal Jayasekara’s Journeyman’s Guide to Lua Unit Testing
- Unverified items: none