| OLD | NEW |
| (Empty) |
| 1 #!/usr/bin/python | |
| 2 # Copyright (c) 2013 The Chromium Authors. All rights reserved. | |
| 3 # Use of this source code is governed by a BSD-style license that can be | |
| 4 # found in the LICENSE file. | |
| 5 | |
| 6 """Provides test coverage for individual recipes. | |
| 7 | |
| 8 Recipe tests are located in ../recipes_test/*.py. | |
| 9 | |
| 10 Each py file's splitext'd name is expected to match a recipe in ../recipes/*.py. | |
| 11 | |
| 12 Each test py file contains one or more test functions: | |
| 13 * A test function's name ends with '_test' and takes an instance of TestAPI | |
| 14 as its only parameter. | |
| 15 * The test should return a dictionary with any of the following keys: | |
| 16 * factory_properties | |
| 17 * build_properties | |
| 18 * test_data | |
| 19 * test_data's value should be a dictionary in the form of | |
| 20 {stepname -> (retcode, json_data)} | |
| 21 * Since the test doesn't run any steps, test_data allows you to simulate | |
| 22 return values for particular steps. | |
| 23 | |
| 24 Once your test methods are set up, run `recipes_test.py --train`. This will | |
| 25 take your tests and simulate what steps would have run, given the test inputs, | |
| 26 and will record them as JSON into files of the form: | |
| 27 ../recipes_test/<recipe_name>.<test_name>.expected | |
| 28 | |
| 29 If those files look right, make sure they get checked in with your changes. | |
| 30 | |
| 31 When this file runs as a test (i.e. as `recipes_test.py`), it will re-evaluate | |
| 32 the recipes using the test function input data and compare the result to the | |
| 33 values recorded in the .expected files. | |
| 34 | |
| 35 Additionally, this test cannot pass unless every recipe in ../recipes has 100% | |
| 36 code coverage when executed via the tests in ../recipes_test. | |
| 37 """ | |
| 38 | |
| 39 import contextlib | |
| 40 import json | |
| 41 import os | |
| 42 import sys | |
| 43 | |
| 44 from glob import glob | |
| 45 | |
| 46 import test_env # pylint: disable=F0401,W0403,W0611 | |
| 47 | |
| 48 import coverage | |
| 49 | |
| 50 import common.python26_polyfill # pylint: disable=W0611 | |
| 51 import unittest | |
| 52 | |
| 53 from common import annotator | |
| 54 from slave import recipe_util | |
| 55 from slave import recipe_config_types | |
| 56 from slave import annotated_run | |
| 57 from slave import recipe_loader | |
| 58 | |
| 59 SCRIPT_PATH = os.path.abspath(os.path.dirname(__file__)) | |
| 60 ROOT_PATH = os.path.abspath(os.path.join(SCRIPT_PATH, os.pardir, os.pardir, | |
| 61 os.pardir)) | |
| 62 SLAVE_DIR = os.path.join(ROOT_PATH, 'slave', 'fake_slave', 'build') | |
| 63 | |
| 64 BASE_DIRS = recipe_util.BASE_DIRS | |
| 65 COVERAGE = None | |
| 66 | |
| 67 | |
| 68 @contextlib.contextmanager | |
| 69 def cover(): | |
| 70 COVERAGE.start() | |
| 71 try: | |
| 72 yield | |
| 73 finally: | |
| 74 COVERAGE.stop() | |
| 75 | |
| 76 def expected_for(recipe_path, test_name): | |
| 77 root, name = os.path.split(recipe_path) | |
| 78 name = os.path.splitext(name)[0] | |
| 79 expect_path = os.path.join(root, '%s.expected' % name) | |
| 80 if not os.path.isdir(expect_path): | |
| 81 os.makedirs(expect_path) | |
| 82 return os.path.join(expect_path, test_name+'.json') | |
| 83 | |
| 84 | |
| 85 def exec_test_file(recipe_name): | |
| 86 with cover(): | |
| 87 recipe = recipe_loader.load_recipe(recipe_name) | |
| 88 try: | |
| 89 test_api = recipe_loader.create_test_api(recipe.DEPS) | |
| 90 gen = recipe.GenTests(test_api) | |
| 91 except Exception, e: | |
| 92 print "Caught exception while processing %s: %s" % (recipe_name, e) | |
| 93 raise | |
| 94 try: | |
| 95 while True: | |
| 96 with cover(): | |
| 97 test_data = next(gen) | |
| 98 yield test_data | |
| 99 except StopIteration: | |
| 100 pass | |
| 101 except: | |
| 102 print 'Exception while processing "%s"!' % recipe_name | |
| 103 raise | |
| 104 | |
| 105 | |
| 106 def execute_test_case(test_data, recipe_path, recipe_name): | |
| 107 try: | |
| 108 props = test_data.properties | |
| 109 props['recipe'] = recipe_name | |
| 110 | |
| 111 stream = annotator.StructuredAnnotationStream(stream=open(os.devnull, 'w')) | |
| 112 | |
| 113 with cover(): | |
| 114 recipe_config_types.ResetTostringFns() | |
| 115 step_data = annotated_run.run_steps( | |
| 116 stream, props, props, test_data).steps_ran.values() | |
| 117 return [s.step for s in step_data] | |
| 118 except: | |
| 119 print 'Exception while processing test case: "%s"!' % test_data.name | |
| 120 raise | |
| 121 | |
| 122 | |
| 123 def train_from_tests((recipe_path, recipe_name)): | |
| 124 for path in glob(expected_for(recipe_path, '*')): | |
| 125 os.unlink(path) | |
| 126 | |
| 127 for test_data in exec_test_file(recipe_name): | |
| 128 steps = execute_test_case(test_data, recipe_path, recipe_name) | |
| 129 expected_path = expected_for(recipe_path, test_data.name) | |
| 130 print 'Writing', expected_path | |
| 131 with open(expected_path, 'wb') as f: | |
| 132 json.dump(steps, f, sort_keys=True, indent=2, separators=(',', ': ')) | |
| 133 | |
| 134 return True | |
| 135 | |
| 136 | |
| 137 def load_tests(loader, _standard_tests, _pattern): | |
| 138 """This method is invoked by unittest.main's automatic testloader.""" | |
| 139 def create_test_class((recipe_path, recipe_name)): | |
| 140 class RecipeTest(unittest.TestCase): | |
| 141 @classmethod | |
| 142 def add_test_methods(cls): | |
| 143 for test_data in exec_test_file(recipe_name): | |
| 144 expected_path = expected_for(recipe_path, test_data.name) | |
| 145 def add_test(test_data, expected_path, recipe_name): | |
| 146 def test_(self): | |
| 147 steps = execute_test_case(test_data, recipe_path, recipe_name) | |
| 148 # Roundtrip json to get same string encoding as load | |
| 149 steps = json.loads(json.dumps(steps)) | |
| 150 with open(expected_path, 'rb') as f: | |
| 151 expected = json.load(f) | |
| 152 self.assertEqual(steps, expected) | |
| 153 test_.__name__ += test_data.name | |
| 154 setattr(cls, test_.__name__, test_) | |
| 155 add_test(test_data, expected_path, recipe_name) | |
| 156 | |
| 157 RecipeTest.add_test_methods() | |
| 158 | |
| 159 RecipeTest.__name__ += '_for_%s' % ( | |
| 160 os.path.splitext(os.path.basename(recipe_path))[0]) | |
| 161 return RecipeTest | |
| 162 | |
| 163 suite = unittest.TestSuite() | |
| 164 for test_class in map(create_test_class, recipe_loader.loop_over_recipes()): | |
| 165 suite.addTest(loader.loadTestsFromTestCase(test_class)) | |
| 166 return suite | |
| 167 | |
| 168 | |
| 169 def main(argv): | |
| 170 # Pop these out so that we always generate consistent expectations, even | |
| 171 # if we're running the tests under a testing slave configuration (or if | |
| 172 # someone just has these set in their shell) | |
| 173 os.environ.pop('TESTING_MASTERNAME', None) | |
| 174 os.environ.pop('TESTING_SLAVENAME', None) | |
| 175 | |
| 176 if not os.path.exists(SLAVE_DIR): | |
| 177 os.makedirs(SLAVE_DIR) | |
| 178 | |
| 179 os.chdir(SLAVE_DIR) | |
| 180 | |
| 181 training = False | |
| 182 is_help = False | |
| 183 if '--help' in argv or '-h' in argv: | |
| 184 print 'Pass --train to enter training mode.' | |
| 185 print | |
| 186 is_help = True | |
| 187 if '--train' in argv: | |
| 188 argv.remove('--train') | |
| 189 training = True | |
| 190 if '--external' in argv: | |
| 191 argv.remove('--external') | |
| 192 BASE_DIRS[:] = [d for d in BASE_DIRS if 'internal' not in d] | |
| 193 global COVERAGE | |
| 194 COVERAGE = coverage.coverage( | |
| 195 include=( | |
| 196 [os.path.join(x, '*') for x in recipe_util.RECIPE_DIRS()] + | |
| 197 [os.path.join(x, '*', '*api.py') for x in recipe_util.MODULE_DIRS()] | |
| 198 ) | |
| 199 ) | |
| 200 | |
| 201 had_errors = False | |
| 202 if training and not is_help: | |
| 203 for result in map(train_from_tests, recipe_loader.loop_over_recipes()): | |
| 204 had_errors = had_errors or result | |
| 205 if had_errors: | |
| 206 break | |
| 207 | |
| 208 retcode = 1 if had_errors else 0 | |
| 209 | |
| 210 if not training: | |
| 211 try: | |
| 212 unittest.main() | |
| 213 except SystemExit as e: | |
| 214 retcode = e.code or retcode | |
| 215 | |
| 216 if not is_help: | |
| 217 total_covered = COVERAGE.report() | |
| 218 if total_covered != 100.0: | |
| 219 print 'FATAL: Recipes are not at 100% coverage.' | |
| 220 retcode = retcode or 2 | |
| 221 | |
| 222 if training: | |
| 223 test_env.print_coverage_warning() | |
| 224 | |
| 225 return retcode | |
| 226 | |
| 227 | |
| 228 if __name__ == '__main__': | |
| 229 sys.exit(main(sys.argv)) | |
| OLD | NEW |