Skip to main content

Testing Your App

Testing Your App

The Turn Lua SDK provides a complete testing environment using lester, a pure Lua BDD testing framework that works with Luerl.

Why lester?

Luerl implements Lua 5.3 on the Erlang BEAM VM and cannot load C-based modules. Many popular Lua testing frameworks like Busted depend on C modules (such as LuaFileSystem), making them incompatible with Luerl. lester is written in pure Lua with no C dependencies and provides a Busted-compatible API (describe, it, before, after), ensuring your tests run in the same environment as production.

Local Test-Driven Development (TDD)

The Docker SDK makes TDD workflows easy:

# Run tests once
make test

# Watch mode - automatically runs tests when files change
make watch

# Or use turn-app directly
turn-app test # Run tests
turn-app watch # Watch mode

The generated app includes a complete test suite that you can run immediately. All tests run in the same Luerl environment that your app will use in production, ensuring confidence in your code.

Writing Tests

Create test files using lester's BDD-style API:

-- test_my_app.lua
local lester = require('lester')
local describe, it, before = lester.describe, lester.it, lester.before

-- Mock turn library
local turn = {
app = { ... },
logger = { ... }
}
package.loaded["turn"] = turn

local App = require("my_app")

-- Test Suite
describe("My App", function()
before(function()
-- Setup code runs before each test
end)

it("should handle install event", function()
local result = App.on_event(
{ uuid = "test-uuid" },
{ vname = "+27123456789" },
"install",
{}
)
assert(result == true, "Expected install to return true")
end)
end)

-- Run the tests
lester.report()
local exit_code = lester.exit()
return exit_code

Running Tests

If you're using the Docker SDK (recommended), tests are already set up:

# Run tests once
make test

# Watch mode for TDD workflow
make watch

For traditional template users, use the Make helper:

# From your project root
make test-lua-app APP=my_app

lester provides BDD-style test organization, color-coded output, and detailed failure messages showing which tests pass or fail.

Testing Patterns and Best Practices

Pattern 1: Mocking External APIs

Test API integrations without making real network calls:

describe("External API integration", function()
before(function()
turn.test.reset()

-- Mock successful API response
turn.test.mock_http(
{method = "POST", url = "https://payment.api/charge"},
{
status = 200,
headers = {["content-type"] = "application/json"},
body = json.encode({
success = true,
charge_id = "ch_123",
amount = 100
})
}
)
end)

it("should process payment successfully", function()
local result = MyApp.process_payment({
amount = 100,
currency = "USD"
})

-- Verify HTTP was called with correct data
turn.test.assert_http_called({
method = "POST",
url = "https://payment.api/charge"
})

-- Verify result
assert(result.success == true)
assert(result.charge_id == "ch_123")
end)

it("should include authentication headers", function()
MyApp.process_payment({amount = 50})

-- Get the actual request
local requests = turn.test.get_http_requests()
assert(requests[1].headers["Authorization"] ~= nil)
end)
end)

Pattern 2: Testing Error Handling

Ensure your app handles failures gracefully:

describe("Error handling", function()
it("should handle HTTP 500 errors", function()
-- Mock failed response
turn.test.mock_http(
{method = "GET", url = "https://api.example.com/data"},
{
status = 500,
body = "Internal Server Error"
}
)

local success, error = pcall(function()
return MyApp.fetch_data()
end)

assert(success == false)
assert(string.match(error, "500"))
end)

it("should handle network timeouts", function()
-- Mock timeout error
turn.test.mock_http(
{method = "GET", url = "https://slow.api/data"},
{
status = 0, -- Indicates connection failure
error = "timeout"
}
)

local result = MyApp.fetch_data()
assert(result.error == "timeout")
end)
end)

Pattern 3: Testing Async Operations with Leases

Test asynchronous workflows that use the lease system:

describe("Async operations", function()
it("should wait for external callback", function()
turn.test.reset()

-- Simulate async operation starting
local result = MyApp.on_event(app, number, "journey_event", {
action = "start_async_process",
webhook_url = "https://callback.example.com"
})

-- Should return wait action with lease
assert(result.action == "wait")
assert(result.lease_id ~= nil)

-- Simulate callback received (in reality, comes from webhook)
turn.test.add_lease({
lease_id = result.lease_id,
data = {
status = "completed",
result = "success"
}
})

-- Resume processing
local resume_result = MyApp.on_event(app, number, "journey_event", {
action = "resume",
lease_id = result.lease_id
})

-- Should continue with result
assert(resume_result.action == "continue")
assert(resume_result.data.result == "success")
end)
end)

Pattern 4: Testing App-to-App Communication

Test multi-app integrations:

