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:
Adam Coldrick 2019-03-18 14:27:12 +00:00
parent a834414372
commit 6cdc6f4bfb
2 changed files with 51 additions and 26 deletions

View File

@ -1,2 +1,3 @@
[DEFAULT] [DEFAULT]
test_path=./storyboard/tests test_path=./storyboard/tests
parallel_class=True

View File

@ -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'