Merge lp:~allenap/launchpad/isolate-tests into lp:launchpad

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~allenap/launchpad/isolate-tests
Merge into: lp:launchpad
Diff against target: 319 lines (+217/-15)
4 files modified
lib/lp/services/job/runner.py (+3/-2)
lib/lp/services/job/tests/test_runner.py (+15/-13)
lib/lp/testing/__init__.py (+68/-0)
lib/lp/testing/tests/test_zope_test_in_subprocess.py (+131/-0)
To merge this branch: bzr merge lp:~allenap/launchpad/isolate-tests
Reviewer Review Type Date Requested Status
Jonathan Lange (community) Approve
Björn Tillenius (community) Abstain
Review via email: mp+20916@code.launchpad.net

Commit message

New class, ZopeTestInSubProcess, a mixin to run tests in forked sub-processes.

Description of the change

Implement a new class - ZopeIsolatedTestCase - that can be used as a mixin with more interesting TestCase subclasses. It runs each test method in a forked sub-process, passing the results back to the parent process with subunit. This is needed to safely run tests that need to, for example, start the Twisted reactor, which cannot be safely restarted in the same process once stopped.

This branch also uses ZopeIsolatedTestCase with a couple of job runner tests. Additionally, one of these tests, TestJobCronScript, has been fixed to override the command line arguments passed to the script class (otherwise sys.argv is used, which is stuffed with arguments passed to bin/test).

To post a comment you must log in.
Revision history for this message
Björn Tillenius (bjornt) wrote :
Download full text (5.2 KiB)

On Mon, Mar 08, 2010 at 06:14:32PM -0000, Gavin Panella wrote:
> Gavin Panella has proposed merging lp:~allenap/launchpad/isolate-tests into lp:launchpad/devel.
>
> Requested reviews:
> Canonical Launchpad Engineering (launchpad): code
> Related bugs:
> #491870 Use Twisted's thread support instead of the threading module in checkwatches
> https://bugs.launchpad.net/bugs/491870
>
>
> Implement a new class - ZopeIsolatedTestCase - that can be used as a
> mixin with more interesting TestCase subclasses. It runs each test
> method in a forked sub-process, passing the results back to the parent
> process with subunit. This is needed to safely run tests that need to,
> for example, start the Twisted reactor, which cannot be safely restarted
> in the same process once stopped.
>
> This branch also uses ZopeIsolatedTestCase with a couple of job runner
> tests. Additionally, one of these tests, TestJobCronScript, has been
> fixed to override the command line arguments passed to the script class
> (otherwise sys.argv is used, which is stuffed with arguments passed to
> bin/test).

    vote needsinfo

If the job runner tests ran fine previously, why is this
ZopeIsolatedTestCase needed?

> === modified file 'lib/lp/services/job/tests/test_runner.py'
> --- lib/lp/services/job/tests/test_runner.py 2010-02-24 19:52:01 +0000
> +++ lib/lp/services/job/tests/test_runner.py 2010-03-08 18:14:32 +0000

> @@ -300,7 +301,7 @@
> self.entries.append(input)
>
>
> -class TestTwistedJobRunner(TestCaseWithFactory):
> +class TestTwistedJobRunner(ZopeIsolatedTestCase, TestCaseWithFactory):
>
> layer = LaunchpadZopelessLayer
>
> @@ -325,7 +326,7 @@
> self.assertIn('Job ran too long.', oops.value)
>
>
> -class TestJobCronScript(TestCaseWithFactory):
> +class TestJobCronScript(ZopeIsolatedTestCase, TestCaseWithFactory):

Will we ever use ZopeIsolatedTestCase without mixing in
TestCaseWithFactory?

> === modified file 'lib/lp/testing/__init__.py'
> --- lib/lp/testing/__init__.py 2010-02-17 20:54:36 +0000
> +++ lib/lp/testing/__init__.py 2010-03-08 18:14:32 +0000
> @@ -586,6 +591,56 @@
> self.client.open(url=u'http://launchpad.dev:8085')
>
>
> +class ZopeIsolatedTestCase(unittest.TestCase):
> + """Run tests in a sub-process, respecting Zope idiosyncrasies.
> +
> + Use this as a baseclass for test cases, or as a mixin with an
> + interesting `TestCase` subclass.
> +
> + This is basically a reimplementation of subunit's
> + `IsolatedTestCase`, but adjusted to work with Zope. In particular,
> + Zope's TestResult object is responsible for calling testSetUp()
> + and testTearDown() on the selected layer.
> + """

I can see why you named it ZopeIsolatedTestCase, but is that really a
good name? I mean, don't TestCase classes in general run their tests in
an isolated environment?

> +
> + def run(self, result):
> + assert isinstance(result, ZopeTestResult), (
> + "result must be a Zope result object, not %r." % (result,))
> + pread, pwrite = os.pipe()
> + pid = os.fork()
> + if pid == 0:
> + # Child.
> + os.close(pread)
> + ...

Read more...

review: Needs Information
Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (7.4 KiB)

> On Mon, Mar 08, 2010 at 06:14:32PM -0000, Gavin Panella wrote:
> > Gavin Panella has proposed merging lp:~allenap/launchpad/isolate-tests into
> lp:launchpad/devel.
> >
> > Requested reviews:
> > Canonical Launchpad Engineering (launchpad): code
> > Related bugs:
> > #491870 Use Twisted's thread support instead of the threading module in
> checkwatches
> > https://bugs.launchpad.net/bugs/491870
> >
> >
> > Implement a new class - ZopeIsolatedTestCase - that can be used as a
> > mixin with more interesting TestCase subclasses. It runs each test
> > method in a forked sub-process, passing the results back to the parent
> > process with subunit. This is needed to safely run tests that need to,
> > for example, start the Twisted reactor, which cannot be safely restarted
> > in the same process once stopped.
> >
> > This branch also uses ZopeIsolatedTestCase with a couple of job runner
> > tests. Additionally, one of these tests, TestJobCronScript, has been
> > fixed to override the command line arguments passed to the script class
> > (otherwise sys.argv is used, which is stuffed with arguments passed to
> > bin/test).
>
> vote needsinfo
>
> If the job runner tests ran fine previously, why is this
> ZopeIsolatedTestCase needed?

Jono once told me - and an IRC discussion between Aaron, Jono and
myself yesterday reconfirmed this - that reactor restarting is not
supported, and is considered by Twisted developers to not be a working
feature right now. They want it to work, but it's not high on the
list. Jono suggested subunit.IsolatedTestCase as a possible solution.

Apart from that, I discovered than one of my new tests for
checkwatches ran fine on its own, or when run alongside many other
tests, but hung indefinitely when run in the same process as the job
runner tests.

> > === modified file 'lib/lp/services/job/tests/test_runner.py'
> > --- lib/lp/services/job/tests/test_runner.py 2010-02-24 19:52:01 +0000
> > +++ lib/lp/services/job/tests/test_runner.py 2010-03-08 18:14:32 +0000
>
> > @@ -300,7 +301,7 @@
> > self.entries.append(input)
> >
> >
> > -class TestTwistedJobRunner(TestCaseWithFactory):
> > +class TestTwistedJobRunner(ZopeIsolatedTestCase, TestCaseWithFactory):
> >
> > layer = LaunchpadZopelessLayer
> >
> > @@ -325,7 +326,7 @@
> > self.assertIn('Job ran too long.', oops.value)
> >
> >
> > -class TestJobCronScript(TestCaseWithFactory):
> > +class TestJobCronScript(ZopeIsolatedTestCase, TestCaseWithFactory):
>
> Will we ever use ZopeIsolatedTestCase without mixing in
> TestCaseWithFactory?

