luaguides

Using the Busted Test Framework

Busted is a behaviour-driven development (BDD) testing framework for Lua. It gives you a readable DSL for writing tests, automatic test discovery, mocking and spying utilities, and built-in support for code coverage. If you are writing Lua code that matters, Busted is the tool you reach for.

Installing Busted

You need LuaRocks, the Lua package manager. Install Busted with a single command:

luarocks install busted

Busted supports Lua >= 5.1, Moonscript, LuaJIT >= 2.0, and Terra. After installation, run busted --version to confirm it is available.

Writing Your First Spec

Busted discovers test files by looking for files matching the pattern *_spec.lua (configurable). Create a file named calculator_spec.lua:

describe("Calculator", function()
  describe("add", function()
    it("adds two positive numbers", function()
      local result = add(2, 3)
      assert.are.equal(5, result)
    end)

    it("returns the first argument when the second is zero", function()
      assert.are.equal(7, add(7, 0))
    end)
  end)
end)

Run it with busted. Busted recursively finds every *_spec.lua in the current directory by default.

The describe() function groups related tests. It takes a label and a function that contains it() blocks. The it() function defines an individual test case. You can nest describe() blocks as deeply as you need, which is useful for mirroring the structure of your application.

Lifecycle Hooks

Real tests need setup and teardown. Busted provides four hooks:

  • setup — runs once before the first test in the block
  • teardown — runs once after the last test in the block
  • before_each — runs before every test
  • after_each — runs after every test
describe("Database", function()
  setup(function()
    db = connect_to_test_database()
  end)

  teardown(function()
    db:close()
  end)

  before_each(function()
    db:clear_all_tables()
  end)

  describe("Users", function()
    it("inserts a new user", function()
      db:insert("users", { name = "Alice" })
      assert.are.equal(1, db:count("users"))
    end)
  end)
end)

before_each and after_each bubble down into nested describe() blocks automatically. If you declare a before_each at an outer level and another at an inner level, both run before each inner test, in order from outer to inner.

Pending Tests

Mark a test as work-in-progress by omitting its body, or call pending() inside:

it("handles concurrent inserts")

it("recovers from a corrupted table", function()
  pending("storage layer not ready yet")
end)

Busted shows these as PENDING rather than passing or failing. This lets you commit skeleton tests without breaking your suite.

Tags for Selective Running

Tags let you mark tests by category. The convention is to prefix tags with a colon:

describe("Slow integration tests", {":slow"}, function()
  it("runs the full pipeline", function()
    -- ...
  end)
end)

it("quick validation", {":focus"}, function()
  -- ...
end)

Run only :slow tests:

busted --tags=slow

Skip :slow tests (useful in CI to keep the fast suite quick):

busted --exclude-tags=slow

You can combine multiple tags with AND logic:

busted --tags=unit,fast

:focus is handy during development to isolate a single test. Just tag it and run with --tags=focus.

Spying on Functions with spy.on()

A spy wraps an existing function and records every call without changing what the function does. Use spy.on():

it("calls greet with the right name", function()
  local s = spy.on(greeter, "greet")
  greeter.greet("Lua")
  greeter.greet("Busted")

  assert.spy(s).was.called()
  assert.spy(s).was.called_with("Lua")
  assert.spy(s).call_count(2)
end)

The match module provides helpers for flexible assertions:

assert.spy(s).was.called_with(match._, "Alice")  -- first arg can be anything

Spies clean themselves up automatically after the test ends.

Replacing Functions with stub()

A stub goes further than a spy — it replaces the function entirely. Use stub() when you need to control the return value or simulate an error:

it("returns the configured value", function()
  stub(network, "fetch"):returns({ body = "fake response" })

  local result = network.fetch("http://example.com")
  assert.are.equal("fake response", result.body)
end)

it("raises an error when the network is down", function()
  stub(network, "fetch"):raises("connection refused")
  assert.has_error(function() network.fetch("http://example.com") end, "connection refused")
end)

Stubs are scoped to the test that creates them and unwrap automatically after the test ends. Declare stubs inside it() blocks, not at module level, or they will leak into other tests.

Conditional stubs let you return different values for different arguments:

stub(config, "get")
  :with("debug"):returns(true)
  :with("timeout"):returns(30)
  :with(match._):returns(nil)  -- default

Mocking Modules with package.preload

When the code under test require()s a dependency, you can replace it using package.preload. This is the idiomatic way to mock modules in Lua:

it("fetches data from the HTTP module", function()
  local mock_http = {
    fetch = spy.new(function(url) return { body = "ok" } end)
  }
  package.preload["http"] = function() return mock_http end

  local service = require("service")  -- gets our mock
  local result = service.fetch_data("http://example.com")

  assert.are.equal("ok", result.body)
end)

Because package.preload affects subsequent require() calls, this pattern works cleanly when each test loads the module afresh.

Running Tests from the Command Line

Busted has flexible options for selecting which tests to run:

busted                         -- all *_spec.lua recursively
busted spec/unit/              -- only files in spec/unit/
busted --pattern="user"        -- files matching "user" in the filename
busted --filter="add"          -- tests whose full name contains "add"
busted --filter-out="slow"     -- skip tests whose name contains "slow"
busted --exclude-tags=integration -- skip tests tagged :integration
busted --shuffle               -- randomize test order
busted --seed=12345            -- reproducible random order
busted --repeat=5              -- run the suite 5 times
busted --verbose               -- detailed output
busted --coverage              -- enable LuaCov coverage
busted --lua=/usr/local/bin/lua5.4  -- use a specific interpreter

The --filter option matches against the full concatenated name, which looks like "Calculator add adds two positive numbers". This lets you isolate tests precisely.

Configuration with .busted

Busted reads a .busted file in the current directory. It is a Lua table:

return {
  _all = {
    coverage = false
  },
  default = {
    verbose = true,
    ["suppress-pending"] = true,
    pattern = "_spec"
  },
  ci = {
    coverage = true,
    verbose = true,
    ["exclude-tags"] = "slow"
  },
  unit = {
    tags = "unit",
    ROOT = { "spec/unit" }
  }
}

Note that keys with hyphens must use bracket notation: ["suppress-pending"]. Options are the long CLI names, not short flags. Run a named task with:

busted --run=ci

Without --run, the default task applies. The _all block is merged into every task automatically.

Code Coverage

Install LuaCov separately:

luarocks install luacov

Then run with the --coverage flag:

busted --coverage

This produces luacov.report.out with a summary and luacov.report.json for machine consumption. For CI systems that understand Cobertura XML, add this to a luacov.lua config:

return {
  ["Cobertura"] = "./luacov-cobertura.xml"
}

Lines inside blocks marked with --luacov-disable or coverage = false are excluded from coverage, which is useful for boilerplate that is hard to test.

Conclusion

Busted gives Lua developers a proper testing framework with a clean DSL, powerful mocking utilities, and flexible filtering. Start with describe and it blocks, add lifecycle hooks for setup and teardown, reach for spy.on() when you want to observe behaviour without changing it, and use stub() when you need to replace a function’s implementation. Configure your CI run with a .busted file and add --coverage to track line-level testing completeness over time.

See Also