Skip to content

Commit b95a1b7

Browse files
committed
Cut test run startup time from 15 seconds (worst case) to 3.
This saves 700 queries before the tests are even run: truncation of tables and population of content_type and auth_permission. To force a flush after your schema has changed, define FORCE_DB. FORCE_DB no longer implies a drop of the DB after the test run. That DB is perfectly good; we can reuse it next time.
1 parent 17ced07 commit b95a1b7

File tree

1 file changed

+87
-49
lines changed

1 file changed

+87
-49
lines changed

test_utils/runner.py

Lines changed: 87 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import os
2-
import warnings
32

43
from django.conf import settings
4+
from django.core.management.color import no_style
55
from django.core.management.commands.loaddata import Command
66
from django.db import connections, DEFAULT_DB_ALIAS
7-
from django.db.backends.creation import TEST_DATABASE_PREFIX
87
from django.db.backends.mysql import creation as mysql
98

109
import django_nose
@@ -15,8 +14,9 @@ def uses_mysql(connection):
1514

1615

1716
_old_handle = Command.handle
18-
def _new_handle(self, *fixture_labels, **options):
19-
"""Wrap the the stock loaddata to ignore foreign key checks.
17+
def _foreign_key_ignoring_handle(self, *fixture_labels, **options):
18+
"""Wrap the the stock loaddata to ignore foreign key checks so we can load
19+
circular references from fixtures.
2020
2121
This is monkeypatched into place in setup_databases().
2222
@@ -39,45 +39,24 @@ def _new_handle(self, *fixture_labels, **options):
3939
connection.close()
4040

4141

42-
# XXX: hard-coded to mysql.
4342
class SkipDatabaseCreation(mysql.DatabaseCreation):
43+
"""Database creation class that skips both creation and flushing
4444
45-
def _create_test_db(self, verbosity, autoclobber):
46-
### Oh yes, let's copy from django/db/backends/creation.py
47-
suffix = self.sql_table_creation_suffix()
45+
The idea is to re-use the perfectly good test DB already created by an
46+
earlier test run, cutting the time spent before any tests run from 5-13
47+
(depending on your I/O luck) down to 3.
4848
49-
if self.connection.settings_dict['TEST_NAME']:
50-
test_database_name = self.connection.settings_dict['TEST_NAME']
51-
else:
52-
test_database_name = TEST_DATABASE_PREFIX + self.connection.settings_dict['NAME']
53-
qn = self.connection.ops.quote_name
54-
55-
# Create the test database and connect to it. We need to autocommit
56-
# if the database supports it because PostgreSQL doesn't allow
57-
# CREATE/DROP DATABASE statements within transactions.
58-
cursor = self.connection.cursor()
59-
self.set_autocommit()
60-
61-
### That's enough copying.
62-
63-
# If we couldn't create the test db, assume it already exists.
64-
try:
65-
cursor.execute("CREATE DATABASE %s %s" %
66-
(qn(test_database_name), suffix))
67-
except Exception, e:
68-
print '...Skipping setup of %s!' % test_database_name
69-
print '...Try FORCE_DB=true if you need fresh databases.'
70-
return test_database_name
71-
72-
# Drop the db we just created, then do the normal setup.
73-
cursor.execute("DROP DATABASE %s" % qn(test_database_name))
74-
return super(SkipDatabaseCreation, self)._create_test_db(
75-
verbosity, autoclobber)
49+
"""
50+
def create_test_db(self, verbosity=1, autoclobber=False):
51+
# Notice that the DB supports transactions. Originally, this was done
52+
# in the method this overrides.
53+
self.connection.features.confirm()
54+
return self._get_test_db_name()
7655

7756

