> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://rivet.chrisboulton.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://rivet.chrisboulton.com/_mcp/server.

# Manifest and module tests

> Compile isolated scenarios and check resources, outputs, content, and relationships.

`rivet test` compiles a configuration or module with saved facts and chosen variables, then checks the resulting catalog: which resources exist, their properties and content, notifications, ordering, and outputs. It never touches a host, so it runs on macOS or Linux without root, and fits in CI.

```sh
rivet test examples/testing
rivet test examples/testing/modules/config
rivet test ./site --recursive
rivet test ./site --filter "different port"
rivet test ./site --format json
```

Tests check what you declared, not what happens on a real host. To check actual convergence, restarts, and recovery, apply to a disposable Linux machine.

## Files and scenarios

Place definitions in `tests/*.rivettest.hcl` immediately below a configuration or module directory. By default, only the directory passed to `rivet test` is searched. `--recursive` also finds suites in child directories, even when the root has no suite. Files are sorted by full path, with tests in declaration order. Discovery skips `.git`, `.rivet`, `target`, `node_modules`, `.cache`, and the contents below `tests/`, so downloaded modules, generated files, and fixture roots are not run. Directory symlinks below the root are not followed, and a symlinked `tests` directory is rejected. Keep test HCL inside `tests/`: the normal manifest loader reads every immediate `*.hcl` file in its source directory.

```text
suite "web" {
  source     = ".."
  facts_file = "fixtures/ubuntu-24.04.json"

  variables = {
    port = 8080
  }

  test "renders the selected port" {
    variables = { port = 9000 }

    expect_resource {
      resource = file["/etc/myapp/config.conf"]

      attributes = {
        path = "/etc/myapp/config.conf"
        mode = "0644"
      }

      content_contains = ["listen = 9000"]
      notifies         = [service.myapp]
      ordered_before   = [service.myapp]
    }

    expect_output "port" {
      equals = 9000
    }
  }
}
```

`source` is required on each suite and points at the configuration directory under test. All filesystem paths resolve relative to the test file, including paths on individual tests. Normal source-package and asset confinement still applies: tests do not widen a module's allowed filesystem boundary. To test a module that needs siblings or resource-handle arguments, use a fixture root that invokes it with normal module declarations.

Every test compiles a fresh catalog. Suite `variables` are defaults for tests; a test replaces individual variables. Map/object variables are replaced as whole values, with no implicit deep merge. Manifest variable defaults apply when neither scope supplies a variable. Inputs and expected values support literal strings, finite numbers, booleans, nulls, lists, and objects; expressions, functions, hooks, and ambient environment inputs are not test values. Selectors are the separate address syntax described below.

`facts_file` accepts the same JSON target assumptions or structured facts snapshot as `rivet compile --facts`. A test may replace its suite's `facts_file`. Without one, compilation uses Rivet's fixed Ubuntu baseline and empty inventory; it never collects facts from the machine running the test. A missing fact remains a compile error. Trusted authentication claims are not supplied by a saved inventory snapshot.

Suite names must be unique within a file and test names within a suite. A test without expectations checks compilation success. `--filter` matches case-sensitive text in `suite > test [variant]`. An empty discovery or filtered run fails unless `--allow-empty` is set.

## Named fact sets

Use `fact_sets` to run the same tests against multiple complete saved snapshots:

```text
suite "web platforms" {
  source = ".."

  fact_sets = {
    ubuntu_x86_64 = "fixtures/ubuntu-x86_64.json"
    ubuntu_arm64  = "fixtures/ubuntu-arm64.json"
  }

  test "enables the service" {
    expect_resource "service.myapp" {
      attributes = { ensure = "running", enabled = true }
    }
  }

  test "exports the ARM architecture" {
    facts_file = "fixtures/ubuntu-arm64.json"
    expect_output "architecture" { equals = "aarch64" }
  }
}
```

`fact_sets` is a nonempty map from names to fixture paths, relative to the test file. Each fixture uses the same assumptions or snapshot format as `facts_file`. Names must be nonempty and contain no control characters. A suite accepts either `facts_file` or `fact_sets`, not both. A test with its own `facts_file` runs once against that file; other tests run once per named set, in name order. Every expanded case gets a fresh compilation. Fixture names do not change architecture, inventory, or capabilities automatically.

