← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~gary/launchpad/bug872089 into lp:launchpad

 

Gary Poster has proposed merging lp:~gary/launchpad/bug872089 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #872089 in Launchpad itself: "yuixhr integration tests require unnecessary boilerplate and unnecessary server restarts"
  https://bugs.launchpad.net/launchpad/+bug/872089

For more details, see:
https://code.launchpad.net/~gary/launchpad/bug872089/+merge/79197

In the course of working on bug 724609, I have encountered some bugs, annoyances, and wishlist items when writing the new yuixhr tests.  This branch addresses many of them.

 - There was a bunch of boiler plate that had to happen at the end of the JS file to get the test to run, and render the log in an attractive and usable way, and stash the results in a place that the html5browser testrunner can find them.  I had that as cut-and-paste code, but I saw how the non-xhr yui tests have a convenience hookup, and thought that would be nice.  I made a simple function that does the work.  This shortens the tests by 10 or 15 lines, and makes it possible to change this code in the future in one place.

 - When working on yuixhr tests, you are working on Python fixtures and Javascript tests simultaneously.  To make working with these tests interactively simpler, I made several changes.

   * The test JS has explicit headers to not cache.

   * The Python fixtures can optionally be reloaded.  This is only allowed in interactive testing, and only if explicitly requested ("?reload=1"), and only when you load the HTML for the test.  I have been burnt by reload in the past and vowed never to use it again, but the reload that I'm doing here I think is among the safer uses for it that I can imagine, and it is really convenient (ah, the seduction...).  As long as the fixtures don't have (important) global state in the module, it should work fine.  We clear out our own global state.  The HTML has explicit headers not to cache so that this trigger will be honored, but because we are using a query string for the control the cache headers are really superfluous.

   * I added instructions and warnings on the test page so users can know how to work interactively.

 - Sometimes you want to have tests run within a particular Launchpad subdomain, or facet.  This might happen if you want a test to interact with a page, for instance.  You can now specify that the test should be run in a facet.  This is honored in the automatic tests and in top-level +yuitest described above.

 - The installed config object was not correctly set when running tests. Notably, we were using the "testing" config as a basis rather than "testing-appserver".  This makes a difference for the facet feature described above.

 - When running "make run-testapp", the developer had no help as to what to do next.  I added simple instructions in the console output to go to a top-level +yuitest page.  I created the corresponding page, which lists all the yui xhr tests it could find, and gives a link to them.  It tries to honor the facet requested in the test_suite, and warns the user if it can't figure out the facet for one reason or another.

 - There was a bug that calling serverfixture.teardown if you had not run any setups would break.  That's a problem because for some tests you have no setup, but you still want to run the test within a suite that has a standard serverfixture.teardown.  Fixed.

 - I broke up the yuixhr render method into smaller methods, one per action.  It seems nicer to me.  My dispatch is a bit terse, but has plenty of Python precedent.

Lint has the following issues.  I don't see that resolving the line length for the two Google URLs is valuable, so I don't intend to change that.  The __traceback_info__ is intentional: it is for extra information in tracebacks, as rendered by zope.exception and lp.services.stacktrace.

./lib/lp/testing/yuixhr.py
     175: Line exceeds 78 characters.
     224: Line exceeds 78 characters.
     373: local variable '__traceback_info__' is assigned to but never used
     394: local variable '__traceback_info__' is assigned to but never used
     175: E501 line too long (100 characters)
     224: E501 line too long (100 characters)

That's it.  Thanks.

Gary
-- 
https://code.launchpad.net/~gary/launchpad/bug872089/+merge/79197
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~gary/launchpad/bug872089 into lp:launchpad.
=== modified file 'lib/canonical/launchpad/scripts/runlaunchpad.py'
--- lib/canonical/launchpad/scripts/runlaunchpad.py	2011-10-04 14:42:14 +0000
+++ lib/canonical/launchpad/scripts/runlaunchpad.py	2011-10-13 03:02:48 +0000
@@ -344,6 +344,7 @@
 
 
 def start_testapp(argv=list(sys.argv)):
+    from canonical.config.fixture import ConfigUseFixture
     from canonical.testing.layers import (
         BaseLayer,
         DatabaseLayer,
@@ -359,11 +360,13 @@
         '%r does not start with "testrunner-appserver"' %
         config.instance_name)
     interactive_tests = 'INTERACTIVE_TESTS' in os.environ
