Merge lp:~nataliabidart/ubuntuone-dev-tools/add-recorder into lp:ubuntuone-dev-tools

Proposed by Natalia Bidart on 2012-03-23
Status: Rejected
Rejected by: Natalia Bidart on 2012-07-27
Proposed branch: lp:~nataliabidart/ubuntuone-dev-tools/add-recorder
Merge into: lp:ubuntuone-dev-tools
Diff against target: 249 lines (+223/-0)
2 files modified
ubuntuone/devtools/testcases/__init__.py (+101/-0)
ubuntuone/devtools/testcases/tests/test_recorder.py (+122/-0)
To merge this branch: bzr merge lp:~nataliabidart/ubuntuone-dev-tools/add-recorder
Reviewer Review Type Date Requested Status
dobey (community) 2012-03-23 Needs Information on 2012-03-26
Alejandro J. Cura (community) Approve on 2012-03-23
Review via email: mp+99058@code.launchpad.net

Commit Message

- Provide a Recorder class, useful for building Fakes, and a class decorator
  to record calls to existent class' methods.

To post a comment you must log in.
dobey (dobey) wrote :

Set to Work in Progress as it's not clear this is 'finished' yet. Also, please commit --fixes for the bug # before setting back to Needs Review. Thanks.

Alejandro J. Cura (alecu) wrote :

Looks fine, tests pass.

review: Approve
dobey (dobey) wrote :

My first thought is, can we move this to a new module under testing/ instead? I think it might also be good to move the current skip decorators out of the testcases/__init__ and into a new module under testing/ instead as well, and having this be a new module there instead of another huge block of code in testcases/__init__ would be a good start to that end.

What do you think?

review: Needs Information
Natalia Bidart (nataliabidart) wrote :

> My first thought is, can we move this to a new module under testing/ instead?
> I think it might also be good to move the current skip decorators out of the
> testcases/__init__ and into a new module under testing/ instead as well, and
> having this be a new module there instead of another huge block of code in
> testcases/__init__ would be a good start to that end.
>
> What do you think?

I think it makes sense, a proposal can be:

ubuntuone.devtools.testing.recording

dobey (dobey) wrote :

So I just had a brilliant idea for some naming here.

ubuntuone.devtools.testing.validation for the module

CallRecorder for the current "Recorder" class name.

