Skip to main content

Testing API Reference

Testing API Reference

The Turn.io SDK provides comprehensive testing utilities through the turn.test.* module. These functions help you write thorough tests for your apps with minimal boilerplate.

State Management

turn.test.reset()

Resets all test state and pre-populates test data. Call this in your before() hooks to start each test with a clean slate.

What it does:

  • Clears all HTTP, database and app call mocks
  • Resets configuration to defaults
  • Creates 2 test contacts with predictable properties
  • Creates 2 test journeys for testing
  • Clears all lease data
describe("My tests", function()
before(function()
turn.test.reset() -- Clean slate before each test
end)

it("should use pre-populated test data", function()
local contacts = turn.test.get_contacts()
assert(#contacts == 2) -- contact-1 and contact-2
end)
end)

Pre-populated Test Data:

After calling reset(), you automatically have:

  • Contact 1: {uuid="contact-1", name="Test Contact 1", wa_id="27123456789"}
  • Contact 2: {uuid="contact-2", name="Test Contact 2", wa_id="27987654321"}
  • Journey 1: {uuid="journey-1", name="Test Journey 1"}
  • Journey 2: {uuid="journey-2", name="Test Journey 2"}

Configuration Testing

turn.test.set_config(key, value)

Set configuration values for your tests. Useful for testing different config scenarios.

turn.test.set_config("api_key", "test-key-123")
turn.test.set_config("timeout", 30)

local api_key = turn.configuration.get("api_key")
assert(api_key == "test-key-123")

turn.test.get_config(key)

Retrieve configuration values set in tests.

local timeout = turn.test.get_config("timeout")
assert(timeout == 30)

HTTP Request Mocking

turn.test.mock_http(url_pattern, mock_response)

Mock HTTP requests to external APIs. Essential for testing API integrations without making real network calls. Matching is done by URL pattern (Lua pattern) and optional method.

Parameters:

  • url_pattern (string) - Lua pattern to match the request URL (e.g. "api%.example%.com/users" — escape dots with `%.). Use a substring of the URL that uniquely identifies the endpoint.
  • mock_response (table) - The mock response. Optional fields: method (string, default "POST" — only requests with this method match), status (number, default 200), body (string, default '{"status":"success"}'), headers (table, default {}).
-- Mock a successful API call (url_pattern matches any URL containing "api.example.com/users")
turn.test.mock_http(
"api%.example%.com/users",
{
method = "POST",
status = 200,
headers = {["content-type"] = "application/json"},
body = json.encode({success = true, user_id = "123"})
}
)

-- turn.http.request returns body, status_code, headers, status_line (four values)
local body, status_code, headers = turn.http.request({
method = "POST",
url = "https://api.example.com/users",
body = json.encode({name = "John"})
})

assert(status_code == 200)
assert(body ~= nil)

turn.test.assert_http_called(url_pattern)

Verify that an HTTP request was made whose URL matches the given Lua pattern.

Parameters:

  • url_pattern (string) - Lua pattern to match the URL of a request that must have been made (e.g. "api%.example%.com/data").
-- Make a request
turn.http.request({
method = "GET",
url = "https://api.example.com/data"
})

-- Verify a request to that URL was made
assert(turn.test.assert_http_called("api%.example%.com/data"))

turn.test.get_http_requests()

Get all HTTP requests made during the test. Useful for detailed verification.

-- Make some requests
turn.http.request({method = "POST", url = "https://api.com/users"})
turn.http.request({method = "GET", url = "https://api.com/users/123"})

-- Get all requests
local requests = turn.test.get_http_requests()
assert(#requests == 2, "Expected 2 HTTP requests")
assert(requests[1].method == "POST")
assert(requests[2].method == "GET")

Database Mocking

There is no SQLite in the test environment, so turn.db is a scriptable double in the same style as turn.http. Statements are answered from rules you register; the SQL is matched but never executed, so run real statements against a real SQLite file to check the SQL itself.

turn.test.mock_db([db_name], sql_pattern, response)

Register a rule for turn.db.execute and turn.db.query. The first rule registered whose database and SQL both match answers the statement.

Parameters:

  • db_name (string, optional) - the exact database name, or a Lua pattern (e.g. "^vault_") to match a family of them. Omit it to match every database.
  • sql_pattern (string) - a plain substring of the SQL, not a Lua pattern, so "WHERE id = ?" matches as written.
  • response - one of:
    • a list of row tables: query returns it, execute reports one change
    • a number: execute returns it as the change count, query returns no rows
    • {error = "..."}: both return nil, error_message
    • function(params, sql, db_name) returning any of the above

Without a matching rule, query returns an empty list and execute reports one change, so an app under test never blocks on a statement you did not script.

turn.test.mock_db("my_database", "FROM users WHERE id = ?", function(params)
return {{id = params[1], name = "Alice"}}
end)

local rows, err = turn.db.query("my_database",
"SELECT * FROM users WHERE id = ?", {1})

assert(err == nil)
assert(rows[1].name == "Alice")

-- Error branches are scripted the same way
turn.test.mock_db("my_database", "INSERT INTO users", {error = "database is read-only"})
local changes, insert_err = turn.db.execute("my_database",
"INSERT INTO users (name) VALUES (?)", {"Bob"})
assert(changes == nil and insert_err ~= nil)

turn.test.assert_db_queried(sql_pattern)

Verify that a statement containing the given substring was executed or queried.

assert(turn.test.assert_db_queried("INSERT INTO users"))

turn.test.get_db_queries(sql_pattern)

Every statement the app issued, in call order, as {fn = "execute"|"query", db = name, sql = sql, params = params}. Pass a substring to filter, or nothing for all of them.

local writes = turn.test.get_db_queries("INSERT INTO users")
assert(#writes == 1)
assert(writes[1].params[1] == "Bob")

turn.test.mock_db_checkpoint([db_name], response)

Scripts turn.db.checkpoint, which the platform can refuse with "rate_limited" when called inside its one-second cooldown. Without a rule checkpoint succeeds, so only a spec that cares about the refusal needs one. response is true, {error = "rate_limited"}, or a function of the database name — a closure is how a spec refuses the first call and accepts the retry.

local calls = 0
turn.test.mock_db_checkpoint("my_database", function()
calls = calls + 1
if calls == 1 then return {error = "rate_limited"} end
return true
end)

local ok, err = turn.db.checkpoint("my_database") -- false, "rate_limited"
assert(turn.db.checkpoint("my_database") == true) -- the retry succeeds

Checkpoints are recorded like statements, as {fn = "checkpoint", sql = "CHECKPOINT"}, so turn.test.get_db_queries("CHECKPOINT") counts the flushes an app made.

What the double does on its own

  • Databases open on demand, as on the platform, and turn.db.list() / list_open() report them.
  • Database names are validated exactly as production validates them, so an invalid name fails in tests too.
  • Bundled databases declared in the manifest are installed read-only by turn.manifest.install, so execute against one returns the same read-only error the platform returns.
  • A nil hole in the middle of a parameter list is refused. The Lua VM drops it, which would silently shift every later binding on the platform.
  • A row-yielding statement sent through execute is refused with "unexpected_row", the error the platform returns: it steps the statement and fails the moment a step produces a row. A SELECT belongs in query, and a setter pragma often yields a row too — PRAGMA secure_delete returns its new value. Not every pragma does, so this is a default rather than a law: an app that legitimately executes one scripts it with mock_db and the rule wins.

App-to-App Call Mocking

turn.test.mock_app_call(app_name, event_name, mock_response)

Mock calls to other apps in your system. Essential for testing multi-app integrations.

Parameters:

  • app_name - Name of the app being called
  • event_name - Event/action name
  • mock_response - Response data to return
-- Mock a payment app call
turn.test.mock_app_call("payment_app", "process_payment", {
success = true,
transaction_id = "txn_123",
amount = 100
})

-- Now app calls will return the mock
local result = turn.apps.call(app, number, "payment_app", "process_payment", {
amount = 100,
currency = "USD"
})

assert(result.success == true)
assert(result.transaction_id == "txn_123")

turn.test.assert_app_called(app_name, event_name, expected_data)

Verify that an app was called with specific data.

-- Call an app
turn.apps.call(app, number, "sms_app", "send_sms", {
phone = "27123456789",
message = "Hello"
})

-- Verify it was called correctly
turn.test.assert_app_called("sms_app", "send_sms", {
phone = "27123456789",
message = "Hello"
})

turn.test.get_app_calls()

Get all app calls made during the test.

-- Make some app calls
turn.apps.call(app, number, "sms_app", "send", {})
turn.apps.call(app, number, "email_app", "send", {})

-- Get all calls
local calls = turn.test.get_app_calls()
assert(#calls == 2)
assert(calls[1].app_name == "sms_app")
assert(calls[2].app_name == "email_app")

Contact and Journey Management

turn.test.add_contact(contact_data)

Add additional test contacts beyond the 2 pre-populated ones.

turn.test.add_contact({
uuid = "custom-contact",
name = "Custom Contact",
wa_id = "27111111111",
fields = {
age = 25,
city = "Cape Town"
}
})

local contact = turn.test.get_contact("custom-contact")
assert(contact.name == "Custom Contact")
assert(contact.fields.city == "Cape Town")

turn.test.get_contact(uuid)

Retrieve a contact by UUID.

local contact = turn.test.get_contact("contact-1")
assert(contact.name == "Test Contact 1")

turn.test.get_contacts()

Get all test contacts.

local contacts = turn.test.get_contacts()
assert(#contacts == 2) -- Pre-populated contacts

turn.test.add_journey(journey_data)

Add test journeys for testing journey-related functionality.

turn.test.add_journey({
uuid = "custom-journey",
name = "Payment Flow",
description = "Handles payments"
})

local journey = turn.test.get_journey("custom-journey")
assert(journey.name == "Payment Flow")

turn.test.get_journey(uuid)

Retrieve a journey by UUID.

local journey = turn.test.get_journey("journey-1")
assert(journey.name == "Test Journey 1")

Media Save Assertions

turn.test.assert_media_saved(filename)

Check whether turn.media.save() was called with a given filename. Returns true if found.

turn.media.save({
data = audio_data,
filename = "voice_note.ogg",
content_type = "audio/ogg"
})

assert(turn.test.assert_media_saved("voice_note.ogg"))

turn.test.get_media_saves()

Get all recorded turn.media.save() calls. Each entry contains filename, content_type, and external_id.

local saves = turn.test.get_media_saves()
assert(#saves == 1)
assert(saves[1].content_type == "audio/ogg")

Audio Conversion Assertions

turn.test.assert_audio_converted(external_id [, target_format])

Check whether turn.audio.convert() was called for a given attachment. Optionally filter by target format. Returns true if found.

local result, ok = turn.audio.convert(attachment_id, "mp3", {bitrate = "160k"})

assert(turn.test.assert_audio_converted(attachment_id))
assert(turn.test.assert_audio_converted(attachment_id, "mp3"))

turn.test.get_audio_conversions()

Get all recorded turn.audio.convert() calls. Each entry contains external_id, target_format, and opts.

local conversions = turn.test.get_audio_conversions()
assert(#conversions == 1)
assert(conversions[1].target_format == "mp3")
assert(conversions[1].opts.bitrate == "160k")

Lease Testing

turn.test.add_lease(lease_data)

Add test leases for testing asynchronous operations that use the lease system.

turn.test.add_lease({
lease_id = "test-lease-1",
data = {
step = "waiting",
user_id = "123"
},
expires_at = os.time() + 300 -- 5 minutes from now
})

local lease = turn.test.get_lease("test-lease-1")
assert(lease.data.step == "waiting")

turn.test.get_lease(lease_id)

Retrieve a test lease by ID.

local lease = turn.test.get_lease("test-lease-1")
if lease then
assert(lease.data.user_id == "123")
end

Manifest Testing

turn.test.create_test_manifest(overrides)

Create a test manifest with sensible defaults, optionally overriding specific fields.

local manifest = turn.test.create_test_manifest({
app = {
name = "test_app",
version = "1.0.0",
title = "Test Application"
},
journeys = {
{name = "welcome", file = "journeys/welcome.md"},
{name = "onboarding", file = "journeys/onboard.md"}
},
contact_fields = {
{type = "text", name = "user_id", display = "User ID"}
}
})

-- Use in install tests
local result = turn.manifest.install(manifest)

turn.test.assert_journey_exists(manifest, journey_name)

Verify that a journey exists in the manifest.

local manifest = turn.test.create_test_manifest({
journeys = {
{name = "payment_flow", file = "journeys/payment.md"}
}
})

turn.test.assert_journey_exists(manifest, "payment_flow") -- Passes
-- turn.test.assert_journey_exists(manifest, "missing") -- Would fail

turn.test.get_journey_by_name(manifest, journey_name)

Retrieve a journey from the manifest by name.

local journey = turn.test.get_journey_by_name(manifest, "payment_flow")
assert(journey.file == "journeys/payment.md")

Logger Testing

turn.test.get_log_messages(level)

Retrieve log messages captured during the test, optionally filtered by log level. Useful for verifying that your app logs appropriate messages.

Parameters:

  • level (string, optional): Filter by log level ("debug", "info", "warning", "error"). If omitted, returns all log messages.

Returns an array of log entries, each containing:

  • level (string): The log level
  • message (string): The log message
  • timestamp (number): When the log was recorded
-- Get all log messages
local all_logs = turn.test.get_log_messages()

-- Get only error logs
local errors = turn.test.get_log_messages("error")
for _, log in ipairs(errors) do
print(log.level .. ": " .. log.message)
end

-- Get warning logs
local warnings = turn.test.get_log_messages("warning")
assert(#warnings == 0, "Expected no warnings")

turn.test.assert_logged(level, pattern)

Assert that a message matching a Lua pattern was logged at the specified level. Returns true if a matching log entry was found, false otherwise.

Parameters:

  • level (string): The log level to check ("debug", "info", "warning", "error")
  • pattern (string): A Lua pattern to match against log messages
-- Verify specific messages were logged
turn.logger.info("Processing order 12345")
turn.logger.error("Failed to connect to payment API")

-- Assert messages were logged
assert(turn.test.assert_logged("info", "Processing order"))
assert(turn.test.assert_logged("error", "payment API"))

-- Pattern matching works with Lua patterns
assert(turn.test.assert_logged("info", "order %d+")) -- Matches "order" followed by digits

Example: Testing Logging Behavior

describe("Order processing", function()
before(function()
turn.test.reset() -- Clears captured logs
end)

it("should log order processing steps", function()
-- Process an order (your app code)
App.on_event(app, number, "journey_event", {
function_name = "process_order",
args = {"ORD-123", 99.99}
})

-- Verify appropriate logging occurred
assert(turn.test.assert_logged("info", "Processing order ORD%-123"))
assert(turn.test.assert_logged("info", "Order total: 99.99"))

-- Verify no errors were logged
local errors = turn.test.get_log_messages("error")
assert(#errors == 0, "Expected no errors during processing")
end)

it("should log errors on invalid input", function()
App.on_event(app, number, "journey_event", {
function_name = "process_order",
args = {nil, -100}
})

-- Verify error was logged
assert(turn.test.assert_logged("error", "Invalid order"))
end)
end)

Spy Testing

turn.test.spy(func)

Create a spy that wraps a function and tracks calls for testing purposes. Returns a callable table that records all invocations with their arguments. This is compatible with the Lua VM and doesn't require C modules or the debug library.

Parameters:

  • func (function, optional): The function to wrap. If nil, creates a no-op spy.

Returns a callable spy object with:

  • .calls (array): List of call records, each with .vals (arguments array)
  • .call_count (number): Total number of times the spy was called
local turn = require("turn")

-- Create a spy wrapping a function
local mock_client = {
get = turn.test.spy(function(self, resource_type, id)
return {
data = {
resourceType = resource_type,
id = id,
name = "Test Resource"
}
}
end)
}

-- Call the spied function
local result = mock_client:get("Patient", "patient-123")

-- Verify calls
assert(mock_client.get.call_count == 1, "Expected get to be called once")
assert(#mock_client.get.calls == 1, "Expected 1 call record")
assert(mock_client.get.calls[1].vals[2] == "Patient", "Expected Patient resource type")
assert(mock_client.get.calls[1].vals[3] == "patient-123", "Expected patient ID")

-- Verify return value still works
assert(result.data.id == "patient-123", "Expected spy to return actual result")

Use spies to verify function calls in your tests:

describe("API client", function()
local client

before(function()
turn.test.reset()

client = {
create = turn.test.spy(function(self, resource)
return { id = "new-123" }
end),
update = turn.test.spy(function(self, id, resource)
return { id = id, updated = true }
end)
}
end)

it("should create and update resources", function()
-- Perform operations
local created = client:create({ name = "Test" })
local updated = client:update("new-123", { name = "Updated" })

-- Verify create was called
assert(client.create.call_count == 1, "Expected create to be called")
assert(client.create.calls[1].vals[2].name == "Test", "Expected correct data")

-- Verify update was called
assert(client.update.call_count == 1, "Expected update to be called")
assert(client.update.calls[1].vals[2] == "new-123", "Expected correct ID")
end)
end)

Skill Testing

Skills are Lua script files that receive args and context as global variables and return a result. The SDK provides specialized utilities for testing skills.

turn.test.skill.create_runner(skill_module_path, options)

Create a skill runner for testing. The runner handles injecting globals and managing module state.

Parameters:

  • skill_module_path (string): The require path for the skill (e.g., "my_app.skills.my_skill")
  • options (table, optional):
    • clear_modules (array): Module paths to clear before each run
    • mocks (table): Map of module_path to mock value to inject

Returns a runner object with:

  • :run(args, context) - Execute the skill with given args and context
  • :cleanup() - Clean up modules and globals after tests
  • :set_mock(module_path, mock_value) - Update a mock between tests
-- Create a skill runner
local runner = turn.test.skill.create_runner("my_app.skills.get_data", {
clear_modules = { "my_app.helpers" },
mocks = {
["my_app.events.fetch_data"] = function(app, data)
return "continue", { success = true, data = "mock" }
end
}
})

-- Run the skill
local result = runner:run(
{ param1 = "value" }, -- args (becomes global `args`)
{ contact = { id = "123" } } -- context (becomes global `context`)
)

-- Update mock for error testing
runner:set_mock("my_app.events.fetch_data", function(app, data)
return "continue", { error = "Failed" }
end)

-- Clean up after tests
runner:cleanup()

turn.test.skill.patient_context(patient_id, extras)

Helper to create a context object with patient information, commonly used in healthcare apps.

Parameters:

  • patient_id (string): The patient ID to include
  • extras (table, optional): Additional contact fields to merge

Returns a context table with { contact = { fhir_patient_id = patient_id, ...extras } }.

-- Basic patient context
local context = turn.test.skill.patient_context("patient-123")
-- Returns: { contact = { fhir_patient_id = "patient-123" } }

-- With additional fields
local context = turn.test.skill.patient_context("patient-123", {
name = "John Doe",
phone = "+1234567890"
})
-- Returns: { contact = { fhir_patient_id = "patient-123", name = "John Doe", phone = "+1234567890" } }

turn.test.skill.create_event_mock(default_response)

Create a mock event handler function that tracks calls and allows response customization.

Parameters:

  • default_response (table): Default response with status and result fields

Returns two values:

  • mock_fn (function): The mock function to use in mocks table
  • tracker (table): Object for inspecting calls and changing responses

Tracker methods:

  • :get_last_call() - Get the most recent call's { app, data }
  • :set_response(response) - Change the response for subsequent calls
  • .calls (array) - All recorded calls
  • .call_count (number) - Total number of calls
-- Create mock with default response
local event_mock, tracker = turn.test.skill.create_event_mock({
status = "continue",
result = { appointments = {}, count = 0 }
})

-- Use in runner
local runner = turn.test.skill.create_runner("my_app.skills.my_skill", {
mocks = { ["my_app.events.get_appointments"] = event_mock }
})

-- Run skill
runner:run({ days = 7 }, {})

-- Inspect what was passed
local last_call = tracker:get_last_call()
assert(last_call.data.args[1] == 7)

-- Change response for error testing
tracker:set_response({
status = "continue",
result = { error = "Server unavailable" }
})

local error_result = runner:run({}, {})
assert(error_result.error == "Server unavailable")

Complete Skill Testing Example:

local turn = require("turn")
local lester = require("lester")

local describe, it, before, after = lester.describe, lester.it, lester.before, lester.after

describe("cancel_appointment skill", function()
local runner
local event_mock, event_tracker

before(function()
turn.test.reset()

-- Create mock event handler
event_mock, event_tracker = turn.test.skill.create_event_mock({
status = "continue",
result = { success = true, status = "cancelled" }
})

-- Create skill runner
runner = turn.test.skill.create_runner("my_app.skills.cancel_appointment", {
mocks = {
["my_app.events.update_status"] = event_mock
}
})
end)

after(function()
runner:cleanup()
end)

it("should require patient context", function()
local result = runner:run({ appointment_id = "appt-123" }, {})
assert(result.error == "No patient linked")
end)

it("should cancel appointment successfully", function()
local result = runner:run(
{ appointment_id = "appt-123" },
turn.test.skill.patient_context("patient-456")
)

assert(result.success == true)
assert(result.status == "cancelled")
end)

it("should pass appointment ID to event handler", function()
runner:run(
{ appointment_id = "appt-789" },
turn.test.skill.patient_context("patient-456")
)

local call = event_tracker:get_last_call()
assert(call.data.args[1] == "appt-789")
end)

it("should handle cancellation failure", function()
event_tracker:set_response({
status = "continue",
result = { error = "Appointment not found" }
})

local result = runner:run(
{ appointment_id = "invalid" },
turn.test.skill.patient_context("patient-456")
)

assert(result.error == "Appointment not found")
end)
end)

lester.report()
lester.exit()