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 |