+    teardowns = []
 
     def setup():
         # This code needs to be run after other zcml setup happens in
         # runlaunchpad, so it is passed in as a callable.
         BaseLayer.setUp()
+        teardowns.append(BaseLayer.tearDown)
         if interactive_tests:
             # The test suite runs its own RabbitMQ.  We only need this
             # for interactive tests.  We set it up here rather than by
@@ -371,31 +374,42 @@
             # the appserver config does not normally need/have
             # RabbitMQ config set.
             RabbitMQLayer.setUp()
+            teardowns.append(RabbitMQLayer.tearDown)
         # We set up the database here even for the test suite because we want
         # to be able to control the database here in the subprocess.  It is
         # possible to do that when setting the database up in the parent
         # process, but it is messier.  This is simple.
         installFakeConnect()
+        teardowns.append(uninstallFakeConnect)
         DatabaseLayer.setUp()
+        teardowns.append(DatabaseLayer.tearDown)
         # The Librarian needs access to the database, so setting it up here
         # where we are setting up the database makes the most sense.
         LibrarianLayer.setUp()
+        teardowns.append(LibrarianLayer.tearDown)
+        # Switch to the appserver config.
+        fixture = ConfigUseFixture(BaseLayer.appserver_config_name)
+        fixture.setUp()
+        teardowns.append(fixture.cleanUp)
         # Interactive tests always need this.  We let functional tests use
         # a local one too because of simplicity.
         LayerProcessController.startSMTPServer()
+        teardowns.append(LayerProcessController.stopSMTPServer)
+        if interactive_tests:
+            root_url = config.appserver_root_url()
+            print '*' * 70
+            print 'In a few seconds, go to ' + root_url + '/+yuitest'
+            print '*' * 70
     try:
         start_launchpad(argv, setup)
     finally:
-        LayerProcessController.stopSMTPServer()
-        LibrarianLayer.tearDown()
-        DatabaseLayer.tearDown()
-        uninstallFakeConnect()
-        if interactive_tests:
+        teardowns.reverse()
+        for teardown in teardowns:
             try:
-                RabbitMQLayer.tearDown()
+                teardown()
             except NotImplementedError:
+                # We are in a separate process anyway.  Bah.
                 pass
-        BaseLayer.tearDown()
 
 
 def start_launchpad(argv=list(sys.argv), setup=None):

=== modified file 'lib/lp/app/javascript/server_fixture.js'
--- lib/lp/app/javascript/server_fixture.js	2011-09-21 00:15:46 +0000
+++ lib/lp/app/javascript/server_fixture.js	2011-10-13 03:02:48 +0000
@@ -44,6 +44,10 @@
 
 module.teardown = function(testcase) {
     var fixtures = testcase._lp_fixture_setups;
+    if (Y.Lang.isUndefined(fixtures)) {
+      // Nothing to be done.
+      return;
+    }
     var data = Y.QueryString.stringify(
         {action: 'teardown',
          fixtures: fixtures.join(','),
@@ -62,4 +66,22 @@
     delete testcase._lp_fixture_data;
 };
 
-  }, "0.1", {"requires": ["io", "json", "querystring"]});
+module.run = function(suite) {
+  var handle_complete = function(data) {
+    window.status = '::::' + Y.JSON.stringify(data);
+  };
+  Y.Test.Runner.on('complete', handle_complete);
+  Y.Test.Runner.add(suite);
+
+  var console = new Y.Console({newestOnTop: false});
+
+  Y.on('domready', function() {
+    console.render('#log');
+    Y.Test.Runner.run();
+  });
+};
+
+  },
+ "0.1",
+ {"requires": [
+   "io", "json", "querystring", "test", "console", "lp.client"]});

=== modified file 'lib/lp/testing/tests/test_standard_yuixhr_test_template.js'
--- lib/lp/testing/tests/test_standard_yuixhr_test_template.js	2011-09-20 22:33:07 +0000
+++ lib/lp/testing/tests/test_standard_yuixhr_test_template.js	2011-10-13 03:02:48 +0000
@@ -2,7 +2,7 @@
     base: '/+icing/yui/',
     filter: 'raw', combine: false, fetchCSS: false
 // TODO: Add other modules you want to test into the "use" list.
-}).use('test', 'console', 'json', 'cookie', 'lp.testing.serverfixture',
+}).use('test', 'lp.testing.serverfixture',
        function(Y) {
 
 // This is one-half of an example yuixhr test.  The other half of a
@@ -47,18 +47,6 @@
     }
 }));
 