The example runs three cases: the service test on each fact set, then the pinned ARM test once. Text reports append `[ubuntu_arm64]` or `[ubuntu_x86_64]`; JSON keeps the original test name and supplies a separate `variant` field. `--filter ubuntu_arm64` selects that variant's cases. Setup errors remain attached to the failing variant and cannot satisfy `expect_error`; other selected cases continue.

## Resource selectors

Both forms select the same resource:

```text
expect_resource "file.config" {
  attributes = { mode = "0644" }
}

expect_resource {
  resource = file.config
  attributes = { mode = "0644" }
}
```

Exactly one selector is required: an address label or `resource` attribute. Parentheses around a selector do not change its meaning, including in output assertions. Quoted address text preserves literal template markers in names. Unquoted selectors avoid escaping when names contain paths or expansion keys:

```text
expect_resource {
  resource = file["/etc/myapp/config.conf"]
  content_equals = "listen = 8080\n"
}

expect_resource {
  resource = module.accounts["deploy"].user.account
  attributes = { name = "deploy", uid = 1001 }
}

expect_resource {
  resource = module.workers[0].file.config
}
```

Selectors identify declarations, not physical paths. `file["/etc/myapp/config.conf"]` selects a resource *named* `/etc/myapp/config.conf`; it does not find any file managing that path. Resource names accept identifier labels or normalized absolute paths. The manifest must still declare its `path` explicitly. Module, variable, and output labels retain identifier rules. Named keys (`["0"]`) and numeric count slots (`[0]`) are distinct.

Tests may inspect the compiled resources inside module instances. This does not allow production manifests to read private child declarations. When a module is compiled directly as the test source, its resources have root addresses such as `user.account`.

```text
expect_resource {
  resource = file.optional[0]
  present = false
}
```

`present = false` checks that the declaration is missing from the catalog. It differs from `attributes = { ensure = "absent" }`, which checks a declaration that requests removal of a host object. An absence expectation cannot also assert properties.

## Assertions

* `attributes` checks only the named properties of compiled desired state. Other properties are allowed. Values use canonical declaration vocabulary: file modes are four-digit octal strings, service names include `.service`, and service activity uses `ensure` (the `state` alias is also accepted). Unmanaged nullable fields compare as null. Asset acquisition inputs such as file `source` and `checksum` are not retained as compiled properties; check content instead.
* `metadata` checks selected effective fields: `description`, `noop`, `sensitive`, `tags`, and `timeout_ms`. Tags are the complete sorted list when asserted.
* `content_equals` checks exact UTF-8 file content, including whitespace and trailing newlines. It works for inline content, templates, local sources, and fixture-backed cloud sources.
* `content_contains` is a list of substrings that must all appear in the content.
* `content_not_contains` is a list of substrings that must all be absent. Like positive content checks, it fails for unmanaged or non-UTF-8 content. Binary content cannot pass an `attributes = { content = null }` assertion.
* `notifies` is a list of resource selectors requiring notification relationships. Both `notify` and `subscribe` declarations satisfy it. An ordering edge alone does not.
* `does_not_notify` forbids notification relationships to the listed resources.
* `notifies_only` checks the exact set of notification targets, ignoring duplicate entries and duplicate edge reasons. `notifies_only = []` requires no notification targets. It cannot be combined with `notifies` or `does_not_notify` in the same block.
* `ordered_before` is a list of resource or module selectors. It checks reachability through the full graph, including module entry and completion barriers. It does not assert an incidental position in a topological sort.
* `not_ordered_before` requires no ordering path to each listed resource/module. It does not require reverse ordering. Targets of positive and negative relationship assertions must exist; a misspelled target fails the test.
* `expect_output "name"` checks root output presence. Optional `equals` accepts a literal scalar or collection or one resource selector; optional `sensitive` checks its sensitivity flag. Literal collections compare exactly, including list order. Resource outputs compare structured identities, not strings.

All failed expectations are reported. Reports include test locations and resource declaration locations when available. Public mismatches show expected and actual values, truncated when large. Sensitive attributes, outputs, and content redact both expected and actual values in text and JSON.

Except for the exact-set `notifies_only` matcher, an empty matcher list means no checks. For example, `notifies = []` does not assert that the resource has no notification targets.

