Source code for runway.cfngin.blueprints.testutil

"""Provides a subclass of unittest.TestCase for testing blueprints."""
from __future__ import annotations

import difflib
import json
import os.path
import unittest
from glob import glob
from pathlib import Path
from typing import TYPE_CHECKING, Any, Iterator, List, Optional, Type, cast

from ...config import CfnginConfig
from ...context import CfnginContext
from ...utils import load_object_from_string
from ...variables import Variable

if TYPE_CHECKING:
    from ...config.models.cfngin import CfnginStackDefinitionModel
    from .base import Blueprint


[docs]def diff(first: str, second: str) -> str: """Human readable differ.""" return "\n".join( list(difflib.Differ().compare(first.splitlines(), second.splitlines())) )
[docs]class BlueprintTestCase(unittest.TestCase): """Extends the functionality of unittest.TestCase for testing blueprints.""" OUTPUT_PATH: str = "tests/fixtures/blueprints"
[docs] def assertRenderedBlueprint( # noqa: N802 pylint: disable=invalid-name self, blueprint: Blueprint ) -> None: """Test that the rendered blueprint json matches the expected result. Result files are to be stored in the repo as ``test/fixtures/blueprints/${blueprint.name}.json``. """ expected_output = f"{self.OUTPUT_PATH}/{blueprint.name}.json" rendered_dict = blueprint.template.to_dict() rendered_text = json.dumps(rendered_dict, indent=4, sort_keys=True) with open( expected_output + "-result", "w", encoding="utf-8" ) as expected_output_file: expected_output_file.write(rendered_text) with open(expected_output, encoding="utf-8") as expected_output_file: expected_dict = json.loads(expected_output_file.read()) expected_text = json.dumps(expected_dict, indent=4, sort_keys=True) self.assertEqual( rendered_dict, expected_dict, diff(rendered_text, expected_text) )
[docs]class YamlDirTestGenerator: """Generate blueprint tests from yaml config files. This class creates blueprint tests from yaml files with a syntax similar to CFNgin configuration syntax. For example:: namespace: test stacks: - name: test_sample class_path: blueprints.test.Sample variables: var1: value1 Will create a test for the specified blueprint, passing that variable as part of the test. The test will generate a ``.json`` file for this blueprint, and compare it with the stored result. By default, the generator looks for files named ``test_*.yaml`` in its same directory. In order to use it, subclass it in a directory containing such tests, and name the class with a pattern that will include it in nosetests' tests (for example, TestGenerator). The subclass may override some ``@property`` definitions: **base_class** By default, the generated tests are subclasses or :class:`runway.cfngin.blueprints.testutil.BlueprintTestCase`. In order to change this, set this property to the desired base class. **yaml_dirs:** By default, the directory where the generator is subclassed is searched for test files. Override this array for specifying more directories. These must be relative to the directory in which the subclass lives in. Globs may be used. Default: ``['.']``. Example override: ``['.', 'tests/*/']`` **yaml_filename:** By default, the generator looks for files named ``test_*.yaml``. Use this to change this pattern. Globs may be used. """
[docs] def __init__(self) -> None: """Instantiate class.""" self.classdir = os.path.relpath(self.__class__.__module__.replace(".", "/")) if not os.path.isdir(self.classdir): self.classdir = os.path.dirname(self.classdir)
# These properties can be overridden from the test generator subclass. @property def base_class(self) -> Type[BlueprintTestCase]: """Return the baseclass.""" return BlueprintTestCase @property def yaml_dirs(self) -> List[str]: """Yaml directories.""" return ["."] @property def yaml_filename(self) -> str: """Yaml filename.""" return "test_*.yaml" # pylint incorrectly detects this
[docs] def test_generator( self, ) -> Iterator[BlueprintTestCase]: """Test generator.""" # Search for tests in given paths configs: List[str] = [] for directory in self.yaml_dirs: configs.extend(glob(f"{self.classdir}/{directory}/{self.yaml_filename}")) class ConfigTest(self.base_class): # type: ignore """Config test.""" context: CfnginContext def __init__( # pylint: disable=super-init-not-called self, config: CfnginConfig, stack: CfnginStackDefinitionModel, filepath: Path, ) -> None: """Instantiate class.""" self.config = config self.stack = stack self.description = f"{stack.name} ({filepath})" def __call__(self) -> None: # pylint: disable=arguments-differ """Run when the class instance is called directly.""" # Use the context property of the baseclass, if present. # If not, default to a basic context. try: ctx = self.context except AttributeError: ctx = CfnginContext( config=self.config, parameters={"environment": "test"} ) configvars = self.stack.variables or {} variables = [Variable(k, v, "cfngin") for k, v in configvars.items()] blueprint_class = load_object_from_string( cast(str, self.stack.class_path) ) blueprint = blueprint_class(self.stack.name, ctx) blueprint.resolve_variables(variables or []) blueprint.setup_parameters() blueprint.create_template() self.assertRenderedBlueprint(blueprint) def assertEqual( # noqa: N802 self, first: Any, second: Any, msg: Optional[str] = None ) -> None: """Test that first and second are equal. If the values do not compare equal, the test will fail. """ assert first == second, msg for config_file in configs: config_path = Path(config_file) config = CfnginConfig.parse_file(file_path=config_path) for stack in config.stacks: # Nosetests supports "test generators", which allows us to # yield a callable object which will be wrapped as a test # case. # # http://nose.readthedocs.io/en/latest/writing_tests.html#test-generators yield ConfigTest(config, stack, filepath=config_path)