Yes, possibly. I've already made some changes to this branch since the
merge proposal went out, one of which was to not inherit from
anything. The reason is that it can be mixed in with TestSuite (and
its subclasses) too, without alteration, to provide process isolation
for a whole suite. I've updated the docs to explain the behaviour.

Full diff of changes since the proposal:
  http://paste.ubuntu.com/391864/

> > === modified file 'lib/lp/testing/__init__.py'
> > --- lib/lp/testing/__init__.py 2010-02-17 20:54:36 +0000
> > +++ lib/lp/testing/__init__.py 2010-03-08 18:14:32 +0000
> > @@ -586,6 ...

Read more...

Revision history for this message
Jonathan Lange (jml) wrote :

2010/3/9 Gavin Panella <email address hidden>:
>> On Mon, Mar 08, 2010 at 06:14:32PM -0000, Gavin Panella wrote:
>> > Gavin Panella has proposed merging lp:~allenap/launchpad/isolate-tests into
>> lp:launchpad/devel.
>> >
>> > Requested reviews:
>> >   Canonical Launchpad Engineering (launchpad): code
>> > Related bugs:
>> >   #491870 Use Twisted's thread support instead of the threading module in
>> checkwatches
>> >   https://bugs.launchpad.net/bugs/491870
>> >
>> >
>> > Implement a new class - ZopeIsolatedTestCase - that can be used as a
>> > mixin with more interesting TestCase subclasses. It runs each test
>> > method in a forked sub-process, passing the results back to the parent
>> > process with subunit. This is needed to safely run tests that need to,
>> > for example, start the Twisted reactor, which cannot be safely restarted
>> >    in the same process once stopped.
>> >
>> > This branch also uses ZopeIsolatedTestCase with a couple of job runner
>> > tests. Additionally, one of these tests, TestJobCronScript, has been
>> > fixed to override the command line arguments passed to the script class
>> > (otherwise sys.argv is used, which is stuffed with arguments passed to
>> > bin/test).
>>
>>     vote needsinfo
>>
>> If the job runner tests ran fine previously, why is this
>> ZopeIsolatedTestCase needed?
>
> Jono once told me - and an IRC discussion between Aaron, Jono and
> myself yesterday reconfirmed this - that reactor restarting is not
> supported, and is considered by Twisted developers to not be a working
> feature right now. They want it to work, but it's not high on the
> list. Jono suggested subunit.IsolatedTestCase as a possible solution.
>

To be clear, this kind of solution (run tests in their own
subprocesses) is hardly ever used within the Twisted world, since it's
considered good practice there for asynchronous APIs to return
Deferreds, and to test things at that level. It's extremely rare to
have application code that needs to start and stop the reactor.

jml

Revision history for this message
Björn Tillenius (bjornt) wrote :
Download full text (6.1 KiB)

On Tue, Mar 09, 2010 at 04:23:44PM -0000, Gavin Panella wrote:
> > > -class TestJobCronScript(TestCaseWithFactory):
> > > +class TestJobCronScript(ZopeIsolatedTestCase, TestCaseWithFactory):
> >
> > Will we ever use ZopeIsolatedTestCase without mixing in
> > TestCaseWithFactory?
>
> Yes, possibly. I've already made some changes to this branch since the
> merge proposal went out, one of which was to not inherit from
> anything. The reason is that it can be mixed in with TestSuite (and
> its subclasses) too, without alteration, to provide process isolation
> for a whole suite. I've updated the docs to explain the behaviour.
>
> Full diff of changes since the proposal:
> http://paste.ubuntu.com/391864/

In the future, could you please attach the changes instead? Makes it
easier to review and comment.

>
> > > === modified file 'lib/lp/testing/__init__.py'
> > > --- lib/lp/testing/__init__.py 2010-02-17 20:54:36 +0000
> > > +++ lib/lp/testing/__init__.py 2010-03-08 18:14:32 +0000
> > > @@ -586,6 +591,56 @@
> > > self.client.open(url=u'http://launchpad.dev:8085')
> > >
> > >
> > > +class ZopeIsolatedTestCase(unittest.TestCase):
> > > + """Run tests in a sub-process, respecting Zope idiosyncrasies.
> > > +
> > > + Use this as a baseclass for test cases, or as a mixin with an
> > > + interesting `TestCase` subclass.
> > > +
> > > + This is basically a reimplementation of subunit's
> > > + `IsolatedTestCase`, but adjusted to work with Zope. In particular,
> > > + Zope's TestResult object is responsible for calling testSetUp()
> > > + and testTearDown() on the selected layer.
> > > + """
> >
> > I can see why you named it ZopeIsolatedTestCase, but is that really a
> > good name? I mean, don't TestCase classes in general run their tests in
> > an isolated environment?
>
> ZopeIsolatedTestCase is now known as ZopeIsolatedTest. The original
> name form came from subunit.IsolatedTestCase, which does a very
> similar job. I'm happy to rename it though.

I would probably have SubProcess, or Fork, in the name, to make it more
clear in what way it provides the isolation.

> > > + def run(self, result):
> > > + assert isinstance(result, ZopeTestResult), (
> > > + "result must be a Zope result object, not %r." % (result,))
> > > + pread, pwrite = os.pipe()
> > > + pid = os.fork()
> > > + if pid == 0:
> > > + # Child.
> > > + os.close(pread)
> > > + fdwrite = os.fdopen(pwrite, 'w', 1)
> > > + # Send results to both the Zope result object (so that
> > > + # layer setup and teardown are done properly, etc.) and to
> > > + # the subunit stream client so that the parent process can
> > > + # obtain the result.
> > > + result = testtools.MultiTestResult(
> > > + result, subunit.TestProtocolClient(fdwrite))
> > > + super(ZopeIsolatedTestCase, self).run(result)
> > > + fdwrite.flush()
> > > + sys.stdout.flush()
> > > + sys.stderr.flush()
> > > + # Exit hard.
> > > + os._exit(0)
> > > + else:
> > > +...

Read more...

Revision history for this message
Gavin Panella (allenap) wrote :

On Wed, 10 Mar 2010 14:15:31 -0000
Björn Tillenius <email address hidden> wrote:

...
> > ZopeIsolatedTestCase is now known as ZopeIsolatedTest. The original
> > name form came from subunit.IsolatedTestCase, which does a very
> > similar job. I'm happy to rename it though.
>
> I would probably have SubProcess, or Fork, in the name, to make it more
> clear in what way it provides the isolation.

It's now ZopeTestInSubProcess.

...
> > > I'm a bit interested in how this works with layers. Would you run away
> > > screaming if I asked you to add some tests demonstrating this? :)

Have a look at the new unit test I've added.

Basically, layer setUp() and tearDown() get called in the parent,
testSetUp() and testTearDown() in the child. For the test case,
__init__() is called in the parent, setUp(), tearDown() and test
methods are called in the child.

> > > It'd be interesting to see in which process the setUp(), testSetUp(),
> > > tearDown(), and testTearDown() methods of the layer are executed. I'm
> > > wondering if we can see subtle errors due to doing the wrong things in
> > > there.

Yes, there probably will be incompatibilities with some layers :-/

...
> > If it's mixed-in with a TestSuite I think it gets more complicated.

