luaguides

Getting Started with Testing in Lua

Lua ships with no built-in testing framework. 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.

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. You can also use pcall() to catch errors without crashing your program:

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
```lua

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.

Busted discovers test files by looking for files that match the pattern `_spec.lua`. 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`.

## 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:

```lua
-- 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

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)

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.

How Assertions Work in Busted

Busted uses luassert for its assertion library. The syntax differs from raw Lua because luassert adds a chainable interface.

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. are.same performs a recursive deep comparison, which is useful for tables that should have identical contents.

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. Lua tables, non-empty strings, and non-zero numbers are truthy but are not true. is_true checks for the exact boolean value.

Nil Checks

assert.is_nil(value)
assert.is_not_nil(value)

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

String and Table Comparisons

assert.contains("hello world", "world")  -- string contains or table includes
assert.matches("^hello", "hello world") -- Lua pattern match

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, you see exactly which function misbehaved.

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. Use before_each and after_each when each test needs a clean 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.

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
```lua

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:

```bash
busted --filter="adds two positive"

This is useful when debugging one failing test without running the whole suite.

Stubbing and Spying

Sometimes you need to isolate a unit of code by replacing its dependencies. Busted provides stub() for this.

Stubbing a Function

it("returns mock data", function()
  local my_mock = stub():returns("mocked value")
  assert.are.equal("mocked value", my_mock())
end)

Stubbing an Existing Function

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.

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.

Using Spies

A spy records calls to a function without replacing its behavior:

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.

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.

See Also