# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Pytest plugin to discover tests specified in YAML files. This plugin uses the pytest_collect_file hook to collect all files matching tftest*.yaml and runs plan_validate for each test found. See FabricTestFile for details on the file structure. """ import fnmatch import json import os import re from pathlib import Path import jsonschema import pytest import yaml from .examples.utils import get_tftest_directive from .fixtures import plan_summary, plan_validator _REPO_ROOT = Path(__file__).parents[1] class FabricTestFile(pytest.File): def collect(self): """Read yaml test spec and yield test items for each test definition. The test spec should contain a `module` key with the path of the terraform module to test, relative to the root of the repository Tests are defined within the top-level `tests` key, and should have the following structure: test-name: extra_files: - bar.tf - foo.tf tfvars: - tfvars1.tfvars - tfvars2.tfvars inventory: - inventory1.yaml - inventory2.yaml All paths specifications are relative to the location of the test spec. The inventory key is optional, if omitted, the inventory will be taken from the file test-name.yaml """ try: raw = yaml.safe_load(self.path.open()) module = raw.pop('module') except (IOError, OSError, yaml.YAMLError) as e: raise Exception(f'cannot read test spec {self.path}: {e}') except KeyError as e: raise Exception(f'`module` key not found in {self.path}: {e}') common = raw.pop('common_tfvars', []) for test_name, spec in raw.get('tests', {}).items(): spec = {} if spec is None else spec if spec.get('skip_tofu') and os.environ.get('TERRAFORM') == 'tofu': continue extra_dirs = spec.get('extra_dirs') extra_files = spec.get('extra_files') inventories = spec.get('inventory', [f'{test_name}.yaml']) tf_var_files = common + [f'{test_name}.tfvars'] + spec.get('tfvars', []) for i in inventories: name = test_name if isinstance(inventories, list) and len(inventories) > 1: name = f'{test_name}[{i}]' yield FabricTestItem.from_parent(self, name=name, module=module, inventory=[i], tf_var_files=tf_var_files, extra_files=extra_files, extra_dirs=extra_dirs) class FabricTestItem(pytest.Item): def __init__(self, name, parent, module, inventory, tf_var_files, extra_files=None, extra_dirs=None): super().__init__(name, parent) self.module = module self.inventory = inventory self.tf_var_files = tf_var_files self.extra_dirs = extra_dirs if extra_dirs else [] self.extra_files = extra_files if extra_files else [] def runtest(self): try: summary = plan_validator(self.module, self.inventory, self.parent.path.parent, self.tf_var_files, self.extra_files, self.extra_dirs) except AssertionError: def full_paths(root_path, paths): return [str(root_path / x) for x in paths] files_root = self.parent.path.parent print( f'Error in inventory file: {" ".join(full_paths(files_root, self.inventory))}' ) test_name = self.name.split('[')[0] print( f'To regenerate inventory run:\nuv run tools/generate_plan_summary.py ' f'{self.parent.path.relative_to(_REPO_ROOT)} {test_name} --save') raise def reportinfo(self): return self.path, None, self.name class FabricSchemaTestFile(pytest.File): def collect(self): try: raw = self.path.read_text() content = yaml.safe_load(raw) except (IOError, OSError, yaml.YAMLError) as e: raise Exception(f'Cannot read test spec {self.path}: {e}') directive = get_tftest_directive(raw) if not directive or directive.name != 'tftest': raise Exception(f'Schema test file without tftest directive: {self.path}') if 'schema' not in directive.kwargs: raise Exception( f'Schema test file does not declare schema: {self.parent.path}') schema_path = _REPO_ROOT / directive.kwargs['schema'] schema = json.load(schema_path.open()) yield SchemaTestItem.from_parent(self, name='schema', schema=schema, content=content, directive=directive) class FastDataFile(pytest.File): @staticmethod def _get_yaml_schema(code): regexp = rf"^ *# *yaml-language-server: *\$schema=([\S]*) *$" if match := re.search(regexp, code, re.M): return match.group(1) def collect(self): try: raw = self.path.read_text() content = yaml.safe_load(raw) except (IOError, OSError, yaml.YAMLError) as e: raise Exception(f'Cannot read test spec {self.path}: {e}') schema_path = self._get_yaml_schema(raw) if schema_path is None or content is None: return print(f"---- {schema_path}") schema_path = (self.path.parent / schema_path).resolve() print(f"---- {schema_path}") schema = json.load(schema_path.open()) yield SchemaTestItem.from_parent(self, name='schema', schema=schema, content=content) class SchemaTestItem(pytest.Item): def __init__(self, name, parent, schema, content, directive=None): super().__init__(name, parent) self.schema = schema self.content = content self.directive = directive def runtest(self): if self.directive and 'fail' in self.directive.args: with pytest.raises(jsonschema.exceptions.ValidationError): jsonschema.validate(instance=self.content, schema=self.schema) else: jsonschema.validate(instance=self.content, schema=self.schema) def reportinfo(self): return self.path, None, self.name def pytest_collect_file(parent, file_path): 'Collect tftest*.yaml files and run plan_validator from them.' if file_path.suffix == '.yaml' and file_path.name.startswith('tftest'): return FabricTestFile.from_parent(parent, path=file_path) # use fnmatch because Path.match doesn't support ** file_path_relative = str(file_path.relative_to(_REPO_ROOT)) if fnmatch.fnmatch(file_path_relative, "tests/schemas/**.yaml"): return FabricSchemaTestFile.from_parent(parent, path=file_path) if fnmatch.fnmatch(file_path_relative, "fast/stages/*/data/**.yaml"): return FastDataFile.from_parent(parent, path=file_path)