# Brat

Brat is the _Brutal Runner for Automated Tests_, a parallel TAP testing harness for the POSIX shell.

![Looping screen recording of Brat running its own test suite](https://assets.sstephenson.dev/brat/terminal.gif)

**Brutal as in architecture.** Brat is true to the “materials” it is built with: shell, awk, and the Unix pipeline. It reveals its internal plumbing in the same way a brutalist building might expose its ductwork. Some will find it ugly; others (maybe you?) will appreciate its didactic honesty.

**POSIX as in zero dependencies.** Brat targets the POSIX.1-2024 specification. Practically speaking, it is designed to run on the minimum common subset of contemporary Unix OSes, with no other dependencies or specific implementation requirements. We [test Brat](#portability) under continuous integration against a variety of platforms.

**Intentionally small.** Brat is designed to be embedded directly into your project. It has no build step and nothing to configure. At just under a thousand lines of shell and awk, you can read and understand the codebase in an afternoon.

---

Jump to: [Installation](#installation) | [Writing Tests](#writing-tests) | [Running Tests](#running-tests) | [Implementation Notes](#implementation-notes) | [Contributing](#contributing) | [License](#license)

<br><br>

## Overview

With Brat, you write tests for Unix programs using a special shell syntax:

```sh
# test/backup.brat

setup() {
  cd "$DIR/.."
}

@test "prints usage when run without arguments" {
  run bin/backup.sh
  [ $status -eq 1 ]
  match "$stderr" 'usage:'
}

@test "errors when source directory does not exist" {
  run bin/backup.sh /nonexistent "$TEST_TMP.tar.gz"
  [ $status -eq 1 ]
  match "$stderr" 'not found'
}

@test "creates backup archive" {
  run bin/backup.sh "$DIR/fixtures/testdata" "$TEST_TMP.tar.gz"
  [ $status -eq 0 ]
  tar tf "$TEST_TMP.tar.gz"
}
```

A preprocessor transforms these test cases into shell functions which run with `set -eu` (exit on error, error when referencing undefined variables). In this way, every line of a test case acts as an assertion.

When you run your tests, Brat displays the results in a streaming TAP format:

```
$ brat test/*.brat
TAP version 14
1..3

ok 1 - backup.brat:7: prints usage when run without arguments
ok 2 - backup.brat:13: errors when source directory does not exist
ok 3 - backup.brat:19: creates backup archive

#  ✓ 3 tests (3 passed, 0 failed, 0 skipped)
```

If any line of a test fails, Brat shows the shell’s _xtrace_ (`set -x`) output up to that point, along with anything written to stdout or stderr:

```
$ brat test/backup.brat:19
TAP version 14
1..1

not ok 1 - backup.brat:19: creates backup archive
#  + setup
#  + cd $DIR/..
#  + run bin/backup.sh $DIR/fixtures/testdata $TEST_TMP.tar.gz
#  + '[' 0 -eq 0 ']'
#  + tar tf $TEST_TMP.tar.gz
#  tar: Error opening archive: Unrecognized archive format
#  (test failed with status 1)

#  ✘ 1 test (0 passed, 1 failed, 0 skipped)
```

Brat [formats its TAP stream](#formatting-output) with color when connected to a terminal. In particular, failing tests are highlighted in red.

<br>

### Parallel Execution

Tests run sequentially by default, but Brat has built-in support for parallel test execution. Use `-j` or set `$BRAT_JOBS` to run tests in parallel. For example, to run up to 8 tests concurrently:

```
$ brat -j 8 test/*.brat
```

Brat spawns each test in a background process, streaming results as they complete, potentially out of order. The [pretty formatter](#formatting-output) buffers and sorts them live for display.

<br>

### Comparison with Bats

Brat is a spiritual successor to [Bats](https://github.com/bats-core/bats-core), the Bash Automated Testing System. If you’ve used Bats, Brat will feel familiar, but more spartan.

| | Bats | Brat |
|---|---|---|
| Shell | Requires Bash | Works with any POSIX shell |
| Parallel execution | Requires GNU parallel | Built-in support, using a FIFO |
| Output | TAP or a proprietary pretty format | TAP always; pretty format is highlighted and sorted TAP |
| Output capture | `$output`, `$lines[]` (in-memory strings) | `$stdout`, `$stderr` (file paths) |
| Built-in helpers | Rich standard library and ecosystem | Minimal |
| Lifecycle hooks | Per-test and per-module setup and teardown | Per-test setup and teardown only |

One important difference is that Brat’s `run` helper captures output to separate files and exposes their paths to you, avoiding the runtime overhead of reading large outputs into strings and arrays.

<br>

### Portability

Brat is written in POSIX shell and awk, targeting the [POSIX.1-2024 standard](https://pubs.opengroup.org/onlinepubs/9799919799/) with no other dependencies. It is architecture-independent and does not require a C compiler.

We test Brat, [using Brat](test/), with continuous integration on the following platforms:

| | sh | awk |
|---|---|---|
| Alpine Linux | busybox ash | busybox awk |
| Debian Linux | dash | mawk |
| Fedora Linux | Bash | gawk |
| FreeBSD | FreeBSD ash | nawk |
| macOS | Bash (3.2) | nawk |

[![Build Status](https://codeberg.org/sstephenson/brat/actions/workflows/ci.yml/badge.svg)](https://codeberg.org/sstephenson/brat/actions?workflow=ci.yml)


<br><br>

## Installation

Brat runs entirely from source and has no build step or dependencies to install.

<br>

### Installing Brat Globally

Download and extract the [latest release archive](https://codeberg.org/sstephenson/brat/archive/latest.tar.gz) and symlink `bin/brat` into your PATH. For example, to install Brat in `/usr/local`:

```
# curl -sL https://codeberg.org/sstephenson/brat/archive/latest.tar.gz | tar -C /usr/local -xzf -
# ln -s /usr/local/brat/bin/brat /usr/local/bin/brat
```

Or if you prefer a per-user installation (assuming `$HOME/.local/bin` is in your PATH):

```
$ curl -sL https://codeberg.org/sstephenson/brat/archive/latest.tar.gz | tar -C ~/.local -xzf -
$ ln -s ~/.local/brat/bin/brat ~/.local/bin/brat
```

<br>

### Embedding Brat in Your Project

Download the single-file bundle from the [package registry](https://codeberg.org/sstephenson/brat/packages) and commit it to your repository:

```
$ mkdir -p vendor
$ curl -sL https://codeberg.org/api/packages/sstephenson/generic/brat/latest/brat >vendor/brat
$ chmod +x vendor/brat
$ vendor/brat test/*.brat
```

The bundle is a self-contained, plain-text POSIX shell script generated by [brut-pack](https://codeberg.org/sstephenson/brut-pack).

Or, if you plan to work on Brat itself, clone Brat into your project and run it directly:

```
$ git clone https://codeberg.org/sstephenson/brat.git vendor/brat
$ vendor/brat/bin/brat test/*.brat
```

<br><br>

## Writing Tests

Test files use the `.brat` extension by convention. Each file is a shell script containing one or more test definitions:

```sh
@test "description of what this tests" {
  # Commands here run with errexit enabled; any
  # command that exits nonzero fails the test
  [ 1 -eq 1 ]
}
```

It’s a good idea to add a standard `#!/bin/sh` shebang to the top of each test file so that your editor or code forge applies proper syntax highlighting. Note, however, that Brat test files cannot be executed directly by the shell.

<br>

### About the Test Environment

Brat automatically sets the following variables before each test run:

- `$FILE` — the path to the test file
- `$DIR` — the directory containing the test file
- `$TEST_TMP` — a unique temporary path prefix for the current test

Use the `$DIR` variable to source test helper scripts or load fixture data relative to the location of the test file.

You can use the `$TEST_TMP` variable as a prefix for temporary files or directories you create during a test. Filenames matching `$TEST_TMP.*` are automatically deleted after each test run.

<br>

### Running Commands with the `run` Helper

Use `run` to execute a command and capture its output and status code:

```sh
@test "captures exit status and output" {
  run ls /nonexistent
  [ $status -eq 1 ]
  match "$stderr" 'No such file'
}
```

After `run`, three variables are available:
- `$status` — the command’s exit code
- `$stdout` — the path to a file containing standard output
- `$stderr` — the path to a file containing standard error

Note that `run` itself always succeeds, even when its command fails, so a failing command won’t end the test. To assert that the command failed, test `$status`, as in `[ $status -ne 0 ]`.

<br>

### Matching Output with the `match` Helper

Use `match` to assert that a file contains a string or pattern:

```sh
match "$stdout" 'Content-Type: text/plain'   # exact substring
match "$stdout" '/^Content-Type:.+$/'        # ERE pattern
match "$stdout" '/^content-type:.+$/i'       # case-insensitive ERE
```

If the second argument begins and ends with `/`, the `match` helper treats it as an [extended regular expression (ERE) pattern](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap09.html#tag_09_04). Otherwise, it is treated as an exact substring to match. Append `i` after the closing `/` of an ERE pattern to perform a case-insensitive match.

<br>

### Comparing Files with the `compare` Helper

Use `compare` to assert that two files have identical contents:

```sh
run my_formatter <input.txt
compare "$stdout" "$DIR/fixtures/expected-output.txt"
```

`compare` uses the POSIX [`cksum`](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/cksum.html) utility to calculate a 32-bit CRC of both files and compare them, along with the files’ lengths, to determine equivalence. **Do not** use this helper if you need cryptographic integrity when comparing files. It is provided as an approximate replacement for `cmp` on systems where that command is not included by default.

<br>

### Making Negative Assertions

The shell’s `!` reserved word negates the result of the command that follows it. For example, `! false` is equivalent to `true`.

It is tempting to use a `!` before an assertion in a test, but **you should avoid it** because it interacts poorly with [`set -e`](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#set), which is the shell mode that Brat uses to stop execution when a command fails. The shell specifically exempts commands beginning with `!` from `set -e` behavior.

#### What Not to Do

A `!`-prefixed command can only stop execution when it occurs as the _last command_ of a function.

The following test behaves as you’d expect:

```sh
@test "no errors" {
  run my_program
  ! match "$stderr" 'error:'  # Works, but don’t do this!
}
```

But you shouldn’t write it this way. As soon as you add another assertion, the `! match` assertion is meaningless. It will be treated as a success regardless of its exit status:

```sh
@test "no errors" {
  run my_program
  ! match "$stderr" 'error:'  # WRONG! This never fails because
  [ $status -eq 0 ]           # another command follows it.
}
```

#### Replace `!` with the `not` Helper

Brat’s `not` helper lets you safely make negative assertions. The `not` helper succeeds only when the command that follows it exits with a non-zero status. Because `not` is a shell function, `set -e` sees its status like that of any other command, and a failed `not` assertion stops the test immediately:

```sh
@test "no errors" {
  run my_program
  not match "$stderr" 'error:'
  [ $status -eq 0 ]
}
```

#### Use an “Inner `!`” in Test Expressions

The `[` utility has its own `!` operator for negating an expression. Because the `!` comes after the command name, the shell treats it as a plain string argument passed to `[`, not as a reserved word.

If you prefer, you can safely use an “inner `!`” in test expressions:

```sh
[ ! -e "$TEST_TMP.lockfile" ]
```

<br>

### Skipping Tests

Use `@skip` to mark tests that shouldn’t run:

```sh
@skip "not yet implemented" {
  # This test body is not executed
  false
}
```

Brat treats a `@skip` test like a passing test. It will appear with a `# SKIP` directive following its name in the TAP output.

<br>

### Marking Works in Progress

Use `@todo` to mark tests you expect to fail:

```sh
@todo "waiting on upstream fix" {
  # Runs, but records as passing even when it fails
  run buggy_command
  [ $status -eq 0 ]
}
```

If a `@todo` test fails, Brat will display its xtrace output but otherwise treat it as a passing test. It will appear with a `# TODO` directive following its name in the TAP output.

<br>

### Lifecycle Hooks

You can define `setup` and `teardown` functions to run code before and after each test case:

```sh
setup() {
  TMPFILE="$(mktemp)"
}

teardown() {
  rm -f "$TMPFILE"
}

@test "uses the temp file" {
  echo data >"$TMPFILE"
  [ -s "$TMPFILE" ]
}
```

`teardown` runs even when a test fails, so it’s safe to use for cleanup.

<br>

### Top-Level Code

Code outside of `@test`, `@skip`, and `@todo` blocks runs twice: once when Brat scans the file to discover tests and their names, and again before each test runs.

```sh
# Runs during both planning and test execution
cd "$DIR/.."

setup() {
  # Only called during test execution
}

@test "example" {
  # ...
}
```

Keep this in mind if your top-level code has side effects. In practice, most test files only define functions (like `setup`) at the top level, which is harmless during planning.


<br><br>

## Running Tests

| To… | Run… |
|---|---|
| Run all tests in a directory | `brat test/*.brat` |
| Run a specific test file | `brat test/backup.brat` |
| Run a specific test by line number | `brat test/backup.brat:19` |
| Run tests in parallel (8 concurrent jobs) | `brat -j 8 test/*.brat` |
| Filter tests by exact name match | `brat -n "creates backup archive" test/*.brat` |
| Filter tests by extended regular expression (ERE) | `brat -n "/backup/" test/*.brat` |
| Exclude tests by exact name or ERE | `brat -e "/usage/" test/*.brat` |

<br>

### Working with Subcommands

Brat exposes the subcommands that make up its internal pipeline. When you run `brat test/*.brat`, Brat orchestrates the following:

1. For each test file, `test-plan` extracts test metadata (file, line, kind, name) into a tab-delimited plan.
2. `plan-build` aggregates these plans, sorts the tests by filename and line number, and applies any `-n`/`-e` filters.
3. `plan-run` executes tests from the plan, invoking `test-run` in parallel and printing the results as TAP.

You can work with this plumbing directly:

| Subcommand | Description |
|---|---|
| `brat plan-run` | The main entry point: build a plan, run tests, format output |
| `brat plan-build` | Build a test plan from files, applying `-n`/`-e` filters |
| `brat test-plan` | Extract test metadata from a single file |
| `brat test-run` | Run a single test by file and line number |

The subcommands are composable. For example, you can build a plan once and pipe it to `brat plan-run -`:

```
$ brat plan-build test/*.brat | grep backup | sort -r >plan.txt
$ brat plan-run - <plan.txt
```

<br>

### Formatting Output

Brat outputs [TAP version 14](https://testanything.org/). When connected to a terminal, the TAP stream passes through a pretty formatter that live-sorts results and applies syntax highlighting. When stdout is not a terminal, or when `$CI` is set, Brat outputs unadorned TAP.

You can force raw TAP output by setting `BRAT_FORMAT=plain`.


<br><br>

![Writing Brat, Biblioteca Vasconcelos, Ciudad de México, 2026.](https://assets.sstephenson.dev/brat/vasconcelos.jpeg)


<br><br>

## Implementation Notes

Brat is built on a small command dispatcher called Brut, the _Brutal Router for Unix Tools_, which discovers and delegates to [subcommand executables in the `libexec/` directory](libexec/). Brut is entirely self-contained in the [`bin/brat`](bin/brat) script.

<br>

### Dispatch Behavior

Before parsing any arguments, `bin/brat` sources [`lib/brat/_init.sh`](lib/brat/_init.sh), which forks a copy of itself to continue subcommand execution, waits on the forked process to exit, and deletes any temporary files it created. This automatic garbage collection removes the need for bookkeeping in subcommands.

After scanning arguments, if `bin/brat` does not find a matching subcommand, it sources [`lib/brat/_unhandled.sh`](lib/brat/_unhandled.sh), which attempts to rewrite the arguments into a `brat plan-run` pipeline. See [Working with Subcommands](#working-with-subcommands) for details on the default pipeline.

<br>

### Subcommand Interaction

Brat locates itself in the filesystem and adds its `libexec/` and `lib/brat/` directories to the front of the PATH. Subcommands invoke each other directly (e.g. `brat-plan-build`, `brat-test-run`) without going through the dispatcher. Subcommands with `--` in the name (e.g. `brat--tap-format`) are “private” and cannot be invoked as arguments to `bin/brat`.

<br>

### Shell Functions

Shared shell functions live in `lib/brat/`. Because this directory is first in the PATH, its files can be sourced directly by subcommands (e.g. `. brat.sh`).
- [`lib/brat/brat.sh`](lib/brat/brat.sh) — caching, preprocessing, EXIT trap chaining; sourced by convention at the top of every subcommand script
- [`lib/brat/eval.sh`](lib/brat/eval.sh) — test environment and lifecycle functions; sourced by `brat-test-plan` and `brat-test-run`
- [`lib/brat/test.sh`](lib/brat/test.sh) — the `run`, `not`, `match`, and `compare` helpers; sourced by `brat-test-run`

<br>

### Awk Filters

Brat’s many awk filters also live in `lib/brat/`.
- [`lib/brat/match.awk`](lib/brat/match.awk) — used by the `match` helper to test file contents
- [`lib/brat/plan-lines.awk`](lib/brat/plan-lines.awk) — used by `brat-plan-build` to parse `file:line` arguments
- [`lib/brat/plan-names.awk`](lib/brat/plan-names.awk) — filters a plan by `-n` (include) and `-e` (exclude) patterns
- [`lib/brat/preprocess.awk`](lib/brat/preprocess.awk) — preprocesses Brat test directives into shell functions
- [`lib/brat/rewrite-paths.awk`](lib/brat/rewrite-paths.awk) — rewrites internal pathnames in test output
- [`lib/brat/tap-format-plain.awk`](lib/brat/tap-format-plain.awk) — passes TAP through unchanged, appending a summary line
- [`lib/brat/tap-format-pretty.awk`](lib/brat/tap-format-pretty.awk) — live-sorts TAP results with color highlighting and a status line
- [`lib/brat/tap-status.awk`](lib/brat/tap-status.awk) — parses TAP to track test counts and generate summaries
- [`lib/brat/terminal.awk`](lib/brat/terminal.awk) — provides functions for ANSI escape sequences and terminal dimensions

<br>

### Tracing Execution

You can set `BRAT_DEBUG=1` to follow the execution of a Brat run. When this variable is set, the `lib/brat/_init.sh` script enables xtrace output to stderr with `set -x`. Note that this does not include the trace output for tests themselves, which is redirected to disk by Brat.


<br><br>

## Contributing

Brat is hosted on Codeberg: <https://codeberg.org/sstephenson/brat>

We welcome issues and tested pull requests from human contributors. However, before submitting a large pull request, or one that changes behavior that is not a bug, we ask that you please open an issue first so we can discuss whether it is a good fit for the project.

<br>

### About the Test Suite

Brat’s tests live in the `test/` directory; the `test/*.brat` files together comprise its _test suite_. The tests in these files primarily invoke Brat on another tree of test files rooted in `test/fixtures/`.

Use the [`script/test`](script/test) command to run the test suite. This script first performs a series of “sentinel” checks to verify that Brat actually runs tests and propagates their exit statuses. Then it runs `bin/brat test/*.brat` in parallel, with a job count equal to the number of CPUs on the host system.

<br>

### Reporting Issues

Brat is portable software and compatibility is a moving target. When reporting issues, please be sure to include information about your operating system, including its release version, and the versions and lineage of the `sh`, `awk`, and `sed` commands.

<br>

### Code Conventions

When contributing changes to Brat, please respect the conventions of existing code in `lib/brat/` and `libexec/`.

Shell should be written with `set -eu` and careful consideration of what is specified by POSIX. See the [Shell Command Language specification](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html) for more details.

Similarly, awk code should be written in the [subset specified by POSIX](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/awk.html). Be sure to declare local variables in awk functions at the end of the parameter list. By convention, Brat separates “real” parameters from local variables with an unused parameter named `__`.


<br><br>

## License

Brat is free software, distributable under the terms of the MIT + Trans Rights License. See [LICENSE.md](LICENSE.md) for details.

Brat includes a copy of [wcwidth.awk](https://github.com/ericpruitt/wcwidth.awk) by Eric Pruitt, released under the 2-Clause BSD license.

<br>

© 2026 [Sam Stephenson](https://sls.name). Handwritten in Mexico City.