-// The remaining lines are necessary boilerplate.  Include them.
-
-var handle_complete = function(data) {
-    window.status = '::::' + Y.JSON.stringify(data);
-    };
-Y.Test.Runner.on('complete', handle_complete);
-Y.Test.Runner.add(suite);
-
-var console = new Y.Console({newestOnTop: false});
-
-Y.on('domready', function() {
-    console.render('#log');
-    Y.Test.Runner.run();
-});
+// The last line is necessary.  Include it.
+serverfixture.run(suite);
 });

=== modified file 'lib/lp/testing/tests/test_yuixhr.py'
--- lib/lp/testing/tests/test_yuixhr.py	2011-09-21 00:15:46 +0000
+++ lib/lp/testing/tests/test_yuixhr.py	2011-10-13 03:02:48 +0000
@@ -29,11 +29,13 @@
 from canonical.testing.layers import LaunchpadFunctionalLayer
 
 from lp.registry.interfaces.product import IProductSet
+from lp.services.osutils import override_environ
 from lp.testing import (
     TestCase,
     login,
     ANONYMOUS,
     )
+from lp.testing.matchers import Contains
 from lp.testing.views import create_view
 from lp.testing.yuixhr import setup
 from lp.testing.tests import test_yuixhr_fixture
@@ -101,7 +103,7 @@
         view = create_traversed_view(
             path_info='/+yuitest/lp/testing/tests/test_yuixhr_fixture')
         view.initialize()
