luaguides

Mocks, Stubs, and Spies in Lua

Lua ships with no testing framework and no mocking library. That sounds like a problem until you realise the language’s dynamism gives you everything you need. You replace a function, capture calls, return fake values, and restore the original when done. This article shows you how.

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. You end up repeating the save-replace-restore boilerplate across every test file.

Busted solves this with stub. Call stub(tbl, key) and Busted replaces tbl[key] with a stub object. When the test finishes, 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. If you omit it, the stub records calls but returns nothing by default. You can also configure the stub fluently:

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 luassert assertions:

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 assert not just the return value but 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 handles this for you:

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: a spy preserves the original print behaviour. If you don’t assert on the spy, the call still goes through normally. With a stub, the original is replaced entirely.

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 a 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 without a network, stub the module and verify the retry behaviour:

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

Stubs, mocks, and spies each solve different problems.

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 not just the outcome but 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.

See Also

  • The Busted Testing Framework — Set up Busted, write your first test, and use describe and it blocks.
  • 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.

Written

  • File: sites/luaguides/src/content/tutorials/testing-mocks-and-stubs.md
  • Words: ~850
  • Read time: 4 min
  • Topics covered: stubs, mocks, spies, package.loaded mocking, Busted stub/spy API, closure-based restoration, HTTP client testing
  • Verified via: Busted documentation patterns, Lua 5.4 reference
  • Unverified items: none