pronounced: /ˈɡɜːrkəl/, gur-kull
Translates Gherkin acceptance tests into code. Written by Human-Agent interaction, for Human-Agent interaction.
gherclj bridges the gap between human-readable feature specifications and executable tests. It parses standard Gherkin .feature files and generates test files that call real code.
Why agents choose gherclj:
- Inspectable pipeline: agents can debug parse, match, and generate stages separately.
- Lower search cost:
gherclj stepsexposes the available step surface directly. - Lower maintenance cost:
gherclj unusedhelps prune dead routes and reduce agent workload. - Polyglot output: one Gherkin pipeline can generate native tests for multiple target languages.
The pipeline:
.feature files → [intermediate representation (IR)] -> generated unit test files
The generated specs are native to the target language's test runner — speclj, JUnit 5, RSpec, Go's testing, pytest, etc. They're readable, debuggable, committable, and have no gherclj runtime dependency. Production code never imports gherclj either; gherclj's footprint is the step-routing namespace plus a bb.edn task.
Internally the pipeline goes feature → IR → spec. The IR isn't persisted by default; pass --ir-edn (or :ir-edn true in config) to also write it to target/gherclj/edn/ for inspection.
;; deps.edn or bb.edn
{:deps {io.github.slagyr/gherclj {:git/tag "v1.5.0" :git/sha "c1df8cc"}}}Create a features/ directory at your project root and add .feature files using standard Gherkin syntax:
myapp/
features/
authentication.feature
checkout.feature
src/
...
Feature: Authentication
Scenario: Admin can log in
Given a user "alice" with role "admin"
When the user logs in
Then the response status should be 200
Scenario: Guest gets 401
Given a user "unknown" with role "guest"
When the user logs in
Then the response status should be 401The features/ directory is the default; configure a different location with :features-dir in gherclj.edn.
A step definition is a single-line routing entry that maps a Gherkin phrase to a helper function. The macro signature is intentionally constrained:
(defgiven phrase helper-ref [docstring])
(defwhen phrase helper-ref [docstring])
(defthen phrase helper-ref [docstring])There is no body and no arg vector. Helpers are plain Clojure functions; the macro builds the call mechanically from the matched template args.
Recommended pattern: helpers in their own namespace, step defs in a routing-only namespace. Each step namespace declares its helper module via helper!:
;; myapp/features/helpers/auth.clj — real test logic lives here
(ns myapp.features.helpers.auth
(:require [gherclj.core :as g]
[myapp.auth :as app]))
(defn create-user! [name role]
(g/assoc! :user {:name name :role role}))
(defn user-logs-in! []
(let [{:keys [role]} (g/get :user)]
(g/assoc! :response {:status (if (= "admin" role) 200 401)})))
(defn response-status [expected]
(g/should= expected (g/get-in [:response :status])));; myapp/features/steps/auth.clj — pure routing
(ns myapp.features.steps.auth
(:require [gherclj.core :refer [defgiven defwhen defthen helper!]]
[myapp.features.helpers.auth]))
(helper! myapp.features.helpers.auth)
(defgiven "a user {name:string} with role {role:string}" auth/create-user!)
(defwhen "the user logs in" auth/user-logs-in!)
(defthen "the response status should be {status:int}" auth/response-status)Why the split: the generated spec inlines (auth/create-user! "alice" "admin") directly. It depends only on the helper namespace — never on the step namespace. Helpers are normal code, can be unit tested, and look like idiomatic functions in your project's tongue.
Step defs accept an optional docstring as the third arg. The docstring surfaces in gherclj steps output:
(defgiven "the following crew exist:" auth/setup-crew!
"Sets :crew atom (test only — does NOT write disk).")
(defwhen "the log has entries matching:" auth/check-logs!
"Polls for up to 2s. Timeout is not configurable.")Template syntax:
{name:string}— greedy string capture (bounded by surrounding literal text){name:int}— integer capture (coerced viaparse-long){name:float}— float capture (coerced viaparse-double){name}— word capture (\S+)
For edge cases, pass a raw regex instead of a template string. Capture groups become positional helper args:
(defthen #"^the output should contain headers (.+)$" auth/check-headers)Steps that match a Gherkin table or doc-string receive it as a final argument; the helper just declares the extra param:
;; helpers
(defn setup-users! [table]
(let [{:keys [headers rows]} table]
(g/assoc! :users (mapv #(zipmap headers %) rows))))
;; routing
(defgiven "the following users:" auth/setup-users!)There are several ways to configure and run the pipeline.
Option A: Task/alias with CLI flags (recommended)
;; bb.edn
{:deps {io.github.slagyr/gherclj {:git/tag "v1.5.0" :git/sha "c1df8cc"}}
:tasks
{features {:doc "Run feature specs"
:requires ([gherclj.main :as main])
:task (main/-main "-s" "myapp.features.steps.auth"
"-s" "myapp.features.steps.cart"
"-t" "speclj")}}}
;; deps.edn
{:deps {io.github.slagyr/gherclj {:git/tag "v1.5.0" :git/sha "c1df8cc"}}
:aliases
{:features {:main-opts ["-m" "gherclj.main"
"-s" "myapp.features.steps.auth"
"-s" "myapp.features.steps.cart"
"-t" "speclj"]}}}bb features
# or
clj -M:featuresOption B: Config file with CLI runner
Create a gherclj.edn at your project root (or on the classpath):
{:step-namespaces [myapp.features.steps.auth
myapp.features.steps.cart]
:framework :clojure/speclj}clj -M -m gherclj.main --verbose
# or
bb -m gherclj.main --verboseOption C: Custom main
(ns myapp.features.runner
(:require [gherclj.pipeline :as pipeline]))
(defn -main [& _]
(pipeline/run!
{:features-dir "features"
:step-namespaces ['myapp.features.steps.auth
'myapp.features.steps.cart]
:framework :clojure/speclj
:verbose true}))All options produce:
target/gherclj/generated/auth_spec.clj— executable spec with qualified function calls
Add --ir-edn (or :ir-edn true in config) to also persist the parsed IR to target/gherclj/edn/auth.edn for inspection.
The generated specs are clean, readable function calls:
(ns authentication-spec
(:require [speclj.core :refer :all]
[gherclj.core :as g]
[myapp.features.steps.auth :as auth]))
(describe "Authentication"
(before-all (lifecycle/run-before-feature-hooks!))
(before (g/reset!) (lifecycle/run-before-scenario-hooks!))
(after (lifecycle/run-after-scenario-hooks!))
(after-all (lifecycle/run-after-feature-hooks!))
(it "Admin can log in"
(auth/create-user "alice" "admin")
(auth/user-logs-in)
(auth/response-status 200))
(it "Guest gets 401"
(auth/create-user "unknown" "guest")
(auth/user-logs-in)
(auth/response-status 401)))Unrecognized steps generate pending scenarios with comments showing the step text, so you can see what needs to be implemented.
Step definitions are written in Clojure, but the production code they exercise — and the test code gherclj generates — can be in any supported language.
| Framework keyword | Generated test runner |
|---|---|
:clojure/speclj |
speclj |
:clojure/test |
clojure.test |
:ruby/rspec |
RSpec |
:python/pytest |
pytest |
:go/testing |
Go's testing package |
:java/junit5 |
JUnit 5 (Jupiter) + Maven |
:javascript/node-test |
Node's built-in node:test |
:typescript/node-test |
Node node:test (via tsx) |
:rust/rustc-test |
cargo test |
:csharp/xunit |
xUnit + dotnet test |
:bash/testing |
shell-based assertions |
Working examples for every supported framework live under examples/space-airlock/ — one shared feature suite, eleven native implementations.
gherclj reads configuration from gherclj.edn (project root or classpath), with CLI flags as overrides.
| Key | Default | Description |
|---|---|---|
:features-dir |
"features" |
Directory containing .feature files |
:output-dir |
"target/gherclj/generated" |
Directory for generated spec files |
:edn-dir |
"target/gherclj/edn" |
Directory for parsed EDN IR files (only used when :ir-edn is true, or when invoking parse!/generate! directly) |
:ir-edn |
false |
When true, pipeline/run! also persists the parsed IR to :edn-dir |
:step-namespaces |
[] |
Namespace symbols or glob pattern strings |
:framework |
:clojure/speclj |
Target test framework — see Supported frameworks |
:verbose |
false |
Print progress to stdout |
:framework-opts |
[] |
Options passed to the test runner |
:include-tags |
[] |
Include scenarios that match any listed tag |
:exclude-tags |
[] |
Exclude scenarios that match any listed tag |
Step namespaces support glob patterns for discovery:
{:step-namespaces [myapp.steps.manual ;; concrete symbol
"myapp.features.steps.*" ;; glob pattern
"myapp.*-steps"]} ;; glob in the middleTags are filtered uniformly. Use -t to include tags and -t '~tag' to exclude tags:
gherclj -t smoke # only @smoke scenarios
gherclj -t '~slow' # exclude @slow
gherclj -t wip # only @wip scenarios
gherclj -t smoke -t '~slow' # combine include and excludeYou can run only specific scenarios by passing file:line selectors as positional arguments. A selector matches the scenario whose declaration contains the given line.
gherclj features/adventure/dragon_cave.feature:42
# Multiple selectors run all selected scenarios in one invocation
gherclj features/adventure/dragon_cave.feature:42 \
features/adventure/moon_castle.feature:73Location selectors combine with normal options like -f, -e, -o, and tag filters.
Pass framework-specific options to the test runner via -- on the CLI or :framework-opts in config. When provided, these are appended to the default runner arguments.
# Pass options to speclj after --
clj -M -m gherclj.main -- -f documentation -c -P
# Or in gherclj.edn
{:framework-opts ["-f" "documentation" "-c" "-P"]}CLI -- arguments override :framework-opts from the config file.
The documentation reporter (-f documentation) with profiling (-P) and color (-c) gives a readable, timed overview of all scenarios:
gherclj -- -f documentation -c -P Authentication
[0.00003s] - Admin can log in
[0.00002s] - Guest gets 401
Checkout
[0.00005s] - Empty cart shows error
[0.00003s] - Valid cart creates order
Add a bb task for easy access:
;; bb.edn
feature-docs {:requires ([gherclj.main :as main])
:task (main/-main "-t" "~wip" "--" "-f" "documentation" "-c" "-P")}
features-slow {:requires ([gherclj.main :as main])
:task (main/-main "-t" "slow" "-t" "~wip" "--" "-f" "documentation" "-P")}gherclj steps lists all registered step definitions grouped by type. Each entry shows the phrase and source location on one line, with an optional docstring on the next. Output is colorized by default.
gherclj -s myapp.features.steps.* stepsGiven:
a user {name:string} (auth_steps.clj:4)
the following crew exist: (crew_steps.clj:18)
Sets :crew atom (test only — does NOT write disk).
When:
the user logs in (auth_steps.clj:12)
Polls for up to 2s. Timeout is not configurable.
Then:
the response status should be {status:int} (auth_steps.clj:20)
Type filters (--given, --when, --then) are additive — each flag includes that type; no flags means all types:
gherclj -s myapp.features.steps.* steps --given --when # Given + When onlyKeyword filter — pass a word as a positional argument to narrow results by phrase or docstring:
gherclj -s myapp.features.steps.* steps crew # only steps mentioning "crew"Color — colorized by default; suppress with --no-color for scripted or piped use:
gherclj -s myapp.features.steps.* steps --no-colorMachine-readable output — --json and --edn emit the catalog in pretty-printed structured form (kebab-case in both, mutually exclusive). Existing filters compose:
gherclj -s myapp.features.steps.* steps --json --givenHelp:
gherclj steps --helpgherclj match "<phrase>" classifies a step phrase against the registered step set and reports how it resolves: matched, no match, or ambiguous. Useful for understanding what an existing phrase routes to before writing similar steps.
gherclj -s myapp.features.steps.* match "Given a user \"alice\""Phrase: a user "alice" (Given)
Matched step:
Pattern: a user {name:string}
Source: auth_steps.clj:4
Helper: auth/create-user
Doc: (none)
Args:
name (string) = "alice"
Matching is type-blind, in line with Cucumber semantics. A leading Gherkin keyword (Given/When/Then/And/But) is stripped from the input but does not constrain the search — a stepdef registered as defwhen will match a phrase pasted with a leading Given. The keyword is narrative; the regex/template is identity.
Machine-readable output — --json and --edn emit a structured report:
gherclj -s myapp.features.steps.* match --json "Given the user logs in"Help:
gherclj match --helpgherclj unused compares registered step definitions against all step texts in your feature files and reports any that are never referenced. Useful for keeping step namespaces clean as features evolve.
gherclj -f features -s myapp.features.steps.* unusedScanned 42 scenarios. No tag filtering applied.
38 of 40 registered steps are in use (2 unused).
Unused steps:
Given:
setup legacy auth (auth_steps.clj:87)
When:
the legacy system responds (auth_steps.clj:93)
Tag filtering — use the same -t flags as the pipeline. Steps that only appear in excluded scenarios are reported as unused, and the output is explicit about what was scanned:
gherclj -f features -s myapp.features.steps.* unused -t ~slowScanned 35 of 42 scenarios. 7 scenarios filtered out by tags: ~slow.
38 of 40 registered steps are in use (2 unused).
Machine-readable output — --json and --edn emit the unused report in structured form. Tag filters compose with it:
gherclj -f features -s myapp.features.steps.* unused --json -t ~slowNote: Steps used as test data (looked up by name via the registry rather than matched by step text) will appear as unused. This is a known limitation.
gherclj ambiguity walks your feature files and reports any step phrase that matches more than one registered step — the same detection as the runtime ambiguous-match error, but as a structured pre-flight report. Useful before running the full pipeline.
gherclj -f features -s myapp.features.steps.* ambiguityScanned 42 scenarios. No tag filtering applied.
Ambiguous step phrases:
a user "alice" (auth.feature:3)
Matches:
a user {name:string} (auth_steps.clj:4)
a user {handle:string} (auth_steps.clj:8)
A phrase is reported once per occurrence (per feature-file:line).
Tag filtering — same -t semantics as the pipeline. The output states explicitly what was scanned:
gherclj -f features -s myapp.features.steps.* ambiguity -t ~slowMachine-readable output — --json and --edn emit a structured report:
gherclj -f features -s myapp.features.steps.* ambiguity --jsonHelp:
gherclj ambiguity --helpgherclj provides a global state atom that is automatically reset before each scenario. Steps interact with state through gherclj.core:
(g/assoc! :key val) ;; set a key
(g/assoc-in! [:a :b] val) ;; set nested
(g/get :key) ;; read a key (or full map with no args)
(g/get-in [:a :b]) ;; read nested
(g/update! :key f & args) ;; update a key
(g/update-in! [:a :b] f) ;; update nested
(g/dissoc! :key) ;; remove a key
(g/swap! f & args) ;; arbitrary transformation
(g/reset!) ;; clear all stateStep namespaces can register lifecycle hooks through gherclj.core:
(ns myapp.features.steps.hooks
(:require [gherclj.core :as g]))
(g/before-all #(println "starting feature run"))
(g/before-feature #(println "starting feature"))
(g/before-scenario #(println "starting scenario"))
(g/after-scenario cleanup!)
(g/after-feature #(println "finished feature"))
(g/after-all #(println "finished feature run"))Hook timing:
g/before-all- once before the generated test run startsg/before-feature- once per generated feature fileg/before-scenario- before each generated scenario, afterg/reset!g/after-scenario- after each generated scenario, even when it failsg/after-feature- once after each generated feature file finishesg/after-all- once after the generated test run finishes
Example cleanup hook:
(defn cleanup! []
(app/stop!)
(when-let [s @mock-ollama-server]
(httpkit/server-stop! s)
(reset! mock-ollama-server nil)))
(g/after-scenario cleanup!)g/before-all and g/after-all run when specs are executed through gherclj's runner. If you invoke generated Speclj or clojure.test files directly, feature and scenario hooks still run, but the all hooks do not.
gherclj provides framework-agnostic assertion functions that delegate to the active test framework:
(g/should= expected actual)
(g/should (some-predicate?)) ; captures the form in the failure message
(g/should-not (bad-predicate?))
(g/should-be-nil value)
(g/should-not-be-nil value)
(g/should-include expected actual) ; substring (string) or membership (coll/set/map key)
(g/should-not-include expected actual)These work under both :clojure/speclj and :clojure/test. Prefer should-include over (should (str/includes? …)) so failures show expected vs actual. If your project uses a single framework, you can use its native assertions directly (e.g., speclj's should=). Use g/should* when helpers need to be framework-agnostic.
gherclj ships with :clojure/speclj and :clojure/test output formats. Add your own by implementing the generator multimethods:
(defmethod gherclj.generator/generate-ns-form :my-framework [config source step-ns-syms] ...)
(defmethod gherclj.generator/wrap-feature :my-framework [config feature-name scenario-blocks] ...)
(defmethod gherclj.generator/wrap-scenario :my-framework [config scenario background] ...)
(defmethod gherclj.generator/wrap-pending :my-framework [config scenario background] ...)
(defmethod gherclj.generator/run-specs :my-framework [config] ...)The following skills are available for AI coding agents working with gherclj:
gherkin— How to specify features. Gherkin writing guide: scenario design, step conventions, anti-patternsgherclj— How to implement features. Step implementation conventions: assertions, state management, definition of done
- Babashka
- Clojure 1.12+
bb parse # Parse .feature files → EDN IR (writes to disk; explicit two-stage flow)
bb generate # Generate spec files from EDN (reads disk; pair with `bb parse`)
bb spec # Run unit specs
bb features # Run feature specs (in-memory parse + generate + execute, excludes @wip by default)
bb test # Run all tests
bb clean # Remove generated files