-        content = view.page()
+        content = view.renderHTML()
         self.assertTrue(content.startswith('<!DOCTYPE HTML'))
         self.assertTextMatchesExpressionIgnoreWhitespace(
             re.escape(
@@ -126,6 +128,9 @@
         self.assertEqual(
             'text/javascript',
             view.request.response.getHeader('Content-Type'))
+        self.assertEqual(
+            'no-cache',
+            view.request.response.getHeader('Cache-Control'))
 
     def test_javascript_must_have_a_py_fixture(self):
         js_dir = tempfile.mkdtemp()
@@ -157,6 +162,9 @@
         self.assertEqual(
             'text/html',
             view.request.response.getHeader('Content-Type'))
+        self.assertEqual(
+            'no-cache',
+            view.request.response.getHeader('Cache-Control'))
 
     def test_get_fixtures(self):
         view = create_traversed_view(
@@ -389,3 +397,99 @@
         del called[:]
         original_fixture.teardown(None, dict())
         self.assertEquals(['original'], called)
+
+    def test_python_fixture_does_not_reload_by_default(self):
+        # Even though the dangers of Python's "reload" are subtle and
+        # real, using it can be very nice, particularly with
+        # Launchpad's slow start-up time.  By default, though, it is
+        # not used.  We will show this by scribbling on one of the
+        # fixtures and showing that the scribble is still there when
+        # we load the page.
+        test_yuixhr_fixture._fixtures_['baseline'].scribble = 'hello'
+        self.addCleanup(
+            delattr, test_yuixhr_fixture._fixtures_['baseline'], 'scribble')
+        view = create_traversed_view(
+            path_info='/+yuitest/lp/testing/tests/'
+                      'test_yuixhr_fixture')
+        view.initialize()
+        view.render()
+        self.assertEquals(
+            'hello', test_yuixhr_fixture._fixtures_['baseline'].scribble)
+
+    def test_python_fixture_does_not_reload_without_environ_var(self):
+        # As a bit of extra paranoia, we only allow a reload if
+        # 'INTERACTIVE_TESTS' is in the environ.  make run-testapp
+        # sets this environmental variable.  However, if we don't set
+        # the environment, even if we request a reload it will not
+        # happen.
+        test_yuixhr_fixture._fixtures_['baseline'].scribble = 'hello'
+        self.addCleanup(
+            delattr, test_yuixhr_fixture._fixtures_['baseline'], 'scribble')
+        view = create_traversed_view(
+            path_info='/+yuitest/lp/testing/tests/'
+                      'test_yuixhr_fixture', form=dict(reload='1'))
+        view.initialize()
+        view.render()
+        self.assertEquals(
+            'hello', test_yuixhr_fixture._fixtures_['baseline'].scribble)
+
+    def test_python_fixture_can_reload(self):
+        # Now we will turn reloading fully on, with the environmental
+        # variable and the query string..
+        test_yuixhr_fixture._fixtures_['baseline'].scribble = 'hello'
+        with override_environ(INTERACTIVE_TESTS='1'):
+            view = create_traversed_view(
+                path_info='/+yuitest/lp/testing/tests/'
+                'test_yuixhr_fixture', form=dict(reload='1'))
+            # reloading only happens at render time, so the scribble is
+            # still there for now.
+            view.initialize()
+            self.assertEquals(
+                'hello', test_yuixhr_fixture._fixtures_['baseline'].scribble)
+            # After a render of the html view, the module is reloaded.
+            view.render()
+            self.assertEquals(
+                None,
+                getattr(test_yuixhr_fixture._fixtures_['baseline'],
+                        'scribble',
+                        None))
+
+    def test_python_fixture_resets_fixtures(self):
+        # When we reload, we also clear out _fixtures_.  This means
+        # that if you rename or delete something, it won't be hanging
+        # around confusing you into thinking everything is fine after
+        # the reload.
+        test_yuixhr_fixture._fixtures_['extra_scribble'] = 42
+        with override_environ(INTERACTIVE_TESTS='1'):
+            view = create_traversed_view(
+                path_info='/+yuitest/lp/testing/tests/'
+                'test_yuixhr_fixture', form=dict(reload='1'))
+            view.initialize()
+            # After a render of the html view, the module is reloaded.
+            view.render()
+            self.assertEquals(
+                None,
+                test_yuixhr_fixture._fixtures_.get('extra_scribble'))
+
+    def test_python_fixture_reload_in_html(self):
+        # The reload is specifically when we load HTML pages only.
+        test_yuixhr_fixture._fixtures_['extra_scribble'] = 42
+        with override_environ(INTERACTIVE_TESTS='1'):
+            view = create_traversed_view(
+                path_info='/+yuitest/lp/testing/tests/'
+                'test_yuixhr_fixture', form=dict(reload='1'))
+            view.initialize()
+            # After a render of the html view, the module is reloaded.
+            view.renderHTML()
+            self.assertEquals(
+                None,
+                test_yuixhr_fixture._fixtures_.get('extra_scribble'))
+
+    def test_index_page(self):
+        view = create_traversed_view(path_info='/+yuitest')
+        view.initialize()
+        output = view.render()
+        self.assertThat(
+            output,
+            Contains(
+                'href="/+yuitest/lp/testing/tests/test_yuixhr_fixture'))

=== modified file 'lib/lp/testing/tests/test_yuixhr_fixture.js'
--- lib/lp/testing/tests/test_yuixhr_fixture.js	2011-09-21 00:15:46 +0000
+++ lib/lp/testing/tests/test_yuixhr_fixture.js	2011-10-13 03:02:48 +0000
@@ -1,7 +1,7 @@
 YUI({
     base: '/+icing/yui/',
     filter: 'raw', combine: false, fetchCSS: false
-}).use('test', 'console', 'json', 'cookie', 'lp.testing.serverfixture',
+}).use('test', 'json', 'cookie', 'lp.testing.serverfixture',
        function(Y) {
 
 var suite = new Y.Test.Suite("lp.testing.yuixhr Tests");
@@ -157,19 +157,12 @@
         // tests, so we are not logged in practically--the user is gone--but
         // also our session cookie is gone.
         Y.Assert.isFalse(Y.Cookie.exists('launchpad_tests'));
+    },
+
+    test_no_setup_can_still_teardown: function() {
+        module.teardown(this);
     }
 }));
 
-var handle_complete = function(data) {
-    window.status = '::::' + Y.JSON.stringify(data);
-    };
-Y.Test.Runner.on('complete', handle_complete);
-Y.Test.Runner.add(suite);
-
-var console = new Y.Console({newestOnTop: false});
-
-Y.on('domready', function() {
-    console.render('#log');
-    Y.Test.Runner.run();
-});
+module.run(suite);
 });

