Migrating from Bats or ShellSpec

On this page

Bats and ShellSpec are mature, well-designed frameworks, and if they fit your suite there is no reason to leave. This guide is for one specific situation: the thing under test is a compiled binary and the shell code around it exists only to run that binary and inspect what happened. That layer is atago’s job, and the mappings below show what each familiar construct becomes.

Everything on this page is executable. The original Bats and ShellSpec suites live at test/e2e/migration next to the migrated atago specs, and the MigrationParity workflow runs all three on every pull request and on every push to main: the originals under pinned Bats-core v1.14.0 and ShellSpec 0.28.1, the migrations under the freshly built atago binary. A snippet you read here is an excerpt of a file CI ran.

For a feature-by-feature table (including what Bats and ShellSpec do that atago does not), see the comparison.

The cheat sheet #

BatsShellSpecatago
run cmd + [ "$status" -eq 0 ] / run -NWhen run cmd + The status should ...run: step + assert: exit_code:
[[ "$output" == *x* ]] / assert_output --partialThe output should include "x"stdout: {contains: x}
[ "$output" = "x" ] / assert_outputThe output should equal "x"stdout: {equals: x}
[[ "$output" =~ regex ]]The output should match patternstdout: {matches: regex}
run --separate-stderr + $stderrThe error should includestderr: {contains: ...}
setup() / teardown()BeforeEach / AfterEachfixture: step; scratch-file cleanup is automatic (fresh temp workdir per scenario)
pipe to jqpipe to jqstdout: {json: {path: ...}}
one @test per caseParameters blockmatrix:
retry loop in bashretry loop in a helperrun.retry with until:
skip "reason"Skip if "reason" cmdskip: / only: gates
# bats test_tags= + --filter-tags--tagtags: + --tag / --skip-tag
diff against a committed fileshould equal "$(cat golden)"snapshot: + atago snapshot update

Exit codes, stdout, stderr #

The everyday assertions translate one to one. In Bats:

@test "an exact exit code" {
  run -3 sh -c 'exit 3'
}

@test "stdout contains" {
  run echo hello world
  [[ "$output" == *world* ]]
}

In ShellSpec:

  It 'an exact exit code'
    When run sh -c 'exit 3'
    The status should equal 3
  End

  It 'stdout contains'
    When run echo hello world
    The output should include "world"
  End

In atago, each becomes a scenario with a run: step and declarative asserts (basics.atago.yaml):

  # ShellSpec: The status should equal 3
  # Bats:      [ "$status" -eq 3 ]
  - name: an exact exit code maps to exit_code N
    skip: { os: windows }        # `exit N` phrasing below is POSIX shell
    steps:
      - run:
          shell: true
          command: "exit 3"
      - assert:
          exit_code: 3

  # ShellSpec: The output should include "world"
  # Bats:      assert_output --partial world
  # runn:      test: current.res.body contains "world"
  - name: stdout contains maps to a substring match
    steps:
      - run:
          shell: true
          command: echo hello world
      - assert:
          stdout:
            contains: world

stderr is a separate stream. Bats needs run --separate-stderr; ShellSpec has The error; atago always captures the two streams independently:

@test "stderr contains" {
  run --separate-stderr sh -c 'echo "warn: heads up" >&2'
  [[ "$stderr" == *warn* ]]
}
  # ShellSpec: The error should include "warn"
  # Bats:      run --separate-stderr; assert_stderr --partial warn
  - name: stderr contains maps to a stderr substring match
    skip: { os: windows }        # `>&2` redirect is POSIX-only
    steps:
      - run:
          shell: true
          command: "echo warn: heads up >&2"
      - assert:
          stderr:
            contains: warn

setup/teardown become fixtures — and file cleanup disappears #

A Bats setup() writes input files and teardown() removes them:

setup() {
  cd "$BATS_TEST_TMPDIR"
  printf 'id,name\n1,Alice\n' > input.txt
}

teardown() {
  rm -f input.txt output.txt
}

@test "a seeded input produces the expected output file" {
  run -0 cp input.txt output.txt
  [ -f output.txt ]
  grep -q Alice output.txt
}

ShellSpec does the same with BeforeEach/AfterEach helpers:

  seed_input() {
    mkdir -p "$work" && printf 'id,name\n1,Alice\n' > "$work/input.txt"
  }
  remove_scratch() {
    rm -rf "$work"
  }

  BeforeEach 'seed_input'
  AfterEach 'remove_scratch'

  It 'a seeded input produces the expected output file'
    When run cp "$work/input.txt" "$work/output.txt"
    The status should be success
    The file "$work/output.txt" should be exist
    The contents of file "$work/output.txt" should include "Alice"
  End