describe("App-to-app communication", function()
before(function()
turn.test.reset()

-- Mock payment app
turn.test.mock_app_call("payment_app", "charge", {
success = true,
transaction_id = "txn_789",
amount_charged = 50
})

-- Mock notification app
turn.test.mock_app_call("notification_app", "send_email", {
sent = true,
message_id = "msg_456"
})
end)

it("should call payment app and send notification", function()
local result = MyApp.on_event(app, number, "journey_event", {
action = "process_order",
amount = 50,
email = "user@example.com"
})

-- Verify payment app was called
turn.test.assert_app_called("payment_app", "charge", {
amount = 50
})

-- Verify notification app was called
turn.test.assert_app_called("notification_app", "send_email")

-- Verify result
assert(result.data.transaction_id == "txn_789")
assert(result.data.notification_sent == true)
end)

it("should handle payment failure", function()
-- Mock payment failure
turn.test.mock_app_call("payment_app", "charge", {
success = false,
error = "insufficient_funds"
})

local result = MyApp.on_event(app, number, "journey_event", {
action = "process_order",
amount = 50
})

-- Should handle failure gracefully
assert(result.data.error == "payment_failed")
end)
end)

Pattern 5: Testing Configuration

Test different configuration scenarios:

describe("Configuration handling", function()
it("should use test API key", function()
turn.test.set_config("api_key", "test_key_123")
turn.test.set_config("api_url", "https://test.api.com")

MyApp.make_api_call()

local requests = turn.test.get_http_requests()
assert(requests[1].url == "https://test.api.com")
assert(requests[1].headers["X-API-Key"] == "test_key_123")
end)

it("should handle missing config", function()
turn.test.reset() -- Clears config

local success, error = pcall(function()
return MyApp.make_api_call()
end)

assert(success == false)
assert(string.match(error, "API key not configured"))
end)
end)

Complete Test Example

Here's a complete test file demonstrating multiple testing patterns:

-- spec/my_app_spec.lua
local lester = require('lester')
local describe, it, before = lester.describe, lester.it, lester.before
local turn = require('turn')
local json = require('turn.json')

local MyApp = require('my_app')

