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. A modern alternative to Robot Framework, Karate, and Venom, without the Python toolchain to babysit, the JavaScript creep, or the YAML soup. One declarative HCL2 syntax, one seedable run, one tool for API, SQL, Browser, iOS, and HTTP load workflows. Android coming soon.
AI agent-ready: ships with a Claude Code skill that writes your tests$ 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
AI agent-ready
Built for the way you code now: with an agent
Tales is designed to be driven by AI coding agents, not just typed by hand. A declarative HCL2 surface, seedable deterministic runs, and structured failure output give an agent exactly what it needs to write a test, run it, read the result, and fix it on its own.
-
A dedicated Claude Code skill
Tales ships the `tales-test-generator` skill: it grounds the agent in the DSL source of truth, then generates valid, runnable `.tales` suites (scenarios, keywords, captures, teardown) instead of plausible-looking guesses.
-
Deterministic, so agents self-correct
Seeded faker plus JSONL output mean the agent gets the same data and the same diagnostics every run. It can reproduce a failure, reason about it, and verify its own fix.
-
Declarative surface, small blast radius
No glue code, no JavaScript escape hatch. The agent edits one contained HCL block, and what it writes is what runs: easier to generate, easier to review.
make install-skill Copies it to ~/.claude/skills/tales-test-generator.
scenario "Login then refresh" {
step "http" "login" {
request {
method = "POST"
url = "${config.base_url}/auth/login"
body { json = { email = config.user, password = config.pass } }
}
expect { status = 200 }
capture { token = response.json.access_token }
}
step "http" "refresh" {
request {
method = "POST"
url = "${config.base_url}/auth/refresh"
headers = { Authorization = "Bearer ${result.login.token}" }
}
expect { status = 200 }
}
} Why Tales exists
Built after years of fighting the same problems with Robot Framework, Karate, and Venom.
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.
No YAML soup either
Venom and similar YAML-driven runners get hard to read once scenarios chain captures and conditionals. HCL2 keeps the same declarative spirit, but with typed values, comments, and expressions that scale to real workflows.
API, SQL, Browser, iOS, Load in one runner
Stop juggling separate tools for HTTP, database state, browser flows, mobile UI tests, and smoke load. Tales runs them in the same scenario file, with the same syntax, in the same report. Android support is on the roadmap.
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.
Five providers, one binary
Drive your API, set up database state (Postgres, MySQL), drive Chrome via CDP with Web performance budgets, tap through an iOS simulator, replay HTTP at concurrent load. Android coming soon.
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 hooks
Run plain SQL statements (Postgres or MySQL) inside a scenario to flip a flag, seed a row, or read internal state the public API does not expose. Not a migration tool, not a fixture loader: a thin escape hatch alongside your HTTP assertions.
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 (Android soon)
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. Android coming soon.
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")
}
}
} Web flows + perf budgets
Drive a real Chrome/Chromium session via the Chrome DevTools Protocol. Click, fill, submit, assert visible elements, URL, and title. Pin Web Performance budgets (FCP, LCP, CLS, load) with the same threshold matchers you use everywhere else.
step "browser" "dashboard_perf" {
target = "chrome"
actions {
goto {
url = "${config.base_url}/web/dashboard"
}
wait_visible {
selector = "[data-testid='dashboard.title']"
}
}
expect {
web_perf {
fcp = lt("1800ms")
lcp = lt("2500ms")
cls = lt(0.1)
}
}
} HTTP smoke benchmarks
Replay one HTTP request concurrently for a duration or a fixed request count and assert latency percentiles, RPS, and error/status ratios. Not a replacement for k6 or Gatling: a regression guard you keep next to your normal scenarios.
step "load" "health" {
http {
method = "GET"
url = "${config.base_url}/healthz"
}
run {
requests = 500
concurrency = 10
}
expect {
status_2xx_ratio = gte(0.99)
p95 = lt("200ms")
error_ratio = lte(0.01)
}
} 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
Four ways in. Pick whichever fits your stack.
Homebrew (macOS / Linux)
The fastest path on a laptop. Linux and macOS, amd64 and arm64.
brew install --cask tales-testing/tap/tales 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/tales-testing/tales
cd tales
make install GitHub Action
Drop one step into your workflow to pin and install Tales on the runner. Used by the example CI recipes.
- uses: tales-testing/setup-tales-action@v1
with:
version: latest