In atago the setup is a declarative fixture: step, and for scratch files there is no teardown to write: every scenario runs in its own temp workdir that atago removes. (teardown: steps do exist — they are for external state a workdir cannot carry away, like database rows or remote resources; see the cookbook.) From fixtures_and_files.atago.yaml:

  # BeforeEach writes input.txt -> fixture step. The command consumes it and
  # writes an output; we assert on the produced file instead of a teardown rm.
  - name: a fixture seeds input and a file assertion checks the output
    skip: { os: windows }        # cp/redirect below are POSIX-only
    steps:
      - fixture:
          file: input.txt
          content: |
            id,name
            1,Alice
      - run:
          shell: true
          command: "cp input.txt output.txt"
      - assert:
          file:
            path: output.txt
            exists: true
      - assert:
          file:
            path: output.txt
            contains: Alice

jq pipelines become JSONPath matchers #

Both Bats and ShellSpec suites commonly shell out to jq:

@test "json output asserted via jq, value captured and reused" {
  run -0 sh -c "printf '%s' '{\"items\":[{\"id\":7,\"name\":\"Alice\"}],\"count\":1}' | jq -r '.items[0].name'"
  [ "$output" = "Alice" ]
  first_id="$(printf '%s' '{"items":[{"id":7,"name":"Alice"}],"count":1}' | jq -r '.items[0].id')"
  run -0 echo "picked $first_id"
  [[ "$output" == *"picked 7"* ]]
}

atago asserts JSON by path without an external tool, and store: replaces the var=$(... | jq) capture:

      - assert:
          stdout:
            json:
              path: "$.items[0].name"
              equals: Alice
      - assert:
          stdout:
            json:
              path: "$.count"
              gte: 1
      # ShellSpec/Bats would capture with `id=$(... | jq)`. atago's `store`
      # binds a captured value into ${name} for a later step — declaratively.
      - store:
          name: first_id
          from:
            stdout:
              json:
                path: "$.items[0].id"
      - run:
          shell: true
          command: echo "picked ${first_id}"
      - assert:
          stdout:
            contains: "picked 7"

Parameterized tests become a matrix #

Bats has no built-in parameterization, so the same test is written once per case (or generated with bats_test_function):

@test "greets Alice in en" {
  run -0 echo "hello Alice (en)"
  [[ "$output" == *Alice* && "$output" == *en* ]]
}

@test "greets Bob in fr" {
  run -0 echo "hello Bob (fr)"
  [[ "$output" == *Bob* && "$output" == *fr* ]]
}

ShellSpec’s Parameters block — a genuinely nice feature — feeds positional parameters into one example:

  Parameters
    Alice en
    Bob   fr
  End

  It "greets $1 in $2"
    When run echo "hello $1 ($2)"
    The output should include "$1"
    The output should include "$2"
  End

atago’s matrix: expands one template scenario into one concrete scenario per row, each key available as ${name} (matrix.atago.yaml):

  - name: "greets ${who} in ${lang}"
    matrix:
      - { who: Alice, lang: en }
      - { who: Bob,   lang: fr }
    steps:
      - run:
          shell: true
          command: echo "hello ${who} (${lang})"
      - assert:
          stdout:
            contains:
              - ${who}
              - ${lang}

Polling loops become retry #

Waiting for an async result is a hand-written loop in shell. The probed command here is stateful, like a service warming up — the first attempt creates a marker and prints “waiting”, the second prints “ready” — so the loop genuinely iterates:

@test "poll until the command reports ready" {
  marker="$BATS_TEST_TMPDIR/marker"
  for _ in 1 2 3 4 5; do
    run sh -c "if [ -f '$marker' ]; then echo ready; else touch '$marker'; echo waiting; fi"
    if [[ "$output" == *ready* ]]; then
      return 0
    fi
    sleep 1
  done
  return 1
}

atago re-runs the same stateful command until an until: assertion passes, with the attempt budget and interval declared — the first attempt prints “waiting”, the retry sees “ready” (retry.atago.yaml):

  - name: retry re-runs the command until until passes
    skip: { os: windows }        # the marker-file idiom below is POSIX shell
    steps:
      - run:
          shell: true
          command: "if [ -f marker ]; then echo ready; else touch marker; echo waiting; fi"
          retry:
            times: 5
            interval: 10ms
            until:
              stdout:
                contains: ready
      - assert:
          stdout:
            contains: ready

