From 8bae4264b52f6734d745bf24397c5955576b58f9 Mon Sep 17 00:00:00 2001 From: Ian Tewksbury Date: Tue, 3 Aug 2021 15:04:09 -0400 Subject: [PATCH] add continue sub steps on failure option --- src/ploigos_step_runner/config/config.py | 50 +++- src/ploigos_step_runner/config/step_config.py | 34 ++- .../config/sub_step_config.py | 31 ++- src/ploigos_step_runner/step_implementer.py | 2 +- src/ploigos_step_runner/step_runner.py | 14 +- tests/config/test_config.py | 103 ++++++- tests/helpers/sample_step_implementers.py | 13 + tests/test_step_runner.py | 252 +++++++++++++++++- 8 files changed, 455 insertions(+), 44 deletions(-) diff --git a/src/ploigos_step_runner/config/config.py b/src/ploigos_step_runner/config/config.py index e3f7842e..c2078a75 100644 --- a/src/ploigos_step_runner/config/config.py +++ b/src/ploigos_step_runner/config/config.py @@ -4,12 +4,14 @@ import copy import glob import os.path +from distutils import util -from ploigos_step_runner.decryption_utils import DecryptionUtils -from ploigos_step_runner.config.step_config import StepConfig from ploigos_step_runner.config.config_value import ConfigValue -from ploigos_step_runner.utils.file import parse_yaml_or_json_file +from ploigos_step_runner.config.step_config import StepConfig +from ploigos_step_runner.decryption_utils import DecryptionUtils from ploigos_step_runner.utils.dict import deep_merge +from ploigos_step_runner.utils.file import parse_yaml_or_json_file + class Config: """Representation of configuration for Ploigos workflow. @@ -42,6 +44,7 @@ class Config: CONFIG_KEY_GLOBAL_DEFAULTS = 'global-defaults' CONFIG_KEY_GLOBAL_ENVIRONMENT_DEFAULTS = 'global-environment-defaults' CONFIG_KEY_ENVIRONMENT_NAME = 'environment-name' + CONFIG_KEY_CONTINUE_SUB_STEPS_ON_FAILURE = 'continue-sub-steps-on-failure' CONFIG_KEY_STEP_IMPLEMENTER = 'implementer' CONFIG_KEY_SUB_STEP_NAME = 'name' CONFIG_KEY_SUB_STEP_CONFIG = 'config' @@ -263,7 +266,7 @@ def __add_config_file(self, config_file): f"Failed to add parsed configuration file ({config_file}): {error}" ) from error - def __add_config_dict(self, config_dict, source_file_path=None): # pylint: disable=too-many-locals, too-many-branches + def __add_config_dict(self, config_dict, source_file_path=None): # pylint: disable=too-many-locals, too-many-branches, too-many-statements """Add a configuration dictionary to the list of configuration dictionaries. Parameters @@ -374,24 +377,41 @@ def __add_config_dict(self, config_dict, source_file_path=None): # pylint: disab else: sub_step_name = sub_step_implementer_name + # determine sub step config if Config.CONFIG_KEY_SUB_STEP_CONFIG in sub_step: sub_step_config_dict = copy.deepcopy( sub_step[Config.CONFIG_KEY_SUB_STEP_CONFIG]) else: sub_step_config_dict = {} + # determine sub step environment config if Config.CONFIG_KEY_SUB_STEP_ENVIRONMENT_CONFIG in sub_step: sub_step_env_config = copy.deepcopy( sub_step[Config.CONFIG_KEY_SUB_STEP_ENVIRONMENT_CONFIG]) else: sub_step_env_config = {} + # determine if continue sub steps on this sub step failure + sub_step_contine_sub_steps_on_failure = False + if Config.CONFIG_KEY_CONTINUE_SUB_STEPS_ON_FAILURE in sub_step: + sub_step_contine_sub_steps_on_failure = sub_step[ + Config.CONFIG_KEY_CONTINUE_SUB_STEPS_ON_FAILURE + ] + if isinstance(sub_step_contine_sub_steps_on_failure.value, bool): + sub_step_contine_sub_steps_on_failure = \ + sub_step_contine_sub_steps_on_failure.value + else: + sub_step_contine_sub_steps_on_failure = bool( + util.strtobool(sub_step_contine_sub_steps_on_failure.value) + ) + self.add_or_update_step_config( step_name=step_name, sub_step_name=sub_step_name, sub_step_implementer_name=sub_step_implementer_name, sub_step_config_dict=sub_step_config_dict, - sub_step_env_config=sub_step_env_config + sub_step_env_config=sub_step_env_config, + sub_step_contine_sub_steps_on_failure=sub_step_contine_sub_steps_on_failure ) @staticmethod @@ -434,12 +454,14 @@ def parse_and_register_decryptors_definitions(decryptors_definitions): ) def add_or_update_step_config( # pylint: disable=too-many-arguments - self, - step_name, - sub_step_name, - sub_step_implementer_name, - sub_step_config_dict, - sub_step_env_config): + self, + step_name, + sub_step_name, + sub_step_implementer_name, + sub_step_config_dict, + sub_step_env_config, + sub_step_contine_sub_steps_on_failure=False + ): """Adds a new step configuration with a single new sub step or updates an existing step with new or updated sub step. @@ -461,6 +483,9 @@ def add_or_update_step_config( # pylint: disable=too-many-arguments new or updated step. If updating this can not have any duplicative leaf keys to the existing sub step environment configuration. + sub_step_contine_sub_steps_on_failure : bool + True to continue executing other sub steps in current step if this sub step fails. + False to fail all step execution if this sub step fails. Raises ------ @@ -482,5 +507,6 @@ def add_or_update_step_config( # pylint: disable=too-many-arguments sub_step_name=sub_step_name, sub_step_implementer_name=sub_step_implementer_name, sub_step_config_dict=sub_step_config_dict, - sub_step_env_config=sub_step_env_config + sub_step_env_config=sub_step_env_config, + sub_step_contine_sub_steps_on_failure=sub_step_contine_sub_steps_on_failure ) diff --git a/src/ploigos_step_runner/config/step_config.py b/src/ploigos_step_runner/config/step_config.py index 0aa9fba8..9f9da000 100644 --- a/src/ploigos_step_runner/config/step_config.py +++ b/src/ploigos_step_runner/config/step_config.py @@ -102,11 +102,13 @@ def step_config_overrides(self, step_config_overrides): self.__step_config_overrides = step_config_overrides if step_config_overrides else {} def add_or_update_sub_step_config( - self, - sub_step_name, - sub_step_implementer_name, - sub_step_config_dict=None, - sub_step_env_config=None): + self, + sub_step_name, + sub_step_implementer_name, + sub_step_config_dict=None, + sub_step_env_config=None, + sub_step_contine_sub_steps_on_failure=False + ): # pylint: disable=too-many-arguments """Add a new or update an existing sub step configuration for this step. Parameters @@ -124,6 +126,9 @@ def add_or_update_sub_step_config( Sub step environment configuration to add or update for named sub step. If updating this can not have any duplicative leaf keys to the existing sub step environment configuration. + sub_step_contine_sub_steps_on_failure : bool + True to continue executing other sub steps in current step if this sub step fails. + False to fail all step execution if this sub step fails. Raises ------ @@ -149,15 +154,24 @@ def add_or_update_sub_step_config( sub_step_name=sub_step_name, sub_step_implementer_name=sub_step_implementer_name, sub_step_config_dict=sub_step_config_dict, - sub_step_env_config=sub_step_env_config + sub_step_env_config=sub_step_env_config, + sub_step_contine_sub_steps_on_failure=sub_step_contine_sub_steps_on_failure ) self.sub_steps.append(sub_step_config) else: - assert sub_step_implementer_name == sub_step_config.sub_step_implementer_name, \ + assert sub_step_implementer_name == existing_sub_step_config.sub_step_implementer_name,\ f"Step ({self.step_name}) failed to update sub step ({sub_step_name})" + \ " with new config due to new sub step implementer" + \ f" ({sub_step_implementer_name}) not matching existing sub step implementer" + \ - f" ({sub_step_config.sub_step_implementer_name})." + f" ({existing_sub_step_config.sub_step_implementer_name})." - sub_step_config.merge_sub_step_config(sub_step_config_dict) - sub_step_config.merge_sub_step_env_config(sub_step_env_config) + assert sub_step_contine_sub_steps_on_failure == \ + existing_sub_step_config.sub_step_contine_sub_steps_on_failure, \ + f"Step ({self.step_name}) failed to update sub step ({sub_step_name})" + \ + " with new config due to new continue sub steps on failure" + \ + f" ({sub_step_contine_sub_steps_on_failure}) not matching existing" + \ + " continue sub steps on failure " + \ + f" ({existing_sub_step_config.sub_step_implementer_name})." + + existing_sub_step_config.merge_sub_step_config(sub_step_config_dict) + existing_sub_step_config.merge_sub_step_env_config(sub_step_env_config) diff --git a/src/ploigos_step_runner/config/sub_step_config.py b/src/ploigos_step_runner/config/sub_step_config.py index 262f4a06..8071bc69 100644 --- a/src/ploigos_step_runner/config/sub_step_config.py +++ b/src/ploigos_step_runner/config/sub_step_config.py @@ -22,6 +22,9 @@ class SubStepConfig: Configuration specific to this sub step. sub_step_env_config : dict, optional Environment specific configuration specific to this sub step. + sub_step_contine_sub_steps_on_failure : bool + True to continue executing other sub steps in current step if this sub step fails. + False to fail all step execution if this sub step fails. Attributes ---------- @@ -33,16 +36,19 @@ class SubStepConfig: """ def __init__( # pylint: disable=too-many-arguments - self, - parent_step_config, - sub_step_name, - sub_step_implementer_name, - sub_step_config_dict=None, - sub_step_env_config=None): + self, + parent_step_config, + sub_step_name, + sub_step_implementer_name, + sub_step_config_dict=None, + sub_step_env_config=None, + sub_step_contine_sub_steps_on_failure=False + ): self.__parent_step_config = parent_step_config self.__sub_step_name = sub_step_name self.__sub_step_implementer_name = sub_step_implementer_name + self.__sub_step_contine_sub_steps_on_failure = sub_step_contine_sub_steps_on_failure if sub_step_config_dict is None: sub_step_config_dict = {} @@ -147,6 +153,19 @@ def sub_step_env_config(self): """ return copy.deepcopy(self.__sub_step_env_config) + @property + def sub_step_contine_sub_steps_on_failure(self): + """Gets whether to continue executing other sub steps that belong to the step that + this sub step belongs to or not if this sub step fails. + + Returns + ------- + bool + True to continue executing other sub steps in current step if this sub step fails. + False to fail all step execution if this sub step fails. + """ + return self.__sub_step_contine_sub_steps_on_failure + def get_global_environment_defaults(self, env): """Convince function for getting the global environment defaults from the parent config. diff --git a/src/ploigos_step_runner/step_implementer.py b/src/ploigos_step_runner/step_implementer.py index 5e084bcc..9afba47f 100644 --- a/src/ploigos_step_runner/step_implementer.py +++ b/src/ploigos_step_runner/step_implementer.py @@ -291,7 +291,7 @@ def run_step(self): # print information about the configuration StepImplementer.__print_section_title( - f"Configuration - {self.step_name}", + f"Configuration - {self.step_name} - {self.sub_step_name}", div_char="-", indent=1 ) diff --git a/src/ploigos_step_runner/step_runner.py b/src/ploigos_step_runner/step_runner.py index 1dc352d1..5f4bbfb1 100644 --- a/src/ploigos_step_runner/step_runner.py +++ b/src/ploigos_step_runner/step_runner.py @@ -145,6 +145,7 @@ def run_step(self, step_name, environment=None): f"Can not run step ({step_name}) because no step configuration provided." # for each sub step in the step config get the step implementer and run it + aggregate_success = True for sub_step_config in sub_step_configs: sub_step_implementer_name = sub_step_config.sub_step_implementer_name @@ -174,11 +175,16 @@ def run_step(self, step_name, environment=None): yml_filename=self.results_file_path ) - # bail out if one of the sub steps fails - if not step_result.success: - return False + # aggregate success + aggregate_success = (aggregate_success and step_result.success) - return True + # if this sub step failed and not configured to continue on failure, bail + # else execute next sub step and continue aggregating success + if (not step_result.success) and \ + (not sub_step_config.sub_step_contine_sub_steps_on_failure): + break + + return aggregate_success @staticmethod def __get_step_implementer_class(step_name, step_implementer_name): diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 8d4c28e6..c8b4bca6 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -1,12 +1,11 @@ import os.path +from ploigos_step_runner.config import Config, ConfigValue +from ploigos_step_runner.config.decryptors.sops import SOPS +from ploigos_step_runner.decryption_utils import DecryptionUtils from testfixtures import TempDirectory - from tests.helpers.base_test_case import BaseTestCase -from ploigos_step_runner.decryption_utils import DecryptionUtils -from ploigos_step_runner.config import Config, ConfigValue -from ploigos_step_runner.config.decryptors.sops import SOPS class TestConfig(BaseTestCase): def test_add_config_invalid_type(self): @@ -922,6 +921,102 @@ def test_multiple_sub_steps(self): } ) + def test_sub_step_with_continue_sub_steps_on_failure_bool(self): + config = Config({ + Config.CONFIG_KEY: { + 'step-foo': [ + { + 'implementer': 'foo1', + 'continue-sub-steps-on-failure': True, + 'config': { + 'test1': 'foo' + } + }, + { + 'implementer': 'foo2', + 'config': { + 'test2': 'foo' + } + } + ] + + } + }) + + step_config = config.get_step_config('step-foo') + self.assertEqual(len(step_config.sub_steps), 2) + + self.assertEqual( + ConfigValue.convert_leaves_to_values( + step_config.get_sub_step('foo1').sub_step_config, + ), + { + 'test1': 'foo' + } + ) + self.assertEqual( + ConfigValue.convert_leaves_to_values( + step_config.get_sub_step('foo2').sub_step_config + ), + { + 'test2': 'foo' + } + ) + self.assertTrue( + step_config.get_sub_step('foo1').sub_step_contine_sub_steps_on_failure + ) + self.assertFalse( + step_config.get_sub_step('foo2').sub_step_contine_sub_steps_on_failure + ) + + def test_sub_step_with_continue_sub_steps_on_failure_str(self): + config = Config({ + Config.CONFIG_KEY: { + 'step-foo': [ + { + 'implementer': 'foo1', + 'continue-sub-steps-on-failure': 'true', + 'config': { + 'test1': 'foo' + } + }, + { + 'implementer': 'foo2', + 'config': { + 'test2': 'foo' + } + } + ] + + } + }) + + step_config = config.get_step_config('step-foo') + self.assertEqual(len(step_config.sub_steps), 2) + + self.assertEqual( + ConfigValue.convert_leaves_to_values( + step_config.get_sub_step('foo1').sub_step_config, + ), + { + 'test1': 'foo' + } + ) + self.assertEqual( + ConfigValue.convert_leaves_to_values( + step_config.get_sub_step('foo2').sub_step_config + ), + { + 'test2': 'foo' + } + ) + self.assertTrue( + step_config.get_sub_step('foo1').sub_step_contine_sub_steps_on_failure + ) + self.assertFalse( + step_config.get_sub_step('foo2').sub_step_contine_sub_steps_on_failure + ) + def test_sub_step_with_name(self): config = Config({ Config.CONFIG_KEY: { diff --git a/tests/helpers/sample_step_implementers.py b/tests/helpers/sample_step_implementers.py index 0dbcec2a..3b95a924 100644 --- a/tests/helpers/sample_step_implementers.py +++ b/tests/helpers/sample_step_implementers.py @@ -30,6 +30,19 @@ def _run_step(self): step_result = StepResult.from_step_implementer(self) return step_result +class FooStepImplementer2(StepImplementer): + @staticmethod + def step_implementer_config_defaults(): + return {} + + @staticmethod + def _required_config_or_result_keys(): + return [] + + def _run_step(self): + step_result = StepResult.from_step_implementer(self) + return step_result + class RequiredStepConfigStepImplementer(StepImplementer): @staticmethod diff --git a/tests/test_step_runner.py b/tests/test_step_runner.py index d2ca9f7f..e7002542 100644 --- a/tests/test_step_runner.py +++ b/tests/test_step_runner.py @@ -1,13 +1,9 @@ -# pylint: disable=line-too-long -# pylint: disable=missing-module-docstring -# pylint: disable=missing-class-docstring -# pylint: disable=missing-function-docstring - import re +from unittest.mock import patch -from testfixtures import TempDirectory -from ploigos_step_runner import StepRunner, StepRunnerException +from ploigos_step_runner import StepResult, StepRunner, StepRunnerException from ploigos_step_runner.config import Config +from testfixtures import TempDirectory from tests.helpers.base_test_case import BaseTestCase @@ -174,3 +170,245 @@ def test__get_step_implementer_class_exists_include_module(self): 'tests.helpers.sample_step_implementers.FooStepImplementer' ) ) + + @patch('tests.helpers.sample_step_implementers.FooStepImplementer2._run_step') + @patch('tests.helpers.sample_step_implementers.FooStepImplementer._run_step') + def test_run_step_multiple_sub_steps_all_succeed( + self, + foo_step_implementer_run_step_mock, + foo_step_implementer2_run_step_mock + ): + config = { + 'step-runner-config': { + 'foo': [ + { + 'name': 'Mock Sub Step 1', + 'implementer': 'tests.helpers.sample_step_implementers.FooStepImplementer' + }, + { + 'name': 'Mock Sub Step 2', + 'implementer': 'tests.helpers.sample_step_implementers.FooStepImplementer2' + } + ] + } + } + + # mock return value + mock_sub_step_1_result = StepResult( + step_name='foo', + sub_step_name='Mock Sub Step 1', + sub_step_implementer_name='tests.helpers.sample_step_implementers.FooStepImplementer' + ) + mock_sub_step_1_result.success = True + mock_sub_step_2_result = StepResult( + step_name='foo', + sub_step_name='Mock Sub Step 2', + sub_step_implementer_name='tests.helpers.sample_step_implementers.FooStepImplementer' + ) + mock_sub_step_2_result.success = True + + foo_step_implementer_run_step_mock.return_value = mock_sub_step_1_result + foo_step_implementer2_run_step_mock.return_value = mock_sub_step_2_result + + # run test + step_runner = StepRunner(config) + actual_success = step_runner.run_step('foo') + + # validate + self.assertTrue(actual_success) + foo_step_implementer_run_step_mock.assert_called_once() + foo_step_implementer2_run_step_mock.assert_called_once() + + @patch('tests.helpers.sample_step_implementers.FooStepImplementer2._run_step') + @patch('tests.helpers.sample_step_implementers.FooStepImplementer._run_step') + def test_run_step_multiple_sub_steps_first_sub_step_fail( + self, + foo_step_implementer_run_step_mock, + foo_step_implementer2_run_step_mock + ): + config = { + 'step-runner-config': { + 'foo': [ + { + 'name': 'Mock Sub Step 1', + 'implementer': 'tests.helpers.sample_step_implementers.FooStepImplementer' + }, + { + 'name': 'Mock Sub Step 2', + 'implementer': 'tests.helpers.sample_step_implementers.FooStepImplementer2' + } + ] + } + } + + # mock return value + mock_sub_step_1_result = StepResult( + step_name='foo', + sub_step_name='Mock Sub Step 1', + sub_step_implementer_name='tests.helpers.sample_step_implementers.FooStepImplementer' + ) + mock_sub_step_1_result.success = False + mock_sub_step_2_result = StepResult( + step_name='foo', + sub_step_name='Mock Sub Step 2', + sub_step_implementer_name='tests.helpers.sample_step_implementers.FooStepImplementer' + ) + mock_sub_step_2_result.success = True + + foo_step_implementer_run_step_mock.return_value = mock_sub_step_1_result + foo_step_implementer2_run_step_mock.return_value = mock_sub_step_2_result + + # run test + step_runner = StepRunner(config) + actual_success = step_runner.run_step('foo') + + # validate + self.assertFalse(actual_success) + foo_step_implementer_run_step_mock.assert_called_once() + foo_step_implementer2_run_step_mock.assert_not_called() + + @patch('tests.helpers.sample_step_implementers.FooStepImplementer2._run_step') + @patch('tests.helpers.sample_step_implementers.FooStepImplementer._run_step') + def test_run_step_multiple_sub_steps_second_sub_step_fail( + self, + foo_step_implementer_run_step_mock, + foo_step_implementer2_run_step_mock + ): + config = { + 'step-runner-config': { + 'foo': [ + { + 'name': 'Mock Sub Step 1', + 'implementer': 'tests.helpers.sample_step_implementers.FooStepImplementer' + }, + { + 'name': 'Mock Sub Step 2', + 'implementer': 'tests.helpers.sample_step_implementers.FooStepImplementer2' + } + ] + } + } + + # mock return value + mock_sub_step_1_result = StepResult( + step_name='foo', + sub_step_name='Mock Sub Step 1', + sub_step_implementer_name='tests.helpers.sample_step_implementers.FooStepImplementer' + ) + mock_sub_step_1_result.success = True + mock_sub_step_2_result = StepResult( + step_name='foo', + sub_step_name='Mock Sub Step 2', + sub_step_implementer_name='tests.helpers.sample_step_implementers.FooStepImplementer' + ) + mock_sub_step_2_result.success = False + + foo_step_implementer_run_step_mock.return_value = mock_sub_step_1_result + foo_step_implementer2_run_step_mock.return_value = mock_sub_step_2_result + + # run test + step_runner = StepRunner(config) + actual_success = step_runner.run_step('foo') + + # validate + self.assertFalse(actual_success) + foo_step_implementer_run_step_mock.assert_called_once() + foo_step_implementer2_run_step_mock.assert_called_once() + + @patch('tests.helpers.sample_step_implementers.FooStepImplementer2._run_step') + @patch('tests.helpers.sample_step_implementers.FooStepImplementer._run_step') + def test_run_step_multiple_sub_steps_first_sub_step_fail_contine_sub_steps_on_failure_bool( + self, + foo_step_implementer_run_step_mock, + foo_step_implementer2_run_step_mock + ): + config = { + 'step-runner-config': { + 'foo': [ + { + 'name': 'Mock Sub Step 1', + 'continue-sub-steps-on-failure': True, + 'implementer': 'tests.helpers.sample_step_implementers.FooStepImplementer' + }, + { + 'name': 'Mock Sub Step 2', + 'implementer': 'tests.helpers.sample_step_implementers.FooStepImplementer2' + } + ] + } + } + + # mock return value + mock_sub_step_1_result = StepResult( + step_name='foo', + sub_step_name='Mock Sub Step 1', + sub_step_implementer_name='tests.helpers.sample_step_implementers.FooStepImplementer' + ) + mock_sub_step_1_result.success = False + mock_sub_step_2_result = StepResult( + step_name='foo', + sub_step_name='Mock Sub Step 2', + sub_step_implementer_name='tests.helpers.sample_step_implementers.FooStepImplementer' + ) + mock_sub_step_2_result.success = True + + foo_step_implementer_run_step_mock.return_value = mock_sub_step_1_result + foo_step_implementer2_run_step_mock.return_value = mock_sub_step_2_result + + # run test + step_runner = StepRunner(config) + actual_success = step_runner.run_step('foo') + + # validate + self.assertFalse(actual_success) + foo_step_implementer_run_step_mock.assert_called_once() + foo_step_implementer2_run_step_mock.assert_called_once() + + @patch('tests.helpers.sample_step_implementers.FooStepImplementer2._run_step') + @patch('tests.helpers.sample_step_implementers.FooStepImplementer._run_step') + def test_run_step_multiple_sub_steps_first_sub_step_fail_contine_sub_steps_on_failure_str( + self, + foo_step_implementer_run_step_mock, + foo_step_implementer2_run_step_mock + ): + config = { + 'step-runner-config': { + 'foo': [ + { + 'name': 'Mock Sub Step 1', + 'continue-sub-steps-on-failure': 'true', + 'implementer': 'tests.helpers.sample_step_implementers.FooStepImplementer' + }, + { + 'name': 'Mock Sub Step 2', + 'implementer': 'tests.helpers.sample_step_implementers.FooStepImplementer2' + } + ] + } + } + + # mock return value + mock_sub_step_1_result = StepResult( + step_name='foo', + sub_step_name='Mock Sub Step 1', + sub_step_implementer_name='tests.helpers.sample_step_implementers.FooStepImplementer' + ) + mock_sub_step_1_result.success = False + mock_sub_step_2_result = StepResult( + step_name='foo', + sub_step_name='Mock Sub Step 2', + sub_step_implementer_name='tests.helpers.sample_step_implementers.FooStepImplementer' + ) + mock_sub_step_2_result.success = True + + foo_step_implementer_run_step_mock.return_value = mock_sub_step_1_result + foo_step_implementer2_run_step_mock.return_value = mock_sub_step_2_result + + # run test + step_runner = StepRunner(config) + actual_success = step_runner.run_step('foo') + + # validate + self.assertFalse(actual_success) + foo_step_implementer_run_step_mock.assert_called_once() + foo_step_implementer2_run_step_mock.assert_called_once()