7857
class RadicalTestSuiteRunner(django_nose.NoseTestSuiteRunner):
7958
"""This is a test runner that monkeypatches connection.creation to skip
80-
database creation if it appears that the db already exists. Your tests
59+
database creation if it appears that the DB already exists. Your tests
8160
will run much faster.
8261
8362
To force the normal database creation, define the environment variable
@@ -86,22 +65,82 @@ class RadicalTestSuiteRunner(django_nose.NoseTestSuiteRunner):
8665
8766
"""
8867
def setup_databases(self):
89-
using_mysql = False
68+
def should_create_database(connection):
69+
"""Return whether we should recreate the given DB.
70+
71+
This is true if the DB doesn't exist or if the FORCE_DB env var is
72+
truthy.
73+
74+
"""
75+
# TODO: Notice when the Model classes change and return True. Worst
76+
# case, we can generate sqlall and hash it, though it's a bit slow
77+
# (2 secs) and hits the DB for no good reason. Until we find a
78+
# faster way, I'm inclined to keep making people explicitly saying
79+
# FORCE_DB if they want a new DB.
80+
81+
# Notice whether the DB exists, and create it if it doesn't:
82+
try:
83+
connection.cursor()
84+
except StandardError: # TODO: Be more discerning but still DB
85+
# agnostic.
86+
return True
87+
return not not os.getenv('FORCE_DB')
88+
89+
def sql_reset_sequences(connection):
90+
"""Return a list of SQL statements needed to reset all sequences
91+
for Django tables."""
92+
# TODO: This is MySQL-specific--see below. It should also work with
93+
# SQLite but not Postgres. :-(
94+
tables = connection.introspection.django_table_names(
95+
only_existing=True)
96+
flush_statements = connection.ops.sql_flush(
97+
no_style(), tables, connection.introspection.sequence_list())
98+
99+
# connection.ops.sequence_reset_sql() is not implemented for MySQL,
100+
# and the base class just returns []. TODO: Implement it by pulling
101+
# the relevant bits out of sql_flush().
102+
return [s for s in flush_statements if s.startswith('ALTER')]
103+
# Being overzealous and resetting the sequences on non-empty tables
104+
# like django_content_type seems to be fine in MySQL: adding a row
105+
# afterward does find the correct sequence number rather than
106+
# crashing into an existing row.
107+
90108
for alias in connections:
91109
connection = connections[alias]
92-
if not os.getenv('FORCE_DB'):
93-
if uses_mysql(connection):
94-
connection.creation.__class__ = SkipDatabaseCreation
95-
else:
96-
warnings.warn('NOT skipping db creation for %s' %
97-
connection.settings_dict['ENGINE'])
98-
99-
Command.handle = _new_handle
110+
creation = connection.creation
111+
test_db_name = creation._get_test_db_name()
112+
113+
# Mess with the DB name so other things operate on a test DB
114+
# rather than the real one. This is done in create_test_db when
115+
# we don't monkeypatch it away with SkipDatabaseCreation.
116+
orig_db_name = connection.settings_dict['NAME']
117+
connection.settings_dict['NAME'] = test_db_name
118+
119+
if not should_create_database(connection):
120+
print ('Reusing old database "%s". Set env var FORCE_DB=1 if '
121+
'you need fresh DBs.' % test_db_name)
122+
123+
# Reset auto-increment sequences. Apparently, SUMO's tests are
124+
# horrid and coupled to certain numbers.
125+
cursor = connection.cursor()
126+
for statement in sql_reset_sequences(connection):
127+
cursor.execute(statement)
128+
connection.commit_unless_managed() # which it is
129+
130+
creation.__class__ = SkipDatabaseCreation
131+
else:
132+
# We're not using SkipDatabaseCreation, so put the DB name
133+
# back.
134+
connection.settings_dict['NAME'] = orig_db_name
135+
136+
Command.handle = _foreign_key_ignoring_handle
137+
138+
# With our class patch, does nothing but return some connection
139+
# objects:
100140
return super(RadicalTestSuiteRunner, self).setup_databases()
101141

102-
def teardown_databases(self, old_config):
103-
if os.getenv('FORCE_DB'):
104-
super(RadicalTestSuiteRunner, self).teardown_databases(old_config)
142+
def teardown_databases(self, old_config, **kwargs):
143+
"""Leave those poor, reusable databases alone."""
105144

106145
def setup_test_environment(self, **kwargs):
107146
# If we have a settings_test.py let's roll it into our settings.
@@ -113,4 +152,3 @@ def setup_test_environment(self, **kwargs):
113152
except ImportError:
114153
pass
115154
super(RadicalTestSuiteRunner, self).setup_test_environment(**kwargs)
116-

0 commit comments

Comments
 (0)