Merge lp:~mwhudson/testtools/moar-matchers into lp:~testtools-committers/testtools/trunk

Proposed by Michael Hudson-Doyle
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
Reviewer Review Type Date Requested Status
testtools developers Pending
Review via email: mp+43489@code.launchpad.net

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://code.launchpad.net/~mwhudson/linaro-image-tools/better-hwpack-matcher/+merge/42902 if the motivations are not obvious.

I've not contributed a big chunk of code to testtools before, so please educate me on local style :-)

Cheers,
mwh

To post a comment you must log in.
Revision history for this message
Michael Hudson-Doyle (mwhudson) 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.

lp:~mwhudson/testtools/moar-matchers updated
163. By Michael Hudson-Doyle

merge trunk

164. By Michael Hudson-Doyle

update NEWS

Revision history for this message
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://code.launchpad.net/~mwhudson/testtools/moar-matchers/+merge/43489
You are subscribed to ...

Revision history for this message
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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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__)

Subscribers

People subscribed via source and target branches