(Bats’ own BATS_TEST_RETRIES re-runs the whole test on failure — useful, but a different tool: it is for flaky tests, not for polling.)

skip and tags become declarative gates #

Bats skips imperatively and tags with comments:

# bats test_tags=smoke
@test "smoke-tagged, selectable with --filter-tags smoke" {
  run -0 echo smoke ok
  [[ "$output" == *ok* ]]
}

# bats test_tags=slow
@test "slow-tagged, droppable with --filter-tags !slow" {
  run -0 echo slow but still green
}

ShellSpec’s Skip if takes a reason and a probe command:

  It 'runs only when an environment variable is set'
    Skip if "CI_ONLY_UNSET_MARKER not set" [ -z "${CI_ONLY_UNSET_MARKER:-}" ]
    When run echo would only run in CI
    The status should be success
  End

atago’s gates are data, so atago explain and atago list can show them without running anything (selection.atago.yaml):

  # ShellSpec: Skip if "not linux" ... ; Bats: skip on non-linux
  # atago: skip: { os: windows } declaratively skips POSIX-only scenarios.
  - name: skip on an OS maps to skip os
    skip: { os: windows }
    steps:
      - run:
          shell: true
          command: "test -d /"        # POSIX filesystem probe
      - assert:
          exit_code: 0

  # ShellSpec: fIt / Bats: bats --filter-tags smoke
  # atago: tag a scenario, then `atago run --tag smoke` to focus on it.
  - name: a smoke-tagged scenario is selectable with --tag
    tags: [smoke]
    steps:
      - run:
          shell: true
          command: echo smoke ok
      - assert:
          stdout:
            contains: ok

The CI parity workflow runs the tag filters too: bats --filter-tags smoke and atago run --tag smoke select the same test on both sides.

Hand-maintained goldens become snapshots #

Neither Bats nor ShellSpec ships snapshot testing, so a golden compare is written by hand and refreshed by editing the file:

@test "stdout matches the committed golden file" {
  run -0 echo "hello from atago"
  diff <(printf '%s\n' "$output") "$BATS_TEST_DIRNAME/../snapshots/greeting.txt"
}
  It 'stdout matches the committed golden file'
    When run echo "hello from atago"
    The output should equal "$(cat "$SHELLSPEC_PROJECT_ROOT/../snapshots/greeting.txt")"
  End

atago’s snapshot: matcher compares against the same kind of committed file, but normalizes ANSI colors, temp paths, UUIDs, timestamps, ports, and CRLF so the golden is stable across machines — and atago snapshot update refreshes every intentionally changed golden in one command (snapshot.atago.yaml):

  - name: stdout matches a committed snapshot
    steps:
      - run:
          shell: true
          command: echo hello from atago
      - assert:
          stdout:
            snapshot: snapshots/greeting.txt

What has no source to migrate from #

Some atago features have no Bats/ShellSpec counterpart, so after the port they are simply new capability: pty: sessions for interactive prompts and TUIs, expect_screen:/screen: asserts on the rendered terminal frame, changes: for the exact set of files a command touched, mock_servers: for offline API simulation, services: with readiness probes, and atago record to turn a real run into a first spec. The cookbook has a runnable recipe for each.

What should stay where it is #

Migrate the black-box layer, not everything. Bats and ShellSpec remain the right tool for what they were built for:

  • Unit tests of shell functions. Both frameworks run shell code in-process and assert on it directly. atago only ever executes a program, so it cannot see inside your shell scripts.
  • Mocking shell internals. ShellSpec’s function and command mocks have no atago equivalent — atago’s mocks are external HTTP servers, a different axis.
  • Coverage of shell scripts. ShellSpec measures kcov coverage for shell code; a black-box runner cannot.

If your repository has both kinds of tests, keeping ShellSpec for the shell library and moving the CLI contract tests to atago is a reasonable end state — it is exactly how atago’s own third-party suites were migrated.

Migrate incrementally #

Both runners coexist happily in one CI job, so port one file at a time:

bats test/                       # the suite you are migrating from
atago run ./specs                # the part that has moved

Start by recording a real run (atago record -- mytool ...) instead of writing YAML from scratch, then tighten the generated spec with the mappings above. Use it in CI shows the GitHub Actions wiring, including --report junit if your CI already consumes Bats’ JUnit output.