I had the opportunity to give a webcast for O’Reilly Media during which I encountered a presenter’s nightmare: a broken demo. Worse than that it was a test failure in a presentation about testing. Is there any way to salvage such an epic failure?
It was my second webcast and I chose to use the same format for both. I started with some brief introductory slides but most of the time was spent as a screen share, going through the code as well as running some commands in the terminal. Since this webcast was about testing this was mostly writing more tests and then running them. I had git branches setup for each phase of the process and for the first forty minutes this was going along great. Then it came to the grand finale. Integrate the server and client tests all together and run one last time. And it failed.
I quickly abandoned the idea of attempting to live debug this error and since I was at the end away I just went into my wrap up. Completely humbled and embarrassed I tried to answer the questions from the audience as gracefully as I could while inside I wanted to just curl up and hide.
Tracing the Error
The webcast was the end of the working day for me so when I was done I packed up and headed home. I had dinner with my family and tried not to obsess about what had just happened. The next morning with a clearer head I decided to dig into the problem. I had done much of the setup on my personal laptop but ran the webcast on my work laptop. Maybe there was something different about the machine setups. I ran the test again on my personal laptop. Still failed. I was sure I had tested this. Was I losing my mind?
I looked through my terminal history. There it was and I ran it again.
It passed! I’m not crazy! But what does that mean? I had run the test in isolation and it passed but when run in the full suite it failed. This points to some global shared state between tests. I took another look at the test.
import os from django.conf import settings from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.test.utils import override_settings from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.ui import WebDriverWait @override_settings(STATICFILES_DIRS=( os.path.join(os.path.dirname(__file__), 'static'), )) class QunitTests(StaticLiveServerTestCase): """Iteractive tests with selenium.""" @classmethod def setUpClass(cls): cls.browser = webdriver.PhantomJS() super().setUpClass() @classmethod def setUpClass(cls): cls.browser = webdriver.PhantomJS() super().setUpClass() @classmethod def tearDownClass(cls): cls.browser.quit() super().tearDownClass() def test_qunit(self): """Load the QUnit tests and check for failures.""" self.browser.get(self.live_server_url + settings.STATIC_URL + 'index.html') results = WebDriverWait(self.browser, 5).until( expected_conditions.visibility_of_element_located( (By.ID, 'qunit-testresult'))) total = int(results.find_element_by_class_name('total').text) failed = int(results.find_element_by_class_name('failed').text) self.assertTrue(total and not failed, results.text)
It seemed pretty isolated to me. The test gets its own webdriver instance. There is no file system manipulation. There is no interaction with the database and even if it did Django runs each test in its own transaction and rolls it back. Maybe this shared state wasn’t in my code.
Finding a Fix
I’ll admit when people on IRC or Stackoverflow claim to have found a bug in Django my first instinct is to laugh. However, Django does have some shared state in its settings configuration. The test is using the
override_settings decorator but perhaps there was something preventing it from working. I started to dig into the staticfiles code and that’s where I found it. Django was using the
lru_cache decorator for the construction of the staticfiles finders. This means they were being cached after their first access. Since this test was running last in the suite it meant that the change to
STATICFILES_DIRS was not taking effect. To fix my test meant that I simply needed to bust this cache at the start of my test.
... from django.contrib.staticfiles import finders, storage ... from django.utils.functional import empty ... class QunitTests(StaticLiveServerTestCase): ... def setUp(self): # Clear the cache versions of the staticfiles finders and storage # See https://code.djangoproject.com/ticket/24197 storage.staticfiles_storage._wrapped = empty finders.get_finder.cache_clear()
Fixing at the Source
Digging into this problem, it became clear that this wasn’t just a problem with the
STATICFILES_DIRS setting but was a problem with using
override_settings with most of the contrib.staticfiles related settings. In fact I found the easiest fix for my test case by looking at Django’s own test suite. I decided this really needed to be fixed in Django so that this issue wouldn’t bite any other developers. I opened a ticket and a few days later I created a pull request with the fix. After some helpful review from Tim Graham it was merged and was included in the recent 1.8 release.
Having a test which passes alone and fails when running in the suite is a very frustrating problem. It wasn’t something that I planned to demonstrate when I started with this webcast but that’s where I ended up. The problem I experienced was entirely preventable if I had prepared for the webcast better. However, my own failing lead to a great example of tracking down global state in a test suite and ultimately helped to improve my favorite web framework in just the slightest amount. All together I think it makes the webcast better than I could have planned it.