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, the Busted test framework is the tool you reach for.

Prerequisites

You need LuaRocks, the Lua package manager, and a working Lua interpreter (5.1 or later). Familiarity with basic Lua concepts — tables, functions, modules, and error handling — is assumed. No prior testing experience is required; this tutorial starts from first principles and builds up to advanced features like mocks and custom configuration. Install busted before proceeding:

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. This cascading behaviour lets you layer common setup at higher levels while keeping test-specific preparation close to the cases that need it.

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, which is especially useful when planning out a feature before the implementation exists. 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)

Tags are declared as the second argument to describe() or it(), and you can attach multiple tags to a single block by listing them all in the table. This tagging system is the primary way to organise tests by category — unit, integration, slow, fast, and so on — without needing separate directories or naming conventions. Once your tests are tagged, busted’s CLI options let you select exactly which subset to execute for a given test run. The --tags flag accepts a comma-separated list and runs any test carrying at least one matching tag.

Run only :slow tests:

busted --tags=slow

The --exclude-tags flag does the opposite, which is particularly useful for CI pipelines where you want to run the fast suite on every commit but reserve the slow integration tests for a nightly build or a dedicated workflow trigger:

busted --exclude-tags=slow

Combining multiple tags with AND logic means a test must carry every listed tag to be selected. This composability is what makes tags more flexible than file-based filtering alone. You can layer :focus on a specific test during development and run busted --tags=focus to isolate it without touching any other test files or configuration.

busted --tags=unit,fast

The AND logic means only tests that carry both :unit and :fast tags will run. This composability is what makes tags more flexible than file-based filtering alone. You can layer :focus on a specific test during development and run busted --tags=focus to isolate it without touching any other test files or configuration.

: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 spy.on() call returns a handle that you can pass to assert.spy() for verification. The original function continues to execute normally — only the call metadata is recorded. This makes spies ideal for checking that callbacks fire, that logging functions receive the expected arguments, or that a particular code path is reached during a test run. Spies are the right tool when you want to observe behaviour without altering it.

The match module provides helpers for flexible assertions that go beyond exact-value matching. You can validate argument types, patterns, or wildcards instead of literal strings and numbers:

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

The match._ wildcard accepts any value, which is useful when you care about some arguments but not others. Busted also provides match.is_number(), match.is_table(), match.is_not(), and other matchers that let you write assertions against the shape of arguments rather than exact values. This flexibility is especially valuable when testing functions that receive tables or callbacks as parameters.

Spies clean themselves up automatically after the test ends, so you never need to worry about manual restoration.

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. Each stub is registered in the test runner’s cleanup queue, so even if your test fails partway through, the stubbed function gets restored before the next test begins.

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

The .with() method chains a specific argument value to a return value, and the final match._ acts as a catch-all fallback. This is the same idea as pattern matching in a function — you define specific cases first, then a default at the end. Conditional stubs are especially useful for configuration objects and service locators where the same function call should behave differently depending on the key or parameter passed in.

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. The mock is delivered to the code under test through Lua’s normal module resolution, so the tested code doesn’t need any special dependency injection support. Just be aware that modules already cached in package.loaded won’t pick up the preload override — you may need to clear the cache key first if the module was loaded earlier in the suite.

Running tests from the command line

Busted has flexible options for selecting which tests to run. Here is a quick reference of the most commonly used flags:

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 without modifying any test files. Combining --filter with --shuffle and --seed is a useful technique for catching order-dependent test failures that only surface intermittently.

Configuration with .busted

Busted reads a .busted file in the current directory. It is a Lua table that defines named task profiles, each with its own set of options. Think of it as saved command-line presets that your team can share through version control.

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, as in ["suppress-pending"], because Lua table keys with hyphens cannot be accessed with dot syntax. Options are the long CLI names, not short flags. You can run a named task profile with:

busted --run=ci

Without --run, the default task applies. The _all block is merged into every task automatically, so you can set project-wide defaults there while keeping environment-specific overrides in the named profiles. The ROOT key lets you scope a profile to specific directories, which is handy when you have separate unit, integration, and end-to-end test folders all in the same repository.

Code coverage

Busted integrates with LuaCov to give you line-level coverage data without a separate test harness. Install LuaCov first using LuaRocks:

luarocks install luacov

Once LuaCov is installed, the --coverage flag becomes available in busted. Running your suite with this flag instruments every loaded Lua file and records which lines were hit during test execution. The coverage data is collected transparently while your tests run, so there’s no separate instrumentation step to remember:

busted --coverage

This produces luacov.report.out with a textual summary and luacov.report.json for programmatic consumption. If you need a machine-readable format that integrates with common CI dashboards, add a Cobertura reporter to a luacov.lua config file in your project root. For CI systems that understand Cobertura XML — Jenkins, GitLab CI, and most coverage dashboards — add this:

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 genuinely hard to test. Use these escapes sparingly — they’re for module-level configuration tables and one-time setup scripts, not for skipping code you haven’t gotten around to writing tests for.

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.

Next steps

See also