Skip to content

Testing Runbooks

Runbooks includes a built-in testing framework that lets you validate that your runbooks work correctly.

To run a test, you define a YAML test configuration file alongside your runbook. Then call runbooks test /path/to/runbook or runbooks test ./... to run the tests. Test are meant to run locally or in CI.

  1. Generate a test configuration for your runbook:
Terminal window
runbooks test init ./my-runbook

This creates runbook_test.yml with reasonable defaults based on your runbook’s blocks. You can edit the file to customize the tests if needed.

  1. Run the tests:
Terminal window
runbooks test ./my-runbook

Tests are defined in a runbook_test.yml file located in the same directory as your runbook.mdx file. Here’s an example of a test configuration file:

version: 1
settings:
# use_temp_working_dir: true # Default: use isolated temp directory
# working_dir: . # Alternative: use runbook's directory
# output_path: generated # Where to write generated files (relative to working_dir)
timeout: 5m # Test timeout
parallelizable: true # Can run in parallel with other runbooks
tests:
- name: happy-path
description: Standard successful execution
inputs:
project.Name: "test-project"
project.Language: "python"
steps:
- block: check-requirements
expect: success
- block: create-project
expect: success
outputs: [project_id]
assertions:
- type: file_exists
path: generated/README.md
- type: file_contains
path: generated/README.md
contains: "test-project"
cleanup:
- command: rm -rf /tmp/test-resources
version: 1 # Required. Must be 1.
settings: { ... } # Optional. Global settings for all tests.
tests: [ ... ] # Required. Array of test cases (minimum 1).
FieldTypeRequiredDescription
versionintegerYesConfiguration version. Must be 1.
settingsobjectNoGlobal settings that apply to all tests.
testsarrayYesArray of runbook tests to run. At least one test case is required.
settings:
use_temp_working_dir: true # boolean, default: true
working_dir: "." # string, default: current directory
output_path: "generated" # string, default: "generated"
timeout: "5m" # duration string, default: "5m"
parallelizable: true # boolean, default: true
FieldTypeDefaultDescription
use_temp_working_dirbooleantrueUse an isolated temporary directory for test execution. Overrides working_dir when true. The temp directory is automatically cleaned up after the test.
working_dirstringcurrent directoryBase directory for script execution and file generation. Use . for the runbook’s directory. Ignored when use_temp_working_dir is true.
output_pathstring"generated"Directory where generated files are written, relative to the working directory.
timeoutstring"5m"Maximum time for each test case. Uses Go duration format (e.g., 30s, 5m, 1h).
parallelizablebooleantrueWhether this runbook’s tests can run in parallel with tests from other runbooks.
tests:
- name: "test-name" # string, required
description: "..." # string, optional
env: { ... } # map[string]string, optional
inputs: { ... } # map[string]InputValue, optional
steps: [ ... ] # array of Step objects, optional
assertions: [ ... ] # array of Assertion objects, optional
cleanup: [ ... ] # array of CleanupAction objects, optional
FieldTypeRequiredDescription
namestringYesUnique identifier for the test case.
descriptionstringNoHuman-readable description of what the test validates.
envmapNoEnvironment variables to set for all blocks in this test. Keys and values are strings.
inputsmapNoVariable values for the test. Keys use inputsID.varName format. Values can be literals or fuzz configs.
stepsarrayNoBlocks to execute in order. If empty, all blocks run in document order.
assertionsarrayNoValidations to run after all steps complete.
cleanuparrayNoActions to run after the test completes (even on failure).
steps:
- block: "block-id" # string, required
expect: success # ExpectedStatus, default: "success"
outputs: ["output1"] # array of strings, optional
missing_outputs: ["..."] # array of strings, optional
error_contains: "..." # string, optional
assertions: [ ... ] # array of Assertion objects, optional
FieldTypeRequiredDescription
blockstringYesID of the block to execute.
expectstringNoExpected execution status. Default: success. See Expected Status values below.
outputsarrayNoOutput names to capture from the block.
missing_outputsarrayNoExpected missing outputs (used with blocked status to verify why a block is blocked).
error_containsstringNoExpected error message substring (used with config_error status).
assertionsarrayNoAssertions to run immediately after this step completes.
StatusDescription
successBlock completed successfully (exit code 0).
failBlock failed (exit code 1+).
warnBlock completed with warning (exit code 2).
blockedBlock should be blocked due to missing dependencies.
skipSkip this block entirely (useful for AwsAuth in non-AWS tests).
config_errorBlock has a configuration error (missing props, invalid config, etc.).