=== added file 'lib/lp/testing/tests/test_yuixhr_fixture_facet.js'
--- lib/lp/testing/tests/test_yuixhr_fixture_facet.js	1970-01-01 00:00:00 +0000
+++ lib/lp/testing/tests/test_yuixhr_fixture_facet.js	2011-10-13 03:02:48 +0000
@@ -0,0 +1,27 @@
+YUI({
+  base: '/+icing/yui/',
+  filter: 'raw', combine: false, fetchCSS: false
+}).use('test', 'lp.testing.serverfixture',
+       function(Y) {
+
+var suite = new Y.Test.Suite("lp.testing.yuixhr facet Tests");
+var serverfixture = Y.lp.testing.serverfixture;
+
+
+/**
+ * Test how the yuixhr server fixture handles specified facets.
+ */
+suite.add(new Y.Test.Case({
+  name: 'Serverfixture facet tests',
+
+  tearDown: function() {
+    serverfixture.teardown(this);
+  },
+
+  test_facet_was_honored: function() {
+    Y.Assert.areEqual('bugs.launchpad.dev', Y.config.doc.location.hostname);
+  }
+}));
+
+serverfixture.run(suite);
+});

=== added file 'lib/lp/testing/tests/test_yuixhr_fixture_facet.py'
--- lib/lp/testing/tests/test_yuixhr_fixture_facet.py	1970-01-01 00:00:00 +0000
+++ lib/lp/testing/tests/test_yuixhr_fixture_facet.py	2011-10-13 03:02:48 +0000
@@ -0,0 +1,17 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test the ability to specify a facet for the yuixhr tests.
+"""
+
+__metaclass__ = type
+__all__ = []
+
+from lp.testing.yuixhr import make_suite
+
+
+def test_suite():
+    # You can specify a facet, as found in the vhost.* names in
+    # [root]/configs/testrunner-appserver/launchpad-lazr.conf .  This
+    # can be convenient for code that must be run within a given subdomain.
+    return make_suite(__name__, 'bugs')

=== modified file 'lib/lp/testing/yuixhr.py'
--- lib/lp/testing/yuixhr.py	2011-09-21 00:24:13 +0000
+++ lib/lp/testing/yuixhr.py	2011-10-13 03:02:48 +0000
@@ -11,6 +11,7 @@
     'YUITestFixtureControllerView',
 ]
 
+from fnmatch import fnmatchcase
 import os
 import simplejson
 import sys
@@ -158,6 +159,7 @@
     HTML = 'HTML'
     SETUP = 'SETUP'
     TEARDOWN = 'TEARDOWN'
+    INDEX = 'INDEX'
 
     page_template = dedent("""\
         <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
@@ -169,6 +171,8 @@
             src="/+icing/rev%(revno)s/build/launchpad.js"></script>
           <link rel="stylesheet"
             href="/+icing/yui/assets/skins/sam/skin.css"/>
+          <link type="text/css" rel="stylesheet" media="screen, print"
+                href="https://fonts.googleapis.com/css?family=Ubuntu:400,400italic,700,700italic"; />
           <link rel="stylesheet" href="/+icing/rev%(revno)s/combo.css"/>
           <style>
           /* Taken and customized from testlogger.css */
@@ -193,6 +197,47 @@
         </head>
         <body class="yui3-skin-sam">
           <div id="log"></div>
