Merge lp:~mwhudson/testtools/moar-matchers into lp:~testtools-committers/testtools/trunk
- moar-matchers
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 171 |
Proposed branch: | lp:~mwhudson/testtools/moar-matchers |
Merge into: | lp:~testtools-committers/testtools/trunk |
Prerequisite: | lp:~mwhudson/testtools/no-newline-for-mismatchesall |
Diff against target: |
514 lines (+428/-8) 3 files modified
NEWS (+25/-8) testtools/matchers.py (+209/-0) testtools/tests/test_matchers.py (+194/-0) |
To merge this branch: | bzr merge lp:~mwhudson/testtools/moar-matchers |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
testtools developers | Pending | ||
Review via email: mp+43489@code.launchpad.net |
Commit message
Description of the change
This branch adds 5 more matchers that I felt compelled to write whilst working on linaro-image-tools to testtools: EachOf, MatchesStructure, MatchesRegex, MatchesSetwise and AfterPreprocessing. As is usually for "add code" code reviews, if there's anything that's unclear, it should be fixed in the branch, not in this description here!
You can see these matchers in action at https:/
I've not contributed a big chunk of code to testtools before, so please educate me on local style :-)
Cheers,
mwh
Michael Hudson-Doyle (mwhudson) wrote : | # |
- 163. By Michael Hudson-Doyle
-
merge trunk
- 164. By Michael Hudson-Doyle
-
update NEWS
Robert Collins (lifeless) wrote : | # |
Interesting, um processing - one c. Re each of,will need to read up.
On 13/12/2010 3:05 PM, "Michael Hudson-Doyle" <email address hidden>
wrote:
Ah, I misunderstood bug 615108 and my EachOf isn't the same as the one
proposed there. So I'm not sure what a good name would be.
--
https:/
You are subscribed to ...
Jonathan Lange (jml) wrote : | # |
The tests & code look good. I'm going to review the documentation changes now and maybe rename a couple of the classes to work around the EachOf bug.
Preview Diff
1 | === modified file 'NEWS' |
2 | --- NEWS 2010-12-13 01:15:11 +0000 |
3 | +++ NEWS 2010-12-13 02:13:08 +0000 |
4 | @@ -30,6 +30,31 @@ |
5 | Improvements |
6 | ------------ |
7 | |
8 | +* New matchers: |
9 | + |
10 | + * ``EachOf`` matches an iterable of matchers against an iterable of values. |
11 | + (Michael Hudson-Doyle) |
12 | + |
13 | + * ``MatchesRegex`` matches a string against a regular expression. (Michael |
14 | + Hudson-Doyle) |
15 | + |
16 | + * ``MatchesStructure`` matches attributes of an object against given |
17 | + matchers. (Michael Hudson-Doyle) |
18 | + |
19 | + * ``AfterPreproccessing`` matches values against a matcher after passing them |
20 | + through a callable. (Michael Hudson-Doyle) |
21 | + |
22 | + * ``MatchesSetwise`` matches an iterable of matchers against an iterable of |
23 | + values, without regard to order. (Michael Hudson-Doyle) |
24 | + |
25 | + * ``EndsWith`` which complements the existing ``StartsWith`` matcher. |
26 | + (Jonathan Lange, #669165) |
27 | + |
28 | + * ``MatchesException`` matches an exception class and parameters. (Robert |
29 | + Collins) |
30 | + |
31 | + * ``KeysEqual`` matches a dictionary with particular keys. (Jonathan Lange) |
32 | + |
33 | * ``assertIsInstance`` supports a custom error message to be supplied, which |
34 | is necessary when using ``assertDictEqual`` on Python 2.7 with a |
35 | ``testtools.TestCase`` base class. (Jelmer Vernooij) |
36 | @@ -43,22 +68,14 @@ |
37 | * Fix the runTest parameter of TestCase to actually work, rather than raising |
38 | a TypeError. (Jonathan Lange, #657760) |
39 | |
40 | -* New matcher ``EndsWith`` added to complement the existing ``StartsWith`` |
41 | - matcher. (Jonathan Lange, #669165) |
42 | - |
43 | * Non-release snapshots of testtools will now work with buildout. |
44 | (Jonathan Lange, #613734) |
45 | |
46 | * Malformed SyntaxErrors no longer blow up the test suite. (Martin [gz]) |
47 | |
48 | -* ``MatchesException`` added to the ``testtools.matchers`` module - matches |
49 | - an exception class and parameters. (Robert Collins) |
50 | - |
51 | * ``MismatchesAll.describe`` no longer appends a trailing newline. |
52 | (Michael Hudson-Doyle, #686790) |
53 | |
54 | -* New ``KeysEqual`` matcher. (Jonathan Lange) |
55 | - |
56 | * New helpers for conditionally importing modules, ``try_import`` and |
57 | ``try_imports``. (Jonathan Lange) |
58 | |
59 | |
60 | === modified file 'testtools/matchers.py' |
61 | --- testtools/matchers.py 2010-12-12 23:48:10 +0000 |
62 | +++ testtools/matchers.py 2010-12-13 02:13:08 +0000 |
63 | @@ -30,7 +30,9 @@ |
64 | import doctest |
65 | import operator |
66 | from pprint import pformat |
67 | +import re |
68 | import sys |
69 | +import types |
70 | |
71 | from testtools.compat import classtypes, _error_repr, isbaseexception |
72 | |
73 | @@ -528,3 +530,210 @@ |
74 | See `Raises` and `MatchesException` for more information. |
75 | """ |
76 | return Raises(MatchesException(exception)) |
77 | + |
78 | + |
79 | +class EachOf(object): |
80 | + """Matches if each matcher matches the corresponding value. |
81 | + |
82 | + More easily explained by example than in words: |
83 | + |
84 | + >>> EachOf([Equals(1)]).match([1]) |
85 | + >>> EachOf([Equals(1), Equals(2)]).match([1, 2]) |
86 | + >>> print EachOf([Equals(1), Equals(2)]).match([2, 1]).describe() |
87 | + Differences: [ |
88 | + 1 != 2 |
89 | + 2 != 1 |
90 | + ] |
91 | + """ |
92 | + |
93 | + def __init__(self, matchers): |
94 | + self.matchers = matchers |
95 | + |
96 | + def match(self, values): |
97 | + mismatches = [] |
98 | + length_mismatch = Annotate( |
99 | + "Length mismatch", Equals(len(self.matchers))).match(len(values)) |
100 | + if length_mismatch: |
101 | + mismatches.append(length_mismatch) |
102 | + for matcher, value in zip(self.matchers, values): |
103 | + mismatch = matcher.match(value) |
104 | + if mismatch: |
105 | + mismatches.append(mismatch) |
106 | + if mismatches: |
107 | + return MismatchesAll(mismatches) |
108 | + |
109 | + |
110 | +class MatchesStructure(object): |
111 | + """Matcher that matches an object structurally. |
112 | + |
113 | + 'Structurally' here means that attributes of the object being matched are |
114 | + compared against given matchers. |
115 | + |
116 | + `fromExample` allows the creation of a matcher from a prototype object and |
117 | + then modified versions can be created with `update`. |
118 | + """ |
119 | + |
120 | + def __init__(self, **kwargs): |
121 | + self.kws = kwargs |
122 | + |
123 | + @classmethod |
124 | + def fromExample(cls, example, *attributes): |
125 | + kwargs = {} |
126 | + for attr in attributes: |
127 | + kwargs[attr] = Equals(getattr(example, attr)) |
128 | + return cls(**kwargs) |
129 | + |
130 | + def update(self, **kws): |
131 | + new_kws = self.kws.copy() |
132 | + for attr, matcher in kws.iteritems(): |
133 | + if matcher is None: |
134 | + new_kws.pop(attr, None) |
135 | + else: |
136 | + new_kws[attr] = matcher |
137 | + return type(self)(**new_kws) |
138 | + |
139 | + def __str__(self): |
140 | + kws = [] |
141 | + for attr, matcher in sorted(self.kws.iteritems()): |
142 | + kws.append("%s=%s" % (attr, matcher)) |
143 | + return "%s(%s)" % (self.__class__.__name__, ', '.join(kws)) |
144 | + |
145 | + def match(self, value): |
146 | + matchers = [] |
147 | + values = [] |
148 | + for attr, matcher in sorted(self.kws.iteritems()): |
149 | + matchers.append(Annotate(attr, matcher)) |
150 | + values.append(getattr(value, attr)) |
151 | + return EachOf(matchers).match(values) |
152 | + |
153 | + |
154 | +class MatchesRegex(object): |
155 | + """Matches if the matchee is matched by a regular expression.""" |
156 | + |
157 | + def __init__(self, pattern, flags=0): |
158 | + self.pattern = pattern |
159 | + self.flags = flags |
160 | + |
161 | + def __str__(self): |
162 | + args = ['%r' % self.pattern] |
163 | + flag_arg = [] |
164 | + # dir() sorts the attributes for us, so we don't need to do it again. |
165 | + for flag in dir(re): |
166 | + if len(flag) == 1: |
167 | + if self.flags & getattr(re, flag): |
168 | + flag_arg.append('re.%s' % flag) |
169 | + if flag_arg: |
170 | + args.append('|'.join(flag_arg)) |
171 | + return '%s(%s)' % (self.__class__.__name__, ', '.join(args)) |
172 | + |
173 | + def match(self, value): |
174 | + if not re.match(self.pattern, value, self.flags): |
175 | + return Mismatch("%r did not match %r" % (self.pattern, value)) |
176 | + |
177 | + |
178 | +class MatchesSetwise(object): |
179 | + """Matches if all the matchers match elements of the value being matched. |
180 | + |
181 | + The difference compared to `EachOf` is that the order of the matchings |
182 | + does not matter. |
183 | + """ |
184 | + |
185 | + def __init__(self, *matchers): |
186 | + self.matchers = matchers |
187 | + |
188 | + def match(self, observed): |
189 | + remaining_matchers = set(self.matchers) |
190 | + not_matched = [] |
191 | + for value in observed: |
192 | + for matcher in remaining_matchers: |
193 | + if matcher.match(value) is None: |
194 | + remaining_matchers.remove(matcher) |
195 | + break |
196 | + else: |
197 | + not_matched.append(value) |
198 | + if not_matched or remaining_matchers: |
199 | + remaining_matchers = list(remaining_matchers) |
200 | + # There are various cases that all should be reported somewhat |
201 | + # differently. |
202 | + |
203 | + # There are two trivial cases: |
204 | + # 1) There are just some matchers left over. |
205 | + # 2) There are just some values left over. |
206 | + |
207 | + # Then there are three more interesting cases: |
208 | + # 3) There are the same number of matchers and values left over. |
209 | + # 4) There are more matchers left over than values. |
210 | + # 5) There are more values left over than matchers. |
211 | + |
212 | + if len(not_matched) == 0: |
213 | + if len(remaining_matchers) > 1: |
214 | + msg = "There were %s matchers left over: " % ( |
215 | + len(remaining_matchers),) |
216 | + else: |
217 | + msg = "There was 1 matcher left over: " |
218 | + msg += ', '.join(map(str, remaining_matchers)) |
219 | + return Mismatch(msg) |
220 | + elif len(remaining_matchers) == 0: |
221 | + if len(not_matched) > 1: |
222 | + return Mismatch( |
223 | + "There were %s values left over: %s" % ( |
224 | + len(not_matched), not_matched)) |
225 | + else: |
226 | + return Mismatch( |
227 | + "There was 1 value left over: %s" % ( |
228 | + not_matched, )) |
229 | + else: |
230 | + common_length = min(len(remaining_matchers), len(not_matched)) |
231 | + if common_length == 0: |
232 | + raise AssertionError("common_length can't be 0 here") |
233 | + if common_length > 1: |
234 | + msg = "There were %s mismatches" % (common_length,) |
235 | + else: |
236 | + msg = "There was 1 mismatch" |
237 | + if len(remaining_matchers) > len(not_matched): |
238 | + extra_matchers = remaining_matchers[common_length:] |
239 | + msg += " and %s extra matcher" % (len(extra_matchers), ) |
240 | + if len(extra_matchers) > 1: |
241 | + msg += "s" |
242 | + msg += ': ' + ', '.join(map(str, extra_matchers)) |
243 | + elif len(not_matched) > len(remaining_matchers): |
244 | + extra_values = not_matched[common_length:] |
245 | + msg += " and %s extra value" % (len(extra_values), ) |
246 | + if len(extra_values) > 1: |
247 | + msg += "s" |
248 | + msg += ': ' + str(extra_values) |
249 | + return Annotate( |
250 | + msg, EachOf(remaining_matchers[:common_length]) |
251 | + ).match(not_matched[:common_length]) |
252 | + |
253 | + |
254 | +class AfterPreproccessing(object): |
255 | + """Matches if the value matches after passing through a function. |
256 | + |
257 | + This can be used to aid in creating trivial matchers as functions, for |
258 | + example: |
259 | + |
260 | + def PathHasFileContent(content): |
261 | + def _read(path): |
262 | + return open(path).read() |
263 | + return AfterPreproccessing(_read, Equals(content)) |
264 | + """ |
265 | + |
266 | + def __init__(self, preprocessor, matcher): |
267 | + self.preprocessor = preprocessor |
268 | + self.matcher = matcher |
269 | + |
270 | + def _str_preprocessor(self): |
271 | + if isinstance(self.preprocessor, types.FunctionType): |
272 | + return '<function %s>' % self.preprocessor.__name__ |
273 | + return str(self.preprocessor) |
274 | + |
275 | + def __str__(self): |
276 | + return "AfterPreproccessing(%s, %s)" % ( |
277 | + self._str_preprocessor(), self.matcher) |
278 | + |
279 | + def match(self, value): |
280 | + value = self.preprocessor(value) |
281 | + return Annotate( |
282 | + "after %s" % self._str_preprocessor(), |
283 | + self.matcher).match(value) |
284 | |
285 | === modified file 'testtools/tests/test_matchers.py' |
286 | --- testtools/tests/test_matchers.py 2010-12-12 23:48:10 +0000 |
287 | +++ testtools/tests/test_matchers.py 2010-12-13 02:13:08 +0000 |
288 | @@ -3,6 +3,8 @@ |
289 | """Tests for matchers.""" |
290 | |
291 | import doctest |
292 | +import re |
293 | +import StringIO |
294 | import sys |
295 | |
296 | from testtools import ( |
297 | @@ -10,11 +12,13 @@ |
298 | TestCase, |
299 | ) |
300 | from testtools.matchers import ( |
301 | + AfterPreproccessing, |
302 | Annotate, |
303 | Equals, |
304 | DocTestMatches, |
305 | DoesNotEndWith, |
306 | DoesNotStartWith, |
307 | + EachOf, |
308 | EndsWith, |
309 | KeysEqual, |
310 | Is, |
311 | @@ -22,6 +26,9 @@ |
312 | MatchesAny, |
313 | MatchesAll, |
314 | MatchesException, |
315 | + MatchesRegex, |
316 | + MatchesSetwise, |
317 | + MatchesStructure, |
318 | Mismatch, |
319 | Not, |
320 | NotEquals, |
321 | @@ -446,6 +453,193 @@ |
322 | self.assertEqual("bar", mismatch.expected) |
323 | |
324 | |
325 | +def run_doctest(obj, name): |
326 | + p = doctest.DocTestParser() |
327 | + t = p.get_doctest( |
328 | + obj.__doc__, sys.modules[obj.__module__].__dict__, name, '', 0) |
329 | + r = doctest.DocTestRunner() |
330 | + output = StringIO.StringIO() |
331 | + r.run(t, out=output.write) |
332 | + return r.failures, output.getvalue() |
333 | + |
334 | + |
335 | +class TestEachOf(TestCase): |
336 | + |
337 | + def test_docstring(self): |
338 | + failure_count, output = run_doctest(EachOf, "EachOf") |
339 | + if failure_count: |
340 | + self.fail("Doctest failed with %s" % output) |
341 | + |
342 | + |
343 | +class TestMatchesStructure(TestCase, TestMatchersInterface): |
344 | + |
345 | + class SimpleClass: |
346 | + def __init__(self, x, y): |
347 | + self.x = x |
348 | + self.y = y |
349 | + |
350 | + matches_matcher = MatchesStructure(x=Equals(1), y=Equals(2)) |
351 | + matches_matches = [SimpleClass(1, 2)] |
352 | + matches_mismatches = [ |
353 | + SimpleClass(2, 2), |
354 | + SimpleClass(1, 1), |
355 | + SimpleClass(3, 3), |
356 | + ] |
357 | + |
358 | + str_examples = [ |
359 | + ("MatchesStructure(x=Equals(1))", MatchesStructure(x=Equals(1))), |
360 | + ("MatchesStructure(y=Equals(2))", MatchesStructure(y=Equals(2))), |
361 | + ("MatchesStructure(x=Equals(1), y=Equals(2))", |
362 | + MatchesStructure(x=Equals(1), y=Equals(2))), |
363 | + ] |
364 | + |
365 | + describe_examples = [ |
366 | + ("""\ |
367 | +Differences: [ |
368 | +3 != 1: x |
369 | +]""", SimpleClass(1, 2), MatchesStructure(x=Equals(3), y=Equals(2))), |
370 | + ("""\ |
371 | +Differences: [ |
372 | +3 != 2: y |
373 | +]""", SimpleClass(1, 2), MatchesStructure(x=Equals(1), y=Equals(3))), |
374 | + ("""\ |
375 | +Differences: [ |
376 | +0 != 1: x |
377 | +0 != 2: y |
378 | +]""", SimpleClass(1, 2), MatchesStructure(x=Equals(0), y=Equals(0))), |
379 | + ] |
380 | + |
381 | + def test_fromExample(self): |
382 | + self.assertThat( |
383 | + self.SimpleClass(1, 2), |
384 | + MatchesStructure.fromExample(self.SimpleClass(1, 3), 'x')) |
385 | + |
386 | + def test_update(self): |
387 | + self.assertThat( |
388 | + self.SimpleClass(1, 2), |
389 | + MatchesStructure(x=NotEquals(1)).update(x=Equals(1))) |
390 | + |
391 | + def test_update_none(self): |
392 | + self.assertThat( |
393 | + self.SimpleClass(1, 2), |
394 | + MatchesStructure(x=Equals(1), z=NotEquals(42)).update( |
395 | + z=None)) |
396 | + |
397 | + |
398 | +class TestMatchesRegex(TestCase, TestMatchersInterface): |
399 | + |
400 | + matches_matcher = MatchesRegex('a|b') |
401 | + matches_matches = ['a', 'b'] |
402 | + matches_mismatches = ['c'] |
403 | + |
404 | + str_examples = [ |
405 | + ("MatchesRegex('a|b')", MatchesRegex('a|b')), |
406 | + ("MatchesRegex('a|b', re.M)", MatchesRegex('a|b', re.M)), |
407 | + ("MatchesRegex('a|b', re.I|re.M)", MatchesRegex('a|b', re.I|re.M)), |
408 | + ] |
409 | + |
410 | + describe_examples = [ |
411 | + ("'a|b' did not match 'c'", 'c', MatchesRegex('a|b')), |
412 | + ] |
413 | + |
414 | + |
415 | +class TestMatchesSetwise(TestCase): |
416 | + |
417 | + def assertMismatchWithDescriptionMatching(self, value, matcher, |
418 | + description_matcher): |
419 | + mismatch = matcher.match(value) |
420 | + if mismatch is None: |
421 | + self.fail("%s matched %s" % (matcher, value)) |
422 | + actual_description = mismatch.describe() |
423 | + self.assertThat( |
424 | + actual_description, |
425 | + Annotate( |
426 | + "%s matching %s" % (matcher, value), |
427 | + description_matcher)) |
428 | + |
429 | + def test_matches(self): |
430 | + self.assertIs( |
431 | + None, MatchesSetwise(Equals(1), Equals(2)).match([2, 1])) |
432 | + |
433 | + def test_mismatches(self): |
434 | + self.assertMismatchWithDescriptionMatching( |
435 | + [2, 3], MatchesSetwise(Equals(1), Equals(2)), |
436 | + MatchesRegex('.*There was 1 mismatch$', re.S)) |
437 | + |
438 | + def test_too_many_matchers(self): |
439 | + self.assertMismatchWithDescriptionMatching( |
440 | + [2, 3], MatchesSetwise(Equals(1), Equals(2), Equals(3)), |
441 | + Equals('There was 1 matcher left over: Equals(1)')) |
442 | + |
443 | + def test_too_many_values(self): |
444 | + self.assertMismatchWithDescriptionMatching( |
445 | + [1, 2, 3], MatchesSetwise(Equals(1), Equals(2)), |
446 | + Equals('There was 1 value left over: [3]')) |
447 | + |
448 | + def test_two_too_many_matchers(self): |
449 | + self.assertMismatchWithDescriptionMatching( |
450 | + [3], MatchesSetwise(Equals(1), Equals(2), Equals(3)), |
451 | + MatchesRegex( |
452 | + 'There were 2 matchers left over: Equals\([12]\), ' |
453 | + 'Equals\([12]\)')) |
454 | + |
455 | + def test_two_too_many_values(self): |
456 | + self.assertMismatchWithDescriptionMatching( |
457 | + [1, 2, 3, 4], MatchesSetwise(Equals(1), Equals(2)), |
458 | + MatchesRegex( |
459 | + 'There were 2 values left over: \[[34], [34]\]')) |
460 | + |
461 | + def test_mismatch_and_too_many_matchers(self): |
462 | + self.assertMismatchWithDescriptionMatching( |
463 | + [2, 3], MatchesSetwise(Equals(0), Equals(1), Equals(2)), |
464 | + MatchesRegex( |
465 | + '.*There was 1 mismatch and 1 extra matcher: Equals\([01]\)', |
466 | + re.S)) |
467 | + |
468 | + def test_mismatch_and_too_many_values(self): |
469 | + self.assertMismatchWithDescriptionMatching( |
470 | + [2, 3, 4], MatchesSetwise(Equals(1), Equals(2)), |
471 | + MatchesRegex( |
472 | + '.*There was 1 mismatch and 1 extra value: \[[34]\]', |
473 | + re.S)) |
474 | + |
475 | + def test_mismatch_and_two_too_many_matchers(self): |
476 | + self.assertMismatchWithDescriptionMatching( |
477 | + [3, 4], MatchesSetwise( |
478 | + Equals(0), Equals(1), Equals(2), Equals(3)), |
479 | + MatchesRegex( |
480 | + '.*There was 1 mismatch and 2 extra matchers: ' |
481 | + 'Equals\([012]\), Equals\([012]\)', re.S)) |
482 | + |
483 | + def test_mismatch_and_two_too_many_values(self): |
484 | + self.assertMismatchWithDescriptionMatching( |
485 | + [2, 3, 4, 5], MatchesSetwise(Equals(1), Equals(2)), |
486 | + MatchesRegex( |
487 | + '.*There was 1 mismatch and 2 extra values: \[[145], [145]\]', |
488 | + re.S)) |
489 | + |
490 | + |
491 | +class TestAfterPreproccessing(TestCase, TestMatchersInterface): |
492 | + |
493 | + def parity(x): |
494 | + return x % 2 |
495 | + |
496 | + matches_matcher = AfterPreproccessing(parity, Equals(1)) |
497 | + matches_matches = [3, 5] |
498 | + matches_mismatches = [2] |
499 | + |
500 | + str_examples = [ |
501 | + ("AfterPreproccessing(<function parity>, Equals(1))", |
502 | + AfterPreproccessing(parity, Equals(1))), |
503 | + ] |
504 | + |
505 | + describe_examples = [ |
506 | + ("1 != 0: after <function parity>", |
507 | + 2, |
508 | + AfterPreproccessing(parity, Equals(1))), |
509 | + ] |
510 | + |
511 | + |
512 | def test_suite(): |
513 | from unittest import TestLoader |
514 | return TestLoader().loadTestsFromName(__name__) |
Ah, I misunderstood bug 615108 and my EachOf isn't the same as the one proposed there. So I'm not sure what a good name would be.