All assertions have a type field that determines which other fields are required.

assertions:
- type: "assertion_type" # AssertionType, required
# Additional fields depend on the assertion type
Assertion TypeRequired FieldsOptional FieldsDescription
file_existspathCheck that a file exists at the given path.
file_not_existspathCheck that a file does not exist.
file_containspath, containsCheck that a file contains a substring.
file_not_containspath, containsCheck that a file does not contain a substring.
file_matchespath, patternCheck that file content matches a regex pattern.
file_equalspath, valueCheck that file content exactly equals a value.
dir_existspathCheck that a directory exists.
dir_not_existspathCheck that a directory does not exist.
output_equalsblock, output, valueCheck that a block output equals a value.
output_matchesblock, output, patternCheck that a block output matches a regex pattern.
output_existsblock, outputCheck that a block output exists (is not empty).
files_generatedblockmin_countCheck that a Template block generated files.
scriptcommandRun a custom script (exit 0 = pass).
FieldTypeDescription
typestringThe assertion type. Required for all assertions.
pathstringFile or directory path (relative to working directory).
containsstringSubstring to search for in file content.
patternstringRegular expression pattern.
valuestringExpected exact value.
blockstringBlock ID for output assertions or files_generated.
outputstringOutput name to check.
min_countintegerMinimum number of files generated (for files_generated). Default: 1.
commandstringShell command to execute (for script assertions).
cleanup:
- command: "rm -rf /tmp/test" # string, optional (inline command)
path: "cleanup/teardown.sh" # string, optional (script file path)
FieldTypeDescription
commandstringInline shell command to execute.
pathstringPath to a script file to execute.

Provide either command or path, not both.

Input values can be specified as either literal values or fuzz configurations.

inputs:
project.Name: "my-project" # string literal
config.Port: 8080 # integer literal
config.Enabled: true # boolean literal

Fuzz values are used to generate random values for testing. The test framework will generate a new random value that meets the given constraints for each test run.

inputs:
project.Name:
fuzz:
type: string # FuzzType, required
# Additional fields depend on the fuzz type

The fuzz object specifies how to generate random values.

