Integration testing, reinvented
End-to-end tests, written once, replayable forever — in a single Go binary.
Tales is the integration testing tool we wished existed. No Python toolchain to babysit. No DSL that turns into a JavaScript codebase. One declarative HCL2 syntax, one seedable run, one tool for API, SQL, and iOS workflows.
$ tales test ./e2e/pass --seed 1234 --parallel 4
tales: loaded 12 scenarios from 5 files; timeout=disabled
● PASS e2e/pass/blog.tales / Create blog post (842ms)
● PASS e2e/pass/keyword.tales / Use keyword (231ms)
● PASS e2e/pass/sql.tales / PostgreSQL operations (95ms)
● PASS e2e/pass/file_upload.tales / Multipart upload (47ms)
● PASS e2e/pass/signed_webhook.tales / Signed webhook (38ms)
Summary: 12 passed · 0 failed · 0 skipped · 1.24s
Why Tales exists
Built after years of fighting the same problems with Robot Framework and Karate.
No Python env to babysit
Robot Framework drags a Python toolchain that breaks on every OS update, every pip upgrade, every CI runner refresh. Tales is one static Go binary you drop into CI and forget about.
No DSL-meets-JavaScript creep
Karate scenarios tend to grow JavaScript blocks until they are a codebase. Tales is fully declarative HCL2 with built-in functions, generators, and matchers — what you write is what you read.
API, SQL, iOS in one runner
Stop juggling separate tools for HTTP, database fixtures, and mobile UI tests. Tales runs them in the same scenario file, with the same syntax, in the same report.
What you get out of the box
A focused toolset for the test problems that actually slow teams down.
Single binary
Drop `tales` into your CI. No runtime, no plugins, no version manager. Static Go binary for Linux and macOS.
Declarative HCL2
Readable scenarios that diff cleanly. The DSL is the test — no callbacks, no glue code, no JavaScript escape hatch.
Deterministic faker
Generate emails, passwords, people, MAC addresses, bytes. Same seed → same data, every run, on every machine.
Seedable replay
Reproduce a flaky CI failure locally with one flag. `--seed 1234` and your laptop replays exactly what the runner saw.
HTTP / SQL / iOS providers
Drive your API, seed your database (Postgres, MySQL), tap through a real iOS simulator — all from one tool.
Parallel by default
Scenarios run concurrently with `--parallel`. Steps inside a scenario stay sequential, so chained captures remain deterministic.
Visual HTML report
A self-contained HTML report with timeline, action tiles, and screenshot replay. Open it. Share it. Debug in two clicks.
CI-native outputs
JUnit XML and JSONL out of the box. Exit codes your pipeline already understands. No glue scripts, no reporters to wire up.
See it in 30 seconds
Three tabs — a scenario, the command that runs it, the report your CI gets.
version = 1
generator "email" "user_email" {
prefix = "qa-"
domain = "example.com"
}
scenario "Create blog post" {
step "http" "create_user" {
request {
method = "POST"
url = "https://api.example.com/users"
body {
json = {
email = generate("user_email")
password = "Sup3rS3cret!"
}
}
}
expect {
status = 201
json = {
id = is_string()
email = request.body.json.email
}
}
capture {
id = response.json.id
email = response.json.email
}
}
step "http" "create_post" {
request {
method = "POST"
url = "https://api.example.com/blog/posts"
headers = { Author = result.create_user.id }
body {
json = {
title = "Hello from Tales"
body = "Reproducible test data, every run."
}
}
}
expect {
status = 201
}
}
teardown {
step "http" "delete_user" {
when = can(result.create_user.id)
request {
method = "DELETE"
url = "https://api.example.com/users/${result.create_user.id}"
}
expect { status = one_of([200, 204, 404]) }
}
}
} # Validate scenarios without running them (parse + reference checks)
$ tales validate ./e2e/pass
# Run the suite with a deterministic seed, 4 scenarios in parallel,
# emit JUnit XML for CI and a single-file visual HTML report
$ tales test ./e2e/pass \
--seed 1234 \
--parallel 4 \
--report-junit ./reports/junit.xml \
--report-html ./reports/visual.html
tales: loaded 12 scenarios from 5 files; timeout=disabled
PASS e2e/pass/blog.tales Create blog post (842ms)
PASS e2e/pass/keyword.tales Use keyword (231ms)
PASS e2e/pass/sql.tales PostgreSQL ops ( 95ms)
…
Summary: 12 passed · 0 failed · 0 skipped · 1.24s
HTML report: ./reports/visual.html {"event":"suite_start","seed":1234,"parallel":4,"files":5,"scenarios":12}
{"event":"scenario_start","scenario":"Create blog post","tags":[]}
{"event":"step","scenario":"Create blog post","step":"create_user","status":"pass","duration_ms":312}
{"event":"step","scenario":"Create blog post","step":"create_post","status":"pass","duration_ms":418}
{"event":"teardown_step","scenario":"Create blog post","step":"delete_user","status":"pass","duration_ms":112}
{"event":"scenario_end","scenario":"Create blog post","status":"pass","duration_ms":842}
{"event":"suite_end","status":"pass","passed":12,"failed":0,"skipped":0,"duration_ms":1240} Built for real-world test problems
Pick a starting point — every use case is one binary away.
HTTP workflows
Chain requests with captured IDs, assert JSON with matchers, sign webhooks with HMAC, upload multipart files. The HTTP provider is the heart of Tales.
step "http" "send_signed_webhook" {
vars {
ts = now_unix()
body = jsonencode({ id = "evt-1", type = "ping" })
sig = hmac_sha256_hex(config.webhook_secret, "${vars.ts}.${vars.body}")
}
request {
method = "POST"
url = "${config.base_url}/webhook"
headers = { X-Signature = "t=${vars.ts},v1=${vars.sig}" }
body { raw = vars.body }
}
} Database fixtures
Set up and tear down database state alongside your HTTP scenarios. Postgres and MySQL, scalar args with int64 precision, DSNs masked in reports.
config {
sql {
connections {
pg = { driver = "postgres", dsn = env("POSTGRES_DSN") }
}
}
}
step "sql" "insert_org" {
connection = "pg"
exec {
sql = "INSERT INTO orgs (id, vip) VALUES ($1, $2)"
args = ["org_123", true]
}
expect { json = { rows_affected = 1 } }
} iOS smoke tests
Drive a real iOS simulator with an embedded XCUITest driver — zero Swift code to write, no test target to maintain. Visual report shows every tap.
step "mobile" "fill_login" {
platform = "ios"
target = "iphone"
actions {
input_text {
id = "login.email"
value = "[email protected]"
}
input_text {
id = "login.password"
value = "secret"
secure = true
}
tap { id = "login.submit" }
wait_visible { id = "home.screen" }
}
expect {
visible { id = "home.welcome" }
text {
id = "home.user"
value = contains("Welcome")
}
}
} Same seed. Same data. Every run.
Tales generators are seeded — pass `--seed 1234` once and your CI gets the same emails, passwords, person names, and IDs as your laptop. No more "works on my machine". No more rerunning a CI job five times hoping the flake goes away.
- A single `--seed` flag controls every faker call across every scenario.
- Generator outputs are mixed with scenario, step, and generator names — so identical runs produce identical values even under `--parallel`.
- Reproduce a red CI build by copying its seed into your local command line. The data lines up byte for byte.
Install Tales
Two paths. Pick whichever fits your stack.
Pre-built binary
Grab the latest release tarball for Linux or macOS (amd64 / arm64) from GitHub Releases.
Open releases →Build from source
You will need Go 1.26+. The Makefile handles the rest.
git clone https://github.com/hyperxlab/tales
cd tales
make install