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)