+          <p>Want to re-run your test?</p>
+          <ul>
+            <li><a href="?">Reload test JS</a></li>
+            <li><a href="?reload=1">Reload test JS and the associated
+                                    Python fixtures</a></li>
+          </ul>
+          <p>Don't forget to run <code>make jsbuild</code> and then do a
+             hard reload of this page if you change a file that is built
+             into launchpad.js!</p>
+          <p>If you change Python code other than the fixtures, you must
+             restart the server.  Sorry.</p>
+        </body>
+        </html>
+        """)
+
+    index_template = dedent("""\
+        <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+          "http://www.w3.org/TR/html4/strict.dtd";>
+        <html>
+          <head>
+          <title>YUI XHR Tests</title>
+          <script type="text/javascript"
+            src="/+icing/rev%(revno)s/build/launchpad.js"></script>
+          <link type="text/css" rel="stylesheet" media="screen, print"
+                href="https://fonts.googleapis.com/css?family=Ubuntu:400,400italic,700,700italic"; />
+          <link rel="stylesheet"
+            href="/+icing/yui/assets/skins/sam/skin.css"/>
+          <link rel="stylesheet" href="/+icing/rev%(revno)s/combo.css"/>
+          <style>
+          ul {
+            text-align: left;
+          }
+          body, ul, h1 {
+            margin: 0.3em;
+            padding: 0.3em;
+          }
+        </style>
+        </head>
+        <body class="yui3-skin-sam">
+          <h1>YUI XHR Tests</h1>
+          <ul>%(tests)s</ul>
         </body>
         </html>
         """)
@@ -208,6 +253,9 @@
         return os.path.join(*self.names)
 
     def initialize(self):
+        if not self.names:
+            self.action = self.INDEX
+            return
         path, ext = os.path.splitext(self.traversed_path)
         full_path = os.path.join(config.root, 'lib', path)
         if not os.path.exists(full_path + '.py'):
@@ -236,72 +284,145 @@
         assert os.path.sep not in name, (
             'traversed name contains os.path.sep: %s' % name)
         assert name != '..', 'traversing to ..'
-        self.names.append(name)
+        if name:
+            self.names.append(name)
         return self
 
     def browserDefault(self, request):
         return self, ()
 
-    def page(self):
+    @property
+    def module_name(self):
+        return '.'.join(self.names)
+
+    def get_fixtures(self):
+        module = __import__(
+            self.module_name, globals(), locals(), ['_fixtures_'], 0)
+        return module._fixtures_
+
+    def renderINDEX(self):
+        root = os.path.join(config.root, 'lib')
+        test_lines = []
+        for path in find_tests(root):
+            test_path = '/+yuitest/' + '/'.join(path)
+            module_name = '.'.join(path)
+            try:
+                module = __import__(
+                    module_name, globals(), locals(), ['test_suite'], 0)
+            except ImportError:
+                warning = 'cannot import Python fixture file'
+            else:
+                try:
+                    suite_factory = module.test_suite
+                except AttributeError:
+                    warning = 'cannot find test_suite'
+                else:
+                    try:
+                        suite = suite_factory()
+                    except EXPLOSIVE_ERRORS:
+                        raise
+                    except:
+                        warning = 'test_suite raises errors'
+                    else:
+                        case = None
+                        for case in suite:
+                            if isinstance(case, YUIAppServerTestCase):
+                                root_url = config.appserver_root_url(
+                                    case.facet)
+                                if root_url != 'None':
+                                    test_path = root_url + test_path
+                                warning = ''
+                                break
+                        else:
+                            warning = (
+                                'test suite is not instance of '
+                                'YUIAppServerTestCase')
+            link = '<a href="%s">%s</a>' % (test_path, test_path)
+            if warning:
+                warning = ' <span class="warning">%s</span>' % warning
+            test_lines.append('<li>%s%s</li>' % (link, warning))
+        return self.index_template % {
+            'revno': revno,
+            'tests': '\n'.join(test_lines)}
+
+    def renderJAVASCRIPT(self):
+        self.request.response.setHeader('Content-Type', 'text/javascript')
+        self.request.response.setHeader('Cache-Control', 'no-cache')
+        return open(
+            os.path.join(config.root, 'lib', self.traversed_path))
+
+    def renderHTML(self):
+        self.request.response.setHeader('Content-Type', 'text/html')
+        self.request.response.setHeader('Cache-Control', 'no-cache')
+        if ('INTERACTIVE_TESTS' in os.environ and
+            'reload' in self.request.form):
+            # We should try to reload the module.
+            module = sys.modules.get(self.module_name)
+            if module is not None:
+                del module._fixtures_
+                reload(module)
         return self.page_template % dict(
             test_module='/+yuitest/%s.js' % self.traversed_path,
             revno=revno)
 
-    def get_fixtures(self):
-        module_name = '.'.join(self.names)
-        test_module = __import__(
-            module_name, globals(), locals(), ['_fixtures_'], 0)
-        return test_module._fixtures_
+    def renderSETUP(self):
+        data = {}
+        fixtures = self.get_fixtures()
+        try:
+            for fixture_name in self.fixtures:
+                __traceback_info__ = (fixture_name, data)
+                fixtures[fixture_name](self.request, data)
+        except EXPLOSIVE_ERRORS:
+            raise
+        except:
+            self.request.response.setStatus(500)
+            result = ''.join(format_exception(*sys.exc_info()))
+        else:
+            self.request.response.setHeader(
+                'Content-Type', 'application/json')
+            # We use the ProxyFactory so that the restful
+            # redaction code is always used.
+            result = simplejson.dumps(
+                ProxyFactory(data), cls=ResourceJSONEncoder)
+        return result
+
+    def renderTEARDOWN(self):
+        data = simplejson.loads(self.request.form['data'])
+        fixtures = self.get_fixtures()
+        try:
+            for fixture_name in reversed(self.fixtures):
+                __traceback_info__ = (fixture_name, data)
+                fixtures[fixture_name].teardown(self.request, data)
+        except EXPLOSIVE_ERRORS:
+            raise
+        except:
+            self.request.response.setStatus(500)
+            result = ''.join(format_exception(*sys.exc_info()))
+        else:
+            # Remove the session cookie, in case we have one.
+            self.request.response.expireCookie(
+                getUtility(IClientIdManager).namespace)
+            # Blow up the database once we are out of this transaction
+            # by passing a result that will do so when it is iterated
+            # through in asyncore.
+            self.request.response.setHeader('Content-Length', 1)
+            result = CloseDbResult()
+        return result
 
     def render(self):
-        if self.action == self.JAVASCRIPT:
-            self.request.response.setHeader('Content-Type', 'text/javascript')
-            result = open(
-                os.path.join(config.root, 'lib', self.traversed_path))
-        elif self.action == self.HTML:
-            self.request.response.setHeader('Content-Type', 'text/html')
-            result = self.page()
-        elif self.action == self.SETUP:
-            data = {}
-            fixtures = self.get_fixtures()
-            try:
-                for fixture_name in self.fixtures:
-                    __traceback_info__ = (fixture_name, data)
-                    fixtures[fixture_name](self.request, data)
-            except EXPLOSIVE_ERRORS:
-                raise
-            except:
-                self.request.response.setStatus(500)
-                result = ''.join(format_exception(*sys.exc_info()))
-            else:
-                self.request.response.setHeader(
-                    'Content-Type', 'application/json')
-                # We use the ProxyFactory so that the restful
-                # redaction code is always used.
-                result = simplejson.dumps(
-                    ProxyFactory(data), cls=ResourceJSONEncoder)
-        elif self.action == self.TEARDOWN:
-            data = simplejson.loads(self.request.form['data'])
-            fixtures = self.get_fixtures()
-            try:
-                for fixture_name in reversed(self.fixtures):
-                    __traceback_info__ = (fixture_name, data)
-                    fixtures[fixture_name].teardown(self.request, data)
-            except EXPLOSIVE_ERRORS:
-                raise
-            except:
-                self.request.response.setStatus(500)
-                result = ''.join(format_exception(*sys.exc_info()))
-            else:
-                # Remove the session cookie, in case we have one.
-                self.request.response.expireCookie(
-                    getUtility(IClientIdManager).namespace)
-                # Blow up the database once we are out of this transaction
-                # by passing a result that will do so when it is iterated
-                # through in asyncore.
-                self.request.response.setHeader('Content-Length', 1)
-                result = CloseDbResult()
-        return result
+        return getattr(self, 'render' + self.action)()
+
+
+def find_tests(root):
+    for dirpath, dirnames, filenames in os.walk(root):
+        dirpath = os.path.relpath(dirpath, root)
+        for filename in filenames:
+            if fnmatchcase(filename, 'test_*.js'):
+                name, ext = os.path.splitext(filename)
+                if name + '.py' in filenames:
+                    names = dirpath.split(os.path.sep)
+                    names.append(name)
+                    yield names
 
 
 # This class cannot be imported directly into a test suite because
@@ -313,19 +434,21 @@
     layer = YUIAppServerLayer
     _testMethodName = 'runTest'
 
-    def __init__(self, module_name=None):
+    def __init__(self, module_name, facet='mainsite'):
         self.module_name = module_name
+        self.facet = facet
         # This needs to be done early so the "id" is set correctly.
         self.test_path = self.module_name.replace('.', '/')
         super(YUIAppServerTestCase, self).__init__()
 
     def setUp(self):
-        root_url = LayerProcessController.appserver_root_url()
-        self.html_uri = '%s+yuitest/%s' % (root_url, self.test_path)
+        config = LayerProcessController.appserver_config
+        root_url = config.appserver_root_url(self.facet)
+        self.html_uri = '%s/+yuitest/%s' % (root_url, self.test_path)
         super(YUIAppServerTestCase, self).setUp()
 
     runTest = AbstractYUITestCase.checkResults
 
 
-def make_suite(module_name):
-    return unittest.TestSuite([YUIAppServerTestCase(module_name)])
+def make_suite(module_name, facet='mainsite'):
+    return unittest.TestSuite([YUIAppServerTestCase(module_name, facet)])