From a1f8a11556fc1a12d6a0c03474e4fe9be7d46779 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 14 Oct 2026 00:48:04 +0900 Subject: [PATCH] feat(terraform.test): add composite action for Terraform tests --- .github/actions/terraform.test/README.md | 100 ++++++ .github/actions/terraform.test/action.yaml | 257 +++++++++++++++ .../terraform.test/tests/test_action.py | 301 ++++++++++++++++++ 3 files changed, 658 insertions(+) create mode 100644 .github/actions/terraform.test/README.md create mode 100644 .github/actions/terraform.test/action.yaml create mode 100644 .github/actions/terraform.test/tests/test_action.py diff --git a/.github/actions/terraform.test/README.md b/.github/actions/terraform.test/README.md new file mode 100644 index 0000000..62ffe62 --- /dev/null +++ b/.github/actions/terraform.test/README.md @@ -0,0 +1,100 @@ +# Terraform - Test + +Initialize a module with `terraform init -backend=false -input=false` and run +`terraform test` once. Like `terraform.validate`, this action expects Terraform +on `PATH`, uses `shell.run` to capture output, and adds failure details to the job +summary. Install Terraform with the repository's `mise.setup-tools` action or +another setup step; disable the wrapper when using `hashicorp/setup-terraform`. + +```yaml +- name: Test Terraform Module + id: test + uses: tedilabs/github-actions/.github/actions/terraform.test@main + with: + target_dir: modules/example + test_directory: tests + filters: | + tests/main.tftest.hcl + tests/outputs.tftest.hcl + var_files: | + fixtures/common.tfvars + fixtures/test.tfvars + vars: | + environment=test + labels={ owner = "platform team" } + parallelism: "5" + junit_xml: test-results.xml +``` + +## Inputs and outputs + +See [action.yaml](action.yaml) for the complete interface and defaults. Inputs map +to the [Terraform test CLI options](https://developer.hashicorp.com/terraform/cli/commands/test): + +| Input | Behavior | +| --- | --- | +| `target_dir` | Working directory for init and test; defaults to `./`. | +| `workspace` | Exports `TF_WORKSPACE` for both commands when provided. | +| `test_directory` | Passes `-test-directory` to both commands; defaults to `tests`. | +| `filters` | One `-filter` per non-empty line. | +| `var_files` | One `-var-file` per non-empty line, in order. | +| `vars` | One `-var` assignment per non-empty line, after all variable files. | +| `format` | `text` (default) or `json` (`-json`). | +| `verbose` | Passes `-verbose` when `true`; defaults to `false`. | +| `parallelism` | Passes `-parallelism`; omitted by default. | +| `junit_xml` | Passes `-junit-xml`; omitted by default. | +| `cloud_run` | Passes `-cloud-run` and skips local init; omitted by default. | +| `annotations_enabled` | Annotates captured diagnostics on failure; defaults to `true`. | + +All relative paths are resolved against `target_dir`, including filters, variable +files, and the JUnit report. List inputs preserve spaces and HCL expressions; +each line is one argument, with no shell quoting or expansion. Variable files are +loaded before explicit assignments. Terraform's test-file and run-block variable +precedence still applies. `TF_VAR_*` environment variables can supply secrets. + +Terraform 1.6 or later is required. `junit_xml` requires 1.11 or later, and +`parallelism` requires 1.12 or later. These flags are only passed when requested, +so the default invocation works with Terraform 1.6. Test-only configurations +without root `.tf` files require Terraform 1.12 or later for initialization. + +The action returns `stdout`, `stderr`, and `exitcode` from test, or from init when +initialization fails. It still fails on non-zero exit codes; use caller-level +`continue-on-error: true` when subsequent steps need to inspect failure outputs. +With `format: json`, test stdout is a newline-delimited JSON message stream. +Init always produces text output. Failure summaries and annotations read the +captured output; tests are never repeated to collect diagnostics. + +The action returns `skipped: 'true'` when there are no `.tftest.hcl` or +`.tftest.json` files directly in `target_dir` or the selected `test_directory`. +Other outputs are empty when skipped. Discovery is non-recursive, matching +Terraform; tests directly in `target_dir` are always considered. Filters select +from discovered test files using Terraform's own matching behavior. + +## Reports and remote execution + +The JUnit report is written by Terraform on both passing and failing test runs. +Its parent directory must already exist. Upload it in a separate caller step +with `if: always()` if it should be retained as an artifact. For the example +above, the repository-relative report path is `modules/example/test-results.xml`. + +For remote execution, set `cloud_run` to a private registry module source, such +as `app.terraform.io/example/module/aws`, and configure credentials before calling +the action. HCP Terraform installs providers and modules remotely. `cloud_run` +and `junit_xml` cannot be combined. + +Test files control whether Terraform plans or applies. Apply tests can create +real resources, and Terraform attempts cleanup at the end; cleanup failures stay +in the captured output and failure summary. + +## Development + +With Terraform, Bash, jq, and Python with PyYAML installed: + +```sh +python3 .github/actions/terraform.test/tests/test_action.py +``` + +Alternatively, use `uv run --with pyyaml python` in place of `python3`. +The tests execute the action's shell snippets and shared output/summary helpers +against temporary, provider-free Terraform fixtures. Remote argument forwarding +is tested with a stub; no HCP Terraform run or cloud resources are created. diff --git a/.github/actions/terraform.test/action.yaml b/.github/actions/terraform.test/action.yaml new file mode 100644 index 0000000..3011d77 --- /dev/null +++ b/.github/actions/terraform.test/action.yaml @@ -0,0 +1,257 @@ +name: Terraform - Test +description: Initialize a Terraform directory without a backend and run its tests with `terraform test`. On failure, the output is added to the job summary. Requires Terraform 1.6 or later on the `PATH`. + + +inputs: + target_dir: + required: false + default: ./ + description: "(Optional) The Terraform directory to initialize and test. Defaults to `./`." + workspace: + required: false + description: "(Optional) The Terraform workspace name to test as, exported as `TF_WORKSPACE`. Defaults to Terraform's `default` workspace." + test_directory: + required: false + default: tests + description: "(Optional) The directory holding the test files, relative to `target_dir`, passed as `-test-directory` to both init and test. Tests directly in `target_dir` are always loaded. Defaults to `tests`." + filters: + required: false + description: "(Optional) The test files to execute, one path per line, each passed as `-filter`. Paths are relative to `target_dir`, including the test directory (e.g. `tests/main.tftest.hcl`). Omit to execute all test files." + var_files: + required: false + description: "(Optional) The variable files to load, one path per line, each passed as `-var-file`. Relative paths are resolved against `target_dir`. Files are loaded in order, before `vars`." + vars: + required: false + description: "(Optional) Input variable assignments, one `key=value` per line, each passed as `-var`. Spaces and HCL expressions are preserved; do not add shell quotes around an assignment. Use `TF_VAR_*` environment variables for sensitive values." + format: + required: false + default: text + description: "(Optional) The test output format. Valid values are `text` or `json`, which passes `-json` for a newline-delimited JSON message stream. Defaults to `text`." + verbose: + required: false + default: "false" + description: "(Optional) Whether to print the plan or state for each test run with `-verbose`. Defaults to `false`." + parallelism: + required: false + description: "(Optional) The maximum concurrent resource operations within each test run, passed as `-parallelism`. Requires Terraform 1.12 or later. Omit to use Terraform's default of 10." + junit_xml: + required: false + description: "(Optional) The path to write a JUnit XML report to, passed as `-junit-xml`. Relative paths are resolved against `target_dir`; the parent directory must exist. Requires Terraform 1.11 or later. Cannot be combined with `cloud_run`." + cloud_run: + required: false + description: "(Optional) The private registry module source to execute tests against remotely in HCP Terraform, passed as `-cloud-run`. The caller configures credentials. Local initialization is skipped. Cannot be combined with `junit_xml`." + annotations_enabled: + required: false + default: "true" + description: "(Optional) Whether to annotate findings when init or test fails, from its captured output. Tests are never re-run to collect diagnostics. Defaults to `true`." + +outputs: + skipped: + value: ${{ steps.target.outputs.skipped }} + description: "`true` when neither `target_dir` nor `test_directory` holds test files and no commands were run. Empty otherwise." + # When `terraform init` fails, `terraform test` does not run, so the outputs come from the command that failed. + stdout: + value: ${{ steps.init.outcome == 'failure' && steps.init.outputs.stdout || steps.test.outputs.stdout }} + description: "The STDOUT stream of the call to `terraform test`, or to `terraform init` when it failed. Empty when skipped." + stderr: + value: ${{ steps.init.outcome == 'failure' && steps.init.outputs.stderr || steps.test.outputs.stderr }} + description: "The STDERR stream of the call to `terraform test`, or to `terraform init` when it failed. Empty when skipped." + exitcode: + value: ${{ steps.init.outcome == 'failure' && steps.init.outputs.exitcode || steps.test.outputs.exitcode }} + description: "The exit code of the call to `terraform test`, or to `terraform init` when it failed. Empty when skipped. The action still fails on a non-zero exit code, so use `continue-on-error: true` to inspect it." + + +runs: + using: composite + + steps: + - name: Resolve Target + id: target + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + TEST_DIRECTORY: ${{ inputs.test_directory }} + FORMAT: ${{ inputs.format }} + JUNIT_XML: ${{ inputs.junit_xml }} + CLOUD_RUN: ${{ inputs.cloud_run }} + run: | + target_dir="${TARGET_DIR%/}" + target_dir="${target_dir:-.}" + echo "target_dir=$target_dir" >> "$GITHUB_OUTPUT" + + case "$FORMAT" in + text|json) ;; + *) + echo "::error::Invalid format: $FORMAT. Valid values are text or json." + exit 1 + ;; + esac + if [ -n "$JUNIT_XML" ] && [ -n "$CLOUD_RUN" ]; then + echo "::error::junit_xml cannot be combined with cloud_run." + exit 1 + fi + if [ ! -d "$target_dir" ]; then + echo "::error::Terraform directory not found: $target_dir." + exit 1 + fi + + # Terraform only discovers test files directly in these two directories, not recursively. A module + # without tests would otherwise report a vacuous pass; test-only configurations are valid too. + shopt -s nullglob + files=("$target_dir"/*.tftest.hcl "$target_dir"/*.tftest.json + "$target_dir/${TEST_DIRECTORY:-tests}"/*.tftest.hcl "$target_dir/${TEST_DIRECTORY:-tests}"/*.tftest.json) + if [ "${#files[@]}" -eq 0 ]; then + echo "::notice::Skipping terraform test: no test files in $target_dir or ${TEST_DIRECTORY:-tests}." + echo "skipped=true" >> "$GITHUB_OUTPUT" + fi + + # Remote execution installs providers and modules in HCP Terraform, so it needs no local init. + - name: Terraform Init + id: init + if: steps.target.outputs.skipped != 'true' && inputs.cloud_run == '' + uses: tedilabs/github-actions/.github/actions/shell.run@main + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + WORKSPACE: ${{ inputs.workspace }} + TEST_DIRECTORY: ${{ inputs.test_directory }} + with: + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi + + terraform -chdir="$TARGET_DIR" init \ + -backend=false \ + -input=false \ + -no-color \ + -test-directory="${TEST_DIRECTORY:-tests}" + + - name: Add Failure Details to Job Summary + id: init-summary + if: always() && steps.init.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ terraform init · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" + file: ${{ steps.init.outputs.log_file }} + lang: text + + - name: Terraform Test + id: test + if: steps.target.outputs.skipped != 'true' + uses: tedilabs/github-actions/.github/actions/shell.run@main + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + WORKSPACE: ${{ inputs.workspace }} + TEST_DIRECTORY: ${{ inputs.test_directory }} + FILTERS: ${{ inputs.filters }} + VAR_FILES: ${{ inputs.var_files }} + VARS: ${{ inputs.vars }} + FORMAT: ${{ inputs.format }} + VERBOSE: ${{ inputs.verbose }} + PARALLELISM: ${{ inputs.parallelism }} + JUNIT_XML: ${{ inputs.junit_xml }} + CLOUD_RUN: ${{ inputs.cloud_run }} + with: + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi + + args=(-no-color -test-directory="${TEST_DIRECTORY:-tests}") + if [ "$FORMAT" = "json" ]; then + args+=(-json) + fi + if [ "$VERBOSE" = "true" ]; then + args+=(-verbose) + fi + if [ -n "$PARALLELISM" ]; then + args+=(-parallelism="$PARALLELISM") + fi + if [ -n "$JUNIT_XML" ]; then + args+=(-junit-xml="$JUNIT_XML") + fi + if [ -n "$CLOUD_RUN" ]; then + args+=(-cloud-run="$CLOUD_RUN") + fi + + # Each line is one argument. Quoted arrays preserve spaces and HCL expressions without evaluating + # shell syntax. Variable files precede explicit assignments so `vars` can override their values. + while IFS= read -r file; do + [ -z "$file" ] || args+=(-filter="$file") + done <<< "$FILTERS" + while IFS= read -r file; do + [ -z "$file" ] || args+=(-var-file="$file") + done <<< "$VAR_FILES" + while IFS= read -r assignment; do + [ -z "$assignment" ] || args+=(-var="$assignment") + done <<< "$VARS" + + terraform -chdir="$TARGET_DIR" test "${args[@]}" + + # Unlike validate, test can create infrastructure. Read the captured output instead of re-running it. + - name: Annotate Findings + id: test-annotate + if: always() && inputs.annotations_enabled == 'true' && (steps.init.outcome == 'failure' || steps.test.outcome == 'failure') + shell: bash + env: + INIT_OUTCOME: ${{ steps.init.outcome }} + INIT_LOG: ${{ steps.init.outputs.log_file }} + TEST_LOG: ${{ steps.test.outputs.log_file }} + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + FORMAT: ${{ inputs.format }} + run: | + tool="terraform test" + log="$TEST_LOG" + if [ "$INIT_OUTCOME" = "failure" ]; then + tool="terraform init" + log="$INIT_LOG" + fi + prefix="" + if [ "$TARGET_DIR" != "." ]; then + prefix="$TARGET_DIR/" + fi + + if [ "$INIT_OUTCOME" != "failure" ] && [ "$FORMAT" = "json" ]; then + # CLI parsing errors can still be plain text even with `-json`; leave those to the text parser below. + jq -Rr --arg prefix "$prefix" --arg tool "$tool" ' + def esc: gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A"); + def prop: esc | gsub(":"; "%3A") | gsub(","; "%2C"); + fromjson? | select(.type == "diagnostic") | .diagnostic + | (if .severity == "error" then "error" else "warning" end) as $severity + | (.summary + (if (.detail // "") != "" then "\n\n" + .detail else "" end) | esc) as $message + | ($tool + " · " + .summary | prop) as $title + | if .range then + "::\($severity) file=\($prefix + .range.filename | prop),line=\(.range.start.line),col=\(.range.start.column),title=\($title)::\($message)" + else + "::\($severity) title=\($title)::\($message)" + end + ' "$log" + fi + + # Text diagnostics start with `Error:` or `Warning:` and optionally name a file on the following lines. + awk -v prefix="$prefix" -v tool="$tool" ' + function esc(text) { gsub(/%/, "%25", text); gsub(/\r/, "%0D", text); return text } + function prop(text) { text = esc(text); gsub(/:/, "%3A", text); gsub(/,/, "%2C", text); return text } + function flush() { + if (summary == "") { return } + printf "::%s title=%s::%s\n", severity, prop(tool " · " summary), esc(summary) + summary = "" + } + /^(Error|Warning): / { flush(); severity = tolower($1); sub(/:$/, "", severity); summary = substr($0, index($0, ": ") + 2); next } + /^[[:space:]]+on .+ line [0-9]+/ && summary != "" { + file = $0; sub(/^[[:space:]]+on /, "", file); sub(/ line [0-9]+.*$/, "", file) + line = $0; sub(/^.* line /, "", line); sub(/[^0-9].*$/, "", line) + printf "::%s file=%s,line=%s,title=%s::%s\n", severity, prop(prefix file), line, prop(tool " · " summary), esc(summary) + summary = "" + } + END { flush() } + ' "$log" + + - name: Add Failure Details to Job Summary + id: test-summary + if: always() && steps.test.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ terraform test · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" + file: ${{ steps.test.outputs.log_file }} + lang: ${{ inputs.format == 'json' && 'json' || 'text' }} diff --git a/.github/actions/terraform.test/tests/test_action.py b/.github/actions/terraform.test/tests/test_action.py new file mode 100644 index 0000000..b0b3af5 --- /dev/null +++ b/.github/actions/terraform.test/tests/test_action.py @@ -0,0 +1,301 @@ +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +import xml.etree.ElementTree as ET + +import yaml + + +ACTION_DIR = Path(__file__).resolve().parents[1] +ACTION = yaml.safe_load((ACTION_DIR / "action.yaml").read_text()) +STEPS = {step["id"]: step for step in ACTION["runs"]["steps"]} +SHELL_RUN = yaml.safe_load( + (ACTION_DIR.parent / "shell.run/action.yaml").read_text() +)["runs"]["steps"][0]["run"] +SUMMARY = yaml.safe_load( + (ACTION_DIR.parent / "github.step-summary/action.yaml").read_text() +)["runs"]["steps"][0]["run"] +VERSION = tuple(int(part) for part in json.loads(subprocess.check_output( + ["terraform", "version", "-json"], text=True +))["terraform_version"].split(".")[:2]) + + +def read_outputs(path): + outputs = {} + lines = iter(path.read_text().splitlines()) + for line in lines: + if "<<" in line: + key, delimiter = line.split("<<", 1) + value = [] + for part in lines: + if part == delimiter: + break + value.append(part) + outputs[key] = "\n".join(value) + else: + key, value = line.split("=", 1) + outputs[key] = value + return outputs + + +class TerraformTestAction(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.module = self.root / "module with spaces" + self.module.mkdir() + self.env = {key: value for key, value in os.environ.items() + if not key.startswith("TF_")} + self.env.update({key.upper(): str(value.get("default", "")) + for key, value in ACTION["inputs"].items()}) + self.env.update(TARGET_DIR="module with spaces", RUNNER_TEMP=str(self.root), + GITHUB_STEP_SUMMARY=str(self.root / "summary")) + self.write("main.tf", ''' +variable "message" { + type = string + default = "hello world" +} +output "message" { value = var.message } +output "workspace" { value = terraform.workspace } +''') + self.write("tests/main.tftest.hcl", ''' +run "check_message" { + command = plan + assert { + condition = output.message == "hello world" + error_message = "unexpected message" + } +} +''') + + def write(self, name, content): + path = self.module / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + def run_script(self, script, **env): + output = self.root / "outputs" + output.write_text("") + result = subprocess.run( + ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script], + cwd=self.root, env={**self.env, "GITHUB_OUTPUT": str(output), **env}, + text=True, capture_output=True, + ) + return result, read_outputs(output) + + def command(self, step): + return self.run_script(SHELL_RUN, SCRIPT=STEPS[step]["with"]["run"], + WORKING_DIRECTORY="") + + def initialize(self): + result, outputs = self.run_script(STEPS["target"]["run"]) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertNotIn("skipped", outputs) + self.env["TARGET_DIR"] = outputs["target_dir"] + result, outputs = self.command("init") + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + return outputs + + def annotate(self, outputs, init=False): + return self.run_script( + STEPS["test-annotate"]["run"], + INIT_OUTCOME="failure" if init else "success", + INIT_LOG=outputs["log_file"] if init else "", + TEST_LOG="" if init else outputs["log_file"], + )[0] + + def test_default_success_and_workspace(self): + self.env["WORKSPACE"] = "testing" + self.write("workspace.tftest.hcl", ''' +run "check_workspace" { + command = plan + assert { + condition = output.workspace == "testing" + error_message = "workspace not forwarded" + } +} +''') + self.initialize() + result, outputs = self.command("test") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(outputs["exitcode"], "0") + self.assertIn("2 passed", outputs["stdout"]) + self.assertEqual(outputs["stderr"], "") + + def test_no_tests_skip_without_recursive_discovery(self): + (self.module / "tests/main.tftest.hcl").unlink() + self.write("tests/nested/ignored.tftest.hcl", 'run "ignored" {}') + result, outputs = self.run_script(STEPS["target"]["run"]) + self.assertEqual(result.returncode, 0) + self.assertEqual(outputs["skipped"], "true") + + def test_json_test_files_at_module_root_are_discovered(self): + (self.module / "tests/main.tftest.hcl").unlink() + self.write("root.tftest.json", json.dumps({ + "run": {"root": {"command": "plan", "assert": [{ + "condition": '${output.message == "hello world"}', "error_message": "bad variable" + }]}} + })) + self.initialize() + result, outputs = self.command("test") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("1 passed", outputs["stdout"]) + + def test_invalid_options_and_missing_target_fail(self): + for overrides in ({"FORMAT": "yaml"}, {"JUNIT_XML": "report.xml", "CLOUD_RUN": "example"}, + {"TARGET_DIR": "missing"}): + with self.subTest(overrides=overrides): + result, outputs = self.run_script(STEPS["target"]["run"], **overrides) + self.assertEqual(result.returncode, 1) + self.assertIn("::error::", result.stdout) + self.assertNotIn("skipped", outputs) + + def test_custom_directory_initializes_test_modules(self): + self.env["TEST_DIRECTORY"] = "integration tests" + self.write("helper/main.tf", 'output "value" { value = "installed" }') + self.write("integration tests/module.tftest.hcl", ''' +run "helper" { + command = plan + module { source = "./helper" } + assert { + condition = output.value == "installed" + error_message = "test module not initialized" + } +} +''') + self.initialize() + result, outputs = self.command("test") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("1 passed", outputs["stdout"]) + self.assertNotIn("check_message", outputs["stdout"]) + + def test_filters_and_variable_precedence_preserve_spaces(self): + self.write("fixtures/first vars.tfvars", 'message = "first"') + self.write("fixtures/second vars.tfvars", 'message = "second"') + self.write("tests/not selected.tftest.hcl", ''' +run "should_not_run" { + assert { + condition = output.message == "never" + error_message = "filter not applied" + } +} +''') + self.write("tests/also selected.tftest.json", json.dumps({ + "run": {"second": {"command": "plan", "assert": [{ + "condition": '${output.message == "hello world"}', "error_message": "bad variable" + }]}} + })) + self.env.update(FILTERS="tests/main.tftest.hcl\n\ntests/also selected.tftest.json", + VAR_FILES="fixtures/first vars.tfvars\nfixtures/second vars.tfvars\n", + VARS="message=hello world") + self.initialize() + result, outputs = self.command("test") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("2 passed", outputs["stdout"]) + self.assertNotIn("should_not_run", outputs["stdout"]) + + def test_failure_outputs_annotations_and_summary(self): + self.env["VARS"] = "message=wrong" + self.initialize() + for format_ in ("text", "json"): + with self.subTest(format=format_): + self.env["FORMAT"] = format_ + result, outputs = self.command("test") + self.assertEqual(result.returncode, 1) + self.assertEqual(outputs["exitcode"], "1") + log = Path(outputs["log_file"]).read_text() + self.assertIn("unexpected message", log) + annotation = self.annotate(outputs) + self.assertEqual(annotation.returncode, 0, annotation.stderr) + self.assertIn("::error file=module with spaces/tests/main.tftest.hcl,line=", annotation.stdout) + result, _ = self.run_script(SUMMARY, TITLE="terraform test failed", FILE=outputs["log_file"], + LANG_=format_, MAX_LINES="200") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("unexpected message", (self.root / "summary").read_text()) + + def test_init_failure_annotated_as_text_even_in_json_mode(self): + self.env["FORMAT"] = "json" + self.write("broken.tf", 'variable "broken" {') + result, outputs = self.command("init") + self.assertEqual(result.returncode, 1) + self.assertEqual(outputs["exitcode"], "1") + annotation = self.annotate(outputs, init=True) + self.assertEqual(annotation.returncode, 0, annotation.stderr) + self.assertIn("file=module with spaces/broken.tf,line=1", annotation.stdout) + self.assertIn("terraform init", annotation.stdout) + + def test_json_success_and_verbose(self): + self.env.update(FORMAT="json", VERBOSE="true") + self.initialize() + result, outputs = self.command("test") + self.assertEqual(result.returncode, 0, result.stderr) + messages = [json.loads(line) for line in outputs["stdout"].splitlines()] + self.assertTrue(any(message["type"] == "test_summary" for message in messages)) + self.assertTrue(any(message["type"] == "test_plan" for message in messages)) + + @unittest.skipUnless(VERSION >= (1, 12), "parallelism and test-only init require Terraform 1.12+") + def test_junit_parallelism_and_test_only_configuration(self): + (self.module / "main.tf").unlink() + self.write("helper/main.tf", 'output "value" { value = "hello world" }') + self.write("tests/main.tftest.hcl", ''' +run "test_only" { + command = plan + module { source = "./helper" } + assert { + condition = output.value == "hello world" + error_message = "wrong output" + } +} +''') + self.env.update(PARALLELISM="2", JUNIT_XML="test results.xml") + self.initialize() + result, _ = self.command("test") + self.assertEqual(result.returncode, 0, result.stderr) + report = ET.parse(self.module / "test results.xml") + self.assertEqual(report.find("testsuite").get("tests"), "1") + + @unittest.skipUnless(VERSION >= (1, 11), "JUnit requires Terraform 1.11+") + def test_junit_report_is_written_on_failure(self): + self.env.update(JUNIT_XML="failure.xml", VARS="message=wrong") + self.initialize() + result, outputs = self.command("test") + self.assertEqual(result.returncode, 1) + self.assertEqual(outputs["exitcode"], "1") + report = ET.parse(self.module / "failure.xml") + self.assertEqual(report.find("testsuite").get("failures"), "1") + + def test_remote_arguments_are_literal_and_tests_are_not_repeated(self): + # A stub records calls without contacting HCP Terraform or creating infrastructure. + bin_dir = self.root / "bin" + bin_dir.mkdir() + stub = bin_dir / "terraform" + stub.write_text('#!/bin/bash\nprintf "%s\\0" "$@" >> "$RUNNER_TEMP/argv"\n' + 'echo "Error: Remote test failed" >&2\nexit 1\n') + stub.chmod(0o755) + self.env.update(PATH=f"{bin_dir}:{self.env['PATH']}", CLOUD_RUN="app.terraform.io/org/module/aws", + VARS='message=$(touch unexpected)\nlabels={ owner = "platform team" }', + FILTERS="tests/main.tftest.hcl\ntests/second file.tftest.hcl") + result, outputs = self.command("test") + self.assertEqual(result.returncode, 1) + args = (self.root / "argv").read_bytes().decode().split("\0")[:-1] + self.assertIn("-cloud-run=app.terraform.io/org/module/aws", args) + self.assertIn("-var=message=$(touch unexpected)", args) + self.assertIn('-var=labels={ owner = "platform team" }', args) + self.assertIn("-filter=tests/second file.tftest.hcl", args) + self.assertFalse((self.root / "unexpected").exists()) + self.assertEqual(args.count("test"), 1) + for format_ in ("text", "json"): + self.env["FORMAT"] = format_ + annotation = self.annotate(outputs) + self.assertEqual(annotation.returncode, 0, annotation.stderr) + self.assertIn("::error title=terraform test", annotation.stdout) + self.assertEqual((self.root / "argv").read_bytes().decode().split("\0")[:-1], args) + + +if __name__ == "__main__": + unittest.main()