← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:remove-zope.app.testing into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:remove-zope.app.testing into launchpad:master with ~cjwatson/launchpad:zope.testbrowser.wsgi as a prerequisite.

Commit message:
Remove all remaining uses of zope.app.testing

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/375206

The main work here is in refactoring functional test setup to use the new style based on zope.component.testlayer.ZCMLFileLayer and zope.testbrowser.wsgi.Layer.  What used to be in wsgi_application is now pulled apart into a few pieces of middleware around zope.app.wsgi.WSGIPublisherApplication.

The extent to which we have to pretend that ZODB exists during test setup is now even smaller: we just need a couple of adjustments in lp.testing.layers._FunctionalBrowserLayer.

The zcml directory now doubles as a (trivial) package, since ZCMLFileLayer expects that.

We have to be a bit more careful in a couple of places:

 * PageTestLayer now removes its log handler on tearDown.
 * LaunchpadWebServiceCaller.addHeadersTo makes sure that the headers it adds are encoded in WSGI-friendly ways.
 * A couple of tests in TestBuilderHistoryView need to clear the permission cache between creating test objects and doing privacy-related tests on them; I haven't found exactly what was doing this before, but I think it may have been an accidental side-effect of something in the guts of ZODB.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:remove-zope.app.testing into launchpad:master.
diff --git a/lib/lp/buildmaster/browser/tests/test_builder_views.py b/lib/lp/buildmaster/browser/tests/test_builder_views.py
index 0da8654..24a735b 100644
--- a/lib/lp/buildmaster/browser/tests/test_builder_views.py
+++ b/lib/lp/buildmaster/browser/tests/test_builder_views.py
@@ -23,6 +23,7 @@ from lp.buildmaster.interfaces.buildfarmjob import (
     )
 from lp.registry.interfaces.person import IPersonSet
 from lp.services.database.sqlbase import flush_database_updates
