Test Framework

The wippy/test module provides a BDD-style testing framework with assertions, lifecycle hooks, and mocking.

Setup

Add the dependency:

wippy add wippy/test
wippy install

The module registers a test command automatically. Once installed, wippy run test discovers and runs all test entries in your project.

Defining Tests

Tests are function.lua entries with meta.type: test:

version: "1.0"
namespace: app.test

entries:
  - name: math
    kind: function.lua
    meta:
      type: test
      suite: math
      description: Math operations
    source: file://math_test.lua
    method: run
    imports:
      test: wippy.test:test

Test Metadata

Field Required Description
type Yes Must be "test" for the runner to discover it
suite No Groups tests in the runner output
description No Human-readable description
order No Sort order within a suite (lower runs first)

Writing Tests

BDD Style

Use describe and it blocks to structure tests:

local test = require("test")

local function define_tests()
    test.describe("calculator", function()
        test.it("adds numbers", function()
            test.eq(1 + 1, 2)
        end)

        test.it("multiplies numbers", function()
            test.eq(3 * 4, 12)
        end)
    end)
end

local run_cases = test.run_cases(define_tests)

local function run(options)
    local result = run_cases(options)
    if result.failed_tests > 0 then
        error("tests failed: " .. result.failed_tests)
    end
    return result
end

return { run = run }

Nested Suites

Suites can be nested for organization:

test.describe("user", function()
    test.describe("validation", function()
        test.it("requires name", function()
            test.ok(validate({}).error)
        end)

        test.it("accepts valid input", function()
            test.is_nil(validate({name = "Alice"}).error)
        end)
    end)

    test.describe("formatting", function()
        test.it("formats display name", function()
            test.eq(format_name("alice"), "Alice")
        end)
    end)
end)

Skipping Tests

test.it_skip("not implemented yet", function()
    test.fail("TODO")
end)

Skipped tests appear in the output but don't count as failures.

Suite Aliases

test.spec and test.context are aliases for test.describe:

test.spec("feature", function()
    test.context("when valid input", function()
        test.it("succeeds", function()
            test.ok(true)
        end)
    end)
end)

Assertions

Equality

test.eq(actual, expected, msg?)       -- actual == expected
test.neq(actual, expected, msg?)      -- actual ~= expected

Truthiness

test.ok(val, msg?)                    -- val is truthy
test.fail(msg?)                       -- unconditional failure

Nil Checks

test.is_nil(val, msg?)                -- val == nil
test.not_nil(val, msg?)               -- val ~= nil

Type Checks

test.is_true(val, msg?)               -- val == true
test.is_false(val, msg?)              -- val == false
test.is_string(val, msg?)
test.is_number(val, msg?)
test.is_table(val, msg?)
test.is_function(val, msg?)
test.is_boolean(val, msg?)

Strings and Collections

test.contains(str, substr, msg?)      -- substring match
test.matches(str, pattern, msg?)      -- Lua pattern match
test.has_key(tbl, key, msg?)          -- table key exists
test.len(val, expected, msg?)         -- #val == expected

Numeric Comparisons

test.gt(a, b, msg?)                   -- a > b
test.gte(a, b, msg?)                  -- a >= b
test.lt(a, b, msg?)                   -- a < b
test.lte(a, b, msg?)                  -- a <= b

Error Handling

test.throws(fn, msg?)                 -- fn() raises error, returns it
test.has_error(val, err, msg?)        -- val is nil, err is not nil
test.no_error(val, err, msg?)         -- err is nil

All assertions accept an optional message as the last argument. On failure, the message is included in the error output.

Lifecycle Hooks

test.describe("database", function()
    test.before_all(function()
        -- runs once before the suite
        db = connect()
    end)

    test.after_all(function()
        -- runs once after the suite
        db:close()
    end)

    test.before_each(function()
        -- runs before each test
        db:begin_transaction()
    end)

    test.after_each(function()
        -- runs after each test
        db:rollback()
    end)

    test.it("inserts a record", function()
        db:exec("INSERT INTO users (name) VALUES ('Alice')")
        local count = db:query_row("SELECT COUNT(*) FROM users")
        test.eq(count, 1)
    end)
end)

Hooks in nested suites execute in order: parent before_each runs before child before_each, and child after_each runs before parent after_each.

Mocking

The mock system replaces global object fields and automatically restores them after each test.

Basic Mocking

test.describe("notifications", function()
    test.it("sends message", function()
        local sent = false
        test.mock("process.send", function(pid, topic, payload)
            sent = true
        end)

        notify_user("hello")
        test.is_true(sent)
        -- mock is auto-restored after this test
    end)
end)

Mock API

test.mock("object.field", replacement)    -- replace a global field
test.mock_process("field", replacement)   -- shorthand for process fields
test.restore_mock("object.field")         -- restore one mock
test.restore_all_mocks()                  -- restore all mocks

Mock paths use dot notation: "process.send" replaces _G.process.send.

Mocks for process.send automatically proxy test framework messages through the original function, so test event reporting continues to work when process.send is mocked.

All mocks are automatically restored after each test via the after_each hook.

Running Tests

Run All Tests

wippy run test

Filter by Pattern

wippy run test math
wippy run test user validation

Filters match against entry IDs. Multiple patterns are combined.

Example Output

3 tests in 1 suites

  calculator
    + adds numbers                           0ms
    + multiplies numbers                     0ms
    - divides by zero                        1ms
      Error: expected error, got nil

  1 suite | 2 passed | 1 failed | 0 skipped | 3ms

Simple Tests

For tests that don't need the BDD framework, define a simple function that returns true or raises an error:

local funcs = require("funcs")

local function main()
    local result, err = funcs.call("app:my_function", "input")
    if err then
        error("call failed: " .. tostring(err))
    end
    if result ~= "expected" then
        error("expected 'expected', got: " .. tostring(result))
    end
    return true
end

return { main = main }
  - name: integration
    kind: function.lua
    meta:
      type: test
      suite: integration
    source: file://integration_test.lua
    method: main
    modules:
      - funcs

The runner detects whether a test uses BDD case events or returns a simple value. Both patterns work with wippy run test.

Project Structure

A typical test layout:

src/
  _index.yaml
  app.lua
  test/
    _index.yaml          # test entries
    math_test.lua
    user_test.lua
    integration_test.lua

The test _index.yaml defines the test namespace and entries:

version: "1.0"
namespace: app.test

entries:
  - name: math
    kind: function.lua
    meta:
      type: test
      suite: math
    source: file://math_test.lua
    method: run
    imports:
      test: wippy.test:test

  - name: user
    kind: function.lua
    meta:
      type: test
      suite: user
    source: file://user_test.lua
    method: run
    imports:
      test: wippy.test:test

Infrastructure Requirements

The test runner needs a process.host and terminal.host in your application. These are typically already present. If not, add them:

entries:
  - name: processes
    kind: process.host
    lifecycle:
      auto_start: true

  - name: terminal
    kind: terminal.host
    lifecycle:
      auto_start: true

See Also