From e6e5f82b229581eab7c96a946817064d1a1ec98f Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sat, 8 Mar 2025 15:55:59 +0000 Subject: [PATCH 01/15] feat: add ability for top document in a YAML file to merge down chore: remove redundant code and update merge logic feat: add support for merging test specs feat: add logging for merging initial block in YamlFile feat: add test for redirecting loops feat: Add unit test for multiple documents in YAML file fix: Import Mock in test_files.py fix: handle errors in YamlFile class test: refactor test file handling and add multiple document support refactor: Move Opener class outside of TestGenerateFiles test: add exception handling and improve test structure docs: Add docstring to test_reraise_exception function fix: Simplify exception test description --- tavern/_core/dict_util.py | 13 +- tavern/_core/pytest/file.py | 19 ++- tests/integration/test_merge_down.tavern.yaml | 14 +++ tests/unit/test_files.py | 115 ++++++++++++++++++ 4 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 tests/integration/test_merge_down.tavern.yaml create mode 100644 tests/unit/test_files.py diff --git a/tavern/_core/dict_util.py b/tavern/_core/dict_util.py index 04aaf6ba9..d5ecfe103 100644 --- a/tavern/_core/dict_util.py +++ b/tavern/_core/dict_util.py @@ -237,7 +237,6 @@ def deep_dict_merge(initial_dct: dict, merge_dct: Mapping) -> dict: dict_merge recurses down into dicts nested to an arbitrary depth and returns the merged dict. Keys values present in merge_dct take precedence over values in initial_dct. - Modified from: https://gist.github.com/angstwad/bf22d1822c38a92ec0a9 Params: initial_dct: dict onto which the merge is executed @@ -246,15 +245,9 @@ def deep_dict_merge(initial_dct: dict, merge_dct: Mapping) -> dict: Returns: recursively merged dict """ - dct = initial_dct.copy() - - for k in merge_dct: - if k in dct and isinstance(dct[k], dict) and isinstance(merge_dct[k], Mapping): - dct[k] = deep_dict_merge(dct[k], merge_dct[k]) - else: - dct[k] = merge_dct[k] - - return dct + initial_box = Box(initial_dct) + initial_box.merge_update(merge_dct) + return dict(initial_box) _CanCheck = Sequence | Mapping | set | Collection diff --git a/tavern/_core/pytest/file.py b/tavern/_core/pytest/file.py index 5b2218f62..d9d2166cc 100644 --- a/tavern/_core/pytest/file.py +++ b/tavern/_core/pytest/file.py @@ -9,7 +9,7 @@ from typing import Any, Union import pytest -import yaml +import yaml.parser from box import Box from pytest import Mark @@ -445,16 +445,31 @@ def collect(self) -> Iterator[YamlItem]: except yaml.parser.ParserError as e: raise exceptions.BadSchemaError from e - for test_spec in all_tests: + merge_down = None + for i, test_spec in enumerate(all_tests): if not test_spec: logger.warning("Empty document in input file '%s'", self.path) continue + if i == 0 and not test_spec.get("stages"): + if test_spec.get("name"): + logger.warning("initial block had no stages, but had a name") + merge_down = test_spec + logger.info( + f"merging initial block from {self.path} into subsequent tests" + ) + continue + + if merge_down: + test_spec = deep_dict_merge(test_spec, merge_down) + try: for i in self._generate_items(test_spec): i.initialise_fixture_attrs() yield i except (TypeError, KeyError) as e: + # If there was one of these errors, we can probably figure out + # if the error is from a bad test layout by calling verify_tests try: verify_tests(test_spec, with_plugins=False) except Exception as e2: diff --git a/tests/integration/test_merge_down.tavern.yaml b/tests/integration/test_merge_down.tavern.yaml new file mode 100644 index 000000000..d0ed0cfc8 --- /dev/null +++ b/tests/integration/test_merge_down.tavern.yaml @@ -0,0 +1,14 @@ +--- +includes: + - !include common.yaml +--- +test_name: Test redirecting loops + +stages: + - name: Expect a 302 without setting the flag + max_retries: 2 + request: + follow_redirects: true + url: "{host}/redirect/loop" + response: + status_code: 200 diff --git a/tests/unit/test_files.py b/tests/unit/test_files.py new file mode 100644 index 000000000..eaefccef7 --- /dev/null +++ b/tests/unit/test_files.py @@ -0,0 +1,115 @@ +import contextlib +import dataclasses +import pathlib +import tempfile +from collections.abc import Callable, Generator +from typing import Any +from unittest.mock import Mock + +import pytest +import yaml + +from tavern._core import exceptions +from tavern._core.pytest.file import YamlFile +from tavern._core.pytest.item import YamlItem + + +@pytest.fixture(scope="function") +def tavern_test_content(): + """return some example tests""" + + test_docs = [ + {"test_name": "First test", "stages": [{"name": "stage 1"}]}, + {"test_name": "Second test", "stages": [{"name": "stage 2"}]}, + {"test_name": "Third test", "stages": [{"name": "stage 3"}]}, + ] + + return test_docs + + +@contextlib.contextmanager +def tavern_test_file(test_content: list[Any]) -> Generator[pathlib.Path, Any, None]: + """Create a temporary YAML file with multiple documents""" + + with tempfile.TemporaryDirectory() as tmpdir: + file_path = pathlib.Path(tmpdir) / "test.yaml" + + # Write the documents to the file + with file_path.open("w", encoding="utf-8") as f: + for doc in test_content: + yaml.dump(doc, f) + f.write("---\n") + + yield file_path + + +@dataclasses.dataclass +class Opener: + """Simple mock for generating items because pytest makes it hard to wrap + their internal functionality""" + + path: pathlib.Path + _generate_items: Callable[[dict], Any] + + +class TestGenerateFiles: + @pytest.mark.parametrize("with_merge_down_test", (True, False)) + def test_multiple_documents(self, tavern_test_content, with_merge_down_test): + """Verify that multiple documents in a YAML file result in multiple tests""" + + # Collect all tests + if with_merge_down_test: + tavern_test_content.insert(0, {"includes": []}) + + def generate_yamlitem(test_spec): + mock = Mock(spec=YamlItem) + mock.name = test_spec["test_name"] + yield mock + + with tavern_test_file(tavern_test_content) as filename: + tests = list( + YamlFile.collect( + Opener( + path=filename, + _generate_items=generate_yamlitem, + ) + ) + ) + + assert len(tests) == 3 + + # Verify each test has the correct name + expected_names = ["First test", "Second test", "Third test"] + for test, expected_name in zip(tests, expected_names): + assert test.name == expected_name + + @pytest.mark.parametrize( + "content, exception", + ( + ({"kookdff": "?A?A??"}, exceptions.BadSchemaError), + ({"test_name": "name", "stages": [{"name": "lflfl"}]}, TypeError), + ), + ) + def test_reraise_exception( + self, tavern_test_content, content: dict, exception: BaseException + ): + """Verify that exceptions are properly reraised when loading YAML test files. + + Test that when an exception occurs during test generation, it is properly + reraised as a schema error if the schema is bad.""" + + def raise_error(test_spec): + raise TypeError + + tavern_test_content.insert(0, content) + + with tavern_test_file(tavern_test_content) as filename: + with pytest.raises(exception): + list( + YamlFile.collect( + Opener( + path=filename, + _generate_items=raise_error, + ) + ) + ) From 03cf151b8d38854edf9143d4739b3b72472d2614 Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sun, 9 Mar 2025 15:02:43 +0000 Subject: [PATCH 02/15] feat: add start_mark import and usage in YamlItem location refactor: update import path for start_mark --- tavern/_core/pytest/item.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tavern/_core/pytest/item.py b/tavern/_core/pytest/item.py index 317332576..fa07f328c 100644 --- a/tavern/_core/pytest/item.py +++ b/tavern/_core/pytest/item.py @@ -25,6 +25,7 @@ from tavern._core.report import attach_text from tavern._core.run import run_test from tavern._core.schema.files import verify_tests +from tavern._core.stage_lines import start_mark from .config import TestConfig from .util import load_global_cfg @@ -130,7 +131,7 @@ def initialise_fixture_attrs(self) -> None: def location(self): """get location in file""" location = super().location - location = (location[0], self.spec.start_mark.line, location[2]) + location = (location[0], start_mark(self.spec).line, location[2]) return location # Hack to stop issue with pytest-rerunfailures From de26af11afdbfe845d5955d4bdb2eb4178d64bea Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sun, 29 Jun 2025 20:26:00 +0100 Subject: [PATCH 03/15] refactor(tavern/_core/pytest/file.py): Improve handling of initial YAML document in test file - The loop variable `i` has been renamed to `document_idx` to better reflect its purpose, which is to track the index of the YAML document being processed. - A comment has been added to explain the implicit behavior when the first YAML document has no "stages" but may have other properties like "name". - The variable `merge_down` is now correctly initialized and used based on the index of the document being processed. --- tavern/_core/pytest/file.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tavern/_core/pytest/file.py b/tavern/_core/pytest/file.py index d9d2166cc..d5cbba2fe 100644 --- a/tavern/_core/pytest/file.py +++ b/tavern/_core/pytest/file.py @@ -446,12 +446,15 @@ def collect(self) -> Iterator[YamlItem]: raise exceptions.BadSchemaError from e merge_down = None - for i, test_spec in enumerate(all_tests): + # Iterate over yaml documents and tests + for document_idx, test_spec in enumerate(all_tests): if not test_spec: logger.warning("Empty document in input file '%s'", self.path) continue - if i == 0 and not test_spec.get("stages"): + if document_idx == 0 and not test_spec.get("stages"): + # If it's the first document and there were no tests, its implicitly the + # 'defaults' for the file and will be merged with all lower documents if test_spec.get("name"): logger.warning("initial block had no stages, but had a name") merge_down = test_spec From ae4fe0743ab2824bf76316b5b340d8511ae63b9c Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sun, 16 Nov 2025 15:21:19 +0000 Subject: [PATCH 04/15] feat(pytest/file): Add explicit 'defaults' marker handling in YAML documents - Introduced a new `is_defaults` marker in YAML documents for specifying defaults explicitly. - Enhanced validation to ensure the `defaults` marker is only used in the first YAML document. - Updated error handling to raise schema errors when required sections (`stages` or `test_name`) are missing in any document. - Modified `merge_down` logic to incorporate explicit `defaults` marker where applicable. - Updated `tests.jsonschema.yaml` to include `defaults` property for schema validation of YAML documents. - Adjusted unit tests in `test_files.py` to account for the new `is_defaults` behavior. --- tavern/_core/pytest/file.py | 40 ++++++++++++++++++----- tavern/_core/schema/tests.jsonschema.yaml | 8 +++-- tests/unit/test_files.py | 2 +- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/tavern/_core/pytest/file.py b/tavern/_core/pytest/file.py index d5cbba2fe..d76bd5537 100644 --- a/tavern/_core/pytest/file.py +++ b/tavern/_core/pytest/file.py @@ -452,16 +452,38 @@ def collect(self) -> Iterator[YamlItem]: logger.warning("Empty document in input file '%s'", self.path) continue - if document_idx == 0 and not test_spec.get("stages"): - # If it's the first document and there were no tests, its implicitly the - # 'defaults' for the file and will be merged with all lower documents - if test_spec.get("name"): - logger.warning("initial block had no stages, but had a name") - merge_down = test_spec - logger.info( - f"merging initial block from {self.path} into subsequent tests" + # Check if this document has the explicit 'defaults' marker + has_defaults_marker = test_spec.pop("is_defaults", False) + + # Validate that 'defaults' marker is only used in the first document + if has_defaults_marker and document_idx > 0: + raise exceptions.BadSchemaError( + f"'defaults' marker can only be used in the first YAML document, but found it in document {document_idx + 1} of '{self.path}'" + ) + + missing_stages_and_name = ( + "stages" not in test_spec or "test_name" not in test_spec + ) + if document_idx == 0: + if has_defaults_marker: + logger.info( + "Found explicit defaults marker in first document from %s", + self.path, + ) + merge_down = test_spec + continue + elif missing_stages_and_name: + # Has a name but no stages - this is an error + raise exceptions.BadSchemaError( + f"First document in '{self.path}' has a name but no stages. " + f"If this is meant to be defaults for the file, add 'defaults: true'. " + f"If this is meant to be a test, add a 'stages' section." + ) + + elif missing_stages_and_name: + raise exceptions.BadSchemaError( + f"Document {document_idx + 1} in '{self.path}' does not have a 'test_name' or 'stages' section" ) - continue if merge_down: test_spec = deep_dict_merge(test_spec, merge_down) diff --git a/tavern/_core/schema/tests.jsonschema.yaml b/tavern/_core/schema/tests.jsonschema.yaml index 5f935573c..e9de7cbdc 100644 --- a/tavern/_core/schema/tests.jsonschema.yaml +++ b/tavern/_core/schema/tests.jsonschema.yaml @@ -525,15 +525,17 @@ definitions: type: object additionalProperties: false -required: - - test_name - - stages properties: test_name: type: string description: Name of test + is_defaults: + type: boolean + description: Whether this document contains default values to be merged with subsequent test documents + default: false + _xfail: oneOf: - type: string diff --git a/tests/unit/test_files.py b/tests/unit/test_files.py index eaefccef7..cdfe032b1 100644 --- a/tests/unit/test_files.py +++ b/tests/unit/test_files.py @@ -59,7 +59,7 @@ def test_multiple_documents(self, tavern_test_content, with_merge_down_test): # Collect all tests if with_merge_down_test: - tavern_test_content.insert(0, {"includes": []}) + tavern_test_content.insert(0, {"includes": [], "is_defaults": True}) def generate_yamlitem(test_spec): mock = Mock(spec=YamlItem) From a5435fcde605a7275e7d8d6f2aea8ef4677427dc Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sun, 16 Nov 2025 15:24:19 +0000 Subject: [PATCH 05/15] refactor(plugins): Add type annotations and improve error handling in `_core/plugins.py` - Added type annotations to `plugins`, `session_spec`, `formatted`, and `request_class` for clarity and type safety. - Introduced an explicit `None` check for `request_class` and raised a `MissingSettingsError` if no request type is found. - Improved code readability and robustness with explicit variable typing and error validation. --- tavern/_core/plugins.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tavern/_core/plugins.py b/tavern/_core/plugins.py index c2ca409fa..3dfa3ac43 100644 --- a/tavern/_core/plugins.py +++ b/tavern/_core/plugins.py @@ -172,7 +172,7 @@ def get_extra_sessions(test_spec: Mapping, test_block_config: TestConfig) -> dic sessions = {} - plugins = load_plugins(test_block_config) + plugins: list[_Plugin] = load_plugins(test_block_config) for p in plugins: if any( @@ -182,8 +182,8 @@ def get_extra_sessions(test_spec: Mapping, test_block_config: TestConfig) -> dic logger.debug( "Initialising session for %s (%s)", p.name, p.plugin.session_type ) - session_spec = test_spec.get(p.name, {}) - formatted = format_keys(session_spec, test_block_config.variables) + session_spec: dict = test_spec.get(p.name, {}) + formatted: dict = format_keys(session_spec, test_block_config.variables) sessions[p.name] = p.plugin.session_type(**formatted) return sessions @@ -229,6 +229,7 @@ def get_request_type( # We've validated that 1 and only 1 is there, so just loop until the first # one is found + request_class: type[BaseRequest] | None = None for p in plugins: try: request_args = stage[p.plugin.request_block_name] @@ -242,6 +243,9 @@ def get_request_type( ) break + if not request_class: + raise exceptions.MissingSettingsError("No request type found") + request_maker = request_class(session, request_args, test_block_config) return request_maker From 1057f2e03ad07059417c9f24d6f7ace0d048a1c1 Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sun, 16 Nov 2025 15:34:23 +0000 Subject: [PATCH 06/15] Fix example test --- tests/integration/test_merge_down.tavern.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/test_merge_down.tavern.yaml b/tests/integration/test_merge_down.tavern.yaml index d0ed0cfc8..b0e8ea4c8 100644 --- a/tests/integration/test_merge_down.tavern.yaml +++ b/tests/integration/test_merge_down.tavern.yaml @@ -1,4 +1,6 @@ --- +is_defaults: true + includes: - !include common.yaml --- From ece25f8d564ded98d2885dfd0ea8d02dceffe489 Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sun, 16 Nov 2025 15:39:45 +0000 Subject: [PATCH 07/15] Add annotations --- tavern/_core/pytest/file.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tavern/_core/pytest/file.py b/tavern/_core/pytest/file.py index d76bd5537..2f3660508 100644 --- a/tavern/_core/pytest/file.py +++ b/tavern/_core/pytest/file.py @@ -453,7 +453,7 @@ def collect(self) -> Iterator[YamlItem]: continue # Check if this document has the explicit 'defaults' marker - has_defaults_marker = test_spec.pop("is_defaults", False) + has_defaults_marker: bool = test_spec.pop("is_defaults", False) # Validate that 'defaults' marker is only used in the first document if has_defaults_marker and document_idx > 0: @@ -479,7 +479,6 @@ def collect(self) -> Iterator[YamlItem]: f"If this is meant to be defaults for the file, add 'defaults: true'. " f"If this is meant to be a test, add a 'stages' section." ) - elif missing_stages_and_name: raise exceptions.BadSchemaError( f"Document {document_idx + 1} in '{self.path}' does not have a 'test_name' or 'stages' section" From 5d639197eb13d519e4108242fa8a99ea1f967878 Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sun, 16 Nov 2025 15:39:51 +0000 Subject: [PATCH 08/15] Add notehr test --- tests/integration/test_merge_down.tavern.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/integration/test_merge_down.tavern.yaml b/tests/integration/test_merge_down.tavern.yaml index b0e8ea4c8..85d6aafe7 100644 --- a/tests/integration/test_merge_down.tavern.yaml +++ b/tests/integration/test_merge_down.tavern.yaml @@ -6,6 +6,17 @@ includes: --- test_name: Test redirecting loops +stages: + - name: Expect a 302 without setting the flag + max_retries: 2 + request: + follow_redirects: true + url: "{host}/redirect/loop" + response: + status_code: 200 +--- +test_name: Test redirecting loops in another test + stages: - name: Expect a 302 without setting the flag max_retries: 2 From 5a166a3a171bf3d45c2495ecfbb62cbdd01faba6 Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sat, 31 Jan 2026 20:08:48 +0000 Subject: [PATCH 09/15] Make code a bit less horrible --- tavern/_core/pytest/file.py | 58 +++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/tavern/_core/pytest/file.py b/tavern/_core/pytest/file.py index 2f3660508..b19c21a65 100644 --- a/tavern/_core/pytest/file.py +++ b/tavern/_core/pytest/file.py @@ -445,47 +445,49 @@ def collect(self) -> Iterator[YamlItem]: except yaml.parser.ParserError as e: raise exceptions.BadSchemaError from e - merge_down = None - # Iterate over yaml documents and tests + defaults_doc = None + for document_idx, test_spec in enumerate(all_tests): if not test_spec: logger.warning("Empty document in input file '%s'", self.path) continue - # Check if this document has the explicit 'defaults' marker - has_defaults_marker: bool = test_spec.pop("is_defaults", False) + # Check for explicit defaults marker and validate position + is_defaults: bool = test_spec.pop("is_defaults", False) - # Validate that 'defaults' marker is only used in the first document - if has_defaults_marker and document_idx > 0: + if is_defaults and document_idx > 0: raise exceptions.BadSchemaError( - f"'defaults' marker can only be used in the first YAML document, but found it in document {document_idx + 1} of '{self.path}'" + f"'is_defaults' can only be used in the first YAML document, " + f"but found it in document {document_idx + 1} of '{self.path}'" ) - missing_stages_and_name = ( - "stages" not in test_spec or "test_name" not in test_spec - ) - if document_idx == 0: - if has_defaults_marker: - logger.info( - "Found explicit defaults marker in first document from %s", - self.path, + # Determine if this document is a test or defaults + is_test_doc = "test_name" in test_spec and "stages" in test_spec + + if document_idx == 0 and is_defaults: + # First document is explicitly marked as defaults + logger.info( + "Using first document as defaults for %s", + self.path, + ) + defaults_doc = test_spec + continue + elif not is_test_doc: + # Document is neither a valid test nor valid defaults + if document_idx == 0: + raise exceptions.BadSchemaError( + f"First document in '{self.path}' is missing 'test_name' or 'stages'. " + f"If this is meant to be defaults for the file, add 'is_defaults: true'. " + f"If this is meant to be a test, add both 'test_name' and 'stages'." ) - merge_down = test_spec - continue - elif missing_stages_and_name: - # Has a name but no stages - this is an error + else: raise exceptions.BadSchemaError( - f"First document in '{self.path}' has a name but no stages. " - f"If this is meant to be defaults for the file, add 'defaults: true'. " - f"If this is meant to be a test, add a 'stages' section." + f"Document {document_idx + 1} in '{self.path}' is missing 'test_name' or 'stages'" ) - elif missing_stages_and_name: - raise exceptions.BadSchemaError( - f"Document {document_idx + 1} in '{self.path}' does not have a 'test_name' or 'stages' section" - ) - if merge_down: - test_spec = deep_dict_merge(test_spec, merge_down) + # Merge defaults into test spec if defaults were defined + if defaults_doc: + test_spec = deep_dict_merge(test_spec, defaults_doc) try: for i in self._generate_items(test_spec): From 579d51d02f882af08c4a7e649bdd3950364e26e7 Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sat, 31 Jan 2026 20:12:41 +0000 Subject: [PATCH 10/15] fix(yaml): prevent defaults document from containing test definitions - Updated `tavern/_core/pytest/file.py` to add a validation step when the first YAML document is marked as defaults. - If the first document also includes `test_name` and `stages`, a `BadSchemaError` is now raised with an explanatory message. - This ensures defaults and test specifications are not mixed, avoiding ambiguous schema handling. --- tavern/_core/pytest/file.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tavern/_core/pytest/file.py b/tavern/_core/pytest/file.py index b19c21a65..e4c14f69d 100644 --- a/tavern/_core/pytest/file.py +++ b/tavern/_core/pytest/file.py @@ -465,6 +465,11 @@ def collect(self) -> Iterator[YamlItem]: is_test_doc = "test_name" in test_spec and "stages" in test_spec if document_idx == 0 and is_defaults: + if is_test_doc: + raise exceptions.BadSchemaError( + f"First document in '{self.path}' is marked as defaults but also contains a 'test_name' and 'stages'" + ) + # First document is explicitly marked as defaults logger.info( "Using first document as defaults for %s", From 70cd8f4b7a1b289f258656fc4eb04c88685f0111 Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sat, 31 Jan 2026 20:22:02 +0000 Subject: [PATCH 11/15] =?UTF-8?q?feat(example):=20add=20MQTT=20merge?= =?UTF-8?q?=E2=80=91down=20test=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduces `example/mqtt/tests/test_mqtt_merge_down.tavern.yaml` as a new test case. - Defines default MQTT connection settings (auth, host, port, client options) and includes shared configuration via `common.yaml`. - Adds a reusable stage `create_test_device` that registers a device via an HTTP request. - Implements a test that references the device‑creation stage, publishes a JSON message to the device’s echo topic, and validates the JSON response on the corresponding response topic. - Demonstrates the “merge down” pattern for combining default settings with test‑specific overrides in Tavern. --- .../tests/test_mqtt_merge_down.tavern.yaml | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 example/mqtt/tests/test_mqtt_merge_down.tavern.yaml diff --git a/example/mqtt/tests/test_mqtt_merge_down.tavern.yaml b/example/mqtt/tests/test_mqtt_merge_down.tavern.yaml new file mode 100644 index 000000000..6b43c6538 --- /dev/null +++ b/example/mqtt/tests/test_mqtt_merge_down.tavern.yaml @@ -0,0 +1,50 @@ +--- +is_defaults: true + +paho-mqtt: + auth: + username: tavern + password: tavern + # tls: + # enable: true + connect: + host: localhost + port: 9001 + timeout: 3 + client: + transport: websockets + client_id: tavern-tester-{random_device_id} + +includes: + - !include common.yaml + - stages: + - id: create_test_device + name: create device + request: + url: "{host}/create_device" + method: PUT + json: + device_id: "{random_device_id}" + clean: True + response: + status_code: 201 + +--- + +test_name: Test mqtt message echo json + +stages: + - type: ref + id: create_test_device + + - name: Echo json + mqtt_publish: + topic: /device/{random_device_id}/echo + json: + message: hello world + mqtt_response: + topic: /device/{random_device_id}/echo/response + json: + message: hello world + timeout: 5 + qos: 1 From 567136c18c17032c3280e2165abb46bb693c8a95 Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sat, 7 Feb 2026 16:57:57 +0000 Subject: [PATCH 12/15] docs: document merging down --- docs/source/basics.md | 109 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/docs/source/basics.md b/docs/source/basics.md index d71f110c4..41fae1c5c 100644 --- a/docs/source/basics.md +++ b/docs/source/basics.md @@ -1021,7 +1021,7 @@ stages: has_premium: true ``` -## Including external files +### Including external files Even with being able to use anchors within the same file, there is often some data which either you want to keep in a separate (possibly autogenerated) file, @@ -1074,6 +1074,113 @@ The environment variable TAVERN_INCLUDE can contain a : separated list of paths to search for include files. Each path in TAVERN_INCLUDE has environment variables expanded before it is searched. +### Default document merge-down + +When multiple tests are defined in a single Tavern file, you may want to share common configuration across all tests +without repeating it. Tavern supports this via the `is_defaults: true` flag in a top-level document. Any document with +this flag set will have its contents merged into all subsequent test documents in the same file. + +This is useful for: + +- Shared includes (common variables, stages, or configuration) +- Default MQTT connection settings +- Common authentication or headers +- Shared test setup stages + +#### Example: Shared includes and configuration + +```yaml +--- +is_defaults: true + +includes: + - !include common.yaml + +paho-mqtt: + auth: + username: tavern + password: tavern + connect: + host: localhost + port: 9001 + client: + transport: websockets +--- + +test_name: Test mqtt message echo json + +stages: + - name: Echo json + mqtt_publish: + topic: /device/test/echo + json: + message: hello world + mqtt_response: + topic: /device/test/echo/response + json: + message: hello world + timeout: 5 +--- + +test_name: Test mqtt message echo binary + +stages: + - name: Echo binary + mqtt_publish: + topic: /device/test/echo + payload: hello world + mqtt_response: + topic: /device/test/echo/response + payload: hello world + timeout: 5 +``` + +In this example, both tests inherit the MQTT connection settings and includes from the defaults document, avoiding +duplication. + +#### Example: Shared HTTP test configuration + +```yaml +--- +is_defaults: true + +includes: + - !include common.yaml +--- + +test_name: Test redirecting loops + +stages: + - name: Expect a 302 without setting the flag + max_retries: 2 + request: + follow_redirects: true + url: "{host}/redirect/loop" + response: + status_code: 200 +--- + +test_name: Test redirecting loops in another test + +stages: + - name: Expect a 302 without setting the flag + max_retries: 2 + request: + follow_redirects: true + url: "{host}/redirect/loop" + response: + status_code: 200 +``` + +Both tests automatically include the shared configuration from `common.yaml` without needing to specify it individually. + +Note: + +- Only the first document in a file can use `is_defaults: true` +- The defaults document cannot contain test definitions (no `test_name` or `stages`) +- Values in the defaults document are merged with subsequent documents, with the test document taking precedence for + conflicting keys + ### Including global configuration files If you do want to run the same tests with a different input data, this can be From b3d9ded9b12991fdfc037760338b75123b570464 Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sat, 7 Feb 2026 17:18:52 +0000 Subject: [PATCH 13/15] refactor(dict_util): Use `to_dict` method for better conversion to dictionary - Updated `dict_util.py` to replace `dict(initial_box)` with `initial_box.to_dict()` for clarity and to leverage the dedicated conversion method. - Ensures more explicit and robust dictionary conversion from `Box` objects. --- tavern/_core/dict_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tavern/_core/dict_util.py b/tavern/_core/dict_util.py index d5ecfe103..e1231f716 100644 --- a/tavern/_core/dict_util.py +++ b/tavern/_core/dict_util.py @@ -247,7 +247,7 @@ def deep_dict_merge(initial_dct: dict, merge_dct: Mapping) -> dict: """ initial_box = Box(initial_dct) initial_box.merge_update(merge_dct) - return dict(initial_box) + return initial_box.to_dict() _CanCheck = Sequence | Mapping | set | Collection From 034cb4a195c552f24b739659f4a871c2b5775948 Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sat, 7 Feb 2026 17:24:42 +0000 Subject: [PATCH 14/15] fix(file): correct merge order for defaults and test spec - Updated `tavern/_core/pytest/file.py` to call `deep_dict_merge` with `defaults_doc` as the first argument and `test_spec` as the second. - This change ensures that values defined in a test file take precedence over default values, fixing the previous behavior where defaults could unintentionally overwrite test-specific settings. --- tavern/_core/pytest/file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tavern/_core/pytest/file.py b/tavern/_core/pytest/file.py index e4c14f69d..f96626126 100644 --- a/tavern/_core/pytest/file.py +++ b/tavern/_core/pytest/file.py @@ -492,7 +492,7 @@ def collect(self) -> Iterator[YamlItem]: # Merge defaults into test spec if defaults were defined if defaults_doc: - test_spec = deep_dict_merge(test_spec, defaults_doc) + test_spec = deep_dict_merge(defaults_doc, test_spec) try: for i in self._generate_items(test_spec): From ec95cc3f724db0c9691e2de139f3a8892b267aef Mon Sep 17 00:00:00 2001 From: Michael Boulton Date: Sat, 7 Feb 2026 17:54:04 +0000 Subject: [PATCH 15/15] refactor(docs/tests): replace example stages with shared stage references from common.yaml - Updated `docs/source/basics.md`: - Replaced individual example stages with a reference to a shared stage defined in `common.yaml`, improving consistency and reducing redundancy in documentation examples. - Adjusted formatting for clarity in the "Skipping stages with simpleeval expressions" section. - Updated `tests/integration/test_merge_down.tavern.yaml`: - Replaced the local stage definition with a shared stage reference (`typetoken-anything-match`) from `common.yaml`, simplifying test setup and promoting reuse. --- docs/source/basics.md | 16 +++++++--------- tests/integration/test_merge_down.tavern.yaml | 11 +++-------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/docs/source/basics.md b/docs/source/basics.md index 41fae1c5c..31d3d721a 100644 --- a/docs/source/basics.md +++ b/docs/source/basics.md @@ -1158,18 +1158,14 @@ stages: url: "{host}/redirect/loop" response: status_code: 200 + --- -test_name: Test redirecting loops in another test +test_name: Using a shared stage from common.yaml stages: - - name: Expect a 302 without setting the flag - max_retries: 2 - request: - follow_redirects: true - url: "{host}/redirect/loop" - response: - status_code: 200 + - type: ref + id: typetoken-anything-match ``` Both tests automatically include the shared configuration from `common.yaml` without needing to specify it individually. @@ -1983,10 +1979,12 @@ stages: ##### Skipping stages with simpleeval expressions -Stages can be skipped by using a `skip` key that contains a [simpleeval](https://pypi.org/project/simpleeval/) expression. +Stages can be skipped by using a `skip` key that contains a [simpleeval](https://pypi.org/project/simpleeval/) +expression. This allows for more complex conditional logic to determine if a stage should be skipped. Example: + ```yaml stages: - name: Skip based on variable value diff --git a/tests/integration/test_merge_down.tavern.yaml b/tests/integration/test_merge_down.tavern.yaml index 85d6aafe7..295c658f4 100644 --- a/tests/integration/test_merge_down.tavern.yaml +++ b/tests/integration/test_merge_down.tavern.yaml @@ -15,13 +15,8 @@ stages: response: status_code: 200 --- -test_name: Test redirecting loops in another test +test_name: Using a shared stage from common.yaml stages: - - name: Expect a 302 without setting the flag - max_retries: 2 - request: - follow_redirects: true - url: "{host}/redirect/loop" - response: - status_code: 200 + - type: ref + id: typetoken-anything-match