Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(143)

Side by Side Diff: recipe_engine/test.py

Issue 2721613004: simulation_test_ng: initial CL (Closed)
Patch Set: tests Created 3 years, 9 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 # Copyright 2017 The LUCI Authors. All rights reserved.
2 # Use of this source code is governed under the Apache License, Version 2.0
3 # that can be found in the LICENSE file.
4
5 from __future__ import print_function
6
7 import argparse
8 import cStringIO
9 import contextlib
10 import copy
11 import coverage
12 import datetime
13 import difflib
14 import functools
15 import json
16 import multiprocessing
17 import os
18 import pprint
19 import re
20 import signal
21 import sys
22 import tempfile
23 import traceback
24
25 from . import checker
26 from . import config_types
27 from . import loader
28 from . import run
29 from . import step_runner
30 from . import stream
31
32
33 # These variables must be set in the dynamic scope of the functions in this
34 # file. We do this instead of passing because they're not picklable, and
35 # that's required by multiprocessing.
36 _UNIVERSE_VIEW = None
37 _ENGINE_FLAGS = None
38
39
40 # An event to signal exit, for example on Ctrl-C.
41 _KILL_SWITCH = multiprocessing.Event()
42
43
44 # This maps from (recipe_name,test_name) -> yielded test_data. It's outside of
45 # run_recipe so that it can persist between RunRecipe calls in the same process.
46 _GEN_TEST_CACHE = {}
47
48
49 # Allow regex patterns to be 'deep copied' by using them as-is.
50 copy._deepcopy_dispatch[re._pattern_type] = copy._deepcopy_atomic
51
52
53 class PostProcessError(ValueError):
54 """Exception raised when any of the post-process hooks fails."""
55 pass
56
57
58 @contextlib.contextmanager
59 def coverage_context(include=None):
60 """Context manager that records coverage data."""
61 c = coverage.coverage(config_file=False, include=include)
62
63 # Sometimes our strict include lists will result in a run
64 # not adding any coverage info. That's okay, avoid output spam.
65 c._warn_no_data = False
66
67 c.start()
68 try:
69 yield c
70 finally:
71 c.stop()
72
73
74 class TestFailure(object):
iannucci 2017/03/10 01:20:09 wdyt about putting some of this test code in a sub
Paweł Hajdan Jr. 2017/03/10 17:20:30 With slightly over 500 lines, I don't see big bene
75 """Generic class for different kinds of test failures."""
iannucci 2017/03/10 01:20:08 Generic or 'Base' class? I assume this is never me
Paweł Hajdan Jr. 2017/03/10 17:20:29 Yeah, changed to base. Obviously it's not meant to
76
77 def format(self):
78 """Returns a human-readable description of the failure."""
79 raise NotImplementedError()
80
81
82 class DiffFailure(TestFailure):
83 """Failure when simulated recipe commands don't match recorded expectations.
84 """
85
86 def __init__(self, diff):
87 self.diff = diff
88
89 def format(self):
90 return self.diff
91
92
93 class CheckFailure(TestFailure):
94 """Failure when any of the post-process checks fails."""
95
96 def __init__(self, check):
97 self.check = check
98
99 def format(self):
100 return self.check.format(indent=4)
101
102
103 class TestResult(object):
104 """Result of running a test."""
105
106 def __init__(self, test_description, failures, coverage_data):
107 self.test_description = test_description
108 self.failures = failures
109 self.coverage_data = coverage_data
110
111
112 class TestDescription(object):
113 """Identifies a specific test.
114
115 Deliberately small and picklable for use with multiprocessing."""
116
117 def __init__(self, recipe_name, test_name, expect_dir, covers):
118 self.recipe_name = recipe_name
119 self.test_name = test_name
120 self.expect_dir = expect_dir
121 self.covers = covers
122
123 @property
124 def full_name(self):
125 return '%s.%s' % (self.recipe_name, self.test_name)
126
127
128 def expectation_path(expect_dir, test_name):
129 """Returns path where serialized expectation data is stored."""
130 return os.path.join(expect_dir, test_name + '.json')
131
132
133 def run_test(test_description):
134 """Runs a test. Returns TestResults object."""
135 expected = None
136 path = expectation_path(
137 test_description.expect_dir, test_description.test_name)
138 if os.path.exists(path):
139 with open(path) as f:
140 # TODO(phajdan.jr): why do we need to re-encode golden data files?
141 expected = re_encode(json.load(f))
142
143 actual, failed_checks, coverage_data = run_recipe(
144 test_description.recipe_name, test_description.test_name,
145 test_description.covers)
146 actual = re_encode(actual)
147
148 failures = []
149
150 # TODO(phajdan.jr): handle exception (errors) in the recipe execution.
151 if failed_checks:
152 sys.stdout.write('C')
iannucci 2017/03/10 01:20:09 are these writes going to happen from multiple thr
Paweł Hajdan Jr. 2017/03/10 17:20:29 Yes.
153 failures.extend([CheckFailure(c) for c in failed_checks])
154 elif actual != expected:
155 diff = '\n'.join(list(difflib.unified_diff(
iannucci 2017/03/10 01:20:09 is the 'list' necessary? afaik, ''.join will work
Paweł Hajdan Jr. 2017/03/10 17:20:30 Done.
156 pprint.pformat(expected).splitlines(),
157 pprint.pformat(actual).splitlines(),
158 fromfile='expected', tofile='actual',
159 n=4, lineterm='')))
160
161 failures.append(DiffFailure(diff))
162 sys.stdout.write('F')
163 else:
164 sys.stdout.write('.')
165 sys.stdout.flush()
166
167 return TestResult(test_description, failures, coverage_data)
168
169
170 def run_recipe(recipe_name, test_name, covers):
171 """Runs the recipe under test in simulation mode.
172
173 Returns a tuple:
174 - expectation data
175 - failed post-process checks (if any)
176 - coverage data
177 """
178 config_types.ResetTostringFns()
179
180 # Reload test data in the worker to avoid pickling complex data structures.
181 cache_key = (recipe_name, test_name)
182 if cache_key not in _GEN_TEST_CACHE:
183 recipe_script = _UNIVERSE_VIEW.load_recipe(recipe_name)
184 test_api = loader.create_test_api(recipe_script.LOADED_DEPS, _UNIVERSE_VIEW)
185 for test_data in recipe_script.gen_tests(test_api):
186 _GEN_TEST_CACHE[(recipe_name, test_data.name)] = copy.deepcopy(test_data)
iannucci 2017/03/10 01:20:09 use cache_key? we had a bug before where we were w
Paweł Hajdan Jr. 2017/03/10 17:20:30 We can't use cache_key on this line, because test_
iannucci 2017/03/10 18:14:33 Oh, heh, yeah I'm dumb.
187 test_data = _GEN_TEST_CACHE[cache_key]
188
189 annotator = SimulationAnnotatorStreamEngine()
190 with stream.StreamEngineInvariants.wrap(annotator) as stream_engine:
191 runner = step_runner.SimulationStepRunner(
192 stream_engine, test_data, annotator)
193
194 props = test_data.properties.copy()
195 props['recipe'] = recipe_name
196 engine = run.RecipeEngine(
197 runner, props, _UNIVERSE_VIEW, engine_flags=_ENGINE_FLAGS)
198 with coverage_context(include=covers) as cov:
199 # Run recipe loading under coverage context. This ensures we collect
200 # coverage of all definitions and globals.
201 recipe_script = _UNIVERSE_VIEW.load_recipe(recipe_name, engine=engine)
202
203 api = loader.create_recipe_api(
204 _UNIVERSE_VIEW.universe.package_deps.root_package,
205 recipe_script.LOADED_DEPS,
206 recipe_script.path, engine, test_data)
207 result = engine.run(recipe_script, api, test_data.properties)
208 coverage_data = cov.get_data()
209
210 raw_expectations = runner.steps_ran.copy()
211 # Don't include tracebacks in expectations because they are too sensitive
212 # to change.
213 result.result.pop('traceback', None)
iannucci 2017/03/10 01:20:09 should we replace the traceback with something ind
Paweł Hajdan Jr. 2017/03/10 17:20:29 Added a TODO.
214 raw_expectations[result.result['name']] = result.result
215
216 failed_checks = []
217
218 for hook, args, kwargs, filename, lineno in test_data.post_process_hooks:
219 input_odict = copy.deepcopy(raw_expectations)
220 # We ignore the input_odict so that it never gets printed in full.
221 # Usually the check invocation itself will index the input_odict or
222 # will use it only for a key membership comparison, which provides
223 # enough debugging context.
224 checker_obj = checker.Checker(
225 filename, lineno, hook, args, kwargs, input_odict)
226
227 with coverage_context(include=covers) as cov:
228 # Run the hook itself under coverage. There may be custom post-process
229 # functions in recipe test code.
iannucci 2017/03/10 01:20:09 +1
230 rslt = hook(checker_obj, input_odict, *args, **kwargs)
231 coverage_data.update(cov.get_data())
232
233 failed_checks += checker_obj.failed_checks
234 if rslt is not None:
235 msg = checker.VerifySubset(rslt, raw_expectations)
236 if msg:
237 raise PostProcessError('post_process: steps'+msg)
238 # restore 'name'
239 for k, v in rslt.iteritems():
240 if 'name' not in v:
241 v['name'] = k
242 raw_expectations = rslt
243
244 # empty means drop expectation
245 result_data = raw_expectations.values() if raw_expectations else None
246 return (result_data, failed_checks, coverage_data)
247
248
249 def get_tests():
250 """Returns a list of tests for current recipe package."""
251 tests = []
252 coverage_data = coverage.CoverageData()
253
254 all_modules = set(_UNIVERSE_VIEW.loop_over_recipe_modules())
255 covered_modules = set()
256
257 base_covers = []
258
259 for module in all_modules:
260 # Run module loading under coverage context. This ensures we collect
261 # coverage of all definitions and globals.
262 coverage_include = os.path.join(_UNIVERSE_VIEW.module_dir, '*', '*.py')
iannucci 2017/03/10 01:20:09 nit: this could be moved outside the loop
Paweł Hajdan Jr. 2017/03/10 17:20:30 Done. FWIW, there's a slight tradeoff between "ef
263 with coverage_context(include=coverage_include) as cov:
264 mod = _UNIVERSE_VIEW.load_recipe_module(module)
265 coverage_data.update(cov.get_data())
266
267 # Recipe modules can only be covered by tests inside the same module.
268 # To make transition possible for existing code (which will require
269 # writing tests), a temporary escape hatch is added.
270 # TODO(phajdan.jr): remove DISABLE_STRICT_COVERAGE (crbug/693058).
271 if mod.DISABLE_STRICT_COVERAGE:
272 covered_modules.add(module)
273 # Make sure disabling strict coverage also disables our additional check
274 # for module coverage. Note that coverage will still raise an error if
275 # the module is executed by any of the tests, but having less than 100%
276 # coverage.
277 base_covers.append(os.path.join(
278 _UNIVERSE_VIEW.module_dir, module, '*.py'))
279
280 for recipe_path, recipe_name in _UNIVERSE_VIEW.loop_over_recipes():
281 try:
282 covers = [recipe_path] + base_covers
283
284 # Example/test recipes in a module always cover that module.
285 if ':' in recipe_name:
286 module, _ = recipe_name.split(':', 1)
287 covered_modules.add(module)
288 covers.append(os.path.join(_UNIVERSE_VIEW.module_dir, module, '*.py'))
289
290 with coverage_context(include=covers) as cov:
291 # Run recipe loading under coverage context. This ensures we collect
292 # coverage of all definitions and globals.
293 recipe = _UNIVERSE_VIEW.load_recipe(recipe_name)
294 test_api = loader.create_test_api(recipe.LOADED_DEPS, _UNIVERSE_VIEW)
295
296 root, name = os.path.split(recipe_path)
297 name = os.path.splitext(name)[0]
298 # TODO(phajdan.jr): move expectation tree outside of the recipe tree.
299 expect_dir = os.path.join(root, '%s.expected' % name)
300
301 # Immediately convert to list to force running the generator under
302 # coverage context. Otherwise coverage would only report executing
303 # the function definition, not GenTests body.
304 recipe_tests = list(recipe.gen_tests(test_api))
305 coverage_data.update(cov.get_data())
306
307 for test_data in recipe_tests:
308 tests.append(TestDescription(
309 recipe_name, test_data.name, expect_dir, covers))
310 except:
311 info = sys.exc_info()
312 new_exec = Exception('While generating results for %r: %s: %s' % (
313 recipe_name, info[0].__name__, str(info[1])))
314 raise new_exec.__class__, new_exec, info[2]
315
316 uncovered_modules = all_modules.difference(covered_modules)
317 return (tests, coverage_data, uncovered_modules)
318
319
320 def run_list():
321 """Implementation of the 'list' command."""
322 tests, _coverage_data, _uncovered_modules = get_tests()
323 print('\n'.join(sorted(t.full_name for t in tests)))
324 return 0
325
326
327 def cover_omit():
328 """Returns list of patterns to omit from coverage analysis."""
329 omit = [ ]
330
331 mod_dir_base = _UNIVERSE_VIEW.module_dir
332 if os.path.isdir(mod_dir_base):
333 omit.append(os.path.join(mod_dir_base, '*', 'resources', '*'))
iannucci 2017/03/10 01:20:09 is the final * necessary? because some modules hav
Paweł Hajdan Jr. 2017/03/10 17:20:30 Yes. Otherwise simulation tests inside recipe engi
334
335 # Exclude recipe engine files from simulation test coverage. Simulation tests
336 # should cover "user space" recipe code (recipes and modules), not the engine.
337 # The engine is covered by unit tests, not simulation tests.
338 omit.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '*'))
339
340 return omit
341
342
343 def report_coverage_version():
344 """Prints info about coverage module (for debugging)."""
345 print('Using coverage %s from %r' % (coverage.__version__, coverage.__file__))
346
347
348 def worker(f):
349 """Wrapper for a multiprocessing worker function.
350
351 This addresses known issues with multiprocessing workers:
352
353 - they can hang on uncaught exceptions
354 - we need explicit kill switch to clearly terminate parent"""
355 @functools.wraps(f)
356 def wrapper(*args, **kwargs):
357 try:
358 if _KILL_SWITCH.is_set():
359 return (False, 'kill switch')
360 return (True, f(*args, **kwargs))
361 except Exception:
362 return (False, traceback.format_exc())
363 return wrapper
364
365
366 @worker
367 def run_worker(test):
368 """Worker for 'run' command (note decorator above)."""
369 return run_test(test)
370
371
372 def run_run(jobs):
373 """Implementation of the 'run' command."""
374 start_time = datetime.datetime.now()
375
376 report_coverage_version()
377
378 tests, coverage_data, uncovered_modules = get_tests()
iannucci 2017/03/10 01:20:09 should this run with the kill switch too? or does
Paweł Hajdan Jr. 2017/03/10 17:20:30 kill_switch is only needed for multiprocessing.
379 if uncovered_modules:
380 raise Exception('The following modules lack test coverage: %s' % (
381 ','.join(sorted(uncovered_modules))))
382
383 with kill_switch():
384 pool = multiprocessing.Pool(jobs)
385 results = pool.map(run_worker, tests)
386
387 print()
388
389 rc = 0
390 for success, details in results:
391 if success:
392 assert isinstance(details, TestResult)
393 if details.failures:
394 rc = 1
395 print('%s failed:' % details.test_description.full_name)
396 for failure in details.failures:
397 print(failure.format())
398 coverage_data.update(details.coverage_data)
399 else:
400 rc = 1
401 print('Internal failure:')
402 print(details)
403
404 try:
405 # TODO(phajdan.jr): Add API to coverage to load data from memory.
406 with tempfile.NamedTemporaryFile(delete=False) as coverage_file:
407 coverage_data.write_file(coverage_file.name)
408
409 cov = coverage.coverage(
410 data_file=coverage_file.name, config_file=False, omit=cover_omit())
411 cov.load()
412 outf = cStringIO.StringIO()
413 percentage = cov.report(file=outf, show_missing=True, skip_covered=True)
414 if int(percentage) != 100:
415 rc = 1
416 print(outf.getvalue())
417 print('FATAL: Insufficient coverage (%.f%%)' % int(percentage))
418 finally:
419 os.unlink(coverage_file.name)
420
421 finish_time = datetime.datetime.now()
422 print('-' * 70)
423 print('Ran %d tests in %0.3fs' % (
424 len(tests), (finish_time - start_time).total_seconds()))
425 print()
426 print('OK' if rc == 0 else 'FAILED')
427
428 return rc
429
430
431 class SimulationAnnotatorStreamEngine(stream.AnnotatorStreamEngine):
432 """Stream engine which just records generated commands."""
433
434 def __init__(self):
435 self._step_buffer_map = {}
436 super(SimulationAnnotatorStreamEngine, self).__init__(
437 self.step_buffer(None))
438
439 def step_buffer(self, step_name):
440 return self._step_buffer_map.setdefault(step_name, cStringIO.StringIO())
441
442 def new_step_stream(self, step_config):
443 return self._create_step_stream(step_config,
444 self.step_buffer(step_config.name))
445
446
447 def handle_killswitch(*_):
448 """Function invoked by ctrl-c. Signals worker processes to exit."""
449 _KILL_SWITCH.set()
450
451 # Reset the signal to DFL so that double ctrl-C kills us for sure.
iannucci 2017/03/10 01:20:09 print "Got Ctrl-C: Stopping. Ctrl-C again to shut
Paweł Hajdan Jr. 2017/03/10 17:20:29 This would be non-trivial. If done as-is, it'd pri
452 signal.signal(signal.SIGINT, signal.SIG_DFL)
453 signal.signal(signal.SIGTERM, signal.SIG_DFL)
454
455
456 @contextlib.contextmanager
457 def kill_switch():
458 """Context manager to handle ctrl-c properly with multiprocessing."""
459 orig_sigint = signal.signal(signal.SIGINT, handle_killswitch)
460 try:
461 orig_sigterm = signal.signal(signal.SIGTERM, handle_killswitch)
462 try:
463 yield
464 finally:
465 signal.signal(signal.SIGTERM, orig_sigterm)
466 finally:
467 signal.signal(signal.SIGINT, orig_sigint)
468
469 if _KILL_SWITCH.is_set():
470 sys.exit(1)
471
472
473 def re_encode(obj):
iannucci 2017/03/10 01:20:08 todo: look into custom decoder class: https://docs
Paweł Hajdan Jr. 2017/03/10 17:20:29 Added a TODO. FWIW, this matches what current code
474 """Ensure consistent encoding for common python data structures."""
475 if isinstance(obj, dict):
476 return {re_encode(k): re_encode(v) for k, v in obj.iteritems()}
477 elif isinstance(obj, list):
478 return [re_encode(i) for i in obj]
479 elif isinstance(obj, (unicode, str)):
480 if isinstance(obj, str):
481 obj = obj.decode('utf-8', 'replace')
482 return obj.encode('utf-8', 'replace')
483 else:
484 return obj
485
486
487 def parse_args(args):
488 """Returns parsed command line arguments."""
489 parser = argparse.ArgumentParser()
490
491 subp = parser.add_subparsers()
492
493 list_p = subp.add_parser('list', description='Print all test names')
494 list_p.set_defaults(func=lambda opts: run_list())
495
496 # TODO(phajdan.jr): support running a subset of tests.
497 run_p = subp.add_parser('run', description='Run the tests')
498 run_p.set_defaults(func=lambda opts: run_run(opts.jobs))
499 run_p.add_argument(
500 '--jobs', metavar='N', type=int,
501 default=multiprocessing.cpu_count(),
502 help='run N jobs in parallel (default %(default)s)')
503
504 return parser.parse_args(args)
505
506
507 def main(universe_view, raw_args, engine_flags):
508 """Runs simulation tests on a given repo of recipes.
509
510 Args:
511 universe_view: an UniverseView object to operate on
512 raw_args: command line arguments to simulation_test_ng
513 engine_flags: recipe engine command-line flags
514 Returns:
515 Exit code
516 """
517 global _UNIVERSE_VIEW
518 _UNIVERSE_VIEW = universe_view
519 global _ENGINE_FLAGS
520 _ENGINE_FLAGS = engine_flags
521
522 args = parse_args(raw_args)
523 return args.func(args)
OLDNEW
« no previous file with comments | « recipe_engine/simulation_test.py ('k') | recipes.py » ('j') | recipes.py » ('J')

Powered by Google App Engine
This is Rietveld 408576698