Chromium Code Reviews| OLD | NEW |
|---|---|
| (Empty) | |
| 1 #!/usr/bin/env python | |
| 2 # Copyright 2017 The LUCI Authors. All rights reserved. | |
| 3 # Use of this source code is governed under the Apache License, Version 2.0 | |
| 4 # that can be found in the LICENSE file. | |
| 5 | |
| 6 import json | |
| 7 import os | |
| 8 import shutil | |
| 9 import subprocess | |
| 10 import sys | |
| 11 import tempfile | |
| 12 import unittest | |
| 13 | |
| 14 | |
| 15 ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 16 sys.path.insert(0, ROOT_DIR) | |
| 17 import recipe_engine.env | |
| 18 | |
| 19 | |
| 20 from recipe_engine import package | |
| 21 from recipe_engine import package_pb2 | |
| 22 | |
| 23 | |
| 24 class RecipeWriter(object): | |
| 25 """Helper to write a recipe for tests.""" | |
| 26 | |
| 27 def __init__(self, root_dir, name): | |
| 28 self.root_dir = root_dir | |
| 29 self.name = name | |
| 30 | |
| 31 # These are expected to be set appropriately by the caller. | |
| 32 self.DEPS = [] | |
| 33 self.RunStepsLines = ['pass'] | |
| 34 self.GenTestsLines = ['yield api.test("basic")'] | |
| 35 self.expectations = {} | |
| 36 | |
| 37 @property | |
| 38 def recipes_dir(self): | |
| 39 return os.path.join(self.root_dir, 'recipes') | |
| 40 | |
| 41 @property | |
| 42 def expect_dir(self): | |
| 43 return os.path.join(self.recipes_dir, '%s.expected' % self.name) | |
| 44 | |
| 45 def add_expectation(self, test_name, commands=None, recipe_result=None, | |
| 46 status_code=0): | |
| 47 """Adds expectation for a simulation test. | |
| 48 | |
| 49 Arguments: | |
| 50 test_name(str): name of the test | |
| 51 commands(list): list of expected commands | |
|
iannucci
2017/03/10 01:20:09
I think this is a list of expectation dictionaries
Paweł Hajdan Jr.
2017/03/10 17:20:30
Done.
| |
| 52 recipe_result(object): expected result of the recipe | |
| 53 status_code(int): expected exit code | |
| 54 """ | |
| 55 self.expectations[test_name] = (commands or []) + [{ | |
| 56 'name': '$result', | |
| 57 'recipe_result': recipe_result, | |
| 58 'status_code': status_code | |
| 59 }] | |
| 60 | |
| 61 def write(self): | |
| 62 """Writes the recipe to disk.""" | |
| 63 for d in (self.recipes_dir, self.expect_dir): | |
| 64 if not os.path.exists(d): | |
| 65 os.makedirs(d) | |
| 66 with open(os.path.join(self.recipes_dir, '%s.py' % self.name), 'w') as f: | |
| 67 f.write('\n'.join([ | |
| 68 'from recipe_engine import post_process', | |
| 69 '', | |
| 70 'DEPS = %r' % self.DEPS, | |
| 71 '', | |
| 72 'def RunSteps(api):', | |
| 73 ] + [' %s' % l for l in self.RunStepsLines] + [ | |
| 74 '', | |
| 75 'def GenTests(api):', | |
| 76 ] + [' %s' % l for l in self.GenTestsLines])) | |
| 77 for test_name, test_contents in self.expectations.iteritems(): | |
| 78 with open(os.path.join(self.expect_dir, '%s.json' % test_name), 'w') as f: | |
| 79 json.dump(test_contents, f) | |
| 80 | |
| 81 | |
| 82 class RecipeModuleWriter(object): | |
| 83 """Helper to write a recipe module for tests.""" | |
| 84 | |
| 85 def __init__(self, root_dir, name): | |
| 86 self.root_dir = root_dir | |
| 87 self.name = name | |
| 88 | |
| 89 # These are expected to be set appropriately by the caller. | |
| 90 self.DEPS = [] | |
| 91 self.disable_strict_coverage = False | |
| 92 self.methods = {} | |
| 93 | |
| 94 self.example = type('example_placeholder', (), {})() | |
| 95 self.example.enabled = False | |
| 96 self.example.DEPS = [] | |
| 97 self.example.RunStepsLines = ['pass'] | |
| 98 self.example.GenTestsLines = ['yield api.test("basic")'] | |
| 99 self.example.expectations = {} | |
| 100 | |
| 101 @property | |
| 102 def module_dir(self): | |
| 103 return os.path.join(self.root_dir, 'recipe_modules', self.name) | |
| 104 | |
| 105 @property | |
| 106 def expect_dir(self): | |
| 107 return os.path.join(self.module_dir, 'example.expected') | |
| 108 | |
| 109 def add_expectation(self, test_name, commands=None, recipe_result=None, | |
| 110 status_code=0): | |
| 111 """Adds expectation for a simulation test. | |
| 112 | |
| 113 Arguments: | |
| 114 test_name(str): name of the test | |
| 115 commands(list): list of expected commands | |
| 116 recipe_result(object): expected result of the recipe | |
| 117 status_code(int): expected exit code | |
| 118 """ | |
| 119 self.example.expectations[test_name] = (commands or []) + [{ | |
| 120 'name': '$result', | |
| 121 'recipe_result': recipe_result, | |
| 122 'status_code': status_code | |
| 123 }] | |
| 124 | |
| 125 def write(self): | |
| 126 """Writes the recipe module to disk.""" | |
| 127 | |
| 128 for d in (self.module_dir, self.expect_dir): | |
| 129 if not os.path.exists(d): | |
| 130 os.makedirs(d) | |
| 131 | |
| 132 with open(os.path.join(self.module_dir, '__init__.py'), 'w') as f: | |
| 133 f.write('DEPS = %r\n' % self.DEPS) | |
| 134 if self.disable_strict_coverage: | |
| 135 f.write('\nDISABLE_STRICT_COVERAGE = True') | |
| 136 | |
| 137 api_lines = [ | |
| 138 'from recipe_engine import recipe_api', | |
| 139 '', | |
| 140 'class MyApi(recipe_api.RecipeApi):', | |
| 141 ] | |
| 142 if self.methods: | |
| 143 for m_name, m_lines in self.methods.iteritems(): | |
| 144 api_lines.extend([ | |
| 145 '', | |
| 146 ' def %s(self):' % m_name, | |
| 147 ] + [' %s' % l for l in m_lines] + [ | |
| 148 '', | |
| 149 ]) | |
| 150 else: | |
| 151 api_lines.append(' pass') | |
| 152 with open(os.path.join(self.module_dir, 'api.py'), 'w') as f: | |
| 153 f.write('\n'.join(api_lines)) | |
| 154 if self.example.enabled: | |
| 155 with open(os.path.join(self.module_dir, 'example.py'), 'w') as f: | |
| 156 f.write('\n'.join([ | |
| 157 'from recipe_engine import post_process', | |
| 158 '', | |
| 159 'DEPS = %r' % self.example.DEPS, | |
| 160 '', | |
| 161 'def RunSteps(api):', | |
| 162 ] + [' %s' % l for l in self.example.RunStepsLines] + [ | |
| 163 '', | |
| 164 'def GenTests(api):', | |
|
iannucci
2017/03/10 01:20:09
this looks duplicated from RecipeWriter... could a
Paweł Hajdan Jr.
2017/03/10 17:20:30
I was considering that, but IMO it could make thin
iannucci
2017/03/10 18:14:33
How is it more complex to reuse a well-defined cla
| |
| 165 ] + [' %s' % l for l in self.example.GenTestsLines])) | |
| 166 for test_name, test_contents in self.example.expectations.iteritems(): | |
| 167 with open(os.path.join( | |
| 168 self.expect_dir, '%s.json' % test_name), 'w') as f: | |
| 169 json.dump(test_contents, f) | |
| 170 | |
| 171 | |
| 172 class TestTest(unittest.TestCase): | |
| 173 def setUp(self): | |
| 174 root_dir = tempfile.mkdtemp() | |
| 175 config_dir = os.path.join(root_dir, 'infra', 'config') | |
| 176 os.makedirs(config_dir) | |
| 177 | |
| 178 self._root_dir = root_dir | |
| 179 self._recipes_cfg = os.path.join(config_dir, 'recipes.cfg') | |
| 180 self._recipe_tool = os.path.join(ROOT_DIR, 'recipes.py') | |
| 181 | |
| 182 test_pkg = package_pb2.Package( | |
| 183 api_version=1, | |
| 184 project_id='test_pkg', | |
| 185 recipes_path='', | |
| 186 deps=[ | |
| 187 package_pb2.DepSpec( | |
| 188 project_id='recipe_engine', | |
| 189 url='file://'+ROOT_DIR), | |
| 190 ], | |
| 191 ) | |
| 192 package.ProtoFile(self._recipes_cfg).write(test_pkg) | |
| 193 | |
| 194 def tearDown(self): | |
| 195 shutil.rmtree(self._root_dir) | |
| 196 | |
| 197 def _run_recipes(self, *args): | |
| 198 return subprocess.check_output(( | |
| 199 sys.executable, | |
| 200 self._recipe_tool, | |
| 201 '--use-bootstrap', | |
| 202 '--package', self._recipes_cfg, | |
| 203 ) + args, stderr=subprocess.STDOUT) | |
| 204 | |
| 205 def test_list(self): | |
| 206 rw = RecipeWriter(self._root_dir, 'foo') | |
| 207 rw.RunStepsLines = ['pass'] | |
| 208 rw.write() | |
| 209 self.assertEqual( | |
| 210 ['foo.basic'], | |
| 211 self._run_recipes('test', 'list').splitlines()) | |
| 212 | |
| 213 def test_test(self): | |
| 214 rw = RecipeWriter(self._root_dir, 'foo') | |
| 215 rw.RunStepsLines = ['pass'] | |
| 216 rw.add_expectation('basic') | |
| 217 rw.write() | |
| 218 self._run_recipes('test', 'run') | |
| 219 | |
| 220 def test_test_expectation_failure_empty(self): | |
| 221 rw = RecipeWriter(self._root_dir, 'foo') | |
| 222 rw.RunStepsLines = ['pass'] | |
| 223 rw.write() | |
| 224 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 225 self._run_recipes('test', 'run') | |
| 226 self.assertNotIn('FATAL: Insufficient coverage', cm.exception.output) | |
|
iannucci
2017/03/10 01:20:09
maybe TODO: use jsonproto output of 'test' instead
Paweł Hajdan Jr.
2017/03/10 17:20:30
Yes, I plan to add JSON output support in subseque
| |
| 227 self.assertNotIn('CHECK(FAIL)', cm.exception.output) | |
| 228 self.assertIn( | |
| 229 'foo.basic failed', | |
| 230 cm.exception.output) | |
| 231 | |
| 232 def test_test_expectation_failure_different(self): | |
| 233 rw = RecipeWriter(self._root_dir, 'foo') | |
| 234 rw.DEPS = ['recipe_engine/step'] | |
| 235 rw.RunStepsLines = ['api.step("test", ["echo", "bar"])'] | |
| 236 rw.add_expectation('basic') | |
| 237 rw.write() | |
| 238 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 239 self._run_recipes('test', 'run') | |
| 240 self.assertNotIn('FATAL: Insufficient coverage', cm.exception.output) | |
| 241 self.assertNotIn('CHECK(FAIL)', cm.exception.output) | |
| 242 self.assertIn( | |
| 243 'foo.basic failed', | |
| 244 cm.exception.output) | |
| 245 self.assertIn( | |
| 246 '+[{\'cmd\': [\'echo\', \'bar\'], \'name\': \'test\'},\n', | |
| 247 cm.exception.output) | |
| 248 | |
| 249 def test_test_expectation_pass(self): | |
| 250 rw = RecipeWriter(self._root_dir, 'foo') | |
| 251 rw.DEPS = ['recipe_engine/step'] | |
| 252 rw.RunStepsLines = ['api.step("test", ["echo", "bar"])'] | |
| 253 rw.add_expectation('basic', [{'cmd': ['echo', 'bar'], 'name': 'test'}]) | |
| 254 rw.write() | |
| 255 self._run_recipes('test', 'run') | |
| 256 | |
| 257 def test_test_recipe_not_covered(self): | |
| 258 rw = RecipeWriter(self._root_dir, 'foo') | |
| 259 rw.RunStepsLines = ['if False:', ' pass'] | |
| 260 rw.add_expectation('basic') | |
| 261 rw.write() | |
| 262 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 263 self._run_recipes('test', 'run') | |
| 264 self.assertIn('FATAL: Insufficient coverage', cm.exception.output) | |
| 265 self.assertNotIn('CHECK(FAIL)', cm.exception.output) | |
| 266 self.assertNotIn('foo.basic failed', cm.exception.output) | |
| 267 | |
| 268 def test_test_check_failure(self): | |
| 269 rw = RecipeWriter(self._root_dir, 'foo') | |
| 270 rw.RunStepsLines = ['pass'] | |
| 271 rw.GenTestsLines = [ | |
| 272 'yield api.test("basic") + \\', | |
| 273 ' api.post_process(post_process.MustRun, "bar")' | |
| 274 ] | |
| 275 rw.add_expectation('basic') | |
| 276 rw.write() | |
| 277 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 278 self._run_recipes('test', 'run') | |
| 279 self.assertNotIn('FATAL: Insufficient coverage', cm.exception.output) | |
| 280 self.assertIn('CHECK(FAIL)', cm.exception.output) | |
| 281 self.assertIn('foo.basic failed', cm.exception.output) | |
| 282 | |
| 283 def test_test_check_success(self): | |
| 284 rw = RecipeWriter(self._root_dir, 'foo') | |
| 285 rw.RunStepsLines = ['pass'] | |
| 286 rw.GenTestsLines = [ | |
| 287 'yield api.test("basic") + \\', | |
| 288 ' api.post_process(post_process.DoesNotRun, "bar")' | |
| 289 ] | |
| 290 rw.add_expectation('basic') | |
| 291 rw.write() | |
| 292 self._run_recipes('test', 'run') | |
| 293 | |
| 294 def test_test_recipe_syntax_error(self): | |
| 295 rw = RecipeWriter(self._root_dir, 'foo') | |
| 296 rw.RunStepsLines = ['baz'] | |
| 297 rw.add_expectation('basic') | |
| 298 rw.write() | |
| 299 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 300 self._run_recipes('test', 'run') | |
| 301 self.assertIn('NameError: global name \'baz\' is not defined', | |
| 302 cm.exception.output) | |
| 303 | |
| 304 def test_test_recipe_module_uncovered(self): | |
| 305 mw = RecipeModuleWriter(self._root_dir, 'foo') | |
| 306 mw.write() | |
| 307 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 308 self._run_recipes('test', 'run') | |
| 309 self.assertIn('The following modules lack test coverage: foo', | |
| 310 cm.exception.output) | |
| 311 | |
| 312 def test_test_recipe_module_syntax_error(self): | |
| 313 mw = RecipeModuleWriter(self._root_dir, 'foo_module') | |
| 314 mw.methods['foo'] = ['baz'] | |
| 315 mw.example.enabled = True | |
| 316 mw.example.DEPS = ['foo_module'] | |
| 317 mw.example.RunStepsLines = ['api.foo_module.foo()'] | |
| 318 mw.write() | |
| 319 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 320 self._run_recipes('test', 'run') | |
| 321 self.assertIn('NameError: global name \'baz\' is not defined', | |
| 322 cm.exception.output) | |
| 323 self.assertIn('FATAL: Insufficient coverage', cm.exception.output) | |
| 324 | |
| 325 def test_test_recipe_module_syntax_error_in_example(self): | |
| 326 mw = RecipeModuleWriter(self._root_dir, 'foo_module') | |
| 327 mw.methods['foo'] = ['pass'] | |
| 328 mw.example.enabled = True | |
| 329 mw.example.DEPS = ['foo_module'] | |
| 330 mw.example.RunStepsLines = ['baz'] | |
| 331 mw.write() | |
| 332 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 333 self._run_recipes('test', 'run') | |
| 334 self.assertIn('NameError: global name \'baz\' is not defined', | |
| 335 cm.exception.output) | |
| 336 self.assertIn('FATAL: Insufficient coverage', cm.exception.output) | |
| 337 | |
| 338 def test_test_recipe_module_example_not_covered(self): | |
| 339 mw = RecipeModuleWriter(self._root_dir, 'foo_module') | |
| 340 mw.methods['foo'] = ['pass'] | |
| 341 mw.example.enabled = True | |
| 342 mw.example.DEPS = ['foo_module'] | |
| 343 mw.example.RunStepsLines = ['if False:', ' pass'] | |
| 344 mw.write() | |
| 345 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 346 self._run_recipes('test', 'run') | |
| 347 self.assertIn('FATAL: Insufficient coverage', cm.exception.output) | |
| 348 | |
| 349 def test_test_recipe_module_uncovered_not_strict(self): | |
| 350 mw = RecipeModuleWriter(self._root_dir, 'foo') | |
| 351 mw.disable_strict_coverage = True | |
| 352 mw.write() | |
| 353 self._run_recipes('test', 'run') | |
| 354 | |
| 355 def test_test_recipe_module_covered_by_recipe_not_strict(self): | |
| 356 mw = RecipeModuleWriter(self._root_dir, 'foo_module') | |
| 357 mw.methods['bar'] = ['pass'] | |
| 358 mw.disable_strict_coverage = True | |
| 359 mw.write() | |
| 360 rw = RecipeWriter(self._root_dir, 'foo_recipe') | |
| 361 rw.DEPS = ['foo_module'] | |
| 362 rw.RunStepsLines = ['api.foo_module.bar()'] | |
| 363 rw.add_expectation('basic') | |
| 364 rw.write() | |
| 365 self._run_recipes('test', 'run') | |
| 366 | |
| 367 def test_test_recipe_module_covered_by_recipe(self): | |
| 368 mw = RecipeModuleWriter(self._root_dir, 'foo_module') | |
| 369 mw.methods['bar'] = ['pass'] | |
| 370 mw.write() | |
| 371 rw = RecipeWriter(self._root_dir, 'foo_recipe') | |
| 372 rw.DEPS = ['foo_module'] | |
| 373 rw.RunStepsLines = ['api.foo_module.bar()'] | |
| 374 rw.add_expectation('basic') | |
| 375 rw.write() | |
| 376 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 377 self._run_recipes('test', 'run') | |
| 378 self.assertIn('The following modules lack test coverage: foo_module', | |
| 379 cm.exception.output) | |
| 380 | |
| 381 def test_test_recipe_module_partially_covered_by_recipe_not_strict(self): | |
| 382 mw = RecipeModuleWriter(self._root_dir, 'foo_module') | |
| 383 mw.methods['bar'] = ['pass'] | |
| 384 mw.methods['baz'] = ['pass'] | |
| 385 mw.disable_strict_coverage = True | |
| 386 mw.example.enabled = True | |
| 387 mw.example.DEPS = ['foo_module'] | |
| 388 mw.example.RunStepsLines = ['api.foo_module.baz()'] | |
| 389 mw.add_expectation('basic') | |
| 390 mw.write() | |
| 391 rw = RecipeWriter(self._root_dir, 'foo_recipe') | |
| 392 rw.DEPS = ['foo_module'] | |
| 393 rw.RunStepsLines = ['api.foo_module.bar()'] | |
| 394 rw.add_expectation('basic') | |
| 395 rw.write() | |
| 396 self._run_recipes('test', 'run') | |
| 397 | |
| 398 def test_test_recipe_module_partially_covered_by_recipe(self): | |
| 399 mw = RecipeModuleWriter(self._root_dir, 'foo_module') | |
| 400 mw.methods['bar'] = ['pass'] | |
| 401 mw.methods['baz'] = ['pass'] | |
| 402 mw.example.enabled = True | |
| 403 mw.example.DEPS = ['foo_module'] | |
| 404 mw.example.RunStepsLines = ['api.foo_module.baz()'] | |
| 405 mw.add_expectation('basic') | |
| 406 mw.write() | |
| 407 rw = RecipeWriter(self._root_dir, 'foo_recipe') | |
| 408 rw.DEPS = ['foo_module'] | |
| 409 rw.RunStepsLines = ['api.foo_module.bar()'] | |
| 410 rw.add_expectation('basic') | |
| 411 rw.write() | |
| 412 with self.assertRaises(subprocess.CalledProcessError) as cm: | |
| 413 self._run_recipes('test', 'run') | |
| 414 self.assertIn('FATAL: Insufficient coverage', cm.exception.output) | |
|
iannucci
2017/03/10 01:20:09
I don't see any tests for the post_process hooks?
Paweł Hajdan Jr.
2017/03/10 17:20:30
Did you see test_test_check_failure and test_test_
iannucci
2017/03/10 18:14:33
Yes, but they don't cover the case of a user-provi
| |
| 415 | |
| 416 | |
| 417 if __name__ == '__main__': | |
| 418 sys.exit(unittest.main()) | |
| OLD | NEW |