Lua Testing
SpyWeb includes a lightweight Lua testing mode for job hooks and the programmable API server. Tests are kept close to production logic while running in fresh, isolated Lua VMs.
CLI Usage
Section titled “CLI Usage”spyweb testspyweb test "Job Name"spyweb test "Job Name" pricespyweb test serverspyweb test server pricespyweb test- runs everytest_*function across all jobs and the programmable API server (ifserver/tests.luaexists)spyweb test "Job Name"- runs tests for the matching jobspyweb test "Job Name" price- runs tests whose name contains “price”spyweb test server- runs tests for the programmable API server atserver/tests.luaspyweb test server price- runs server tests whose name contains “price”
Test Discovery
Section titled “Test Discovery”SpyWeb loads hooks.lua, defer.lua, and tests.lua from the job directory, then scans for global functions starting with test_.
function extract_id(text) return text:match("ID%-(%d+)")end
function test_extract_id() spyweb.assert_eq(extract_id("Product ID-9982"), "9982")endExecution Model
Section titled “Execution Model”Each test function runs in its own fresh Lua VM:
- Global mutations don’t leak between tests
- Each test gets a new temporary database (backend depends on build feature flags)
defer.luais loaded alongsidehooks.luaandtests.lua, so tests see the same lifecycle helpers as production- Production Lua bindings are available (HTTP bindings are async under the hood - the test runner executes them asynchronously)
- Production
ctxis registered asactive_ctx
Available Bindings
Section titled “Available Bindings”http_get,http_post,http_request,http_multipartfs_read,fs_read_binary,store_*,global_store_*spyweb.assert_eq(left, right, [message])spyweb.assert_ne(left, right, [message])
Example: HTTP Test
Section titled “Example: HTTP Test”function test_fetch_remote_page() local res = http_get("http://127.0.0.1:8080/") spyweb.assert_eq(res.status, 200) spyweb.assert_eq(res.body, "OK")end
function test_head_request() local res = http_request({ method = "HEAD", url = "http://127.0.0.1:8080/" }) spyweb.assert_eq(res.status, 200) spyweb.assert_eq(res.body, "")endExample: Storage Seed
Section titled “Example: Storage Seed”function test_seed_state() store_set("page", "3") spyweb.assert_eq(store_get("page"), "3")endMocking
Section titled “Mocking”Tests can overwrite globals inside the Lua VM:
function test_override_fetch_logic() override_fetch = function(req, ctx) return { status = 200, body = "mock", url = req.url } end local result = override_fetch({ url = "https://example.com" }) spyweb.assert_eq(result.body, "mock")endAssert Helpers
Section titled “Assert Helpers”spyweb.assert_eq(left, right, [message])- passes ifleft == right. The optionalmessageis included in failure output.spyweb.assert_ne(left, right, [message])- passes ifleft ~= right.
Practical Limits
Section titled “Practical Limits”- Name-based discovery - matches by job name or normalized job id
- Only globals -
localfunctions are not visible to the test runner - Isolated VMs - each test runs in its own VM; globals do not leak between tests
- defer lifecycle -
defer()works as in production; registered cleanup runs when the test finishes
Recommended Structure
Section titled “Recommended Structure”For small jobs - keep tests in hooks.lua next to the hook they verify.
For larger jobs:
Directoryproject/
Directoryjobs/
Directoryinventory-sync/
- hooks.lua Production hooks
- defer.lua Lifecycle helpers
- tests.lua Shared helpers and broader test cases
Example Suite
Section titled “Example Suite”hooks.lua - production hook and helper:
function extract_id(text) return text:match("ID%-(%d+)")end
function override_fetch(req, ctx) -- production logicendtests.lua - shared helper and test cases:
-- shared helperfunction make_test_request(url) return http_request({ method = "GET", url = url })end
function test_extract_id() spyweb.assert_eq(extract_id("ID-001"), "001")end
function test_fetch_reachable() local res = make_test_request("http://127.0.0.1:8080/") spyweb.assert_eq(res.status, 200)endServer Testing
Section titled “Server Testing”The programmable API server (server/init.lua) can be tested the same way. When server/tests.lua exists alongside server/init.lua, spyweb test automatically:
- Starts the server on a random port
- Loads
init.luaso helper functions are available to tests - Runs every
test_*function fromtests.lua - Shuts the server down when done
No manual spyweb start needed.
-- server/init.luafunction format_price(price) return "$" .. string.format("%.2f", price)end
get.hello = function(self) return { status = 200, body = "Hello!" }end-- server/tests.lua
-- Logic test — no HTTP needed, helpers from init.lua are availablefunction test_format_price() spyweb.assert_eq(format_price(100), "$100.00") spyweb.assert_eq(format_price(0), "$0.00")end
-- Integration test — hits the server over HTTPfunction test_hello_endpoint() local resp = http_get("http://127.0.0.1:" .. SERVER_PORT .. "/api/v/hello") spyweb.assert_eq(resp.status, 200) spyweb.assert_eq(resp.body, "Hello!")end
-- Test a 404 for missing routesfunction test_404_endpoint() local resp = http_get("http://127.0.0.1:" .. SERVER_PORT .. "/api/v/nonexistent") spyweb.assert_eq(resp.status, 404)end
-- Test a public endpoint (no auth required)function test_public_endpoint() local resp = http_get("http://127.0.0.1:" .. SERVER_PORT .. "/api/public/status") spyweb.assert_eq(resp.status, 200)endThe SERVER_PORT global is injected by the test runner so tests know where to reach the server. The server and all tests share the same temporary database.
Both private (get:name, post:name) and public (public.get:name, public.post:name) route handlers can be unit-tested directly by calling them from Lua, or integration-tested over HTTP via /api/v/ and /api/public/ respectively.
File Structure
Section titled “File Structure”Directoryproject/
Directoryserver/
- init.lua Programmable API routes
- tests.lua Lua tests
Failure Output
Section titled “Failure Output”running 1 test for job 'inventory-sync'test test_id_extraction ... FAILED
failures:
---- test_id_extraction stdout ----...
test result: FAILED. 0 passed; 1 failed; finished in 0.01sTroubleshooting
Section titled “Troubleshooting”No tests found: Ensure function name starts with test_, is global (not local), and is in hooks.lua, defer.lua, or tests.lua.
HTTP test fails: Confirm URL is reachable. Each test runs in a fresh VM.
A helper defined in defer.lua is missing: Verify the filename is defer.lua (not defer.luau) and it is placed next to hooks.lua.