|
1 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
|
2 # For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt |
|
3 |
|
4 """Mixin classes to help make good tests.""" |
|
5 |
|
6 import atexit |
|
7 import collections |
|
8 import contextlib |
|
9 import os |
|
10 import random |
|
11 import shutil |
|
12 import sys |
|
13 import tempfile |
|
14 import textwrap |
|
15 |
|
16 from coverage.backunittest import TestCase |
|
17 from coverage.backward import StringIO, to_bytes |
|
18 |
|
19 |
|
20 class Tee(object): |
|
21 """A file-like that writes to all the file-likes it has.""" |
|
22 |
|
23 def __init__(self, *files): |
|
24 """Make a Tee that writes to all the files in `files.`""" |
|
25 self._files = files |
|
26 if hasattr(files[0], "encoding"): |
|
27 self.encoding = files[0].encoding |
|
28 |
|
29 def write(self, data): |
|
30 """Write `data` to all the files.""" |
|
31 for f in self._files: |
|
32 f.write(data) |
|
33 |
|
34 def flush(self): |
|
35 """Flush the data on all the files.""" |
|
36 for f in self._files: |
|
37 f.flush() |
|
38 |
|
39 if 0: |
|
40 # Use this if you need to use a debugger, though it makes some tests |
|
41 # fail, I'm not sure why... |
|
42 def __getattr__(self, name): |
|
43 return getattr(self._files[0], name) |
|
44 |
|
45 |
|
46 @contextlib.contextmanager |
|
47 def change_dir(new_dir): |
|
48 """Change directory, and then change back. |
|
49 |
|
50 Use as a context manager, it will give you the new directory, and later |
|
51 restore the old one. |
|
52 |
|
53 """ |
|
54 old_dir = os.getcwd() |
|
55 os.chdir(new_dir) |
|
56 try: |
|
57 yield os.getcwd() |
|
58 finally: |
|
59 os.chdir(old_dir) |
|
60 |
|
61 |
|
62 @contextlib.contextmanager |
|
63 def saved_sys_path(): |
|
64 """Save sys.path, and restore it later.""" |
|
65 old_syspath = sys.path[:] |
|
66 try: |
|
67 yield |
|
68 finally: |
|
69 sys.path = old_syspath |
|
70 |
|
71 |
|
72 def setup_with_context_manager(testcase, cm): |
|
73 """Use a contextmanager to setUp a test case. |
|
74 |
|
75 If you have a context manager you like:: |
|
76 |
|
77 with ctxmgr(a, b, c) as v: |
|
78 # do something with v |
|
79 |
|
80 and you want to have that effect for a test case, call this function from |
|
81 your setUp, and it will start the context manager for your test, and end it |
|
82 when the test is done:: |
|
83 |
|
84 def setUp(self): |
|
85 self.v = setup_with_context_manager(self, ctxmgr(a, b, c)) |
|
86 |
|
87 def test_foo(self): |
|
88 # do something with self.v |
|
89 |
|
90 """ |
|
91 val = cm.__enter__() |
|
92 testcase.addCleanup(cm.__exit__, None, None, None) |
|
93 return val |
|
94 |
|
95 |
|
96 class ModuleAwareMixin(TestCase): |
|
97 """A test case mixin that isolates changes to sys.modules.""" |
|
98 |
|
99 def setUp(self): |
|
100 super(ModuleAwareMixin, self).setUp() |
|
101 |
|
102 # Record sys.modules here so we can restore it in cleanup_modules. |
|
103 self.old_modules = list(sys.modules) |
|
104 self.addCleanup(self.cleanup_modules) |
|
105 |
|
106 def cleanup_modules(self): |
|
107 """Remove any new modules imported during the test run. |
|
108 |
|
109 This lets us import the same source files for more than one test. |
|
110 |
|
111 """ |
|
112 for m in [m for m in sys.modules if m not in self.old_modules]: |
|
113 del sys.modules[m] |
|
114 |
|
115 |
|
116 class SysPathAwareMixin(TestCase): |
|
117 """A test case mixin that isolates changes to sys.path.""" |
|
118 |
|
119 def setUp(self): |
|
120 super(SysPathAwareMixin, self).setUp() |
|
121 setup_with_context_manager(self, saved_sys_path()) |
|
122 |
|
123 |
|
124 class EnvironmentAwareMixin(TestCase): |
|
125 """A test case mixin that isolates changes to the environment.""" |
|
126 |
|
127 def setUp(self): |
|
128 super(EnvironmentAwareMixin, self).setUp() |
|
129 |
|
130 # Record environment variables that we changed with set_environ. |
|
131 self.environ_undos = {} |
|
132 |
|
133 self.addCleanup(self.cleanup_environ) |
|
134 |
|
135 def set_environ(self, name, value): |
|
136 """Set an environment variable `name` to be `value`. |
|
137 |
|
138 The environment variable is set, and record is kept that it was set, |
|
139 so that `cleanup_environ` can restore its original value. |
|
140 |
|
141 """ |
|
142 if name not in self.environ_undos: |
|
143 self.environ_undos[name] = os.environ.get(name) |
|
144 os.environ[name] = value |
|
145 |
|
146 def cleanup_environ(self): |
|
147 """Undo all the changes made by `set_environ`.""" |
|
148 for name, value in self.environ_undos.items(): |
|
149 if value is None: |
|
150 del os.environ[name] |
|
151 else: |
|
152 os.environ[name] = value |
|
153 |
|
154 |
|
155 class StdStreamCapturingMixin(TestCase): |
|
156 """A test case mixin that captures stdout and stderr.""" |
|
157 |
|
158 def setUp(self): |
|
159 super(StdStreamCapturingMixin, self).setUp() |
|
160 |
|
161 # Capture stdout and stderr so we can examine them in tests. |
|
162 # nose keeps stdout from littering the screen, so we can safely Tee it, |
|
163 # but it doesn't capture stderr, so we don't want to Tee stderr to the |
|
164 # real stderr, since it will interfere with our nice field of dots. |
|
165 old_stdout = sys.stdout |
|
166 self.captured_stdout = StringIO() |
|
167 sys.stdout = Tee(sys.stdout, self.captured_stdout) |
|
168 |
|
169 old_stderr = sys.stderr |
|
170 self.captured_stderr = StringIO() |
|
171 sys.stderr = self.captured_stderr |
|
172 |
|
173 self.addCleanup(self.cleanup_std_streams, old_stdout, old_stderr) |
|
174 |
|
175 def cleanup_std_streams(self, old_stdout, old_stderr): |
|
176 """Restore stdout and stderr.""" |
|
177 sys.stdout = old_stdout |
|
178 sys.stderr = old_stderr |
|
179 |
|
180 def stdout(self): |
|
181 """Return the data written to stdout during the test.""" |
|
182 return self.captured_stdout.getvalue() |
|
183 |
|
184 def stderr(self): |
|
185 """Return the data written to stderr during the test.""" |
|
186 return self.captured_stderr.getvalue() |
|
187 |
|
188 |
|
189 class DelayedAssertionMixin(TestCase): |
|
190 """A test case mixin that provides a `delayed_assertions` context manager. |
|
191 |
|
192 Use it like this:: |
|
193 |
|
194 with self.delayed_assertions(): |
|
195 self.assertEqual(x, y) |
|
196 self.assertEqual(z, w) |
|
197 |
|
198 All of the assertions will run. The failures will be displayed at the end |
|
199 of the with-statement. |
|
200 |
|
201 NOTE: this only works with some assertions. These are known to work: |
|
202 |
|
203 - `assertEqual(str, str)` |
|
204 |
|
205 - `assertMultilineEqual(str, str)` |
|
206 |
|
207 """ |
|
208 def __init__(self, *args, **kwargs): |
|
209 super(DelayedAssertionMixin, self).__init__(*args, **kwargs) |
|
210 # This mixin only works with assert methods that call `self.fail`. In |
|
211 # Python 2.7, `assertEqual` didn't, but we can do what Python 3 does, |
|
212 # and use `assertMultiLineEqual` for comparing strings. |
|
213 self.addTypeEqualityFunc(str, 'assertMultiLineEqual') |
|
214 self._delayed_assertions = None |
|
215 |
|
216 @contextlib.contextmanager |
|
217 def delayed_assertions(self): |
|
218 """The context manager: assert that we didn't collect any assertions.""" |
|
219 self._delayed_assertions = [] |
|
220 old_fail = self.fail |
|
221 self.fail = self._delayed_fail |
|
222 try: |
|
223 yield |
|
224 finally: |
|
225 self.fail = old_fail |
|
226 if self._delayed_assertions: |
|
227 if len(self._delayed_assertions) == 1: |
|
228 self.fail(self._delayed_assertions[0]) |
|
229 else: |
|
230 self.fail( |
|
231 "{0} failed assertions:\n{1}".format( |
|
232 len(self._delayed_assertions), |
|
233 "\n".join(self._delayed_assertions), |
|
234 ) |
|
235 ) |
|
236 |
|
237 def _delayed_fail(self, msg=None): |
|
238 """The stand-in for TestCase.fail during delayed_assertions.""" |
|
239 self._delayed_assertions.append(msg) |
|
240 |
|
241 |
|
242 class TempDirMixin(SysPathAwareMixin, ModuleAwareMixin, TestCase): |
|
243 """A test case mixin that creates a temp directory and files in it. |
|
244 |
|
245 Includes SysPathAwareMixin and ModuleAwareMixin, because making and using |
|
246 temp directories like this will also need that kind of isolation. |
|
247 |
|
248 """ |
|
249 |
|
250 # Our own setting: most of these tests run in their own temp directory. |
|
251 # Set this to False in your subclass if you don't want a temp directory |
|
252 # created. |
|
253 run_in_temp_dir = True |
|
254 |
|
255 # Set this if you aren't creating any files with make_file, but still want |
|
256 # the temp directory. This will stop the test behavior checker from |
|
257 # complaining. |
|
258 no_files_in_temp_dir = False |
|
259 |
|
260 def setUp(self): |
|
261 super(TempDirMixin, self).setUp() |
|
262 |
|
263 if self.run_in_temp_dir: |
|
264 # Create a temporary directory. |
|
265 self.temp_dir = self.make_temp_dir("test_cover") |
|
266 self.chdir(self.temp_dir) |
|
267 |
|
268 # Modules should be importable from this temp directory. We don't |
|
269 # use '' because we make lots of different temp directories and |
|
270 # nose's caching importer can get confused. The full path prevents |
|
271 # problems. |
|
272 sys.path.insert(0, os.getcwd()) |
|
273 |
|
274 class_behavior = self.class_behavior() |
|
275 class_behavior.tests += 1 |
|
276 class_behavior.temp_dir = self.run_in_temp_dir |
|
277 class_behavior.no_files_ok = self.no_files_in_temp_dir |
|
278 |
|
279 self.addCleanup(self.check_behavior) |
|
280 |
|
281 def make_temp_dir(self, slug="test_cover"): |
|
282 """Make a temp directory that is cleaned up when the test is done.""" |
|
283 name = "%s_%08d" % (slug, random.randint(0, 99999999)) |
|
284 temp_dir = os.path.join(tempfile.gettempdir(), name) |
|
285 os.makedirs(temp_dir) |
|
286 self.addCleanup(shutil.rmtree, temp_dir) |
|
287 return temp_dir |
|
288 |
|
289 def chdir(self, new_dir): |
|
290 """Change directory, and change back when the test is done.""" |
|
291 old_dir = os.getcwd() |
|
292 os.chdir(new_dir) |
|
293 self.addCleanup(os.chdir, old_dir) |
|
294 |
|
295 def check_behavior(self): |
|
296 """Check that we did the right things.""" |
|
297 |
|
298 class_behavior = self.class_behavior() |
|
299 if class_behavior.test_method_made_any_files: |
|
300 class_behavior.tests_making_files += 1 |
|
301 |
|
302 def make_file(self, filename, text="", newline=None): |
|
303 """Create a file for testing. |
|
304 |
|
305 `filename` is the relative path to the file, including directories if |
|
306 desired, which will be created if need be. |
|
307 |
|
308 `text` is the content to create in the file, a native string (bytes in |
|
309 Python 2, unicode in Python 3). |
|
310 |
|
311 If `newline` is provided, it is a string that will be used as the line |
|
312 endings in the created file, otherwise the line endings are as provided |
|
313 in `text`. |
|
314 |
|
315 Returns `filename`. |
|
316 |
|
317 """ |
|
318 # Tests that call `make_file` should be run in a temp environment. |
|
319 assert self.run_in_temp_dir |
|
320 self.class_behavior().test_method_made_any_files = True |
|
321 |
|
322 text = textwrap.dedent(text) |
|
323 if newline: |
|
324 text = text.replace("\n", newline) |
|
325 |
|
326 # Make sure the directories are available. |
|
327 dirs, _ = os.path.split(filename) |
|
328 if dirs and not os.path.exists(dirs): |
|
329 os.makedirs(dirs) |
|
330 |
|
331 # Create the file. |
|
332 with open(filename, 'wb') as f: |
|
333 f.write(to_bytes(text)) |
|
334 |
|
335 return filename |
|
336 |
|
337 # We run some tests in temporary directories, because they may need to make |
|
338 # files for the tests. But this is expensive, so we can change per-class |
|
339 # whether a temp directory is used or not. It's easy to forget to set that |
|
340 # option properly, so we track information about what the tests did, and |
|
341 # then report at the end of the process on test classes that were set |
|
342 # wrong. |
|
343 |
|
344 class ClassBehavior(object): |
|
345 """A value object to store per-class.""" |
|
346 def __init__(self): |
|
347 self.tests = 0 |
|
348 self.skipped = 0 |
|
349 self.temp_dir = True |
|
350 self.no_files_ok = False |
|
351 self.tests_making_files = 0 |
|
352 self.test_method_made_any_files = False |
|
353 |
|
354 # Map from class to info about how it ran. |
|
355 class_behaviors = collections.defaultdict(ClassBehavior) |
|
356 |
|
357 @classmethod |
|
358 def report_on_class_behavior(cls): |
|
359 """Called at process exit to report on class behavior.""" |
|
360 for test_class, behavior in cls.class_behaviors.items(): |
|
361 bad = "" |
|
362 if behavior.tests <= behavior.skipped: |
|
363 bad = "" |
|
364 elif behavior.temp_dir and behavior.tests_making_files == 0: |
|
365 if not behavior.no_files_ok: |
|
366 bad = "Inefficient" |
|
367 elif not behavior.temp_dir and behavior.tests_making_files > 0: |
|
368 bad = "Unsafe" |
|
369 |
|
370 if bad: |
|
371 if behavior.temp_dir: |
|
372 where = "in a temp directory" |
|
373 else: |
|
374 where = "without a temp directory" |
|
375 print( |
|
376 "%s: %s ran %d tests, %d made files %s" % ( |
|
377 bad, |
|
378 test_class.__name__, |
|
379 behavior.tests, |
|
380 behavior.tests_making_files, |
|
381 where, |
|
382 ) |
|
383 ) |
|
384 |
|
385 def class_behavior(self): |
|
386 """Get the ClassBehavior instance for this test.""" |
|
387 return self.class_behaviors[self.__class__] |
|
388 |
|
389 # When the process ends, find out about bad classes. |
|
390 atexit.register(TempDirMixin.report_on_class_behavior) |