@record_calls for the decorator name, if we really need it at all? Do you have an example of how it would be useful in tests only, vs having to define a new wrapper class in the tests which this would wrap anyway? It seems not very useful to have, after thinking about it. Also, the way it works can create problems with class hierarchy when multiple inheritance is used in code (particularly thinking about how TwistedTestCase stuff works as an example, and the problems we've had in the past with it).

Also, do we want the assert methods to be on the BaseTestCase, or do we want a new CallRecorderTestCase for them? Also, standard style for assert calls is to do assertCalled, assertNotCalled, etc… here no?

Unmerged revisions

63. By Natalia Bidart on 2012-03-23

- Attaching bug #963265.

62. By Natalia Bidart on 2012-03-23

- Making the _next_result attribute to be a dict so it supports different
results for every method.

61. By Natalia Bidart on 2012-03-23

- Prefer using class_ instead of klass to conform pep8 better.

60. By Natalia Bidart on 2012-03-23

Forgotten file.

59. By Natalia Bidart on 2012-03-23

- Provide a Recorder class, useful for building Fakes, and a class decorator
  to record existent classes.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'ubuntuone/devtools/testcases/__init__.py'
2--- ubuntuone/devtools/testcases/__init__.py 2011-12-08 18:58:43 +0000
3+++ ubuntuone/devtools/testcases/__init__.py 2012-03-23 17:43:19 +0000
4@@ -23,6 +23,7 @@
5 import shutil
6 import sys
7
8+from collections import defaultdict
9 from functools import wraps
10
11 from twisted.trial.unittest import TestCase, SkipTest
12@@ -104,6 +105,79 @@
13 # pylint: enable=C0103
14
15
16+class Recorder(object):
17+ """A class that records every call clients made to it.
18+
19+ You can use this class to either:
20+
21+ - along with the 'add_recorder' decorator, have an existing class to
22+ behave as usual but every call made to it will be recorded in the
23+ '_called' attribute.
24+
25+ - quickly create a fake to mimic another class behavior. To do so, you
26+ should set _handle_attr_error to True, and define a set of predefined
27+ results for each function and assign that to _results.
28+
29+ Aditionally, every method name listed under '_no_wrap' will not be wrap
30+ with the recorder machinery.
31+
32+ """
33+
34+ _no_wrap = ['_called'] # do not record calls to these
35+ _handle_attr_error = False # propagate AttributeError
36+ _results = {} # return a given result for a given method
37+
38+ def __init__(self, *args, **kwargs):
39+ super(Recorder, self).__init__()
40+ self._called = defaultdict(list)
41+
42+ def __getattribute__(self, attr_name):
43+ """Override so we can record calls to members."""
44+ attr_getter = super(Recorder, self).__getattribute__
45+ try:
46+ # try to get the real result
47+ result = attr_getter(attr_name)
48+ except AttributeError:
49+ if not self._handle_attr_error:
50+ raise
51+
52+ # fake the result if non-existent - useful for faking a class
53+ result = lambda *a, **kw: self._results.get(attr_name, None)
54+ super(Recorder, self).__setattr__(attr_name, result)
55+
56+ if attr_name in attr_getter('_no_wrap'):
57+ # do not wrap any attributes listed in self.no_wrap
58+ return result
59+
60+ called = attr_getter('_called')
61+
62+ def wrap_me(f):
63+ """Wrap 'f' so we record when 'f' is called."""
64+
65+ @wraps(f)
66+ def inner(*a, **kw):
67+ """Keep track of calls to 'f', execute it and return result."""
68+ called[attr_name].append((a, kw))
69+ return f(*a, **kw)
70+
71+ return inner
72+
73+ # if the original result is a callable, wrap it with the recorder
74+ if callable(result):
75+ return wrap_me(result)
76+ else:
77+ return result
78+
79+
80+def add_recorder(class_):
81+ """Class decorator to wrap 'class_' and record every call made to it."""
82+
83+ class Recorded(Recorder, class_):
84+ """Record every call."""
85+
86+ return Recorded
87+
88+
89 class BaseTestCase(TestCase):
90 """Base TestCase with helper methods to handle temp dir.
91
92@@ -164,3 +238,30 @@
93 if os.path.exists(parent):
94 os.chmod(parent, 0755)
95 os.makedirs(path)
96+
97+ # Access to a protected member _called of a client class
98+ # pylint: disable=W0212
99+
100+ def assert_method_was_called(self, target, method, *args, **kwargs):
101+ """Check that 'target.method(*args, **kwargs)' was called."""
102+ self.assertTrue(getattr(target, '_called', None) is not None,
103+ '%r must have a _called attribute to perform the test.' % target)
104+ self.assertTrue(method in target._called,
105+ 'method %r must be called on %r.' % (method, target))
106+ self.assertEqual(target._called[method], [(args, kwargs)])
107+
108+ def assert_method_was_not_called(self, target, method):
109+ """Check that 'target.method' was never called."""
110+ self.assertTrue(getattr(target, '_called', None) is not None,
111+ '%r must have a _called attribute to perform the test.' % target)
112+ self.assertTrue(method not in target._called,
113+ 'method %r must not be called on %r.' % (method, target))
114+
115+ def assert_nothing_called(self, target):
116+ """Check that no method was called over 'target'."""
117+ self.assertTrue(getattr(target, '_called', None) is not None,
118+ '%r must have a _called attribute to perform the test.' % target)
119+ self.assertEqual(target._called, {},
120+ 'No method at all must be called on %r.' % target)
121+
122+ # pylint: enable=W0212
123
124=== added file 'ubuntuone/devtools/testcases/tests/test_recorder.py'
125--- ubuntuone/devtools/testcases/tests/test_recorder.py 1970-01-01 00:00:00 +0000
126+++ ubuntuone/devtools/testcases/tests/test_recorder.py 2012-03-23 17:43:19 +0000
127@@ -0,0 +1,122 @@
128+# -*- coding: utf-8 -*-
129+#
130+# Copyright 2012 Canonical Ltd.
131+#
132+# This program is free software: you can redistribute it and/or modify it
133+# under the terms of the GNU General Public License version 3, as published
134+# by the Free Software Foundation.
135+#
136+# This program is distributed in the hope that it will be useful, but
137+# WITHOUT ANY WARRANTY; without even the implied warranties of
138+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
139+# PURPOSE. See the GNU General Public License for more details.
140+#
141+# You should have received a copy of the GNU General Public License along
142+# with this program. If not, see <http://www.gnu.org/licenses/>.
143+
144+"""Test the Recorder test case."""
145+
146+from twisted.internet import defer
147+
148+from ubuntuone.devtools.testcases import add_recorder, BaseTestCase, Recorder
149+
150+
151+class RecordMe(object):
152+ """A dummy class to test the add_recorder decorator."""
153+
154+ def __init__(self):
155+ self._private = object()
156+ self.somethings = []
157+
158+ def test_me(self):
159+ """Dummy call."""
160+ return 'foo'
161+
162+ def something(self, *a, **kw):
163+ """Dummy call."""
164+ self.somethings.append((a, kw))
165+
166+
167+class AddRecorderTestCase(BaseTestCase):
168+ """Test suite for the add_recorder class decorator."""
169+
170+ @defer.inlineCallbacks
171+ def setUp(self):
172+ yield super(AddRecorderTestCase, self).setUp()
173+ self.obj = add_recorder(RecordMe)()
174+
175+ def test_non_callables(self):
176+ """The non-callables attributes are not modified."""
177+ self.assertEqual(self.obj.somethings, [])
178+ self.assert_nothing_called(self.obj)
179+
180+ def test_original_result(self):
181+ """The original result is returned."""
182+ result = self.obj.test_me()
183+
184+ self.assertEqual(RecordMe().test_me(), result)
185+ self.assert_method_was_called(self.obj, 'test_me')
186+ self.assert_method_was_not_called(self.obj, 'something')
187+
188+ def test_result_is_none(self):
189+ """If the original result is None, nothing breaks."""
190+ args = (1, object(), [object(), ValueError()])
191+ kwargs = dict(foo='bar', baz=TypeError())
192+
193+ result = self.obj.something(*args, **kwargs)
194+
195+ self.assertTrue(result is None)
196+ self.assertEqual(len(self.obj.somethings), 1)
197+ self.assertEqual(self.obj.somethings, [(args, kwargs)])
198+
199+ self.assert_method_was_called(self.obj, 'something', *args, **kwargs)
200+ self.assert_method_was_not_called(self.obj, 'test_me')
201+
202+ def test_attribute_error(self):
203+ """AttributeError is propagated."""
204+ # pylint: disable=W0108
205+ self.assertRaises(AttributeError, lambda: self.obj.bar())
206+
207+ def test_no_wrap(self):
208+ """Names listed under self._no_wrap should not be wrapped."""
209+ self.patch(self.obj, '_no_wrap', ['test_me'])
210+
211+ result = self.obj.test_me()
212+
213+ self.assertEqual(RecordMe().test_me(), result)
214+ self.assert_nothing_called(self.obj)
215+
216+ def test_can_access_private_attrs(self):
217+ """Private attributes can also be accessed."""
218+ # pylint: disable=W0212
219+ self.assertTrue(self.obj._private, RecordMe()._private)
220+
221+
222+class AddRecorderNoAttrErrorTestCase(AddRecorderTestCase):
223+ """Test suite for the add_recorder when handle_attr_error is set."""
224+
225+ @defer.inlineCallbacks
226+ def setUp(self):
227+ self.patch(Recorder, '_handle_attr_error', True)
228+ yield super(AddRecorderNoAttrErrorTestCase, self).setUp()
229+
230+ def test_attribute_error(self):
231+ """AttributeError is not propagated."""
232+ result = self.obj.bar()
233+
234+ self.assertTrue(result is None)
235+
236+ def test_next_result(self):
237+ """Handling attribute error, self._results[method] is returned."""
238+ expected = object()
239+ self.patch(self.obj, '_results', {'foobarbaz': expected})
240+
241+ result = self.obj.foobarbaz(1, 2, 3, 4)
242+ self.assertTrue(result is expected)
243+
244+ def test_next_result_not_in_results(self):
245+ """Handling attribute error, None is returned if no given result."""
246+ self.patch(self.obj, '_results', {})
247+
248+ result = self.obj.foobarbaz(1, 2, 3, 4)
249+ self.assertTrue(result is None)

Subscribers

People subscribed via source and target branches

to all changes: