Tales is a single-binary integration and end-to-end testing tool inspired by Robot Framework and Karate.
Scenarios are written in declarative HCL2 files with the .tales extension.
- Single Go binary, easy to run in CI.
- Declarative DSL focused on API workflows.
- Deterministic seeded data generation.
- Scenario and step execution with dependency-aware scheduling.
- Built-in HTTP provider (including ConnectRPC JSON over HTTP).
- Native iOS UI automation via XCUITest (
step "mobile"), no Appium / no Maestro at runtime. The XCUITest driver is embedded in thetalesbinary and built on first use into~/Library/Caches/tales/apple-driver/, so a released binary runs iOS tests on any macOS+Xcode host without the repository on disk.tales doctor(--jsonfor CI) inspects the cache, embedded source, Xcode, and simctl state in one place. See docs/mobile/ios.md. - Human-readable console output.
- JUnit XML and JSONL reports with artifact paths (screenshots, UI hierarchy).
This repository contains a pragmatic V1 focused on HTTP workflows:
scenario+stepexecution model.expectassertions.capturefor stable step outputs.teardownblock always executed after main steps.when = can(...)support in teardown.- executable
keywordblocks withinputsandoutputs. - Parallel scenario execution (
--parallel). - Deterministic generation via
--seed.
Requirements:
- Go
1.26(seego.mod) golangci-lintformake lint
Build:
go build -o build/tales ./cmd/talesOr:
make buildTagged releases (v*) are built by the release workflow
and published to GitHub Releases. Pre-built binaries are provided for
linux/{amd64,arm64} and darwin/{amd64,arm64}, with a checksums.txt
file alongside. See docs/release.md for the release
process and how to verify a downloaded binary.
Create a file like tests/blog.tales:
version = 1
config {
base_url = env("BASE_URL", "http://localhost:1337")
}
generator "email" "user_email" {
prefix = "test-"
domain = "example.com"
}
generator "password" "user_password" {
length = 16
min_upper = 1
min_lower = 1
min_digit = 1
min_special = 1
specials = "!@#$%^&*"
}
generator "timezone" "user_timezone" {}
generator "locale" "user_locale" {
separator = "-"
}
generator "person" "user_person" {
gender = "female"
}
generator "mac_address" "device_mac" {
prefix = "aa:bb"
separator = "-"
lowercase = true
}
generator "bytes" "trace_id" {
length = 8
encoding = "hex"
}
scenario "Create a blog post" {
tags = ["demo"]
step "http" "create_user" {
request {
method = "POST"
url = "${config.base_url}/users"
headers = {
Accept = "application/json"
Content-Type = "application/json"
}
body {
json = {
email = generate("user_email")
password = generate("user_password")
timezone = generate("user_timezone")
locale = generate("user_locale")
person = generate("user_person")
device = {
mac_address = generate("device_mac")
}
trace_id = generate("trace_id")
}
}
}
expect {
status = 201
json = {
id = is_string()
email = request.body.json.email
}
}
capture {
id = response.json.id
email = response.json.email
password = request.body.json.password
}
}
step "http" "auth_user" {
request {
method = "POST"
url = "${config.base_url}/auth"
headers = {
Content-Type = "application/json"
}
body {
json = {
email = result.create_user.email
password = result.create_user.password
}
}
}
expect {
status = 200
json = {
access_token = is_string()
}
}
capture {
token = response.json.access_token
}
}
teardown {
step "http" "delete_user" {
when = can(result.create_user.id)
request {
method = "DELETE"
url = "${config.base_url}/users/${result.create_user.id}"
headers = {
Authorization = "Bearer ${result.auth_user.token}"
}
}
expect {
status = one_of([200, 204, 404])
}
}
}
}Run:
BASE_URL=http://localhost:1337 ./build/tales test ./tests --seed 1234tales validate <path>Examples:
tales validate ./e2e
tales validate ./e2e/pass/blog.talestales test <path> [flags]Flags:
--seed <int>: deterministic seed (if omitted, current time is used).--parallel <int>: scenario-level parallelism.--tag <tag>: run only scenarios containing one of these tags (repeatable).--scenario <name>: run one scenario by exact name.--report-junit <path>: write JUnit XML.--report-jsonl <path>: write JSONL events.--report-html <path>: write a single-file visual HTML report (mobile screenshots replay).--capture-screenshots <mode>: mobile screenshot capture mode. One ofnone,failures,steps,actions. Defaults tofailures, oractionswhen--report-htmlis set.
Examples:
tales test ./e2e/pass --seed 1234 --parallel 4
tales test ./e2e/pass --tag demo
tales test ./e2e/pass --scenario "Create a blog post"
tales test ./e2e/pass --report-junit build/reports/e2e.junit.xml --report-jsonl build/reports/e2e.jsonlExit codes:
0: all scenarios passed.1: at least one scenario failed.2: parse/validation failure.3: runtime/reporting fatal error.
scenario "..." { ... }step "http" "name" { ... }request.body { json = ... }for JSON payloads.request.body { form = ... }forapplication/x-www-form-urlencodedpayloads.request.body { raw = ... }for raw string payloads.request.auth.basicfor HTTP Basic Authentication.expectassertions for status/headers/json.captureto expose a stable contract for next steps.result.<step_name>.<field>for cross-step references.generator "email",generator "password",generator "timezone",generator "locale",generator "person",generator "mac_address", andgenerator "bytes"for deterministic test data.teardown { ... }for deterministic cleanup.keyword \"...\" { ... }for reusable flows.skip_if { ... }/skip_unless { ... }on a scenario or step to gate execution on OS, architecture, env vars, or any HCL expression. See docs/skip.md.
Backward-compatible aliases currently accepted:
caseas alias forstepresponseas alias forexpect
Request body examples:
request {
body {
form = {
grant_type = "password"
username = result.user.email
password = result.user.password
}
}
}body.form values are encoded with application/x-www-form-urlencoded semantics, so characters such as &, =, +, %, #, and spaces are safe in generated values.
General:
env(name)env(name, default)generate(name)jsonencode(value)url_encode(value)
Top-level expression variables:
host.os—runtime.GOOS(e.g."darwin","linux","windows")host.arch—runtime.GOARCH(e.g."amd64","arm64")config.<key>,result.<step>.<field>, plusrequest,response,inputin step scope
Matchers:
contains(value)matches(regex)exists()not_exists()is_string()is_number()is_bool()is_array()is_object()one_of(values)can(expression)optional(value)— field-level matcher: passes when the key is absent, or when present and the inner expectation matches.required(value)— field-level matcher: explicit version of the default behavior. Fails when the key is absent, otherwise delegates to the inner expectation. Useful for readability when paired withoptional(...).any()— value matcher: matches any present value (null, string, number, bool, array, object). Does not make the field optional by itself — combine withoptional(any())to also accept omitted keys.
ConnectRPC and protobuf JSON often omit fields holding their default value
(unspecified enums, empty arrays, empty strings, ...). Tales remains
strict-by-default: a field declared in expect.json must be present.
Wrap the expected value with optional(...) to allow the response to omit it:
expect {
status = 200
json = {
id = required(is_string())
role = optional("ROLE_UNSPECIFIED") # absent or equal to default
permissions = optional([]) # absent or empty array
display_name = optional("") # absent or empty string
metadata = optional(any()) # absent or any value
}
}Semantics:
- Object matching stays partial: extra fields on the actual response are
ignored unless
strict = true. - Fields are required by default.
required(...)is a no-op wrapper that exists for readability when other fields in the same block useoptional(...). optional(expected)passes when the key is absent. When the key is present the actual value must matchexpected.optional("")does not match an actualnull. Useoptional(null)to accept eithernullor a missing key.any()alone still requires the key to be present. Useoptional(any())to also accept omitted keys.
Generation is deterministic with --seed and stable derivation inputs.
Running the same suite with the same seed produces the same generated values, even with parallel execution. Step retries reuse the same deterministic generation context, so generated values do not change between retry attempts.
Password generators default to a 16-character password with at least one uppercase letter, lowercase letter, digit, and special character. Supported password options are length, min_upper, min_lower, min_digit, min_special, and specials.
Locale generators support language, country, and separator. Timezone generators return IANA tzdb names or aliases.
Person generators return an object with first_name, last_name, gender, and name. MAC address generators support prefix, separator, lowercase, and uppercase. Bytes generators return deterministic encoded bytes and support length plus encoding (hex or base64).
Default output includes:
- Per-scenario status and duration.
- Per-step and teardown status.
- Failure details with request/response summaries.
- Replay command including seed.
Use --report-junit <path> for CI systems expecting JUnit.
Use --report-jsonl <path> for lightweight tooling and LLM pipelines.
Each line is one event (scenario, step, or action) with fields like:
type,phase,statusfile,scenario,step,providerduration_ms,seederror(when failing)
When per-action recording is on (any --capture-screenshots other than none), each step event is followed by one "type":"action" event per UI action. Action events carry index, kind, label, selector_id, secure, value, status, duration_ms, and optional screenshot / hierarchy paths. Secure values are masked to "***".
Use --report-html <path> to produce a single offline HTML file that replays the mobile test action by action — screenshot on the left, vertical action timeline on the right with the active action highlighted, plus playback controls (Space, ←/→, speed selector). The file is self-contained: vanilla CSS + JS are inlined; screenshots are referenced by relative paths next to the report.
Picking a capture mode:
none— no screenshots or hierarchy ever (failures included).driver_logartifact is still surfaced for non-starting drivers.failures— default. Step-level screenshot + hierarchy only when a step fails (legacy behavior).steps— one end-of-step screenshot per step.actions— one screenshot per UI action. Required for a usable visual replay; selected automatically when--report-htmlis set.
Per-action artifacts live under:
build/artifacts/mobile/<scenario>-<hash>/<step>/<phase>/attempt-<n>/actions/NNNN-<kind>-<id>/
See docs/reports/visual.md for a full walk-through, security notes, and limitations.
The repository includes:
- Passing suites:
./e2e/pass/*.tales - Intentional failure suite:
./e2e/fail/*.tales - Real mock HTTP server:
./e2e/mockserver
Run all passing E2E suites:
make e2eRun intentional-failure E2E suite (expects CLI exit code 1):
make e2e-failuremake test
make lint
make e2ecmd/tales: CLI entrypoint.internal/cli: command wiring (test,validate).internal/parser: loading and HCL decoding.internal/model: suite/scenario/step models.internal/lang: expression evaluation and functions.internal/runtime: execution engine, scheduler, seed logic.internal/dag: dependency graph/topological layering.internal/assertion: matcher and JSON assertion logic.internal/provider/http: HTTP execution provider.internal/report: console/JUnit/JSONL reporting.e2e/mockserver: in-memory test API used by E2E.
- HTTP is the only production-ready external provider.
- No browser/mobile providers.
- No external plugin system.
- No dedicated ConnectRPC provider (Connect JSON works through HTTP).
See LICENSE.md.