Mocks, Stubs, and Spies in Lua
Lua ships with no testing framework and no mocking library, but the language’s dynamism gives you everything you need for mocks, stubs, and spies. You replace a function, capture calls, return fake values, and restore the original when done. This article shows you how.
Prerequisites
You need Lua 5.1 or later and the Busted testing framework installed. This tutorial assumes you are comfortable writing basic Lua functions and understand closures. A passing familiarity with describe and it blocks in Busted will help, but is not essential.
Stubs: fake functions with preset returns
A stub replaces a real function with a fake that returns whatever you need for the test. The goal is isolation: you want to test one piece of code without triggering its network calls, file reads, or database queries.
The manual approach is straightforward. Save the original, assign a closure, use the stub, then restore:
local socket = require "socket"
-- Save original function
local original_send = socket.send
-- Replace with stub
socket.send = function(self, data)
return #data -- simulate success: return bytes sent
end
-- In your test, the code under test now uses the stub
local bytes = some_function_that_calls_send()
assert(bytes == 5)
-- Restore so other tests are unaffected
socket.send = original_send
That pattern works but gets tedious when you have many stubs across a test suite. You end up repeating the save-replace-restore boilerplate in every test file, and if a test errors before the restore line runs, the stub leaks into other tests.
Busted solves both problems with its built-in stub function. Call stub(tbl, key) and Busted replaces tbl[key] with a stub object that records every invocation. When the test finishes, even if it fails or errors, Busted restores the original automatically:
local stub = require "busted".stub
describe("network client", function()
it("reports bytes sent", function()
local s = stub(socket, "send", function() return 42 end)
local result = code_that_sends_data()
assert.equals(42, result)
-- Busted restores socket.send automatically when the test ends
end)
end)
The third argument to stub is an optional replacement function that Busted calls whenever the stub is invoked. If you omit it, the stub silently records each call (tracking arguments and call count) but returns nothing. This is useful when you only want to verify that a function was called, without caring about its return value.
For more control, you can also configure the stub after creation using a fluent API:
local s = stub(http, "request")
s:returns({ status = 200, body = "ok" })
s:raises("timeout") -- make it error instead
Mocks: stubs with built-in expectations
A mock is a stub that also verifies it was called correctly. Where a stub silently accepts any call, a mock fails the test if its expectations aren’t met.
You can build a mock manually with a call tracker:
local function mock_read(file)
local calls = {}
local mock = function(path)
table.insert(calls, { path = path })
return "file contents"
end
return mock, calls
end
local read_file, calls = mock_read("data.txt")
local content = process_file(read_file)
assert(#calls == 1)
assert(calls[1].path == "data.txt")
For more formal verification, Busted’s stub doubles as a mock through the luassert assertion library. After your test code runs, you call assert.stub(s) to begin a chain of expectations. The was.called() assertion checks that the stub was invoked at all, while was.called_with(...) verifies the exact arguments. If the expectation fails, you get a clear error message showing what was expected and what actually happened:
local stub = require "busted".stub
describe("config loader", function()
it("calls the parser with the file path", function()
local s = stub(parser, "parse")
load_config("config.yaml")
assert.stub(s).was.called()
assert.stub(s).was.called_with("config.yaml")
end)
end)
The assert.stub(s).was.called_with(...) assertion fails the test immediately if the stub wasn’t invoked with the expected arguments. That makes mocks useful for contract verification: you can verify the return value and confirm that the code under test reached the right dependency with the right inputs.
Spies: wrapping without replacing
A spy wraps an existing function. It intercepts calls and records what happened, but the original function still runs. Spies are for when you want to observe side effects without changing behaviour.
The manual spy pattern wraps the original in a closure:
local function spy(fn)
local calls = {}
local wrapped = function(...)
table.insert(calls, { ... })
return fn(...) -- pass through to original
end
return wrapped, calls
end
local original_execute = os.execute
os.execute, os.execute_calls = spy(os.execute)
-- run code that calls os.execute ...
local ok = run_build_command()
assert(#os.execute_calls > 0)
assert(os.execute_calls[1][1]:match("make"))
os.execute = original_execute -- restore
Busted’s spy function wraps the original automatically and tracks every call without you having to manage the closure or the call table yourself. Like stub, a spy restores the original when the test ends. The main advantage over a manual spy is that Busted’s assertions integrate directly: you can call assert.spy(s).was.called_with(...) and get clear failure messages showing expected vs actual arguments:
local spy = require "busted".spy
describe("logger", function()
it("logs warnings", function()
local log_spy = spy(_G, "print")
run_code_with_warnings()
assert.spy(log_spy).was.called()
assert.spy(log_spy).was.called_with("WARNING: disk space low")
end)
end)
The key difference from stub is that a spy preserves the original print behaviour. If you do not assert on the spy at all, the call still goes through to the real function. This makes spies ideal for integration tests where you want to observe side effects (like log output or file writes) without interfering with the system under test. With a stub, the original function is replaced entirely, so any code that depends on its real side effects would break.
Mocking Modules with package.loaded
Lua’s require caches loaded modules in package.loaded. By pre-filling this table before a require, you substitute the real module with a stub:
-- Install stub before requiring
package.loaded["socket.http"] = {
request = function(url)
return 200, '{"user": "alice"}', {}
end
}
-- This require returns the stub, not the real socket.http
local http = require "socket.http"
local status, body = http.request("https://api.example.com/users/1")
assert(body == '{"user": "alice"}')
This is the idiomatic Lua approach for HTTP stubs, database clients, or any module you want to fake without touching globals at runtime.
Cleaning up with closure-based restoration
Manual stubbing works fine for isolated tests, but when a test fails before restoration runs, you leak state into the next test. The closure pattern wraps setup and teardown together:
local function with_stub(tbl, key, stub_fn)
local original = tbl[key]
tbl[key] = stub_fn
return function() tbl[key] = original end -- teardown
end
describe("email sender", function()
it("retries on temporary failure", function()
local restore = with_stub(smtp, "send", function()
error("temporary failure")
end)
local ok, err = send_email_with_retry("test@example.com")
restore() -- always runs, even if the test assertion fails
assert.is_true(ok)
end)
end)
Returning a restore function from setup and calling it in the test body ensures cleanup happens regardless of whether the test passes or fails. You can extend this pattern to track multiple stubbed values and restore them all at once.
Putting it together: testing an HTTP client
Here’s a realistic example combining stubs, spies, and package.loaded. Suppose you have an HTTP client that fetches a user and retries on failure:
-- client.lua
local function fetch_user(url)
local http = require "socket.http"
local status, body = http.request(url)
if not status or status >= 500 then
error("server error: " .. tostring(status))
end
return require("cjson").decode(body)
end
return { fetch_user = fetch_user }
To test this client without hitting a real network, you stub socket.http through package.loaded before the require runs. This is critical: Lua caches modules in package.loaded, so pre-filling that table with a fake module means every subsequent require gets your stub instead of the real library. The test below simulates two timeouts followed by a successful response, verifying that the retry mechanism eventually returns data:
package.loaded["socket.http"] = {
request = function(url)
return nil, "timeout" -- simulate failure on first call
end
}
package.loaded["socket"] = nil
package.loaded["ltn12"] = nil
local client = require "client"
local attempt = 0
package.loaded["socket.http"] = {
request = function(url)
attempt = attempt + 1
if attempt < 3 then
return nil, "timeout"
end
return 200, '{"id": 42, "name": "Bob"}'
end
}
local user = client.fetch_user("https://api.example.com/users/42")
assert.equals(42, user.id)
assert.equals(3, attempt)
The retry logic itself isn’t visible here . the test just asserts that after three attempts the function succeeds. For full coverage you would also stub a permanent failure and assert that the function eventually raises.
Choosing the right test double
Mocks, stubs, and spies each solve different problems, and picking the right test double keeps your unit tests focused and maintainable.
Use a stub when you need to control indirect inputs . return a specific value from a dependency so you can test the path that depends on it. Stubs are the most common choice.
Use a mock when you need to verify the contract: assert both the outcome and that the code called the right dependency with the right arguments. Mocks are more brittle than stubs, so use them when the contract is the thing you’re actually testing.
Use a spy when you need to observe behaviour without changing it: check that a function was called while letting the original run. Spies are useful for testing side effects like logging or event dispatching.
Next steps
Once you can isolate code with test doubles, set up test coverage reporting to measure which paths your stubs and mocks exercise. For broader testing patterns, the Busted framework guide covers setup, teardown, and async testing.
See Also
- The Busted Testing Framework — Set up Busted, write your first test, and use
describeanditblocks. - Error Handling in Lua , How to raise and catch errors, which matters when stubbing functions that need to throw.
- Functions in Lua — Closures and first-class functions are the foundation every stub and spy is built on.