```text
expect_resource {
  resource = file["/etc/myapp/config.conf"]
  content_not_contains = ["debug = true"]
  notifies = [service.myapp]
  does_not_notify = [service.legacy]
  not_ordered_before = [exec.unrelated]
}
```

## Resource counts

`expect_resources` counts matching declarations in the compiled catalog:

```text
expect_resources {
  type = "file"
  count = 3
}

expect_resources {
  resource = module.accounts[*].user.account
  count = 2
}

expect_resources {
  resource = file.optional[*]
  count = 0
}
```

Require exactly one selector, `type` or `resource`, and a nonnegative integer `count`. `type` counts that registered resource type across the entire catalog, including child modules. `resource` matches a structured address and may use `[*]` at any module or resource expansion position. A wildcard matches any named key or numeric slot, keeping those key types distinct when a concrete key is supplied. It matches expanded instances only; omit `[*]` to select an unexpanded declaration. Module depth, declaration labels, and resource types must match exactly. Path labels use the same bracket syntax as singular selectors.

These patterns do not evaluate HCL expressions and do not enable splats in manifests. Singular resource expectations and relationship selectors still require concrete addresses. On a mismatch, the report shows the actual count and up to 50 matching addresses. Counting is the plural block's only assertion; use `expect_resource` to check an individual instance's properties.

## Expected compilation failures

```text
test "rejects the wrong input type" {
  variables = { port = "invalid" }

  expect_error {
    contains = "expected number"
  }
}
```

`expect_error` must be the test's only expectation and requires a nonempty substring. It matches the compiler's diagnostic message, excluding its location. Compilation success or an unrelated diagnostic fails the test. Broken test definitions, missing fixture or source files, unavailable or unprepared modules, and asset-loading errors are setup failures and cannot satisfy it. This includes files read by `file()` and `templatefile()`. The compiler classifies acquisition failures separately from configuration diagnostics; checksum mismatches remain configuration diagnostics that a test may expect.

## Offline assets and modules

Run `rivet init` separately for pinned remote modules. Tests reuse the normal lockfile and cache integrity checks and never fetch missing modules.

Cloud asset acquisition is disabled. Supply local files in their place:

```text
suite "remote configuration" {
  source = ".."

  cloud_assets = {
    "s3://example-config/config.json" = "fixtures/config.json"
  }

  test "bundles the expected content" {
    expect_resource "file.config" {
      content_equals = "{}\n"
    }
  }
}
```

A test can replace individual suite `cloud_assets` mappings. Fixture bytes still pass the manifest's required SHA-256 checks. Paths must identify regular files, not symlinks. File validation commands and exec guards are compiled but never executed.

## Results and bounds

Exit codes are `0` for success, `1` for failed expectations or unexpected compilation failures, and `2` for invalid definitions, setup errors, or an empty run. JSON output contains `passed`, `failed`, `errors`, and an ordered `tests` array with per-case failures and source locations. Cases using a saved snapshot include `facts_file`; named fact-set cases also include `variant`. Invalid test definitions are reported on stderr before cases run.

One run accepts at most 256 definition files, 16 MiB of combined test HCL, and 1000 selected cases after fact-set expansion and filtering. The case budget is checked before any compilation. Recursive discovery is bounded to 10000 directories and depth 64. HCL nesting is bounded before parsing. Cloud fixture bytes are bounded to 256 MiB per case, and normal compiler/catalog/bundle limits still apply. Tests currently have no nested contexts, shared examples, module mocks, variable-set products, or whole-catalog snapshots.

## Approved plugins

Use `rivet --plugin-lock /absolute/path/to/plugins.lock.json test ./site` to include installed resource definitions, service and package providers, and functions. The policy is host-owned; a test suite cannot authorize an executable. Provide saved facts with the required plugin capabilities rather than probing a runtime.

Plugin resources expose declaration-facing attributes and bundled content through `ResourceDefinition::test_view` over definition RPC. The same count, relationship, content, output, and sensitivity assertions apply to built-in and external resources. Service assertions include `provider` plus normalized provider options under the provider's child-block name.

Plugin definition code executes during testing, but Rivet sends no runtime, action, apply, or recovery requests. Keep definition and projection hooks pure. Transport failures are setup errors and cannot satisfy `expect_error`; projection failures remain visible in the test report. See [subprocess plugins](/contributing/plugins) for installation, and [resource authoring](/contributing/resource-authoring#manifest-test-views) for how resources expose test attributes.