Writing Faster ModuleStore Tests

Update: SharedModuleStoreTestCase has evolved since this was written. Please see the docstring for updated usage info.

Most developers who have added features to edx-platform are familiar with ModuleStoreTestCase. If your tests exercise anything relating to courseware content (even it's just creating an empty course), inheriting from this class will ensure that data gets cleaned up properly between individual tests. This is extremely valuable, but can also be wasteful in many situations. During last week's hackathon, I created a faster alternative called SharedModuleStoreTestCase.

Unlike ModuleStoreTestCase, SharedModuleStoreTestCase only does ModuleStore cleanup at the tearDownClass() level. It's meant to be employed in situations where one or a small handful of courses can be initialized up front, and then shared in a read-only manner across many tests. This usage pattern is commonly found in LMS tests, which often simply recreate the same course over and over again in their setUp() methods.

Performance Impact

To get an idea of the effect it could have, I switched over a few test modules as part of my hackathon work. These are only rough figures, as they are based on a relatively small number of Jenkins test runs. That being said, the results are promising:

File # Tests Before After Delta
lms/djangoapps/ccx/tests/test_ccx_modulestore.py 5 38s 4s -89%
lms/djangoapps/discussion_api/tests/test_api.py 409 2m 45s 51s -69%
lms/djangoapps/teams/tests/test_views.py 152 1m 17s 33s -57%

So how do you convert your own tests?

Making the Switch

Most classes that inherit from ModuleStoreTestCase start something like this:

from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory

class MyContentModifyingTestCase(ModuleStoreTestCase):

    def setUp(self):
        super(MyContentModifyingTestCase, self).setUp()
        self.course = CourseFactory.create()
        self.user = UserFactory.create()
        CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)

If you are modifying self.course in your individual test functions, then this is perfect, and you should continue to use ModuleStoreTestCase. However, if you're just setting up the course once and treating it as read-only in your tests, you can now do this instead:

from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory

class MySharedModuleStoreTestCase(SharedModuleStoreTestCase):
    @classmethod
    def setUpClass(cls):
        """Any ModuleStore course/content operations can go here."""
        super(MySharedModuleStoreTestCase, cls).setUpClass()
        cls.course = CourseFactory.create()        

    def setUp(self):
        """Django ORM operations still need to go in setUp() for now."""
        super(MySharedModuleStoreTestCase, self).setUp()
        self.user = UserFactory.create()
        CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)

It's important that Django ORM operations remain in setUp(). Any models that you create in setUpClass() must be manually deleted in your tearDownClass() method — SharedModuleStoreTestCase will not properly clean them up. Even if you're careful, you're still likely to break other tests in the system in unpredictable ways because they make bad assumptions about sequences and what IDs will be created when they set up their data. This can be extremely tedious to debug.

When we upgrade to Django 1.8, you'll be able to use setUpTestData() to safely do class-level initialization of Django models with automatic cleanup. Please wait for that upgrade and place model manipulations in setUp() for now, even if it is a bit slower.

Which Tests Should I Convert?

The easiest place to hunt for test optimization targets is the Jenkins test build report. Click on "Duration" to sort by that column.

We primarily want to target expensive tests that either create complex course data (e.g. CCX) or have simple course data but many, many tests (e.g. discussions). Creating even the simplest course takes about 250-300ms or so, which really adds up when using tools like ddt that effectively multiply the number of tests in a class.

Overall Takeaway

Test data creation and cleanup can be an expensive, and the ModuleStore is a prime example of that. I hope that SharedModuleStoreTestCase can be a useful tool for bringing down test execution times. But beyond that, I hope that understanding why it works will allow us to design faster test suites in general.