I haven't written tests for this yet. I may not do so (and restrict
ZopeTestInSubProcess to just TestCase for now).

...
> > > Also, how this this TestClass related to TwistedLayer? Oh right, that
> > > leads me to another question. You say that the reactor can't be
> > > restarted easily. But can it be shared between tests, just like the
> > > component architecture?
> >
> > That's an interesting idea. reactor.run() blocks, but I don't know of
> > any reason why it couldn't be started in a thread. I might get sent to
> > hell for it, but at least it's warmer than here.
>
> If it could, than maybe it would be better to use a layer instead, since
> that what we use elsewhere.

I haven't thought about this yet, but I will.

> Some other questions popped into my mind. What happens if you specify
> -D, and a test in the forked process fails?

This works as normal; you get a pdb prompt in the right place in the
child process.

> How well does this play with profiling? Do you still get all profiling
> information if you specify --profile=cProfile?

Not tried this yet. Not actually sure how to test this; it's a very
long time since I did any profiling.

So, I still have some work to do!

Gavin.

1=== modified file 'lib/lp/services/job/tests/test_runner.py'
2--- lib/lp/services/job/tests/test_runner.py 2010-03-09 14:28:11 +0000
3+++ lib/lp/services/job/tests/test_runner.py 2010-03-11 16:36:43 +0000
4@@ -27,7 +27,7 @@
5 from lp.services.job.model.job import Job
6 from lp.services.job.runner import (
7 BaseRunnableJob, JobCronScript, JobRunner, TwistedJobRunner)
8-from lp.testing import TestCaseWithFactory, ZopeIsolatedTest
9+from lp.testing import TestCaseWithFactory, ZopeTestInSubProcess
10 from lp.testing.mail_helpers import pop_notifications
11
12
13@@ -301,7 +301,7 @@
14 self.entries.append(input)
15
16
17-class TestTwistedJobRunner(ZopeIsolatedTest, TestCaseWithFactory):
18+class TestTwistedJobRunner(ZopeTestInSubProcess, TestCaseWithFactory):
19
20 layer = LaunchpadZopelessLayer
21
22@@ -326,7 +326,7 @@
23 self.assertIn('Job ran too long.', oops.value)
24
25
26-class TestJobCronScript(ZopeIsolatedTest, TestCaseWithFactory):
27+class TestJobCronScript(ZopeTestInSubProcess, TestCaseWithFactory):
28
29 layer = LaunchpadZopelessLayer
30
31
32=== modified file 'lib/lp/testing/__init__.py'
33--- lib/lp/testing/__init__.py 2010-03-09 14:28:11 +0000
34+++ lib/lp/testing/__init__.py 2010-03-11 16:36:43 +0000
35@@ -29,7 +29,7 @@
36 'validate_mock_class',
37 'WindmillTestCase',
38 'with_anonymous_login',
39- 'ZopeIsolatedTest',
40+ 'ZopeTestInSubProcess',
41 ]
42
43 import copy
44@@ -590,7 +590,7 @@
45 self.client.open(url=u'http://launchpad.dev:8085')
46
47
48-class ZopeIsolatedTest:
49+class ZopeTestInSubProcess:
50 """Run tests in a sub-process, respecting Zope idiosyncrasies.
51
52 Use this as a mixin with an interesting `TestCase` or `TestSuite`
53@@ -631,7 +631,7 @@
54 # obtain the result.
55 result = testtools.MultiTestResult(
56 result, subunit.TestProtocolClient(fdwrite))
57- super(ZopeIsolatedTest, self).run(result)
58+ super(ZopeTestInSubProcess, self).run(result)
59 fdwrite.flush()
60 sys.stdout.flush()
61 sys.stderr.flush()
62
63=== added file 'lib/lp/testing/tests/test_zope_test_in_subprocess.py'
64--- lib/lp/testing/tests/test_zope_test_in_subprocess.py 1970-01-01 00:00:00 +0000
65+++ lib/lp/testing/tests/test_zope_test_in_subprocess.py 2010-03-11 21:36:45 +0000
66@@ -0,0 +1,118 @@
67+# Copyright 2010 Canonical Ltd. This software is licensed under the
68+# GNU Affero General Public License version 3 (see the file LICENSE).
69+
70+"""Test `lp.testing.ZopeTestInSubProcess`."""
71+
72+__metaclass__ = type
73+
74+import functools
75+import os
76+import unittest
77+
78+from lp.testing import ZopeTestInSubProcess
79+
80+
81+def record_pid(method):
82+ """Decorator that records the pid at method invocation.
83+
84+ Will probably only DTRT with class methods or bound instance
85+ methods.
86+ """
87+ @functools.wraps(method)
88+ def wrapper(self, *args, **kwargs):
89+ setattr(self, 'pid_in_%s' % method.__name__, os.getpid())
90+ return method(self, *args, **kwargs)
91+ return wrapper
92+
93+
94+class TestZopeTestInSubProcessLayer:
95+ """Helper to test `ZopeTestInSubProcess`.
96+
97+ Asserts that layers are set up and torn down in the expected way,
98+ namely that setUp() and tearDown() are called in the parent
99+ process, and testSetUp() and testTearDown() are called in the
100+ child process.
101+
102+ The assertions for tearDown() and testTearDown() must be done here
103+ because the test case runs before these methods are called. In the
104+ interests of symmetry and clarity, the assertions for setUp() and
105+ testSetUp() are done here too.
106+ """
107+
108+ @record_pid
109+ def __init__(self, test):
110+ self.test = test
111+ # These are needed to satisfy the requirements of the
112+ # byzantine Zope layer machinery.
113+ self.__name__ = self.__class__.__name__
114+ self.__bases__ = self.__class__.__bases__
115+
116+ @record_pid
117+ def setUp(self):
118+ # Runs in the parent process.
119+ assert self.pid_in___init__ == self.pid_in_setUp, (
120+ "layer.setUp() not called in parent process.")
121+
122+ @record_pid
123+ def tearDown(self):
124+ # Runs in the parent process.
125+ assert self.pid_in___init__ == self.pid_in_tearDown, (
126+ "layer.tearDown() not called in parent process.")
127+
128+ @record_pid
129+ def testSetUp(self):
130+ # Runs in the child process.
131+ assert self.pid_in___init__ != self.pid_in_testSetUp, (
132+ "layer.testSetUp() called in parent process.")
133+
134+ @record_pid
135+ def testTearDown(self):
136+ # Runs in the child process.
137+ assert self.pid_in_testSetUp == self.pid_in_testTearDown, (
138+ "layer.testTearDown() not called in same process as testSetUp().")
139+
140+
141+class TestZopeTestInSubProcess(ZopeTestInSubProcess, unittest.TestCase):
142+ """Test `ZopeTestInSubProcess`.
143+
144+ Assert that setUp(), test() and tearDown() are called in the child
145+ process.
146+ """
147+
148+ @record_pid
149+ def __init__(self, method_name='runTest'):
150+ # Runs in the parent process.
151+ super(TestZopeTestInSubProcess, self).__init__(method_name)
152+ self.layer = TestZopeTestInSubProcessLayer(self)
153+
154+ @record_pid
155+ def setUp(self):
156+ # Runs in the child process.
157+ super(TestZopeTestInSubProcess, self).setUp()
158+ self.failUnlessEqual(
159+ self.layer.pid_in_testSetUp, self.pid_in_setUp,
160+ "setUp() not called in same process as layer.testSetUp().")
161+
162+ @record_pid
163+ def test(self):
164+ # Runs in the child process.
165+ self.failUnlessEqual(
166+ self.pid_in_setUp, self.pid_in_test,
167+ "test method not run in same process as setUp().")
168+
169+ @record_pid
170+ def tearDown(self):
171+ # Runs in the child process.
172+ super(TestZopeTestInSubProcess, self).tearDown()
173+ self.failUnlessEqual(
174+ self.pid_in_setUp, self.pid_in_tearDown,
175+ "tearDown() not run in same process as setUp().")
176+
177+
178+def test_suite():
179+ suite = unittest.TestSuite()
180+ loader = unittest.TestLoader()
181+ suite.addTests(
182+ loader.loadTestsFromTestCase(
183+ TestZopeTestInSubProcess))
184+ return suite
Revision history for this message
Gavin Panella (allenap) wrote :

> On Wed, 10 Mar 2010 14:15:31 -0000
> Björn Tillenius <email address hidden> wrote:
>
> ...
> > > If it's mixed-in with a TestSuite I think it gets more complicated.
>
> I haven't written tests for this yet. I may not do so (and restrict
> ZopeTestInSubProcess to just TestCase for now).

I've removed the suggestion that it can be mixed-in with a TestSuite,
with the following commit message:

  Don't advertise ZopeTestInSubProcess's use with a TestSuite. When
  one suite is added to another with addTests() the *tests* are
  copied, not the suite, so behaviour in a custom TestSuite is
  lost. This is too fragile and likely to cause confusion.

> ...
> > > > Also, how this this TestClass related to TwistedLayer? Oh right, that
> > > > leads me to another question. You say that the reactor can't be
> > > > restarted easily. But can it be shared between tests, just like the
> > > > component architecture?
> > >
> > > That's an interesting idea. reactor.run() blocks, but I don't know of
> > > any reason why it couldn't be started in a thread. I might get sent to
> > > hell for it, but at least it's warmer than here.
> >
> > If it could, than maybe it would be better to use a layer instead, since
> > that what we use elsewhere.
>
> I haven't thought about this yet, but I will.

Now that this works, and seems to work well in the situations I'm
using it (I am also using it in a dependent branch for checkwatches),
I'm reluctant to spend more time on figuring out how to do it in a
layer.

Both the job runner code and my checkwatches code start and stop the
reactor as part of their operation, so trying to fudge them to use an
already-started reactor skews the test and means more moving parts.

Forking is cheap as chips so, although I warn in the docstring that it
will slow down tests, I doubt it will make much of a difference,
especially when testing network code, database code, etc. I haven't
measured it though.

...
> > How well does this play with profiling? Do you still get all profiling
> > information if you specify --profile=cProfile?
>
> Not tried this yet. Not actually sure how to test this; it's a very
> long time since I did any profiling.

It looks like no profiling information is collected from the
sub-process. I'm not sure how to fix this. How important is this to
you? Can this branch be landed without that support, with a bug filed
to record that it needs to be added?

Gavin.

Revision history for this message
Björn Tillenius (bjornt) wrote :

On Fri, Mar 12, 2010 at 11:27:26AM -0000, Gavin Panella wrote:
> > On Wed, 10 Mar 2010 14:15:31 -0000
> > Björn Tillenius <email address hidden> wrote:
> >
> > ...
> > > > If it's mixed-in with a TestSuite I think it gets more complicated.
> >
> > I haven't written tests for this yet. I may not do so (and restrict
> > ZopeTestInSubProcess to just TestCase for now).
>
> I've removed the suggestion that it can be mixed-in with a TestSuite,
> with the following commit message:
>
> Don't advertise ZopeTestInSubProcess's use with a TestSuite. When
> one suite is added to another with addTests() the *tests* are
> copied, not the suite, so behaviour in a custom TestSuite is
> lost. This is too fragile and likely to cause confusion.

Ok.

> > > > > Also, how this this TestClass related to TwistedLayer? Oh right, that
> > > > > leads me to another question. You say that the reactor can't be
> > > > > restarted easily. But can it be shared between tests, just like the
> > > > > component architecture?
> > > >
> > > > That's an interesting idea. reactor.run() blocks, but I don't know of
> > > > any reason why it couldn't be started in a thread. I might get sent to
> > > > hell for it, but at least it's warmer than here.
> > >
> > > If it could, than maybe it would be better to use a layer instead, since
> > > that what we use elsewhere.
> >
> > I haven't thought about this yet, but I will.
>
> Now that this works, and seems to work well in the situations I'm
> using it (I am also using it in a dependent branch for checkwatches),
> I'm reluctant to spend more time on figuring out how to do it in a
> layer.

Ok.

> > > How well does this play with profiling? Do you still get all profiling
> > > information if you specify --profile=cProfile?
> >
> > Not tried this yet. Not actually sure how to test this; it's a very
> > long time since I did any profiling.
>
> It looks like no profiling information is collected from the
> sub-process. I'm not sure how to fix this. How important is this to
> you? Can this branch be landed without that support, with a bug filed
> to record that it needs to be added?

As long as it's not used too much, it's not crucial to have it.

However, I don't see any tests for this. Oh, now I found them, they just
don't look like tests. Hmm. Some more comments would help, to understand
why, and what's being tested. I would have expected a test that manually
calls the run() method. Would that be hard? It would mean that you would
get much better failure messages, in case of something went wrong.
Having tests in setUp and tearDown isn't ideal.

Sorry, but you'll have to ask someone else to carry on reviewing this
branch, since I'm unavailable next week.

    vote abstain

--
Björn Tillenius | https://launchpad.net/~bjornt

review: Abstain
Revision history for this message
Gavin Panella (allenap) wrote :

Björn Tillenius wrote:
> > > > How well does this play with profiling? Do you still get all profiling
> > > > information if you specify --profile=cProfile?
> > >
> > > Not tried this yet. Not actually sure how to test this; it's a very
> > > long time since I did any profiling.
> >
> > It looks like no profiling information is collected from the
> > sub-process. I'm not sure how to fix this. How important is this to
> > you? Can this branch be landed without that support, with a bug filed
> > to record that it needs to be added?
>
> As long as it's not used too much, it's not crucial to have it.
>
> However, I don't see any tests for this. Oh, now I found them, they just
> don't look like tests. Hmm. Some more comments would help, to understand
> why, and what's being tested. I would have expected a test that manually
> calls the run() method. Would that be hard? It would mean that you would
> get much better failure messages, in case of something went wrong.

I've looked into calling the run() method manually, but it means that we
would only be able to test layer.testSetUp(), test.setUp(), the test
method itself, test.tearDown() and layer.testTearDown(). I guess that's
not bad, but it misses out on layer.setUp() and layer.tearDown(), which
are quite important. The way it's structured at the moment means that
everything gets tested, and tested with the real test objects (i.e. the
runner and result) that zope.testing provides.

I have tried to replicate the zope.testing set up - by using Runner from
zope.testing.testresult.runner - but it's fiddly and adds a ton of noise
to the tests and the output. And I haven't yet figured out how to get it
to work properly.

> Having tests in setUp and tearDown isn't ideal.

It's not ideal, but failures here get reported as test failures, rather
than causing a complete test run failure. Unfortunately, failures in
layer methods do cause the run to fail.

To summarise, I'd like to leave the tests as they are. They demonstrate
the operation of ZopeTestInSubProcess right from layer set-up to
tear-down, which I think is valuable. I'll add some more comments to
make it clearer to future developers.

> Sorry, but you'll have to ask someone else to carry on reviewing this
> branch, since I'm unavailable next week.

Thank you for asking so many good questions :) Have a good break.

Revision history for this message
Gavin Panella (allenap) wrote :

Latest incremental diff, r10440..

1=== modified file 'lib/lp/testing/__init__.py'
2--- lib/lp/testing/__init__.py 2010-03-11 16:37:22 +0000
3+++ lib/lp/testing/__init__.py 2010-03-12 10:58:17 +0000
4@@ -593,22 +593,12 @@
5 class ZopeTestInSubProcess:
6 """Run tests in a sub-process, respecting Zope idiosyncrasies.
7
8- Use this as a mixin with an interesting `TestCase` or `TestSuite`
9- subclass to quickly isolate tests with side-effects.
10-
11- When mixed-in with a `TestCase`:
12-
13- Each and every test *method* in the test case is run in a new,
14- forked, sub-process. This will slow down your tests, so use it
15- sparingly. However, when you need to, for example, start the
16- Twisted reactor (which cannot currently be safely stopped and
17- restarted in process) it is invaluable.
18-
19- When mixed-in with a `TestSuite`:
20-
21- All tests in this suite are run in a single, newly forked,
22- sub-process. This isolates the parent process from side-effects,
23- but the tests in this suite are not isolated from one another.
24+ Use this as a mixin with an interesting `TestCase` to isolate
25+ tests with side-effects. Each and every test *method* in the test
26+ case is run in a new, forked, sub-process. This will slow down
27+ your tests, so use it sparingly. However, when you need to, for
28+ example, start the Twisted reactor (which cannot currently be
29+ safely stopped and restarted in process) it is invaluable.
30
31 This is basically a reimplementation of subunit's
32 `IsolatedTestCase` or `IsolatedTestSuite`, but adjusted to work
33
34=== modified file 'lib/lp/testing/tests/test_zope_test_in_subprocess.py'
35--- lib/lp/testing/tests/test_zope_test_in_subprocess.py 2010-03-11 21:37:41 +0000
36+++ lib/lp/testing/tests/test_zope_test_in_subprocess.py 2010-03-15 11:46:49 +0000
37@@ -1,7 +1,18 @@
38 # Copyright 2010 Canonical Ltd. This software is licensed under the
39 # GNU Affero General Public License version 3 (see the file LICENSE).
40
41-"""Test `lp.testing.ZopeTestInSubProcess`."""
42+"""Test `lp.testing.ZopeTestInSubProcess`.
43+
44+How does it do this?
45+
46+A `TestCase`, mixed-in with `ZopeTestInSubProcess`, is run by the Zope
47+test runner. This test case sets its own layer, to keep track of the
48+PIDs when certain methods are called. It also records pids for its own
49+methods. Assertions are made as these methods are called to ensure that
50+they are running in the correct process - the parent or the child.
51+
52+Recording of the PIDs is handled using the `record_pid` decorator.
53+"""
54
55 __metaclass__ = type
56
57@@ -37,11 +48,13 @@
58 because the test case runs before these methods are called. In the
59 interests of symmetry and clarity, the assertions for setUp() and
60 testSetUp() are done here too.
61+
62+ This layer expects to be *instantiated*, which is not the norm for
63+ Zope layers. See `TestZopeTestInSubProcess` for its use.
64 """
65
66 @record_pid
67- def __init__(self, test):
68- self.test = test
69+ def __init__(self):
70 # These are needed to satisfy the requirements of the
71 # byzantine Zope layer machinery.
72 self.__name__ = self.__class__.__name__
73@@ -54,12 +67,6 @@
74 "layer.setUp() not called in parent process.")
75
76 @record_pid
77- def tearDown(self):
78- # Runs in the parent process.
79- assert self.pid_in___init__ == self.pid_in_tearDown, (
80- "layer.tearDown() not called in parent process.")
81-
82- @record_pid
83 def testSetUp(self):
84 # Runs in the child process.
85 assert self.pid_in___init__ != self.pid_in_testSetUp, (
86@@ -71,19 +78,30 @@
87 assert self.pid_in_testSetUp == self.pid_in_testTearDown, (
88 "layer.testTearDown() not called in same process as testSetUp().")
89
90+ @record_pid
91+ def tearDown(self):
92+ # Runs in the parent process.
93+ assert self.pid_in___init__ == self.pid_in_tearDown, (
94+ "layer.tearDown() not called in parent process.")
95+
96
97 class TestZopeTestInSubProcess(ZopeTestInSubProcess, unittest.TestCase):
98 """Test `ZopeTestInSubProcess`.
99
100 Assert that setUp(), test() and tearDown() are called in the child
101 process.
102+
103+ Sets its own layer attribute. This layer is then responsible for
104+ recording the PID at interesting moments. Specifically,
105+ layer.testSetUp() must be called in the same process as
106+ test.setUp().
107 """
108
109 @record_pid
110 def __init__(self, method_name='runTest'):
111 # Runs in the parent process.
112 super(TestZopeTestInSubProcess, self).__init__(method_name)
113- self.layer = TestZopeTestInSubProcessLayer(self)
114+ self.layer = TestZopeTestInSubProcessLayer()
115
116 @record_pid
117 def setUp(self):
118@@ -110,9 +128,4 @@
119
120
121 def test_suite():
122- suite = unittest.TestSuite()
123- loader = unittest.TestLoader()
124- suite.addTests(
125- loader.loadTestsFromTestCase(
126- TestZopeTestInSubProcess))
127- return suite
128+ return unittest.TestLoader().loadTestsFromName(__name__)
Revision history for this message
Jonathan Lange (jml) wrote :
Download full text (7.1 KiB)

On Mon, Mar 15, 2010 at 1:30 PM, Gavin Panella
<email address hidden> wrote:
> Latest incremental diff, r10440..
>
> === modified file 'lib/lp/services/job/runner.py'
> --- lib/lp/services/job/runner.py 2010-02-25 12:07:15 +0000
> +++ lib/lp/services/job/runner.py 2010-03-15 13:26:40 +0000
> @@ -401,9 +401,10 @@
> class JobCronScript(LaunchpadCronScript):
> """Base class for scripts that run jobs."""
>
> - def __init__(self, runner_class=JobRunner):
> + def __init__(self, runner_class=JobRunner, test_args=None):
> self.dbuser = getattr(config, self.config_name).dbuser
> - super(JobCronScript, self).__init__(self.config_name, self.dbuser)
> + super(JobCronScript, self).__init__(
> + self.config_name, self.dbuser, test_args)
> self.runner_class = runner_class
>
> def main(self):
>

What's the motivation for this change?

> === modified file 'lib/lp/services/job/tests/test_runner.py'
> --- lib/lp/services/job/tests/test_runner.py 2010-02-24 19:52:01 +0000
> +++ lib/lp/services/job/tests/test_runner.py 2010-03-15 13:26:40 +0000
> @@ -11,23 +11,24 @@
> from unittest import TestLoader
>
> import transaction
> -from canonical.testing import LaunchpadZopelessLayer
> +
> from zope.component import getUtility
> from zope.error.interfaces import IErrorReportingUtility
> from zope.interface import implements
>
> +from canonical.launchpad.webapp import errorlog
> +from canonical.launchpad.webapp.interfaces import (
> + DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE)
> +from canonical.testing import LaunchpadZopelessLayer
> +
> from lp.code.interfaces.branchmergeproposal import (
> - IUpdatePreviewDiffJobSource,)
> -from lp.testing.mail_helpers import pop_notifications
> -from lp.services.job.runner import (
> - JobCronScript, JobRunner, BaseRunnableJob, TwistedJobRunner
> -)
> + IUpdatePreviewDiffJobSource)
> from lp.services.job.interfaces.job import JobStatus, IRunnableJob
> from lp.services.job.model.job import Job
> -from lp.testing import TestCaseWithFactory
> -from canonical.launchpad.webapp import errorlog
> -from canonical.launchpad.webapp.interfaces import (
> - IStoreSelector, MAIN_STORE, DEFAULT_FLAVOR)
> +from lp.services.job.runner import (
> + BaseRunnableJob, JobCronScript, JobRunner, TwistedJobRunner)
> +from lp.testing import TestCaseWithFactory, ZopeTestInSubProcess
> +from lp.testing.mail_helpers import pop_notifications
>
>
> class NullJob(BaseRunnableJob):
> @@ -300,7 +301,7 @@
> self.entries.append(input)
>
>
> -class TestTwistedJobRunner(TestCaseWithFactory):
> +class TestTwistedJobRunner(ZopeTestInSubProcess, TestCaseWithFactory):
>
> layer = LaunchpadZopelessLayer
>
> @@ -325,7 +326,7 @@
> self.assertIn('Job ran too long.', oops.value)
>
>
> -class TestJobCronScript(TestCaseWithFactory):
> +class TestJobCronScript(ZopeTestInSubProcess, TestCaseWithFactory):
>
> layer = LaunchpadZopelessLayer
>
> @@ -351,7 +352,8 @@
> source_interface = IUpdatePreviewDiffJobSource
>
> def __init__(self):
> - super(JobCronScriptSubclass, self).__init__(DummyRunner)
> + super(Job...

Read more...

Revision history for this message
Jonathan Lange (jml) wrote :

Just a few changes required to the comments.

review: Needs Fixing
Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (9.5 KiB)

> On Mon, Mar 15, 2010 at 1:30 PM, Gavin Panella
> <email address hidden> wrote:
> > Latest incremental diff, r10440..
> >
> > === modified file 'lib/lp/services/job/runner.py'
> > --- lib/lp/services/job/runner.py 2010-02-25 12:07:15 +0000
> > +++ lib/lp/services/job/runner.py 2010-03-15 13:26:40 +0000
> > @@ -401,9 +401,10 @@
> > class JobCronScript(LaunchpadCronScript):
> > """Base class for scripts that run jobs."""
> >
> > - def __init__(self, runner_class=JobRunner):
> > + def __init__(self, runner_class=JobRunner, test_args=None):
> > self.dbuser = getattr(config, self.config_name).dbuser
> > - super(JobCronScript, self).__init__(self.config_name, self.dbuser)
> > + super(JobCronScript, self).__init__(
> > + self.config_name, self.dbuser, test_args)
> > self.runner_class = runner_class
> >
> > def main(self):
> >
>
> What's the motivation for this change?

It makes testing easier. I'll explain more below.

>
> > === modified file 'lib/lp/services/job/tests/test_runner.py'
> > --- lib/lp/services/job/tests/test_runner.py 2010-02-24 19:52:01 +0000
> > +++ lib/lp/services/job/tests/test_runner.py 2010-03-15 13:26:40 +0000
> > @@ -11,23 +11,24 @@
> > from unittest import TestLoader
> >
> > import transaction
> > -from canonical.testing import LaunchpadZopelessLayer
> > +
> > from zope.component import getUtility
> > from zope.error.interfaces import IErrorReportingUtility
> > from zope.interface import implements
> >
> > +from canonical.launchpad.webapp import errorlog
> > +from canonical.launchpad.webapp.interfaces import (
> > + DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE)
> > +from canonical.testing import LaunchpadZopelessLayer
> > +
> > from lp.code.interfaces.branchmergeproposal import (
> > - IUpdatePreviewDiffJobSource,)
> > -from lp.testing.mail_helpers import pop_notifications
> > -from lp.services.job.runner import (
> > - JobCronScript, JobRunner, BaseRunnableJob, TwistedJobRunner
> > -)
> > + IUpdatePreviewDiffJobSource)
> > from lp.services.job.interfaces.job import JobStatus, IRunnableJob
> > from lp.services.job.model.job import Job
> > -from lp.testing import TestCaseWithFactory
> > -from canonical.launchpad.webapp import errorlog
> > -from canonical.launchpad.webapp.interfaces import (
> > - IStoreSelector, MAIN_STORE, DEFAULT_FLAVOR)
> > +from lp.services.job.runner import (
> > + BaseRunnableJob, JobCronScript, JobRunner, TwistedJobRunner)
> > +from lp.testing import TestCaseWithFactory, ZopeTestInSubProcess
> > +from lp.testing.mail_helpers import pop_notifications
> >
> >
> > class NullJob(BaseRunnableJob):
> > @@ -300,7 +301,7 @@
> > self.entries.append(input)
> >
> >
> > -class TestTwistedJobRunner(TestCaseWithFactory):
> > +class TestTwistedJobRunner(ZopeTestInSubProcess, TestCaseWithFactory):
> >
> > layer = LaunchpadZopelessLayer
> >
> > @@ -325,7 +326,7 @@
> > self.assertIn('Job ran too long.', oops.value)
> >
> >
> > -class TestJobCronScript(TestCaseWithFactory):
> > +class TestJobCronScript(ZopeTestInSubProcess, TestCaseWithFactory):
> >
> > layer = LaunchpadZopelessLayer
> >
> >...

Read more...

Revision history for this message
Jonathan Lange (jml) wrote :
Download full text (9.7 KiB)

On Tue, Mar 16, 2010 at 4:58 PM, Gavin Panella
<email address hidden> wrote:
>> On Mon, Mar 15, 2010 at 1:30 PM, Gavin Panella
>> <email address hidden> wrote:
>> > Latest incremental diff, r10440..
>> >
>> > === modified file 'lib/lp/services/job/runner.py'
>> > --- lib/lp/services/job/runner.py     2010-02-25 12:07:15 +0000
>> > +++ lib/lp/services/job/runner.py     2010-03-15 13:26:40 +0000
>> > @@ -401,9 +401,10 @@
>> >  class JobCronScript(LaunchpadCronScript):
>> >      """Base class for scripts that run jobs."""
>> >
>> > -    def __init__(self, runner_class=JobRunner):
>> > +    def __init__(self, runner_class=JobRunner, test_args=None):
>> >          self.dbuser = getattr(config, self.config_name).dbuser
>> > -        super(JobCronScript, self).__init__(self.config_name, self.dbuser)
>> > +        super(JobCronScript, self).__init__(
>> > +            self.config_name, self.dbuser, test_args)
>> >          self.runner_class = runner_class
>> >
>> >      def main(self):
>> >
>>
>> What's the motivation for this change?
>
> It makes testing easier. I'll explain more below.
>

>>
>> > === modified file 'lib/lp/services/job/tests/test_runner.py'
>> > --- lib/lp/services/job/tests/test_runner.py  2010-02-24 19:52:01 +0000
>> > +++ lib/lp/services/job/tests/test_runner.py  2010-03-15 13:26:40 +0000
>> > @@ -11,23 +11,24 @@
>> >  from unittest import TestLoader
>> >
>> >  import transaction
>> > -from canonical.testing import LaunchpadZopelessLayer
>> > +
>> >  from zope.component import getUtility
>> >  from zope.error.interfaces import IErrorReportingUtility
>> >  from zope.interface import implements
>> >
>> > +from canonical.launchpad.webapp import errorlog
>> > +from canonical.launchpad.webapp.interfaces import (
>> > +    DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE)
>> > +from canonical.testing import LaunchpadZopelessLayer
>> > +
>> >  from lp.code.interfaces.branchmergeproposal import (
>> > -    IUpdatePreviewDiffJobSource,)
>> > -from lp.testing.mail_helpers import pop_notifications
>> > -from lp.services.job.runner import (
>> > -    JobCronScript, JobRunner, BaseRunnableJob, TwistedJobRunner
>> > -)
>> > +    IUpdatePreviewDiffJobSource)
>> >  from lp.services.job.interfaces.job import JobStatus, IRunnableJob
>> >  from lp.services.job.model.job import Job
>> > -from lp.testing import TestCaseWithFactory
>> > -from canonical.launchpad.webapp import errorlog
>> > -from canonical.launchpad.webapp.interfaces import (
>> > -    IStoreSelector, MAIN_STORE, DEFAULT_FLAVOR)
>> > +from lp.services.job.runner import (
>> > +    BaseRunnableJob, JobCronScript, JobRunner, TwistedJobRunner)
>> > +from lp.testing import TestCaseWithFactory, ZopeTestInSubProcess
>> > +from lp.testing.mail_helpers import pop_notifications
>> >
>> >
>> >  class NullJob(BaseRunnableJob):
>> > @@ -300,7 +301,7 @@
>> >          self.entries.append(input)
>> >
>> >
>> > -class TestTwistedJobRunner(TestCaseWithFactory):
>> > +class TestTwistedJobRunner(ZopeTestInSubProcess, TestCaseWithFactory):
>> >
>> >      layer = LaunchpadZopelessLayer
>> >
>> > @@ -325,7 +326,7 @@
>> >          self.assertIn('Job ran too long.', oops.value)
>> >
>> >
>> > -class...

Read more...

Revision history for this message
Jonathan Lange (jml) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/services/job/runner.py'
2--- lib/lp/services/job/runner.py 2010-02-25 12:07:15 +0000
3+++ lib/lp/services/job/runner.py 2010-03-16 18:02:40 +0000
4@@ -401,9 +401,10 @@
5 class JobCronScript(LaunchpadCronScript):
6 """Base class for scripts that run jobs."""
7
8- def __init__(self, runner_class=JobRunner):
9+ def __init__(self, runner_class=JobRunner, test_args=None):
10 self.dbuser = getattr(config, self.config_name).dbuser
11- super(JobCronScript, self).__init__(self.config_name, self.dbuser)
12+ super(JobCronScript, self).__init__(
13+ self.config_name, self.dbuser, test_args)
14 self.runner_class = runner_class
15
16 def main(self):
17
18=== modified file 'lib/lp/services/job/tests/test_runner.py'
19--- lib/lp/services/job/tests/test_runner.py 2010-02-24 19:52:01 +0000
20+++ lib/lp/services/job/tests/test_runner.py 2010-03-16 18:02:40 +0000
21@@ -11,23 +11,24 @@
22 from unittest import TestLoader
23
24 import transaction
25-from canonical.testing import LaunchpadZopelessLayer
26+
27 from zope.component import getUtility
28 from zope.error.interfaces import IErrorReportingUtility
29 from zope.interface import implements
30
31+from canonical.launchpad.webapp import errorlog
32+from canonical.launchpad.webapp.interfaces import (
33+ DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE)
34+from canonical.testing import LaunchpadZopelessLayer
35+
36 from lp.code.interfaces.branchmergeproposal import (
37- IUpdatePreviewDiffJobSource,)
38-from lp.testing.mail_helpers import pop_notifications
39-from lp.services.job.runner import (
40- JobCronScript, JobRunner, BaseRunnableJob, TwistedJobRunner
41-)
42+ IUpdatePreviewDiffJobSource)
43 from lp.services.job.interfaces.job import JobStatus, IRunnableJob
44 from lp.services.job.model.job import Job
45-from lp.testing import TestCaseWithFactory
46-from canonical.launchpad.webapp import errorlog
47-from canonical.launchpad.webapp.interfaces import (
48- IStoreSelector, MAIN_STORE, DEFAULT_FLAVOR)
49+from lp.services.job.runner import (
50+ BaseRunnableJob, JobCronScript, JobRunner, TwistedJobRunner)
51+from lp.testing import TestCaseWithFactory, ZopeTestInSubProcess
52+from lp.testing.mail_helpers import pop_notifications
53
54
55 class NullJob(BaseRunnableJob):
56@@ -300,7 +301,7 @@
57 self.entries.append(input)
58
59
60-class TestTwistedJobRunner(TestCaseWithFactory):
61+class TestTwistedJobRunner(ZopeTestInSubProcess, TestCaseWithFactory):
62
63 layer = LaunchpadZopelessLayer
64
65@@ -325,7 +326,7 @@
66 self.assertIn('Job ran too long.', oops.value)
67
68
69-class TestJobCronScript(TestCaseWithFactory):
70+class TestJobCronScript(ZopeTestInSubProcess, TestCaseWithFactory):
71
72 layer = LaunchpadZopelessLayer
73
74@@ -351,7 +352,8 @@
75 source_interface = IUpdatePreviewDiffJobSource
76
77 def __init__(self):
78- super(JobCronScriptSubclass, self).__init__(DummyRunner)
79+ super(JobCronScriptSubclass, self).__init__(
80+ DummyRunner, test_args=[])
81 self.logger = ListLogger()
82
83 old_errorlog = errorlog.globalErrorUtility
84
85=== modified file 'lib/lp/testing/__init__.py'
86--- lib/lp/testing/__init__.py 2010-03-01 03:06:02 +0000
87+++ lib/lp/testing/__init__.py 2010-03-16 18:02:40 +0000
88@@ -29,6 +29,7 @@
89 'validate_mock_class',
90 'WindmillTestCase',
91 'with_anonymous_login',
92+ 'ZopeTestInSubProcess',
93 ]
94
95 import copy
96@@ -38,6 +39,8 @@
97 from pprint import pformat
98 import shutil
99 import subprocess
100+import subunit
101+import sys
102 import tempfile
103 import time
104
105@@ -62,6 +65,7 @@
106 from zope.interface.verify import verifyClass, verifyObject
107 from zope.security.proxy import (
108 isinstance as zope_isinstance, removeSecurityProxy)
109+from zope.testing.testrunner.runner import TestResult as ZopeTestResult
110
111 from canonical.launchpad.webapp import errorlog
112 from canonical.config import config
113@@ -586,6 +590,70 @@
114 self.client.open(url=u'http://launchpad.dev:8085')
115
116
117+class ZopeTestInSubProcess:
118+ """Run tests in a sub-process, respecting Zope idiosyncrasies.
119+
120+ Use this as a mixin with an interesting `TestCase` to isolate
121+ tests with side-effects. Each and every test *method* in the test
122+ case is run in a new, forked, sub-process. This will slow down
123+ your tests, so use it sparingly. However, when you need to, for
124+ example, start the Twisted reactor (which cannot currently be
125+ safely stopped and restarted in process) it is invaluable.
126+
127+ This is basically a reimplementation of subunit's
128+ `IsolatedTestCase` or `IsolatedTestSuite`, but adjusted to work
129+ with Zope. In particular, Zope's TestResult object is responsible
130+ for calling testSetUp() and testTearDown() on the selected layer.
131+ """
132+
133+ def run(self, result):
134+ # The result must be an instance of Zope's TestResult because
135+ # we construct a super() of it later on. Other result classes
136+ # could be supported with a more general approach, but it's
137+ # unlikely that any one approach is going to work for every
138+ # class. It's better to fail early and draw attention here.
139+ assert isinstance(result, ZopeTestResult), (
140+ "result must be a Zope result object, not %r." % (result,))
141+ pread, pwrite = os.pipe()
142+ pid = os.fork()
143+ if pid == 0:
144+ # Child.
145+ os.close(pread)
146+ fdwrite = os.fdopen(pwrite, 'w', 1)
147+ # Send results to both the Zope result object (so that
148+ # layer setup and teardown are done properly, etc.) and to
149+ # the subunit stream client so that the parent process can
150+ # obtain the result.
151+ result = testtools.MultiTestResult(
152+ result, subunit.TestProtocolClient(fdwrite))
153+ super(ZopeTestInSubProcess, self).run(result)
154+ fdwrite.flush()
155+ sys.stdout.flush()
156+ sys.stderr.flush()
157+ # Exit hard to avoid running onexit handlers and to avoid
158+ # anything that could suppress SystemExit; this exit must
159+ # not be prevented.
160+ os._exit(0)
161+ else:
162+ # Parent.
163+ os.close(pwrite)
164+ fdread = os.fdopen(pread, 'rU')
165+ # Skip all the Zope-specific result stuff by using a
166+ # super() of the result. This is because the Zope result
167+ # object calls testSetUp() and testTearDown() on the
168+ # layer, and handles post-mortem debugging. These things
169+ # do not make sense in the parent process. More
170+ # immediately, it also means that the results are not
171+ # reported twice; they are reported on stdout by the child
172+ # process, so they need to be suppressed here.
173+ result = super(ZopeTestResult, result)
174+ # Accept the result from the child process.
175+ protocol = subunit.TestProtocolServer(result)
176+ protocol.readFrom(fdread)
177+ fdread.close()
178+ os.waitpid(pid, 0)
179+
180+
181 def capture_events(callable_obj, *args, **kwargs):
182 """Capture the events emitted by a callable.
183
184
185=== added file 'lib/lp/testing/tests/test_zope_test_in_subprocess.py'
186--- lib/lp/testing/tests/test_zope_test_in_subprocess.py 1970-01-01 00:00:00 +0000
187+++ lib/lp/testing/tests/test_zope_test_in_subprocess.py 2010-03-16 18:02:40 +0000
188@@ -0,0 +1,131 @@
189+# Copyright 2010 Canonical Ltd. This software is licensed under the
190+# GNU Affero General Public License version 3 (see the file LICENSE).
191+
192+"""Test `lp.testing.ZopeTestInSubProcess`.
193+
194+How does it do this?
195+
196+A `TestCase`, mixed-in with `ZopeTestInSubProcess`, is run by the Zope
197+test runner. This test case sets its own layer, to keep track of the
198+PIDs when certain methods are called. It also records pids for its own
199+methods. Assertions are made as these methods are called to ensure that
200+they are running in the correct process - the parent or the child.
201+
202+Recording of the PIDs is handled using the `record_pid` decorator.
203+"""
204+
205+__metaclass__ = type
206+
207+import functools
208+import os
209+import unittest
210+
211+from lp.testing import ZopeTestInSubProcess
212+
213+
214+def record_pid(method):
215+ """Decorator that records the pid at method invocation.
216+
217+ Will probably only DTRT with class methods or bound instance
218+ methods.
219+ """
220+ @functools.wraps(method)
221+ def wrapper(self, *args, **kwargs):
222+ setattr(self, 'pid_in_%s' % method.__name__, os.getpid())
223+ return method(self, *args, **kwargs)
224+ return wrapper
225+
226+
227+class TestZopeTestInSubProcessLayer:
228+ """Helper to test `ZopeTestInSubProcess`.
229+
230+ Asserts that layers are set up and torn down in the expected way,
231+ namely that setUp() and tearDown() are called in the parent
232+ process, and testSetUp() and testTearDown() are called in the
233+ child process.
234+
235+ The assertions for tearDown() and testTearDown() must be done here
236+ because the test case runs before these methods are called. In the
237+ interests of symmetry and clarity, the assertions for setUp() and
238+ testSetUp() are done here too.
239+
240+ This layer expects to be *instantiated*, which is not the norm for
241+ Zope layers. See `TestZopeTestInSubProcess` for its use.
242+ """
243+
244+ @record_pid
245+ def __init__(self):
246+ # These are needed to satisfy the requirements of the
247+ # byzantine Zope layer machinery.
248+ self.__name__ = self.__class__.__name__
249+ self.__bases__ = self.__class__.__bases__
250+
251+ @record_pid
252+ def setUp(self):
253+ # Runs in the parent process.
254+ assert self.pid_in___init__ == self.pid_in_setUp, (
255+ "layer.setUp() not called in parent process.")
256+
257+ @record_pid
258+ def testSetUp(self):
259+ # Runs in the child process.
260+ assert self.pid_in___init__ != self.pid_in_testSetUp, (
261+ "layer.testSetUp() called in parent process.")
262+
263+ @record_pid
264+ def testTearDown(self):
265+ # Runs in the child process.
266+ assert self.pid_in_testSetUp == self.pid_in_testTearDown, (
267+ "layer.testTearDown() not called in same process as testSetUp().")
268+
269+ @record_pid
270+ def tearDown(self):
271+ # Runs in the parent process.
272+ assert self.pid_in___init__ == self.pid_in_tearDown, (
273+ "layer.tearDown() not called in parent process.")
274+
275+
276+class TestZopeTestInSubProcess(ZopeTestInSubProcess, unittest.TestCase):
277+ """Test `ZopeTestInSubProcess`.
278+
279+ Assert that setUp(), test() and tearDown() are called in the child
280+ process.
281+
282+ Sets its own layer attribute. This layer is then responsible for
283+ recording the PID at interesting moments. Specifically,
284+ layer.testSetUp() must be called in the same process as
285+ test.setUp().
286+ """
287+
288+ @record_pid
289+ def __init__(self, method_name='runTest'):
290+ # Runs in the parent process.
291+ super(TestZopeTestInSubProcess, self).__init__(method_name)
292+ self.layer = TestZopeTestInSubProcessLayer()
293+
294+ @record_pid
295+ def setUp(self):
296+ # Runs in the child process.
297+ super(TestZopeTestInSubProcess, self).setUp()
298+ self.failUnlessEqual(
299+ self.layer.pid_in_testSetUp, self.pid_in_setUp,
300+ "setUp() not called in same process as layer.testSetUp().")
301+
302+ @record_pid
303+ def test(self):
304+ # Runs in the child process.
305+ self.failUnlessEqual(
306+ self.pid_in_setUp, self.pid_in_test,
307+ "test method not run in same process as setUp().")
308+
309+ @record_pid
310+ def tearDown(self):
311+ # Runs in the child process.
312+ super(TestZopeTestInSubProcess, self).tearDown()
313+ self.failUnlessEqual(
314+ self.pid_in_setUp, self.pid_in_tearDown,
315+ "tearDown() not run in same process as setUp().")
316+
317+
318+def test_suite():
319+ return unittest.TestLoader().loadTestsFromName(__name__)