luaguides

Testing Lua Code with Busted

The Busted tutorial walks through the full DSL and all the built-in tools. This guide assumes you have read that and want to use Busted effectively in a real project. Here we focus on the workflows that actually come up once you have a test suite running: selecting which tests to run during development, keeping tests properly isolated from each other, integrating with CI, and the mistakes you will hit the first time you try to test something realistic.

Running Specific Tests During Development

When you are iterating on a single feature, running the full suite every time is slow. Busted has several ways to narrow the run.

By file or directory — pass a path and Busted recursively finds all *_spec.lua files under it:

busted spec/unit/              # all tests in spec/unit/
busted spec/integration/       # integration tests only

By file name pattern — use --pattern to match files:

busted --pattern="user"       # runs *_spec.lua files with "user" in the name

By test name--filter matches against the full test name (describe + it labels concatenated):

busted --filter="add"         # runs any test with "add" in its name
busted --filter="Calculator"  # runs all tests in the Calculator group

For more precise targeting, combine --filter with a file path:

busted spec/unit/calculator_spec.lua --filter="add"

By tag — tag tests to create logical groups, then select by tag:

busted --tags=unit            # only tests tagged :unit
busted --tags=fast,unit       # tests with both :fast AND :unit
busted --exclude-tags=slow    # run everything except :slow

The :focus tag is particularly useful during development. Tag a test you are working on and run with --tags=focus to isolate it:

it("handles edge case in sort", {":focus"}, function()
  -- your test here
end)

Test Isolation and the State Problem

Busted runs tests in the same Lua interpreter, which means state leaks between tests if you are not careful. The most common mistake is initializing mutable state at the describe level:

-- WRONG: state is shared across all tests in this describe block
describe("Cache", function()
  local cache = {}

  it("stores a value", function()
    cache["key"] = "value"
    assert.is_not_nil(cache["key"])
  end)

  it("retrieves the stored value", function()
    -- this test runs second, but the cache from the previous test may persist
    assert.is_not_nil(cache["key"])  -- depends on the test above running first
  end)
end)

Use before_each to reset state before every test:

-- RIGHT: each test gets a fresh cache
describe("Cache", function()
  before_each(function()
    cache = {}
  end)

  it("stores a value", function()
    cache["key"] = "value"
    assert.is_not_nil(cache["key"])
  end)

  it("retrieves the stored value", function()
    assert.is_nil(cache["key"])  -- clean slate each time
  end)
end)

This is especially important when testing code that relies on the package table or module-level globals. The before_each pattern works correctly regardless of test order.

When to use before_each versus setup

setup runs once before the first test in a describe block. before_each runs before every test. If your setup is cheap and the state might have been modified, before_each is safer.

A typical pattern for tests against a real (non-mocked) module:

describe("UserService", function()
  local service

  setup(function()
    service = require("services.user")
  end)

  before_each(function()
    -- reset the database to a known state before each test
    test_db:reset()
  end)

  it("creates a user", function()
    local user = service:create({ name = "Alice" })
    assert.is_table(user)
    assert.equals("Alice", user.name)
  end)
end)

Structuring a Large Test Suite

As your project grows, a flat spec/ directory stops scaling. A layout that works well:

spec/
  unit/
    calculator_spec.lua
    validator_spec.lua
  integration/
    api_spec.lua
  helpers/
    test_db.lua
    factories.lua

Then run specific subdirectories in CI:

# Fast unit tests for every commit
busted spec/unit/

# Full suite on merge
busted spec/

Environment-Specific Testing

Neovim

If you are testing Neovim configuration code, load the plenary library’s busted helper which provides the describe and it globals:

-- spec/my_plugin_spec.lua (in your nvim plugin repo)
require('plenary.busted')

describe("my plugin", function()
  it("formats buffer", function()
    -- test code here with access to nvim API
    vim.api.nvim_buf_set_lines(0, 0, -1, false, {"hello"})
    -- ...assert on buffer state
  end)
end)

Run with nvim -l spec/my_plugin_spec.lua or configure plenary to discover specs automatically.

OpenResty and Nginx

Testing code that uses ngx.* APIs requires an nginx context. The busted CLI does not give you that — you need busted running inside the nginx worker process. There are two approaches:

End-to-end test via http — make HTTP requests to a running nginx instance and assert on the response. This is the most reliable approach for OpenResty APIs.

Direct ngx stubbing — if you only need to test pure Lua functions that happen to reference ngx at the top level, stub the ngx global before requiring your module:

before_each(function()
  -- stub ngx globals so pure Lua business logic can be tested standalone
  _G.ngx = {
    say = function() end,
    log = function() end,
    var = {},
    NULL = {}
  }
end)

The real answer for complex ngx code is end-to-end tests with a real nginx process.

Integrating with CI

Busted exits with code 0 on success and a non-zero code on failure, which works fine with most CI systems.

For GitHub Actions with Lua, the simplest setup:

- name: Run tests
  run: |
    luarocks install busted
    busted --output=TAP --verbose spec/

The --output=TAP option emits Test Anything Protocol format, which most CI servers understand natively. For JSON output:

busted --output=json --verbose spec/

Exit codes

Busted returns different exit codes for different situations:

  • 0 — all tests passed
  • 1 — one or more tests failed
  • 2 — test execution error (syntax error in a spec file, for example)

Your CI should treat any non-zero as a failure.

Custom Assertions

Busted’s built-in assertions cover most cases, but you can add your own. An assertion is a function that throws if the condition is not met:

local function assert_positive(value)
  if value <= 0 then
    error("Expected positive number, got " .. tostring(value), 2)
  end
end

describe("Counter", function()
  it("increments by a positive amount", function()
    local counter = Counter.new()
    counter:add(5)
    assert_positive(counter:value())
  end)
end)

For more complex assertions with readable failure messages, return a table with an __unm metamethod (the unary minus metamethod):

local function assert_valid_user(user)
  local mt = {
    __unm = function()
      return "Invalid user: expected non-empty name and positive id"
    end
  }
  return setmetatable({}, mt)
end

This pattern lets you write assert.has_error with a descriptive message when the assertion fails.

Common Mistakes

Stubbing at module level — if you stub a function at the describe level (outside an it() block), the stub persists for all subsequent tests in that file. Declare stubs inside it() blocks so they clean up automatically:

-- WRONG
describe("Network", function()
  stub(network, "fetch")  -- lives for entire describe block

  it("calls the API", function()
    -- ...
  end)
end)

-- RIGHT
describe("Network", function()
  it("calls the API", function()
    stub(network, "fetch"):returns({ body = "ok" })
    -- ...
  end)
end)

Confusing pending with skipped testspending means the test is written but intentionally not implemented yet. It shows up in test output as PENDING. There is no built-in “skip this test” in standard Busted, but you can achieve it with tags and --exclude-tags.

Relying on test order — tests that depend on running after a specific other test will eventually break. Busted can shuffle tests with --shuffle, and --repeat runs them multiple times in random order to surface these dependencies. Use before_each to ensure each test is self-contained.

Not checking auto-insulate behavior — file insulation is on by default, which means each test file runs in a fresh Lua state. If your tests share a fixture file or database, this can cause confusion when the file isolation does not match your expectations of shared state.

See Also