Use one database per test class rather than per test
When running the test suite using MySQL, a lot of time is spent creating the database, running the migrations, and then dropping the database when the test finishes. Obviously we need a clean database at the start of each test, but that doesn't mean we need to run the migrations every single time. Setting parallel_class to True in .stestr.conf makes all the tests in a given class run in the same subprocess, whilst retaining parallelisation when there is more than one test class discovered. This commit moves the setup and cleanup of the database to the class level rather than the test level, so that the slow migration step only runs once per test class, rather than once per test. Cleaning the database between tests is done by deleting everything from the tables (excepting the tables that are populated by the migrations themselves), and resetting any autoincrement counters. This reduces the runtime of the individual tests by an order of magnitude locally, from about 10 seconds per test to about 1 second per test with this patch applied. There is still some overhead for each class, but I can now run the test suite in about 15 minutes with MySQL on my machine, as opposed to over an hour previously. Change-Id: I1f38a3c4bf88cba8abfaa3f7d39d1403be6952b7
This commit is contained in:
parent
a834414372
commit
6cdc6f4bfb
@ -1,2 +1,3 @@
|
|||||||
[DEFAULT]
|
[DEFAULT]
|
||||||
test_path=./storyboard/tests
|
test_path=./storyboard/tests
|
||||||
|
parallel_class=True
|
@ -35,6 +35,7 @@ import testtools
|
|||||||
import storyboard.common.working_dir as working_dir
|
import storyboard.common.working_dir as working_dir
|
||||||
from storyboard.db.api import base as db_api_base
|
from storyboard.db.api import base as db_api_base
|
||||||
from storyboard.db.migration.cli import get_alembic_config
|
from storyboard.db.migration.cli import get_alembic_config
|
||||||
|
from storyboard.db import models
|
||||||
import storyboard.tests.mock_data as mock_data
|
import storyboard.tests.mock_data as mock_data
|
||||||
|
|
||||||
|
|
||||||
@ -52,6 +53,15 @@ class TestCase(testtools.TestCase):
|
|||||||
|
|
||||||
"""Test case base class for all unit tests."""
|
"""Test case base class for all unit tests."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
env_test_db = os.environ.get('STORYBOARD_TEST_DB')
|
||||||
|
if env_test_db is not None:
|
||||||
|
cls.test_connection = env_test_db
|
||||||
|
else:
|
||||||
|
cls.test_connection = ("mysql+pymysql://openstack_citest:"
|
||||||
|
"openstack_citest@127.0.0.1:3306")
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Run before each test method to initialize test environment."""
|
"""Run before each test method to initialize test environment."""
|
||||||
|
|
||||||
@ -65,13 +75,6 @@ class TestCase(testtools.TestCase):
|
|||||||
if test_timeout > 0:
|
if test_timeout > 0:
|
||||||
self.useFixture(fixtures.Timeout(test_timeout, gentle=True))
|
self.useFixture(fixtures.Timeout(test_timeout, gentle=True))
|
||||||
|
|
||||||
env_test_db = os.environ.get('STORYBOARD_TEST_DB')
|
|
||||||
if env_test_db is not None:
|
|
||||||
self.test_connection = env_test_db
|
|
||||||
else:
|
|
||||||
self.test_connection = ("mysql+pymysql://openstack_citest:"
|
|
||||||
"openstack_citest@127.0.0.1:3306")
|
|
||||||
|
|
||||||
self.useFixture(fixtures.NestedTempfile())
|
self.useFixture(fixtures.NestedTempfile())
|
||||||
self.useFixture(fixtures.TempHomeDir())
|
self.useFixture(fixtures.TempHomeDir())
|
||||||
self.useFixture(lockutils_fixture.ExternalLockFixture())
|
self.useFixture(lockutils_fixture.ExternalLockFixture())
|
||||||
@ -138,39 +141,60 @@ class WorkingDirTestCase(TestCase):
|
|||||||
|
|
||||||
class DbTestCase(WorkingDirTestCase):
|
class DbTestCase(WorkingDirTestCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super(DbTestCase, cls).setUpClass()
|
||||||
|
cls.setup_db()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
super(DbTestCase, cls).setUpClass()
|
||||||
|
cls._drop_db()
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(DbTestCase, self).setUp()
|
super(DbTestCase, self).setUp()
|
||||||
self.setup_db()
|
|
||||||
|
|
||||||
def setup_db(self):
|
def tearDown(self):
|
||||||
|
super(DbTestCase, self).tearDown()
|
||||||
|
|
||||||
self.db_name = "storyboard_test_db_%s" % uuid.uuid4()
|
# Remove test data from the database, and reset counters
|
||||||
self.db_name = self.db_name.replace("-", "_")
|
for tbl in reversed(models.Base.metadata.sorted_tables):
|
||||||
dburi = self.test_connection + "/%s" % self.db_name
|
if tbl.name not in ('story_types', 'may_mutate_to'):
|
||||||
|
self._engine.execute(tbl.delete())
|
||||||
|
if not self.using_sqlite:
|
||||||
|
self._engine.execute(
|
||||||
|
"ALTER TABLE %s AUTO_INCREMENT = 0;" % tbl.name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_db(cls):
|
||||||
|
cls.db_name = "storyboard_test_db_%s" % uuid.uuid4()
|
||||||
|
cls.db_name = cls.db_name.replace("-", "_")
|
||||||
|
dburi = cls.test_connection + "/%s" % cls.db_name
|
||||||
if dburi.startswith('mysql+pymysql://'):
|
if dburi.startswith('mysql+pymysql://'):
|
||||||
dburi += "?charset=utf8mb4"
|
dburi += "?charset=utf8mb4"
|
||||||
CONF.set_override("connection", dburi, group="database")
|
CONF.set_override("connection", dburi, group="database")
|
||||||
self._full_db_name = self.test_connection + '/' + self.db_name
|
cls._full_db_name = cls.test_connection + '/' + cls.db_name
|
||||||
LOG.info('using database %s', CONF.database.connection)
|
LOG.info('using database %s', CONF.database.connection)
|
||||||
|
|
||||||
if self.test_connection.startswith('sqlite://'):
|
if cls.test_connection.startswith('sqlite://'):
|
||||||
self.using_sqlite = True
|
cls.using_sqlite = True
|
||||||
else:
|
else:
|
||||||
self.using_sqlite = False
|
cls.using_sqlite = False
|
||||||
# The engine w/o db name
|
# The engine w/o db name
|
||||||
engine = sqlalchemy.create_engine(
|
engine = sqlalchemy.create_engine(
|
||||||
self.test_connection)
|
cls.test_connection)
|
||||||
engine.execute("CREATE DATABASE %s" % self.db_name)
|
engine.execute("CREATE DATABASE %s" % cls.db_name)
|
||||||
|
|
||||||
alembic_config = get_alembic_config()
|
alembic_config = get_alembic_config()
|
||||||
alembic_config.storyboard_config = CONF
|
alembic_config.storyboard_config = CONF
|
||||||
|
|
||||||
command.upgrade(alembic_config, "head")
|
command.upgrade(alembic_config, "head")
|
||||||
self.addCleanup(self._drop_db)
|
|
||||||
|
|
||||||
def _drop_db(self):
|
cls._engine = sqlalchemy.create_engine(dburi)
|
||||||
if self.test_connection.startswith('sqlite://'):
|
|
||||||
filename = self._full_db_name[9:]
|
@classmethod
|
||||||
|
def _drop_db(cls):
|
||||||
|
if cls.test_connection.startswith('sqlite://'):
|
||||||
|
filename = cls._full_db_name[9:]
|
||||||
if filename[:2] == '//':
|
if filename[:2] == '//':
|
||||||
filename = filename[1:]
|
filename = filename[1:]
|
||||||
if os.path.exists(filename):
|
if os.path.exists(filename):
|
||||||
@ -182,12 +206,12 @@ class DbTestCase(WorkingDirTestCase):
|
|||||||
filename, err)
|
filename, err)
|
||||||
else:
|
else:
|
||||||
engine = sqlalchemy.create_engine(
|
engine = sqlalchemy.create_engine(
|
||||||
self.test_connection)
|
cls.test_connection)
|
||||||
try:
|
try:
|
||||||
engine.execute("DROP DATABASE %s" % self.db_name)
|
engine.execute("DROP DATABASE %s" % cls.db_name)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
LOG.error('failed to drop database %s: %s',
|
LOG.error('failed to drop database %s: %s',
|
||||||
self.db_name, err)
|
cls.db_name, err)
|
||||||
db_api_base.cleanup()
|
db_api_base.cleanup()
|
||||||
|
|
||||||
PATH_PREFIX = '/v1'
|
PATH_PREFIX = '/v1'
|
||||||
|
Loading…
Reference in New Issue
Block a user