{"id":51011530,"url":"https://github.com/fossable/attest","last_synced_at":"2026-06-21T03:03:55.665Z","repository":{"id":353781119,"uuid":"1218591861","full_name":"fossable/attest","owner":"fossable","description":"Dead simple test framework for the age of AI","archived":false,"fork":false,"pushed_at":"2026-06-03T02:52:43.000Z","size":868,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2026-06-03T04:26:10.234Z","etag":null,"topics":["shell","shell-scripting","test-automation","testing","testing-tools"],"latest_commit_sha":null,"homepage":"","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"unlicense","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/fossable.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-04-23T02:58:27.000Z","updated_at":"2026-06-03T02:52:23.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/fossable/attest","commit_stats":null,"previous_names":["fossable/attest"],"tags_count":8,"template":false,"template_full_name":null,"purl":"pkg:github/fossable/attest","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fossable%2Fattest","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fossable%2Fattest/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fossable%2Fattest/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fossable%2Fattest/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/fossable","download_url":"https://codeload.github.com/fossable/attest/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fossable%2Fattest/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34592059,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-21T02:00:05.568Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["shell","shell-scripting","test-automation","testing","testing-tools"],"created_at":"2026-06-21T03:03:54.799Z","updated_at":"2026-06-21T03:03:55.658Z","avatar_url":"https://github.com/fossable.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n\t\u003cimg src=\"https://raw.githubusercontent.com/fossable/fossable/master/emblems/attest.svg\" style=\"width:90%; height:auto;\"/\u003e\n\u003c/p\u003e\n\n![License](https://img.shields.io/github/license/fossable/attest)\n![GitHub repo size](https://img.shields.io/github/repo-size/fossable/attest)\n![Stars](https://img.shields.io/github/stars/fossable/attest?style=social)\n\n\u003chr\u003e\n\n![](./.github/assets/parallel.gif)\n\n\u003e perfection is finally attained not when there is no longer anything to add,\n\u003e but when there is no longer anything to take away\n\u003e\n\u003e Terre des Hommes (1939) - Antoine de Saint Exupéry\n\n**attest** is a simple and modern test framework for CLI programs. There is no\nexotic test syntax to remember, assertion API, plugins, or hidden lifecycle\nmethods to know about. Tests are just regular shell functions where every\nstatement is an assertion.\n\nWe already have all of the tools we need to write tests in the shell:\n\n- Shell functions neatly organize tests into runnable units\n- Need to compare text? `[` and `[[` have been around for decades\n- Need to compare JSON? `jq -c` has you covered.\n- Need some test setup/cleanup? Idiomatic with helper functions and traps.\n\nBy keeping the framework lightweight, tests are easy to write and quick to\nunderstand, leading to an overall more effective testing experience.\n\n## Writing tests\n\nHere's an illustrative example of a test for the `md5sum` command:\n\n```sh\n## Test the md5sum command with known input/output\ntestHello() {\n\tresult=$(echo hello | md5sum) # Test fails if nonzero exit\n\n\t[ \"${result}\" = \"b1946ac92492d2347c6235b4d2611184  -\" ] # Test fails if output changes\n}\n```\n\nIt looks like an ordinary shell script because it **is** an ordinary shell\nscript. You could even source it into your shell and run it directly if you\nwanted to. Don't try that with Bats :).\n\nThere are only three implicit pieces of knowledge that you need for writing\ntests:\n\n- All test functions are named starting with `test`\n- If any command in your function exits nonzero, the whole test fails\n- Each test runs in a separate temporary directory\n\n### Inline tests\n\nIf you're testing something that's itself a shell script, you can also include\nyour tests inline with the script.\n\n\u003cdetails\u003e\n\u003csummary\u003eFor example:\u003c/summary\u003e\n\n```sh\n#!/usr/bin/env bash\n\n## Inline tests can be placed anywhere in the script. You can use $0 to call\n## the script we're embedded in.\n\ntestGoodInput() {\n\tresult=$($0 1 2)\n\n\t[ ${result} -eq 3 ]\n}\n\ntestNoInput() {\n\t! $0\n}\n\ntestBadInput() {\n\t! $0 1 1.2\n}\n\ntestTooManyArgs() {\n\t! $0 1 2 3\n}\n\n# Here is the actual implementation of the script. It's not important for our\n# purposes; I just prompted Claude for the most complicated way to add two\n# numbers. The model calls it \"enterprise grade\" :)\n\npython3 -c \"\nimport subprocess, json, sys, os, tempfile, re\n\ndef validate(x):\n  result = subprocess.run(\n      ['bash', '-c', f'printf \\\"%d\\\" \\\"{x}\\\" 2\u003e/dev/null || exit 1'],\n      capture_output=True, text=True\n  )\n  if result.returncode != 0:\n      raise ValueError(f'not an integer: {x}')\n  return x\n\ndef add(a, b):\n  validate(a)\n  validate(b)\n\n  with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:\n      json.dump({'operands': [a, b], 'operation': 'addition'}, f)\n      fname = f.name\n\n  with open(fname) as f:\n      payload = json.load(f)\n\n  os.unlink(fname)\n\n  result = subprocess.run(\n      ['awk', '-v', f'a={payload[\\\"operands\\\"][0]}', '-v', f'b={payload[\\\"operands\\\"][1]}',\n       'BEGIN { print a + b }'],\n      capture_output=True, text=True\n  )\n  return result.stdout.strip()\n\nif len(sys.argv) != 3:\n  sys.exit(1)\n\ntry:\n  print(add(sys.argv[1], sys.argv[2]))\nexcept ValueError:\n  sys.exit(1)\n\" \"$1\" \"$2\"\n```\n\nInline tests can be nice because the closer the tests are to the code that's\nbeing tested, the more likely they are maintained over time.\n\n\u003c/details\u003e\n\n### Generating tests with AI\n\nTests are also easy for AIs to write. Use `attest skill` to print the a skill\nwhich is pre-tuned to produce good tests. Here's an example prompt:\n\n\u003e Write attest-style tests for the `tac` command.\n\n\u003cdetails\u003e\n\u003csummary\u003eHere's the output from Haiku 4.5:\u003c/summary\u003e\n\n```sh\n#!/usr/bin/env bash\n\n## Basic functionality - reverse lines from a file\ntestBasicFile() {\n\techo -e \"line1\\nline2\\nline3\" \u003e input.txt\n\tresult=$(tac input.txt)\n\t[ \"$result\" = \"$(echo -e \"line3\\nline2\\nline1\")\" ]\n}\n\n## Reverse lines from stdin\ntestStdin() {\n\tresult=$(echo -e \"first\\nsecond\\nthird\" | tac)\n\t[ \"$result\" = \"$(echo -e \"third\\nsecond\\nfirst\")\" ]\n}\n\n## Single line should remain unchanged\ntestSingleLine() {\n\techo \"only line\" \u003e input.txt\n\tresult=$(tac input.txt)\n\t[ \"$result\" = \"only line\" ]\n}\n\n## Empty file produces no output\ntestEmptyFile() {\n\ttouch empty.txt\n\tresult=$(tac empty.txt)\n\t[ -z \"$result\" ]\n}\n\n## Multiple files are concatenated and reversed\ntestMultipleFiles() {\n\techo -e \"a\\nb\" \u003e file1.txt\n\techo -e \"c\\nd\" \u003e file2.txt\n\tresult=$(tac file1.txt file2.txt)\n\t[ \"$result\" = \"$(echo -e \"d\\nc\\nb\\na\")\" ]\n}\n\n## Non-existent file produces error\ntestNonExistentFile() {\n\t! tac nonexistent.txt 2\u003e/dev/null\n}\n\n## Trailing newline handling\ntestTrailingNewline() {\n\tprintf \"line1\\nline2\\nline3\" \u003e input.txt\n\tresult=$(tac input.txt)\n\t[ \"$result\" = \"$(echo -e \"line3\\nline2\\nline1\")\" ]\n}\n\n## Help flag works\ntestHelp() {\n\ttac --help || tac -h\n}\n\n## Version flag works\ntestVersion() {\n\ttac --version || tac -v\n}\n\n## Lines with special characters\ntestSpecialCharacters() {\n\techo -e \"hello\\$world\\nfoo@bar\\n#comment\" \u003e input.txt\n\tresult=$(tac input.txt)\n\techo \"$result\" | grep -q \"^#comment\"\n\techo \"$result\" | grep -q \"foo@bar\"\n}\n\n## File with only whitespace lines\ntestWhitespaceLines() {\n\tprintf \"line1\\n  \\nline3\\n\" \u003e input.txt\n\tresult=$(tac input.txt)\n\t[ \"$(echo \"$result\" | wc -l)\" -eq 3 ]\n}\n\n## Very long lines are handled correctly\ntestLongLines() {\n\tlong_line=$(printf 'a%.0s' {1..1000})\n\techo -e \"$long_line\\nshort\" \u003e input.txt\n\tresult=$(tac input.txt)\n\techo \"$result\" | grep -q \"^short$\"\n}\n\n## Reads from multiple sources correctly\ntestMixedInput() {\n\techo \"file line\" \u003e file.txt\n\techo \"stdin line\" | tac - file.txt | head -1 | grep -q \"file line\"\n}\n```\n\n\u003c/details\u003e\n\nAI can generate tests all day, so the important thing is how easy it is for a\nhuman to quickly understand and assess the quality of AI-produced tests.\n\n## Running tests\n\n![](./.github/assets/serial.gif)\n\nNow that we have some tests, AI-generated or not, it's time for the good part.\n\n```sh\n# Just run the tests in one file\nattest example.test\n\n# Run all tests in this directory\nattest .\n\n# Tests run in parallel by default; use --parallel to limit concurrency\nattest --parallel 1 .\n```\n\nEvery test runs in a temporary _context directory_ that collects logs and\ntemporary files created by the test.\n\n### Containerized tests\n\nIf your application requires some dependencies in a Docker container, you can\nrun `attest` in a container with this recipe:\n\n```sh\ndocker run --rm -v $(which attest):/bin/attest -v $(pwd):/tests \u003cimage name\u003e attest /tests\n```\n\n### Fuzz testing\n\nIf your application spawns subprocesses, `attest` can randomly inflate the\ntiming of those subprocesses at random:\n\n```sh\nattest --fuzz examples/race_condition.test\n```\n\nThis works by choosing a subprocess at random and sending `SIGSTOP` followed by\n`SIGCONT`. This option also works nicely with `--repeat`.\n\n\u003cdetails\u003e\n\u003csummary\u003eExample\u003c/summary\u003e\n\nWithout `--fuzz`, you might not realize there's a nasty race condition hiding in\nthis file:\n\n```\n❯ attest --parallel 1 --repeat 10 examples/race_condition.test\nPASS  testGrepQ#1                              (1.06s)\n      cpu=7.8ms+4.8ms  mem=2.8MiB  pids=5\nPASS  testGrepQ#2                              (1.07s)\n      cpu=6.2ms+6.2ms  mem=3.1MiB  pids=5\nPASS  testGrepQ#3                              (1.07s)\n      cpu=6.2ms+6.2ms  mem=2.6MiB  pids=5\nPASS  testGrepQ#4                              (1.07s)\n      cpu=6.2ms+6.2ms  mem=3.4MiB  pids=5\nPASS  testGrepQ#5                              (1.07s)\n      cpu=6.3ms+6.3ms  mem=3.4MiB  pids=5\nPASS  testGrepQ#6                              (1.06s)\n      cpu=8.7ms+3.7ms  mem=3.4MiB  pids=5\nPASS  testGrepQ#7                              (1.07s)\n      cpu=5.9ms+6.9ms  mem=3.0MiB  pids=5\nPASS  testGrepQ#8                              (1.07s)\n      cpu=6.3ms+6.3ms  mem=3.1MiB  pids=5\nPASS  testGrepQ#9                              (1.06s)\n      cpu=6.1ms+6.1ms  mem=3.1MiB  pids=5\nPASS  testGrepQ#10                             (1.07s)\n      cpu=6.2ms+6.2ms  mem=2.8MiB  pids=5\n\nResults: 10 passed, 10 total\nTime:   10.69s\n```\n\nNow let's add some fuzziness to the timing:\n\n```\n❯ attest --parallel 1 --fuzz 0.9 --repeat 10 examples/race_condition.test                                                                                                                                                                10s\nPASS  testGrepQ#1                              (3.47s)\n      cpu=5.5ms+7.3ms  mem=2.8MiB  pids=5\nFAIL  testGrepQ#2                              (4.09s)\n      cpu=6.7ms+6.0ms  mem=3.2MiB  pids=5\nFAIL  testGrepQ#3                              (5.10s)\n      cpu=4.7ms+7.8ms  mem=2.9MiB  pids=5\nPASS  testGrepQ#4                              (3.59s)\n      cpu=6.3ms+6.3ms  mem=3.5MiB  pids=5\nFAIL  testGrepQ#5                              (2.39s)\n      cpu=5.8ms+6.8ms  mem=3.1MiB  pids=5\nPASS  testGrepQ#6                              (6.51s)\n      cpu=5.3ms+7.4ms  mem=3.1MiB  pids=5\nFAIL  testGrepQ#7                              (3.08s)\n      cpu=7.1ms+5.7ms  mem=3.1MiB  pids=5\nFAIL  testGrepQ#8                              (3.34s)\n      cpu=4.3ms+8.1ms  mem=3.4MiB  pids=5\nPASS  testGrepQ#9                              (2.68s)\n      cpu=6.3ms+6.3ms  mem=3.1MiB  pids=5\nFAIL  testGrepQ#10                             (2.08s)\n      cpu=6.2ms+6.2ms  mem=2.6MiB  pids=5\n\nResults: 4 passed, 6 failed, 10 total\nTime:   36.35s\n```\n\nWe were able to shake out the race condition by adding random delays in the\ntest. The `grep -q` example above is obviously contrived, but imagine you were\nchecking for firewall rules with `iptables | grep -q`.\n\nYou'll also notice the test took over 3 times longer. You can adjust how\naggressive the fuzzer is by passing a higher number to `--fuzz`.\n\n\u003c/details\u003e\n\n## Debugging tests\n\n![](./.github/assets/diagnostic.gif)\n\nWhen a test fails, you can obtain the context directory:\n\n```sh\nattest . --save-context ./results\n```\n\nThis directory contains everything: the test's xtrace, stdout, any files created\nby the tests, etc.\n\nYou can also just view the xtrace output with the `--xtrace` flag:\n\n![](./.github/assets/xtrace.gif)\n\n## Installation\n\n\u003cdetails\u003e\n\u003csummary\u003eCrates.io\u003c/summary\u003e\n\n![Crates.io Total Downloads](https://img.shields.io/crates/d/attest)\n\n#### Install from crates.io\n\n```sh\ncargo install attest\n```\n\n\u003c/details\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffossable%2Fattest","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffossable%2Fattest","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffossable%2Fattest/lists"}