+from lp.services.webapp.authorization import clear_cache
 from lp.soyuz.browser.build import getSpecificJobs
 from lp.testing import (
     celebrity_logged_in,
@@ -224,6 +225,7 @@ class TestBuilderHistoryView(TestCaseWithFactory, BuildCreationMixin):
         self.createRecipeBuildWithBuilder(builder=self.builder)
         self.createRecipeBuildWithBuilder(
             private_branch=True, builder=self.builder)
+        clear_cache()
         view = create_initialized_view(self.builder, '+history')
         view.setupBuildList()
 
@@ -233,6 +235,7 @@ class TestBuilderHistoryView(TestCaseWithFactory, BuildCreationMixin):
         self.createRecipeBuildWithBuilder(builder=self.builder)
         self.createRecipeBuildWithBuilder(
             private_branch=True, builder=self.builder)
+        clear_cache()
         view = create_initialized_view(self.builder, '+history')
         private_build_icon_matcher = soupmatchers.HTMLContains(
             soupmatchers.Tag(
diff --git a/lib/lp/services/profile/profiling.txt b/lib/lp/services/profile/profiling.txt
index 852a222..9a16054 100644
--- a/lib/lp/services/profile/profiling.txt
+++ b/lib/lp/services/profile/profiling.txt
@@ -18,10 +18,7 @@ variable.
 
 The pagetests profiler is created by the layer during its setUp.
 
-    >>> from lp.testing.layers import (
-    ...     PageTestLayer,
-    ...     wsgi_application,
-    ...     )
+    >>> from lp.testing.layers import PageTestLayer
 
 (Save the existing configuration.)
 
@@ -58,7 +55,7 @@ other things).
 Requests made with a testbrowser will also be profiled.
 
     >>> from zope.testbrowser.wsgi import Browser
-    >>> browser = Browser(wsgi_app=wsgi_application)
+    >>> browser = Browser()
     >>> browser.open('http://launchpad.test/')
     >>> len(PageTestLayer.profiler.getstats()) > profile_count
     True
diff --git a/lib/lp/services/webapp/tests/test_authutility.py b/lib/lp/services/webapp/tests/test_authutility.py
index d2dfcbc..279dd3d 100644
--- a/lib/lp/services/webapp/tests/test_authutility.py
+++ b/lib/lp/services/webapp/tests/test_authutility.py
@@ -6,14 +6,16 @@ __metaclass__ = type
 import base64
 
 import testtools
-from zope.app.testing.placelesssetup import PlacelessSetup
 from zope.authentication.interfaces import ILoginPassword
 from zope.component import getUtility
+from zope.container.testing import ContainerPlacelessSetup
 from zope.interface import implementer
 from zope.principalregistry.principalregistry import UnauthenticatedPrincipal
 from zope.publisher.browser import TestRequest
 from zope.publisher.http import BasicAuthAdapter
 from zope.publisher.interfaces.http import IHTTPCredentials
+from zope.security.management import newInteraction
+from zope.security.testing import addCheckerPublic
 
 from lp.registry.interfaces.person import IPerson
 from lp.services.config import config
@@ -58,11 +60,13 @@ class DummyPlacelessLoginSource(object):
         return [Bruce]
 
 
-class TestPlacelessAuth(PlacelessSetup, testtools.TestCase):
+class TestPlacelessAuth(ContainerPlacelessSetup, testtools.TestCase):
 
     def setUp(self):
         testtools.TestCase.setUp(self)
-        PlacelessSetup.setUp(self)
+        ContainerPlacelessSetup.setUp(self)
+        addCheckerPublic()
+        newInteraction()
         self.useFixture(ZopeUtilityFixture(
             DummyPlacelessLoginSource(), IPlacelessLoginSource))
         self.useFixture(ZopeUtilityFixture(
@@ -71,7 +75,7 @@ class TestPlacelessAuth(PlacelessSetup, testtools.TestCase):
             BasicAuthAdapter, (IHTTPCredentials,), ILoginPassword))
 
     def tearDown(self):
-        PlacelessSetup.tearDown(self)
+        ContainerPlacelessSetup.tearDown(self)
         testtools.TestCase.tearDown(self)
 
     def _make(self, login, pwd):
diff --git a/lib/lp/services/webapp/tests/test_error.py b/lib/lp/services/webapp/tests/test_error.py
index 3170ae3..7f52168 100644
--- a/lib/lp/services/webapp/tests/test_error.py
+++ b/lib/lp/services/webapp/tests/test_error.py
@@ -8,7 +8,6 @@ import httplib
 import logging
 import socket
 import time
-import urllib2
 
 from fixtures import FakeLogger
 import psycopg2
@@ -44,7 +43,6 @@ from lp.testing.fixture import (
 from lp.testing.layers import (
     DatabaseLayer,
     LaunchpadFunctionalLayer,
-    wsgi_application,
     )
 from lp.testing.matchers import Contains
 
@@ -80,7 +78,7 @@ class TestDatabaseErrorViews(TestCase):
 
     def getHTTPError(self, url):
         try:
-            Browser(wsgi_app=wsgi_application).open(url)
+            Browser().open(url)
         except HTTPError as error:
             return error
         else:
@@ -107,7 +105,7 @@ class TestDatabaseErrorViews(TestCase):
 
         Raise a TimeoutException if the connection cannot be established.
         """
-        browser = Browser(wsgi_app=wsgi_application)
+        browser = Browser()
         for i in range(retries):
             try:
                 browser.open(url)
diff --git a/lib/lp/services/webapp/tests/test_login.py b/lib/lp/services/webapp/tests/test_login.py
index 3d93b8c..9aaae8f 100644
--- a/lib/lp/services/webapp/tests/test_login.py
+++ b/lib/lp/services/webapp/tests/test_login.py
@@ -81,7 +81,6 @@ from lp.testing.layers import (
     AppServerLayer,
     DatabaseFunctionalLayer,
     FunctionalLayer,
-    wsgi_application,
     )
 from lp.testing.pages import (
     extract_text,
@@ -732,7 +731,7 @@ class TestMissingServerShowsNiceErrorPage(TestCase):
 
         fixture.replacement = OpenIDLoginThatFailsDiscovery
         self.useFixture(fixture)
-        browser = TestBrowser(wsgi_app=wsgi_application)
+        browser = TestBrowser()
         self.assertRaises(HTTPError,
                           browser.open, 'http://launchpad.test/+login')
         self.assertEqual('503 Service Unavailable',
diff --git a/lib/lp/services/webapp/tests/test_notifications.py b/lib/lp/services/webapp/tests/test_notifications.py
index c516ac6..7473838 100644
--- a/lib/lp/services/webapp/tests/test_notifications.py
+++ b/lib/lp/services/webapp/tests/test_notifications.py
@@ -8,8 +8,11 @@ __metaclass__ = type
 from doctest import DocTestSuite
 import unittest
 
-from zope.app.testing import placelesssetup
 from zope.component import provideAdapter
+from zope.container.testing import (
+    setUp as containerSetUp,
+    tearDown as containerTearDown,
+    )
 from zope.interface import implementer
 from zope.publisher.browser import TestRequest
 from zope.publisher.interfaces.browser import IBrowserRequest
@@ -68,7 +71,7 @@ def adaptNotificationRequestToResponse(request):
 
 
 def setUp(test):
-    placelesssetup.setUp()
+    containerSetUp()
     mock_session = MockSession()
     provideAdapter(lambda x: mock_session, (INotificationRequest,), ISession)
     provideAdapter(lambda x: mock_session, (INotificationResponse,), ISession)
@@ -86,7 +89,7 @@ def setUp(test):
 
 
 def tearDown(test):
-    placelesssetup.tearDown()
+    containerTearDown()
 
 
 def test_suite():
diff --git a/lib/lp/services/worlddata/tests/test_helpers.py b/lib/lp/services/worlddata/tests/test_helpers.py
index 54a8a2f..a129604 100644
--- a/lib/lp/services/worlddata/tests/test_helpers.py
+++ b/lib/lp/services/worlddata/tests/test_helpers.py
@@ -95,8 +95,8 @@ class DummyLaunchBag:
 
 def test_preferred_or_request_languages():
     '''
-    >>> from zope.app.testing.placelesssetup import setUp, tearDown
     >>> from zope.component import provideAdapter, provideUtility
+    >>> from zope.container.testing import setUp, tearDown
     >>> from zope.i18n.interfaces import IUserPreferredLanguages
     >>> from lp.services.geoip.interfaces import IRequestPreferredLanguages
     >>> from lp.services.geoip.interfaces import IRequestLocalLanguages
diff --git a/lib/lp/testing/browser.py b/lib/lp/testing/browser.py
index 7f4284b..7bacfbe 100644
--- a/lib/lp/testing/browser.py
+++ b/lib/lp/testing/browser.py
@@ -17,7 +17,6 @@ __all__ = [
 import ssl
 
 from lazr.uri import URI
-import transaction
 from urllib3 import PoolManager
 from wsgiproxy.proxies import TransparentProxy
 from wsgiproxy.urllib3_client import HttpClient
@@ -26,6 +25,7 @@ from zope.testbrowser.wsgi import (
     Browser as _Browser,
     )
 
+from lp.testing.layers import TransactionMiddleware
 from lp.testing.pages import (
     extract_text,
     find_main_content,
@@ -34,21 +34,6 @@ from lp.testing.pages import (
     )
 
 
-class TransactionMiddleware:
-    """Middleware to commit the current transaction before the test.
-
-    This is like `zope.app.wsgi.TransactionMiddleware`, but avoids ZODB.
-    """
-
-    def __init__(self, app):
-        self.app = app
-
-    def __call__(self, environ, start_response):
-        transaction.commit()
-        for entry in self.app(environ, start_response):
-            yield entry
-
-
 class Browser(_Browser):
 
     def __init__(self, url=None, wsgi_app=None):
diff --git a/lib/lp/testing/layers.py b/lib/lp/testing/layers.py
index c38af8e..b90c853 100644
--- a/lib/lp/testing/layers.py
+++ b/lib/lp/testing/layers.py
@@ -50,7 +50,6 @@ __all__ = [
     'ZopelessLayer',
     'disconnect_stores',
     'reconnect_stores',
-    'wsgi_application',
     ]
 
 from cProfile import Profile
@@ -86,26 +85,24 @@ import transaction
 from webob.request import environ_from_url as orig_environ_from_url
 import wsgi_intercept
 from wsgi_intercept import httplib2_intercept
-from zope.app.publication.httpfactory import (
-    chooseClasses,
-    HTTPPublicationRequestFactory,
-    )
-from zope.app.testing.functional import (
-    FunctionalTestSetup,
-    ZopePublication,
-    )
+from zope.app.publication.httpfactory import HTTPPublicationRequestFactory
+from zope.app.wsgi import WSGIPublisherApplication
 from zope.component import (
     getUtility,
     globalregistry,
     provideUtility,
     )
 from zope.component.interfaces import ComponentLookupError
-import zope.publisher.publish
+from zope.component.testlayer import ZCMLFileLayer
+from zope.event import notify
+from zope.processlifetime import DatabaseOpened
 from zope.security.management import (
     endInteraction,
     getSecurityPolicy,
     )
 from zope.server.logger.pythonlogger import PythonLogger
+import zope.testbrowser.wsgi
+from zope.testbrowser.wsgi import AuthorizationMiddleware
 
 from lp.services import pidfile
 from lp.services.auditor.server import AuditorServer
@@ -157,16 +154,13 @@ from lp.testing import (
     )
 from lp.testing.pgsql import PgTestSetup
 from lp.testing.smtpd import SMTPController
+import zcml
 
 
 COMMA = ','
 WAIT_INTERVAL = datetime.timedelta(seconds=180)
 
 
-def set_up_functional_test():
-    return FunctionalTestSetup('zcml/ftesting.zcml')
-
-
 class LayerError(Exception):
     pass
 
@@ -258,20 +252,6 @@ def wait_children(seconds=120):
             break
 
 
-class MockRootFolder:
-    """Implement the minimum functionality required by Z3 ZODB dependencies
-
-    Installed as part of FunctionalLayer.testSetUp() to allow the http()
-    method (zope.app.testing.functional.HTTPCaller) to work.
-    """
-    @property
-    def _p_jar(self):
-        return self
-
-    def sync(self):
-        pass
-
-
 class BaseLayer:
     """Base layer.
 
@@ -993,59 +973,110 @@ class LaunchpadLayer(LibrarianLayer, MemcachedLayer, RabbitMQLayer):
             "DELETE FROM SessionData")
 
 
-def raw_wsgi_application(environ, start_response):
-    """This is a wsgi application for Zope functional testing.
+class TransactionMiddleware:
+    """Middleware to commit the current transaction before the test.
+
+    This is like `zope.app.wsgi.testlayer.TransactionMiddleware`, but avoids
+    ZODB.
+    """
+
+    def __init__(self, app):
+        self.app = app
+
+    def __call__(self, environ, start_response):
+        transaction.commit()
+        for entry in self.app(environ, start_response):
+            yield entry
+
+
+class RemoteAddrMiddleware:
+    """Middleware to set a default for `REMOTE_ADDR`.
+
+    zope.app.testing.functional.HTTPCaller used to set this, but WebTest
+    doesn't.  However, some tests rely on it.
+    """
+
+    def __init__(self, app):
+        self.app = app
+
+    def __call__(self, environ, start_response):
+        environ.setdefault('REMOTE_ADDR', wsgi_native_string('127.0.0.1'))
+        return self.app(environ, start_response)
+
+
+class SortHeadersMiddleware:
+    """Middleware to sort response headers.
+
+    This makes it easier to write reliable tests.
+    """
+
+    def __init__(self, app):
+        self.app = app
+
+    def __call__(self, environ, start_response):
+        def wrap_start_response(status, response_headers, exc_info=None):
+            return start_response(status, sorted(response_headers), exc_info)
+
+        return self.app(environ, wrap_start_response)
+
 
-    We use it with wsgi_intercept, which is itself mostly interesting
-    for our webservice (lazr.restful) tests.
+class _FunctionalBrowserLayer(zope.testbrowser.wsgi.Layer, ZCMLFileLayer):
+    """A variant of zope.app.wsgi.testlayer.BrowserLayer for FunctionalLayer.
+
+    This is not a layer for use in Launchpad tests (hence the leading
+    underscore), as zope.component's layer composition strategy is different
+    from the one zope.testrunner expects.
     """
-    # Committing work done up to now is a convenience that the Zope
-    # zope.app.testing.functional.HTTPCaller does.  We're replacing that bit,
-    # so it is easiest to follow that lead, even if it feels a little loose.
-    transaction.commit()
-    # Let's support post-mortem debugging.
-    if environ.pop('HTTP_X_ZOPE_HANDLE_ERRORS', 'True') == 'False':
-        environ['wsgi.handleErrors'] = False
-    handle_errors = environ.get('wsgi.handleErrors', True)
-
-    # Make sure the request method is something Launchpad will
-    # recognize. httplib2 usually takes care of this, but we've
-    # bypassed that code in our test environment.
-    environ['REQUEST_METHOD'] = environ['REQUEST_METHOD'].upper()
-    # zope.app.testing.functional.HTTPCaller used to set this, but WebTest
-    # doesn't.  However, some tests rely on it.
-    environ.setdefault('REMOTE_ADDR', wsgi_native_string('127.0.0.1'))
-    # Now we do the proper dance to get the desired request.  This is an
-    # almalgam of code from zope.app.testing.functional.HTTPCaller and
-    # zope.publisher.paste.Application.
-    request_cls, publication_cls = chooseClasses(
-        environ['REQUEST_METHOD'], environ)
-    publication = publication_cls(set_up_functional_test().db)
-    request = request_cls(environ['wsgi.input'], environ)
-    request.setPublication(publication)
-    # The rest of this function is an amalgam of
-    # zope.publisher.paste.Application.__call__ and van.testing.layers.
-    request = zope.publisher.publish.publish(
-        request, handle_errors=handle_errors)
-    response = request.response
-    # We sort these because it makes it easier to write reliable tests.
-    headers = sorted(response.getHeaders())
-    status = response.getStatusString()
-    # Start the WSGI server response.
-    start_response(status, headers)
-    # Return the result body iterable.
-    return response.consumeBodyIter()
-
-
-_wsgi_application_middlewares = []
-
-
-def wsgi_application(environ, start_response):
-    """As `raw_wsgi_application`, but possibly wrapped in middleware."""
-    app = raw_wsgi_application
-    for middleware in reversed(_wsgi_application_middlewares):
-        app = middleware(app)
-    return app(environ, start_response)
+
+    # A meaningless object passed to publication classes that just require
+    # something other than None.  In Zope this would be a ZODB connection,
+    # but we don't use ZODB in Launchpad.
+    fake_db = object()
+
+    def __init__(self, *args, **kwargs):
+        super(_FunctionalBrowserLayer, self).__init__(*args, **kwargs)
+        self.middlewares = [
+            AuthorizationMiddleware,
+            RemoteAddrMiddleware,
+            SortHeadersMiddleware,
+            TransactionMiddleware,
+            ]
+
+    def setUp(self):
+        super(_FunctionalBrowserLayer, self).setUp()
+        # We don't use ZODB, but the webapp subscribes to IDatabaseOpened to
+        # perform some post-configuration tasks, so emit that event
+        # manually.
+        notify(DatabaseOpened(None))
+
+    def _resetWSGIApp(self):
+        """Reset `zope.testbrowser.wsgi.Layer.get_app`'s cache.
+
+        `zope.testbrowser.wsgi.Layer` sets up a cached WSGI application in
+        `setUp` and assumes that it won't change for the lifetime of the
+        layer.  This assumption doesn't hold in Launchpad, but
+        `zope.testbrowser.wsgi.Layer` doesn't provide a straightforward way
+        to avoid making it.  We do our best.
+        """
+        zope.testbrowser.wsgi._APP_UNDER_TEST = self.make_wsgi_app()
+
+    def addMiddlewares(self, *middlewares):
+        self.middlewares.extend(middlewares)
+        self._resetWSGIApp()
+
+    def removeMiddlewares(self, *middlewares):
+        for middleware in middlewares:
+            self.middlewares.remove(middleware)
+        self._resetWSGIApp()
+
+    def setupMiddleware(self, app):
+        for middleware in self.middlewares:
+            app = middleware(app)
+        return app
+
+    def make_wsgi_app(self):
+        """See `zope.testbrowser.wsgi.Layer`."""
+        return self.setupMiddleware(WSGIPublisherApplication(self.fake_db))
 
 
 class FunctionalLayer(BaseLayer):
@@ -1058,9 +1089,17 @@ class FunctionalLayer(BaseLayer):
     @profiled
     def setUp(cls):
         FunctionalLayer.isSetUp = True
-        set_up_functional_test().setUp()
 
-        # Assert that set_up_functional_test did what it says it does
+        # zope.component.testlayer.LayerBase has a different strategy for
+        # layer composition that doesn't play well with zope.testrunner's
+        # approach to setting up and tearing down individual layers.  Work
+        # around this by creating a BrowserLayer instance here rather than
+        # having this layer subclass it.
+        FunctionalLayer.browser_layer = _FunctionalBrowserLayer(
+            zcml, zcml_file='ftesting.zcml')
+        FunctionalLayer.browser_layer.setUp()
+
+        # Assert that ZCMLFileLayer did what it says it does
         if not is_ca_available():
             raise LayerInvariantError("Component architecture failed to load")
 
@@ -1068,14 +1107,18 @@ class FunctionalLayer(BaseLayer):
         # If we don't, it may issue extra queries depending on test order.
         lp.services.webapp.session.idmanager.secret
         # If our request publication factories were defined using ZCML,
-        # they'd be set up by set_up_functional_test().setUp(). Since
-        # they're defined by Python code, we need to call that code
-        # here.
+        # they'd be set up by ZCMLFileLayer. Since they're defined by Python
+        # code, we need to call that code here.
         register_launchpad_request_publication_factories()
+
+        # Most tests use the WSGI application directly via
+        # zope.testbrowser.wsgi.Layer.get_app, but some (especially those
+        # that use lazr.restfulclient or launchpadlib) still talk to the app
+        # server over HTTP and need to be intercepted.
         wsgi_intercept.add_wsgi_intercept(
-            'localhost', 80, lambda: wsgi_application)
+            'localhost', 80, _FunctionalBrowserLayer.get_app)
         wsgi_intercept.add_wsgi_intercept(
-            'api.launchpad.test', 80, lambda: wsgi_application)
+            'api.launchpad.test', 80, _FunctionalBrowserLayer.get_app)
         httplib2_intercept.install()
 
         # webob.request.environ_from_url defaults to HTTP/1.0, which is
@@ -1099,6 +1142,7 @@ class FunctionalLayer(BaseLayer):
         wsgi_intercept.remove_wsgi_intercept('localhost', 80)
         wsgi_intercept.remove_wsgi_intercept('api.launchpad.test', 80)
         httplib2_intercept.uninstall()
+        FunctionalLayer.browser_layer.tearDown()
         # Signal Layer cannot be torn down fully
         raise NotImplementedError
 
@@ -1108,13 +1152,6 @@ class FunctionalLayer(BaseLayer):
         transaction.abort()
         transaction.begin()
 
-        # Fake a root folder to keep Z3 ZODB dependencies happy.
-        fs = set_up_functional_test()
-        if not fs.connection:
-            fs.connection = fs.db.open()
-        root = fs.connection.root()
-        root[ZopePublication.root_name] = MockRootFolder()
-
         # Allow the WSGI test browser to talk to our various test hosts.
         def assert_allowed_host(self):
             host = self.host
@@ -1128,6 +1165,7 @@ class FunctionalLayer(BaseLayer):
             'zope.testbrowser.wsgi.WSGIConnection.assert_allowed_host',
             assert_allowed_host)
         FunctionalLayer._testbrowser_allowed.setUp()
+        FunctionalLayer.browser_layer.testSetUp()
 
         # Should be impossible, as the CA cannot be unloaded. Something
         # mighty nasty has happened if this is triggered.
@@ -1138,6 +1176,7 @@ class FunctionalLayer(BaseLayer):
     @classmethod
     @profiled
     def testTearDown(cls):
+        FunctionalLayer.browser_layer.testTearDown()
         FunctionalLayer._testbrowser_allowed.cleanUp()
 
         # Should be impossible, as the CA cannot be unloaded. Something
@@ -1676,18 +1715,19 @@ class PageTestLayer(LaunchpadFunctionalLayer, BingServiceLayer):
             ProfilingMiddleware, profiler=PageTestLayer.profiler)
         PageTestLayer._access_logging_middleware = partial(
             AccessLoggingMiddleware, access_logger=access_logger)
-        _wsgi_application_middlewares.extend([
+        FunctionalLayer.browser_layer.addMiddlewares(
             PageTestLayer._profiling_middleware,
-            PageTestLayer._access_logging_middleware,
-            ])
+            PageTestLayer._access_logging_middleware)
 
     @classmethod
     @profiled
     def tearDown(cls):
-        _wsgi_application_middlewares.remove(
-            PageTestLayer._access_logging_middleware)
-        _wsgi_application_middlewares.remove(
+        FunctionalLayer.browser_layer.removeMiddlewares(
+            PageTestLayer._access_logging_middleware,
             PageTestLayer._profiling_middleware)
+        logger = PythonLogger('pagetests-access')
+        for handler in list(logger.logger.handlers):
+            logger.logger.removeHandler(handler)
         if PageTestLayer.profiler:
             PageTestLayer.profiler.dump_stats(
                 os.environ.get('PROFILE_PAGETESTS_REQUESTS'))
diff --git a/lib/lp/testing/pages.py b/lib/lp/testing/pages.py
index 311feac..cff8368 100644
--- a/lib/lp/testing/pages.py
+++ b/lib/lp/testing/pages.py
@@ -46,14 +46,17 @@ from lazr.restful.testing.webservice import WebServiceCaller
 import six
 import transaction
 from webtest import TestRequest
-from zope.app.wsgi.testlayer import FakeResponse as _FakeResponse
+from zope.app.wsgi.testlayer import (
+    FakeResponse as _FakeResponse,
+    NotInBrowserLayer,
+    )
 from zope.component import getUtility
 from zope.security.management import setSecurityPolicy
 from zope.security.proxy import removeSecurityProxy
 from zope.session.interfaces import ISession
 from zope.testbrowser.wsgi import (
-    AuthorizationMiddleware,
     Browser,
+    Layer as TestBrowserWSGILayer,
     )
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -83,10 +86,7 @@ from lp.testing import (
     )
 from lp.testing.dbuser import dbuser
 from lp.testing.factory import LaunchpadObjectFactory
-from lp.testing.layers import (
-    PageTestLayer,
-    wsgi_application,
-    )
+from lp.testing.layers import PageTestLayer
 from lp.testing.systemdocs import (
     LayeredDocFileSuite,
     stop,
@@ -103,8 +103,8 @@ SAMPLEDATA_ACCESS_SECRETS = {
 class FakeResponse(_FakeResponse):
     """A fake response for use in tests.
 
-    This is like `zope.app.wsgi.FakeResponse`, but does a better job of
-    emulating `zope.app.testing.functional` by using the request's
+    This is like `zope.app.wsgi.testlayer.FakeResponse`, but does a better
+    job of emulating `zope.app.testing.functional` by using the request's
     `SERVER_PROTOCOL` in the response.
     """
 
@@ -133,6 +133,10 @@ def http(string, handle_errors=True):
     `SERVER_PORT` to 80, which confuses
     `VirtualHostRequestPublicationFactory.canHandle`.
     """
+    app = TestBrowserWSGILayer.get_app()
+    if app is None:
+        raise NotInBrowserLayer(NotInBrowserLayer.__doc__)
+
     if not isinstance(string, bytes):
         string = string.encode('UTF-8')
     request = TestRequest.from_file(BytesIO(string.lstrip()))
@@ -145,7 +149,7 @@ def http(string, handle_errors=True):
             port = 80
         request.environ['SERVER_NAME'] = host
         request.environ['SERVER_PORT'] = int(port)
-    response = request.get_response(AuthorizationMiddleware(wsgi_application))
+    response = request.get_response(app)
     return FakeResponse(response, request)
 
 
@@ -191,7 +195,11 @@ class LaunchpadWebServiceCaller(WebServiceCaller):
             request.sign_request(
                 OAuthSignatureMethod_PLAINTEXT(), self.consumer,
                 self.access_token)
-            full_headers.update(request.to_header(OAUTH_REALM))
+            oauth_headers = request.to_header(OAUTH_REALM)
+            full_headers.update({
+                six.ensure_str(key, encoding='ISO-8859-1'):
+                    six.ensure_str(value, encoding='ISO-8859-1')
+                for key, value in oauth_headers.items()})
         if not self.handle_errors:
             full_headers['X_Zope_handle_errors'] = 'False'
 
@@ -692,7 +700,7 @@ def setupBrowser(auth=None):
         string of the form 'Basic email:password' for an authenticated user.
     :return: A `Browser` object.
     """
-    browser = Browser(wsgi_app=AuthorizationMiddleware(wsgi_application))
+    browser = Browser()
     # Set up our Browser objects with handleErrors set to False, since
     # that gives a tracebacks instead of unhelpful error messages.
     browser.handleErrors = False
diff --git a/lib/lp/testopenid/testing/helpers.py b/lib/lp/testopenid/testing/helpers.py
index c35c8f1..27f9855 100644
--- a/lib/lp/testopenid/testing/helpers.py
+++ b/lib/lp/testopenid/testing/helpers.py
@@ -22,7 +22,6 @@ from six.moves.urllib.error import HTTPError
 from zope.testbrowser.wsgi import Browser
 
 from lp.services.webapp import LaunchpadView
-from lp.testing.layers import wsgi_application
 from lp.testopenid.interfaces.server import get_server_url
 
 
@@ -42,7 +41,7 @@ class ZopeFetcher(fetchers.HTTPFetcher):
     """An `HTTPFetcher` based on zope.testbrowser."""
 
     def fetch(self, url, body=None, headers=None):
-        browser = Browser(wsgi_app=wsgi_application)
+        browser = Browser()
         if headers is not None:
             for key, value in headers.items():
                 browser.addHeader(key, value)
diff --git a/lib/zcml b/lib/zcml
new file mode 120000
index 0000000..e0e5cba
--- /dev/null
+++ b/lib/zcml
@@ -0,0 +1 @@
+../zcml
\ No newline at end of file
diff --git a/setup.py b/setup.py
index ed2da29..a0518bf 100644
--- a/setup.py
+++ b/setup.py
@@ -243,7 +243,6 @@ setup(
         'zope.app.publication',
         'zope.app.publisher',
         'zope.app.server',
-        'zope.app.testing',
         'zope.app.wsgi',
         'zope.authentication',
         'zope.component[zcml]',
diff --git a/utilities/list-pages b/utilities/list-pages
index 823128d..7de2538 100755
--- a/utilities/list-pages
+++ b/utilities/list-pages
@@ -47,7 +47,7 @@ import _pythonpath
 from inspect import getmro
 import os
 
-from zope.app.testing.functional import FunctionalTestSetup
+from zope.app.wsgi.testlayer import BrowserLayer
 from zope.browserpage.simpleviewclass import simple
 from zope.component import adapter, getGlobalSiteManager
 from zope.interface import directlyProvides, implementer
@@ -57,6 +57,7 @@ from lp.services.config import config
 from lp.services.scripts import execute_zcml_for_scripts
 from lp.services.webapp.interfaces import ICanonicalUrlData
 from lp.services.webapp.publisher import canonical_url
+import zcml
 
 
 ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
@@ -69,7 +70,7 @@ def load_zcml(zopeless=False):
     if zopeless:
         execute_zcml_for_scripts()
     else:
-        FunctionalTestSetup(os.path.join(ROOT, 'zcml', 'webapp.zcml')).setUp()
+        BrowserLayer(zcml, zcml_file='webapp.zcml').setUp()
 
 
 def is_page_adapter(a):
diff --git a/zcml/__init__.py b/zcml/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zcml/__init__.py