| Tor Norbye | 02cf98d | 2014-08-19 12:53:10 -0700 | [diff] [blame] | 1 | # coding=utf-8 |
| 2 | """ |
| 3 | Behave BDD runner. |
| 4 | *FIRST* param now: folder to search "features" for. |
| 5 | Each "features" folder should have features and "steps" subdir. |
| 6 | |
| 7 | Other args are tag expressionsin format (--tags=.. --tags=..). |
| 8 | See https://pythonhosted.org/behave/behave.html#tag-expression |
| 9 | """ |
| 10 | import functools |
| 11 | import sys |
| 12 | import os |
| 13 | import traceback |
| 14 | |
| 15 | from behave.formatter.base import Formatter |
| 16 | from behave.model import Step, ScenarioOutline, Feature, Scenario |
| 17 | from behave.tag_expression import TagExpression |
| 18 | |
| 19 | import _bdd_utils |
| 20 | |
| 21 | |
| 22 | _MAX_STEPS_SEARCH_FEATURES = 5000 # Do not look for features in folder that has more that this number of children |
| 23 | _FEATURES_FOLDER = 'features' # "features" folder name. |
| 24 | |
| 25 | __author__ = 'Ilya.Kazakevich' |
| 26 | |
| 27 | from behave import configuration, runner |
| 28 | from behave.formatter import formatters |
| 29 | |
| 30 | |
| 31 | def _get_dirs_to_run(base_dir_to_search): |
| 32 | """ |
| 33 | Searches for "features" dirs in some base_dir |
| 34 | :return: list of feature dirs to run |
| 35 | :rtype: list |
| 36 | :param base_dir_to_search root directory to search (should not have too many children!) |
| 37 | :type base_dir_to_search str |
| 38 | |
| 39 | """ |
| 40 | result = set() |
| 41 | for (step, (folder, sub_folders, files)) in enumerate(os.walk(base_dir_to_search)): |
| 42 | if os.path.basename(folder) == _FEATURES_FOLDER and os.path.isdir(folder): |
| 43 | result.add(os.path.abspath(folder)) |
| 44 | if step == _MAX_STEPS_SEARCH_FEATURES: # Guard |
| 45 | err = "Folder {} is too deep to find any features folder. Please provider concrete folder".format( |
| 46 | base_dir_to_search) |
| 47 | raise Exception(err) |
| 48 | return list(result) |
| 49 | |
| 50 | |
| 51 | def _merge_hooks_wrapper(*hooks): |
| 52 | """ |
| 53 | Creates wrapper that runs provided behave hooks sequentally |
| 54 | :param hooks: hooks to run |
| 55 | :return: wrapper |
| 56 | """ |
| 57 | # TODO: Wheel reinvented!!!! |
| 58 | def wrapper(*args, **kwargs): |
| 59 | for hook in hooks: |
| 60 | hook(*args, **kwargs) |
| 61 | |
| 62 | return wrapper |
| 63 | |
| 64 | |
| 65 | class _RunnerWrapper(runner.Runner): |
| 66 | """ |
| 67 | Wrapper around behave native wrapper. Has nothing todo with BddRunner! |
| 68 | We need it to support dry runs (to fetch data from scenarios) and hooks api |
| 69 | """ |
| 70 | |
| 71 | def __init__(self, config, hooks): |
| 72 | """ |
| 73 | :type config configuration.Configuration |
| 74 | :param config behave configuration |
| 75 | :type hooks dict |
| 76 | :param hooks hooks in format "before_scenario" => f(context, scenario) to load after/before hooks, provided by user |
| 77 | """ |
| 78 | super(_RunnerWrapper, self).__init__(config) |
| 79 | self.dry_run = False |
| 80 | """ |
| 81 | Does not run tests (only fetches "self.features") if true. Runs tests otherwise. |
| 82 | """ |
| 83 | self.__hooks = hooks |
| 84 | |
| 85 | def load_hooks(self, filename='environment.py'): |
| 86 | """ |
| 87 | Overrides parent "load_hooks" to add "self.__hooks" |
| 88 | :param filename: env. file name |
| 89 | """ |
| 90 | super(_RunnerWrapper, self).load_hooks(filename) |
| 91 | for (hook_name, hook) in self.__hooks.items(): |
| 92 | hook_to_add = hook |
| 93 | if hook_name in self.hooks: |
| 94 | user_hook = self.hooks[hook_name] |
| 95 | if hook_name.startswith("before"): |
| 96 | user_and_custom_hook = [user_hook, hook] |
| 97 | else: |
| 98 | user_and_custom_hook = [hook, user_hook] |
| 99 | hook_to_add = _merge_hooks_wrapper(*user_and_custom_hook) |
| 100 | self.hooks[hook_name] = hook_to_add |
| 101 | |
| 102 | def run_model(self, features=None): |
| 103 | """ |
| 104 | Overrides parent method to stop (do nothing) in case of "dry_run" |
| 105 | :param features: features to run |
| 106 | :return: |
| 107 | """ |
| 108 | if self.dry_run: # To stop further execution |
| 109 | return |
| 110 | return super(_RunnerWrapper, self).run_model(features) |
| 111 | |
| 112 | def clean(self): |
| 113 | """ |
| 114 | Cleans runner after dry run (clears hooks, features etc). To be called before real run! |
| 115 | """ |
| 116 | self.dry_run = False |
| 117 | self.hooks.clear() |
| 118 | self.features = [] |
| 119 | |
| 120 | |
| 121 | class _BehaveRunner(_bdd_utils.BddRunner): |
| 122 | """ |
| 123 | BddRunner for behave |
| 124 | """ |
| 125 | |
| 126 | |
| 127 | def __process_hook(self, is_started, context, element): |
| 128 | """ |
| 129 | Hook to be installed. Reports steps, features etc. |
| 130 | :param is_started true if test/feature/scenario is started |
| 131 | :type is_started bool |
| 132 | :param context behave context |
| 133 | :type context behave.runner.Context |
| 134 | :param element feature/suite/step |
| 135 | """ |
| 136 | element.location.file = element.location.filename # To preserve _bdd_utils contract |
| 137 | if isinstance(element, Step): |
| 138 | # Process step |
| Tor Norbye | 1aa2e09 | 2014-08-20 17:01:23 -0700 | [diff] [blame] | 139 | step_name = "{} {}".format(element.keyword, element.name) |
| Tor Norbye | 02cf98d | 2014-08-19 12:53:10 -0700 | [diff] [blame] | 140 | if is_started: |
| Tor Norbye | 1aa2e09 | 2014-08-20 17:01:23 -0700 | [diff] [blame] | 141 | self._test_started(step_name, element.location) |
| Tor Norbye | 02cf98d | 2014-08-19 12:53:10 -0700 | [diff] [blame] | 142 | elif element.status == 'passed': |
| Tor Norbye | 1aa2e09 | 2014-08-20 17:01:23 -0700 | [diff] [blame] | 143 | self._test_passed(step_name, element.duration) |
| Tor Norbye | 02cf98d | 2014-08-19 12:53:10 -0700 | [diff] [blame] | 144 | elif element.status == 'failed': |
| 145 | try: |
| 146 | trace = traceback.format_exc() |
| 147 | except Exception: |
| 148 | trace = "".join(traceback.format_tb(element.exc_traceback)) |
| Tor Norbye | 1aa2e09 | 2014-08-20 17:01:23 -0700 | [diff] [blame] | 149 | if trace in str(element.error_message): |
| 150 | trace = None # No reason to duplicate output (see PY-13647) |
| 151 | self._test_failed(step_name, element.error_message, trace) |
| Tor Norbye | 02cf98d | 2014-08-19 12:53:10 -0700 | [diff] [blame] | 152 | elif element.status == 'undefined': |
| Tor Norbye | 1aa2e09 | 2014-08-20 17:01:23 -0700 | [diff] [blame] | 153 | self._test_undefined(step_name, element.location) |
| Tor Norbye | 02cf98d | 2014-08-19 12:53:10 -0700 | [diff] [blame] | 154 | else: |
| Tor Norbye | 1aa2e09 | 2014-08-20 17:01:23 -0700 | [diff] [blame] | 155 | self._test_skipped(step_name, element.status, element.location) |
| Tor Norbye | 02cf98d | 2014-08-19 12:53:10 -0700 | [diff] [blame] | 156 | elif not is_started and isinstance(element, Scenario) and element.status == 'failed': |
| 157 | # To process scenarios with undefined/skipped tests |
| 158 | for step in element.steps: |
| 159 | assert isinstance(step, Step), step |
| 160 | if step.status not in ['passed', 'failed']: # Something strange, probably skipped or undefined |
| 161 | self.__process_hook(False, context, step) |
| 162 | self._feature_or_scenario(is_started, element.name, element.location) |
| 163 | elif isinstance(element, ScenarioOutline): |
| 164 | self._feature_or_scenario(is_started, str(element.examples), element.location) |
| 165 | else: |
| 166 | self._feature_or_scenario(is_started, element.name, element.location) |
| 167 | |
| 168 | def __init__(self, config, base_dir): |
| 169 | """ |
| 170 | :type config configuration.Configuration |
| 171 | """ |
| 172 | super(_BehaveRunner, self).__init__(base_dir) |
| 173 | self.__config = config |
| 174 | # Install hooks |
| 175 | self.__real_runner = _RunnerWrapper(config, { |
| 176 | "before_feature": functools.partial(self.__process_hook, True), |
| 177 | "after_feature": functools.partial(self.__process_hook, False), |
| 178 | "before_scenario": functools.partial(self.__process_hook, True), |
| 179 | "after_scenario": functools.partial(self.__process_hook, False), |
| 180 | "before_step": functools.partial(self.__process_hook, True), |
| 181 | "after_step": functools.partial(self.__process_hook, False) |
| 182 | }) |
| 183 | |
| 184 | def _run_tests(self): |
| 185 | self.__real_runner.run() |
| 186 | |
| 187 | |
| 188 | def __filter_scenarios_by_tag(self, scenario): |
| 189 | """ |
| 190 | Filters out scenarios that should be skipped by tags |
| 191 | :param scenario scenario to check |
| 192 | :return true if should pass |
| 193 | """ |
| 194 | assert isinstance(scenario, Scenario), scenario |
| 195 | expected_tags = self.__config.tags |
| 196 | if not expected_tags: |
| 197 | return True # No tags are required |
| 198 | return isinstance(expected_tags, TagExpression) and expected_tags.check(scenario.tags) |
| 199 | |
| 200 | |
| 201 | def _get_features_to_run(self): |
| 202 | self.__real_runner.dry_run = True |
| 203 | self.__real_runner.run() |
| 204 | features_to_run = self.__real_runner.features |
| 205 | self.__real_runner.clean() # To make sure nothing left after dry run |
| 206 | |
| 207 | # Change outline scenario skeletons with real scenarios |
| 208 | for feature in features_to_run: |
| 209 | assert isinstance(feature, Feature), feature |
| 210 | scenarios = [] |
| 211 | for scenario in feature.scenarios: |
| 212 | if isinstance(scenario, ScenarioOutline): |
| 213 | scenarios.extend(scenario.scenarios) |
| 214 | else: |
| 215 | scenarios.append(scenario) |
| 216 | feature.scenarios = filter(self.__filter_scenarios_by_tag, scenarios) |
| 217 | |
| 218 | return features_to_run |
| 219 | |
| 220 | |
| 221 | if __name__ == "__main__": |
| 222 | # TODO: support all other params instead |
| 223 | |
| 224 | class _Null(Formatter): |
| 225 | """ |
| 226 | Null formater to prevent stdout output |
| 227 | """ |
| 228 | pass |
| 229 | |
| 230 | command_args = list(filter(None, sys.argv[1:])) |
| Tor Norbye | c3d3a90 | 2014-09-04 13:24:04 -0700 | [diff] [blame^] | 231 | if command_args: |
| 232 | _bdd_utils.fix_win_drive(command_args[0]) |
| Tor Norbye | 02cf98d | 2014-08-19 12:53:10 -0700 | [diff] [blame] | 233 | my_config = configuration.Configuration(command_args=command_args) |
| 234 | formatters.register_as(_Null, "com.intellij.python.null") |
| 235 | my_config.format = ["com.intellij.python.null"] # To prevent output to stdout |
| 236 | my_config.reporters = [] # To prevent summary to stdout |
| 237 | my_config.stdout_capture = False # For test output |
| 238 | my_config.stderr_capture = False # For test output |
| 239 | (base_dir, what_to_run) = _bdd_utils.get_path_by_args(sys.argv) |
| 240 | if not my_config.paths: # No path provided, trying to load dit manually |
| 241 | if os.path.isfile(what_to_run): # File is provided, load it |
| 242 | my_config.paths = [what_to_run] |
| 243 | else: # Dir is provided, find subdirs ro run |
| 244 | my_config.paths = _get_dirs_to_run(base_dir) |
| 245 | _BehaveRunner(my_config, base_dir).run() |
| 246 | |
| 247 | |