FieldTypeApplies ToDescription
typestringAllRequired. The fuzz type (see Fuzz Types below).
lengthintegerstringExact length of the generated string.
minLengthintegerstring, listMinimum length. Default: 8 for strings, 5 for list items.
maxLengthintegerstring, listMaximum length. Default: minLength + 10 for strings.
prefixstringstringPrefix to prepend to the generated string.
suffixstringstringSuffix to append to the generated string.
includeSpacesbooleanstringInclude space characters. Default: false.
includeSpecialCharsbooleanstringInclude special characters. Default: false.
minintegerint, floatMinimum numeric value. Default: 0.
maxintegerint, floatMaximum numeric value. Default: 100.
optionsarrayenumArray of valid enum options. Required for enum type.
domainstringemail, urlDomain to use. Default: random from example.com, test.org, etc.
minDatestringdate, timestampMinimum date (supports RFC3339, YYYY-MM-DD). Default: 365 days ago.
maxDatestringdate, timestampMaximum date. Default: now.
formatstringdate, timestampOutput format. Default: 2006-01-02 for date, RFC3339 for timestamp.
wordCountintegerwordsExact number of words.
minWordCountintegerwordsMinimum word count. Default: 2.
maxWordCountintegerwordsMaximum word count. Default: minWordCount + 3.
countintegerlist, mapExact number of items/entries.
minCountintegerlist, mapMinimum items/entries. Default: 2.
maxCountintegerlist, mapMaximum items/entries. Default: minCount + 3.
schemaarraymapField names for nested map values (generates map[string]map[string]string).
TypeOutputDescription
stringstringAlphanumeric string with optional spaces and special characters.
intintegerRandom integer within min/max range.
floatfloatRandom float within min/max range.
boolbooleanRandom true or false.
enumstringRandom selection from options array.
emailstringValid email address (e.g., abc123@example.com).
urlstringValid URL (e.g., https://example.com/path).
uuidstringUUID v4 (e.g., 550e8400-e29b-41d4-a716-446655440000).
datestringDate string. Default format: YYYY-MM-DD.
timestampstringTimestamp string. Default format: RFC3339.
wordsstringSpace-separated random words.
liststringJSON array of strings (e.g., ["abc", "def"]).
mapstring or mapJSON object or nested map depending on schema.
version: 1
settings:
use_temp_working_dir: true
output_path: generated
timeout: 10m
parallelizable: true
tests:
- name: happy-path
description: Standard successful execution with all features
env:
LOG_LEVEL: debug
RUNBOOK_DRY_RUN: "false"
inputs:
# Literal values
project.Name: "test-project"
config.Port: 8080
# Fuzz values
project.Description:
fuzz: { type: words, minWordCount: 3, maxWordCount: 6 }
user.Email:
fuzz: { type: email, domain: "test.example.com" }
resource.ID:
fuzz: { type: uuid }
config.Region:
fuzz: { type: enum, options: ["us-east-1", "us-west-2", "eu-west-1"] }
steps:
- block: validate-inputs
expect: success
- block: create-resources
expect: success
outputs: [resource_id, resource_arn]
assertions:
- type: output_exists
output: resource_id
- block: generate-config
expect: success
assertions:
- type: file_exists
path: generated/config.json
- type: file_contains
path: generated/config.json
contains: "test-project"
- type: file_matches
path: generated/config.json
pattern: '"version":\s*"\d+\.\d+\.\d+"'
- type: output_matches
block: create-resources
output: resource_id
pattern: "^[a-f0-9]{12}$"
cleanup:
- command: rm -rf /tmp/test-resources
- name: missing-dependency
description: Test that blocks are properly blocked
steps:
- block: create-resources
expect: blocked
missing_outputs:
- _blocks.validate_inputs.outputs.validated
- block: validate-inputs
expect: success
- block: create-resources
expect: success

Cleanup actions run after the test completes, even if the test fails:

cleanup:
# Inline command
- command: rm -rf /tmp/test-resources
# Script file
- path: cleanup/teardown.sh

For runbooks with <AwsAuth> blocks, use prefilledCredentials={{ type: "env" }} to enable headless testing:

<AwsAuth
id="aws-auth"
prefilledCredentials={{ type: "env" }}
allowOverridePrefilled={false}
/>

This will look specifically for the following environment variables:

  • AWS_ACCESS_KEY_ID
  • AWS_SECRET_ACCESS_KEY
  • AWS_SESSION_TOKEN
  • AWS_REGION

Consider using the prefix RUNBOOKS_TEST_ for environment variables that are used for testing. This will help you avoid conflicts with other environment variables.

<AwsAuth
id="aws-auth"
prefilledCredentials={{ type: "env", prefix: "RUNBOOKS_TEST_" }}
allowOverridePrefilled={false}
/>

This will look specifically for the following environment variables:

  • RUNBOOKS_TEST_AWS_ACCESS_KEY_ID
  • RUNBOOKS_TEST_AWS_SECRET_ACCESS_KEY
  • RUNBOOKS_TEST_AWS_SESSION_TOKEN
  • RUNBOOKS_TEST_AWS_REGION

In CI, be sure to set the environment variables before running tests. The following example shows one way to set the environment variables for a GitHub Actions workflow:

.github/workflows/test.ymlls
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: us-west-2
- name: Run runbook tests
run: runbooks test ./runbooks/...
env:
RUNBOOK_TEST_AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
RUNBOOK_TEST_AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
RUNBOOK_TEST_AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
RUNBOOK_TEST_AWS_REGION: ${{ env.AWS_REGION }}

When you run a runbook test, the test framework will:

  • Run the runbook in a temporary directory
  • Capture the output of any scripts or generated files
  • Validate the output of the runbook against the assertions in your test configuration file, if any
  • Return a status code of 0 if the test passed, 1 if the test failed, and 2 if the test was skipped

If a runbook test fails, you will see additional details about which block failed and why.

You can run a test on a single runbook.

Terminal window
# Run all tests for a runbook
runbooks test ./my-runbook
# Run a specific test case
runbooks test ./my-runbook --test happy-path
# Verbose output
runbooks test ./my-runbook -v

Use the ... glob pattern to discover and test all runbooks:

Terminal window
# Test all runbooks in a directory tree
runbooks test ./runbooks/...
# Control parallel execution
runbooks test ./runbooks/... --max-parallel 4

Generate JUnit XML for CI integration:

Terminal window
runbooks test ./runbooks/... --output junit --output-file results.xml

In some cases, for testing purposes, you may wish to execute blocks in an order different than how they’re defined in the runbook. You can do this by explicitly specifying the order of the blocks in the test configuration file.

Keep in mind, though, that when new blocks are added to the runbook, they will not be included in the test unless you explicitly add them to the test configuration file.

That’s why we recommend leaving the steps section empty in your test configuration file if possible. This will ensure that all future blocks are included in the test.

tests:
- name: out-of-order
description: Test dependency enforcement
steps:
# This should be blocked because create-account hasn't run
- block: create-resources
expect: blocked
missing_outputs:
- _blocks.create_account.outputs.account_id
# Run the dependency
- block: create-account
expect: success
# Now this should succeed
- block: create-resources
expect: success
.github/workflows/test-runbooks.yml
name: Test Runbooks
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up runbooks
run: |
curl -sL https://github.com/gruntwork-io/runbooks/releases/latest/download/runbooks_linux_amd64 -o runbooks
chmod +x runbooks
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Run runbook tests
run: ./runbooks test ./runbooks/... --output junit --output-file results.xml
env:
# Map OIDC credentials to RUNBOOKS_TEST_ prefix
RUNBOOKS_TEST_AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
RUNBOOKS_TEST_AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
RUNBOOKS_TEST_AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
RUNBOOKS_TEST_AWS_REGION: ${{ vars.AWS_REGION }}
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results
path: results.xml

Runbooks that interact with external services (GitHub, AWS, Terraform, etc.) require special testing strategies. We recommend a tiered approach.

As you ascend the tiers, you will gain more completeness at the expense of slower speed and more complexity. This builds on the concepts of the traditional test pyramid.

Template validation tests that templates generate correct files without making any external calls.

tests:
- name: template-validation
description: Validate template generation
inputs:
config.ProjectName: "test-project"
config.Region: "us-east-1"
steps:
- block: generate-config # Template block
expect: success
- block: deploy-resources # Skip external calls
expect: skip
assertions:
- type: files_generated
block: generate-config
min_count: 1

Dry-run mode tests that script logic works correctly without making any external calls. They leverage the fact that the script was written to support a RUNBOOK_DRY_RUN environment variable.

Use the RUNBOOK_DRY_RUN environment variable pattern to test script logic without side effects. Note that the RUNBOOK_DRY_RUN environment variable is not special in any way, it is just a convention. You can use any environment variable you want to trigger the dry-run mode.

In your Command or Check script:

#!/bin/bash
set -e
# Dry-run support
DRY_RUN="${RUNBOOK_DRY_RUN:-false}"
if [[ "$DRY_RUN" == "true" ]]; then
echo "[DRY-RUN] Would create PR in $GITHUB_ORG/$REPO_NAME"
echo "[DRY-RUN] gh pr create --title '$TITLE'"
exit 0
fi
# Real execution
gh pr create --title "$TITLE" --body "$BODY"

In your test configuration file:

tests:
- name: dry-run-flow
description: Test full flow without real API calls
env:
RUNBOOK_DRY_RUN: "true"
steps:
- block: generate-config
expect: success
- block: create-pr
expect: success # Succeeds in dry-run mode

For full integration testing with real services, set environment variables for credentials in CI and run tests manually or on a schedule:

tests:
- name: integration-test
description: Full integration (requires credentials)
env:
# Credentials set in CI environment will automatically be available to the test.
# So no need to set them in the runbook_test.yml file.
steps:
- block: deploy-resources
expect: success
cleanup:
- command: ./scripts/cleanup-test-resources.sh
  1. Start with test init: Generate your test configuration automatically, then customize it.

  2. Test happy paths first: Ensure your runbook works when everything goes right.

  3. Test edge cases: Add tests for expected failures, missing dependencies, and error handling.

  4. Use fuzz values: Generate random inputs to catch edge cases you might not think of.

  5. Keep tests fast: Use use_temp_working_dir: true and avoid slow external dependencies when possible.

  6. Clean up resources: Use the cleanup section to remove any resources created during testing.

  7. Use CI: Run tests on every pull request to catch issues early.

  8. Use tiered testing: For runbooks with external dependencies, use dry-run mode in CI and save integration tests for manual/scheduled runs.

Finally, note that tests are not free to write or maintain. But it is generally worth the investment to know that your runbooks work as expected. Choose accordingly how much to invest in testing your runbooks.