describe("MyApp", function()
local app, number

before(function()
-- Reset state before each test
turn.test.reset()

-- Set up test fixtures
app = {uuid = "test-app-uuid"}
number = {vname = "+27123456789"}

-- Set test configuration
turn.test.set_config("api_key", "test-key-123")
turn.test.set_config("api_url", "https://test.api.com")
end)

describe("install event", function()
it("should create required contact fields", function()
local manifest = turn.test.create_test_manifest({
contact_fields = {
{type = "text", name = "user_id", display = "User ID"},
{type = "number", name = "balance", display = "Account Balance"}
}
})

local result = turn.manifest.install(manifest)
assert(result == true)

-- Verify fields exist (would check via turn.contacts API in real test)
end)

it("should install journeys", function()
local manifest = turn.test.create_test_manifest({
journeys = {
{name = "welcome_flow", file = "journeys/welcome.md"},
{name = "support_flow", file = "journeys/support.md"}
}
})

turn.manifest.install(manifest)

turn.test.assert_journey_exists(manifest, "welcome_flow")
turn.test.assert_journey_exists(manifest, "support_flow")
end)
end)

describe("journey_event handling", function()
before(function()
-- Mock external verification API
turn.test.mock_http(
{method = "POST", url = "https://test.api.com/verify"},
{
status = 200,
headers = {["content-type"] = "application/json"},
body = json.encode({
verified = true,
user_id = "usr_123"
})
}
)
end)

it("should verify user with external API", function()
local result = MyApp.on_event(app, number, "journey_event", {
action = "verify_user",
phone = "27123456789"
})

-- Verify HTTP was called correctly
turn.test.assert_http_called({
method = "POST",
url = "https://test.api.com/verify"
})

-- Verify result
assert(result.action == "continue")
assert(result.data.verified == true)
assert(result.data.user_id == "usr_123")
end)

it("should handle API errors gracefully", function()
-- Mock error response
turn.test.mock_http(
{method = "POST", url = "https://test.api.com/verify"},
{
status = 500,
body = "Internal Server Error"
}
)

local result = MyApp.on_event(app, number, "journey_event", {
action = "verify_user",
phone = "27123456789"
})

-- Should handle error gracefully
assert(result.action == "continue")
assert(result.data.error ~= nil)
assert(result.data.verified == false)
end)

it("should include authentication header", function()
MyApp.on_event(app, number, "journey_event", {
action = "verify_user",
phone = "27123456789"
})

local requests = turn.test.get_http_requests()
assert(requests[1].headers["X-API-Key"] == "test-key-123")
end)
end)

describe("app-to-app communication", function()
before(function()
-- Mock payment processing app
turn.test.mock_app_call("payment_app", "charge", {
success = true,
transaction_id = "txn_789",
amount_charged = 50
})

-- Mock notification app
turn.test.mock_app_call("notification_app", "send_sms", {
sent = true,
message_id = "sms_456"
})
end)

it("should process payment and send notification", function()
local result = MyApp.on_event(app, number, "journey_event", {
action = "process_payment",
amount = 50,
phone = "27123456789"
})

-- Verify payment app was called
turn.test.assert_app_called("payment_app", "charge", {
amount = 50
})

-- Verify notification app was called
turn.test.assert_app_called("notification_app", "send_sms")

-- Verify result
assert(result.data.transaction_id == "txn_789")
assert(result.data.notification_sent == true)
end)

it("should handle payment failure", function()
-- Mock payment failure
turn.test.mock_app_call("payment_app", "charge", {
success = false,
error = "insufficient_funds"
})

local result = MyApp.on_event(app, number, "journey_event", {
action = "process_payment",
amount = 50
})

-- Should not send notification on failure
local calls = turn.test.get_app_calls()
local notification_calls = {}
for _, call in ipairs(calls) do
if call.app_name == "notification_app" then
table.insert(notification_calls, call)
end
end
assert(#notification_calls == 0)

-- Should return error
assert(result.data.error == "payment_failed")
end)
end)

describe("async operations", function()
it("should wait for webhook callback", function()
-- Start async operation
local result = MyApp.on_event(app, number, "journey_event", {
action = "start_verification",
callback_url = "https://myapp.com/callback"
})

-- Should return wait action
assert(result.action == "wait")
assert(result.lease_id ~= nil)

-- Simulate webhook callback received
turn.test.add_lease({
lease_id = result.lease_id,
data = {
verified = true,
verification_code = "ABC123"
}
})

-- Resume processing
local resume_result = MyApp.on_event(app, number, "journey_event", {
action = "resume_verification",
lease_id = result.lease_id
})

-- Should continue with result
assert(resume_result.action == "continue")
assert(resume_result.data.verified == true)
assert(resume_result.data.verification_code == "ABC123")
end)
end)
end)

-- Run tests and report results
lester.report()
return lester.exit()

Debugging Test Failures

When tests fail, here are some debugging strategies:

1. Inspect HTTP Requests

-- Get all HTTP requests to see what was actually sent
local requests = turn.test.get_http_requests()
for i, req in ipairs(requests) do
turn.logger.info("Request " .. i .. ": " .. req.method .. " " .. req.url)
turn.logger.info(" Body: " .. (req.body or "none"))
end

2. Check App Calls

-- See what app calls were made
local calls = turn.test.get_app_calls()
for i, call in ipairs(calls) do
turn.logger.info("App call " .. i .. ": " .. call.app_name .. "." .. call.event_name)
turn.logger.info(" Data: " .. json.encode(call.data))
end

3. Verify Test Data

-- Check pre-populated contacts
local contacts = turn.test.get_contacts()
turn.logger.info("Available contacts:")
for _, contact in ipairs(contacts) do
turn.logger.info(" - " .. contact.uuid .. ": " .. contact.name)
end

4. Use Descriptive Assertions

-- Instead of:
assert(result.success == true)

-- Use:
assert(result.success == true, "Expected payment to succeed, got: " .. json.encode(result))

Common Testing Pitfalls

Pitfall 1: Forgetting to Reset State

-- BAD: State leaks between tests
describe("My tests", function()
it("test 1", function()
turn.test.mock_http({...}, {...})
-- Test logic
end)

it("test 2", function()
-- Still has mocks from test 1!
end)
end)

-- GOOD: Clean state for each test
describe("My tests", function()
before(function()
turn.test.reset() -- Clean slate
end)

it("test 1", function()
turn.test.mock_http({...}, {...})
-- Test logic
end)

it("test 2", function()
-- Fresh state
end)
end)

Pitfall 2: Not Mocking All HTTP Calls

-- BAD: Unmocked HTTP call will fail
it("should fetch and process data", function()
local result = MyApp.fetch_and_process() -- Makes real HTTP call!
assert(result.processed == true)
end)

-- GOOD: Mock all external calls
it("should fetch and process data", function()
turn.test.mock_http(
{method = "GET", url = "https://api.example.com/data"},
{status = 200, body = json.encode({value = 42})}
)

local result = MyApp.fetch_and_process()
assert(result.processed == true)
end)

Pitfall 3: Not Testing Error Paths

-- BAD: Only testing happy path
it("should process payment", function()
turn.test.mock_app_call("payment_app", "charge", {success = true})
local result = MyApp.process_payment({amount = 100})
assert(result.success == true)
end)

-- GOOD: Test both success and failure
describe("payment processing", function()
it("should handle successful payment", function()
turn.test.mock_app_call("payment_app", "charge", {success = true})
local result = MyApp.process_payment({amount = 100})
assert(result.success == true)
end)

it("should handle payment failure", function()
turn.test.mock_app_call("payment_app", "charge", {
success = false,
error = "insufficient_funds"
})
local result = MyApp.process_payment({amount = 100})
assert(result.success == false)
assert(result.error == "payment_failed")
end)
end)

Testing Skills

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

Skill Testing Pattern

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

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

describe("my_skill", function()
local runner
local event_mock, event_tracker

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

-- Create a mock for the event handler the skill depends on
event_mock, event_tracker = turn.test.skill.create_event_mock({
status = "continue",
result = { success = true, data = "mock data" }
})

-- Create the skill runner with mocked dependencies
runner = turn.test.skill.create_runner("my_app.skills.my_skill", {
mocks = {
["my_app.events.my_event"] = event_mock
}
})
end)

after(function()
runner:cleanup()
turn.test.reset()
end)

it("should return success with valid args", function()
local result = runner:run(
{ param1 = "value1" }, -- args
{ contact = { user_id = "123" } } -- context
)

assert(result.success == true)
end)

it("should return error when param1 is missing", function()
local result = runner:run({}, {})

assert(result.error ~= nil)
end)

it("should pass correct arguments to event handler", function()
runner:run({ param1 = "test" }, {})

local last_call = event_tracker:get_last_call()
assert(last_call ~= nil, "Event should have been called")
assert(last_call.data.args[1] == "test")
end)
end)

lester.report()
lester.exit()

Using the Skill Runner

The skill runner handles the complexity of:

  • Injecting args and context as globals
  • Clearing and reloading modules between test runs
  • Managing mock dependencies
-- Create a runner with mocked modules
local runner = turn.test.skill.create_runner("my_app.skills.my_skill", {
-- Modules to clear before each run (automatically includes the skill itself)
clear_modules = { "my_app.some_dependency" },

-- Mock modules to inject
mocks = {
["my_app.events.my_event"] = function(app, data)
return "continue", { success = true }
end
}
})

-- Run the skill with args and context
local result = runner:run(
{ name = "John", amount = 100 }, -- These become global `args`
{ contact = { user_id = "123" } } -- This becomes global `context`
)

-- Update mocks between tests
runner:set_mock("my_app.events.my_event", function(app, data)
return "continue", { error = "Something failed" }
end)

-- Clean up after tests
runner:cleanup()

Testing Patient Context

Many skills require a patient ID from context. Use the helper:

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

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

-- Use in tests
it("should return patient data", function()
local result = runner:run(
{},
turn.test.skill.patient_context("patient-456")
)

assert(result.error == nil)
end)

Mocking Event Handlers with Tracking

Use create_event_mock to create mock event handlers that track calls:

-- Create mock with default response
local event_mock, tracker = turn.test.skill.create_event_mock({
status = "continue",
result = { appointments = {}, count = 0 }
})

-- Use the mock
runner = turn.test.skill.create_runner("my_app.skills.my_skill", {
mocks = { ["my_app.events.my_event"] = event_mock }
})

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

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

-- Change the response for error testing
tracker:set_response({
status = "continue",
result = { error = "Connection failed" }
})

local error_result = runner:run({}, {})
assert(error_result.error == "Connection failed")

Complete Skill Test Example

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

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

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

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

event_mock, event_tracker = turn.test.skill.create_event_mock({
status = "continue",
result = {
appointments = {
{ id = "appt-1", date = "2024-03-20", doctor = "Dr. Smith" }
},
count = 1
}
})

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

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

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

describe("successful retrieval", function()
it("should return appointments for valid patient", function()
local result = runner:run(
{ days_ahead = 30 },
turn.test.skill.patient_context("patient-123")
)

assert(result.error == nil)
assert(result.count == 1)
assert(result.appointments[1].doctor == "Dr. Smith")
end)

it("should pass correct parameters to event handler", function()
runner:run(
{ days_ahead = 7 },
turn.test.skill.patient_context("patient-456")
)

local call = event_tracker:get_last_call()
assert(call.data.args[1] == "patient-456")
assert(call.data.args[2] == 7)
end)
end)

describe("error handling", function()
it("should propagate errors from event handler", function()
event_tracker:set_response({
status = "continue",
result = { error = "FHIR server unavailable" }
})

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

assert(result.error == "FHIR server unavailable")
end)
end)
end)

lester.report()
lester.exit()