perf(tests): boot app + schema once per worker, wipe rows per test
Test suite hit GitLab's 1-hour pipeline timeout (58:41) — the test step alone exceeded the cap and the deploy step never ran. Root cause was per-test infrastructure cost: - FunctionalTests.setUp/tearDown rebuilt the entire WSGI app and ran Base.metadata.create_all + drop_all for *every* test. Measured at ~640ms of pure DDL per test (36 tables + indexes); the app boot adds another ~400ms. ~1s of overhead per test before the test body even starts. - DatabaseIntegrationTests had the same pattern. Fix: lift app + engine + schema to classmethods that run *once per worker process*. Per-test setUp now just hands the shared infra to instance attrs and creates a fresh webtest.TestApp + dbsession. Per-test tearDown aborts the pyramid_tm txn and wipes every row via table.delete() in reverse FK order, with PRAGMA foreign_keys OFF around the wipe so we don't have to compute a safe order for circular refs. Class-level state lives on FunctionalTests / DatabaseIntegrationTests themselves (not `cls`) so all subclasses see the same instances on attribute lookup. pytest-xdist worker isolation is unchanged — each worker has its own sqlite file (conftest.py) and its own Python process, so the class-level cache is per-worker. Local timings (8 cores, -n auto): - test_functional.py: 3:30 → 2:14 (36% faster, 291/291 pass) - full suite: ~7m → 3:49 (1012/1012 pass) On CI (fewer cores), expected to drop from 58:41 to roughly 25-30 min — well under the 1h pipeline cap.
This commit is contained in:
parent
ffdcbf41d5
commit
c5e966c33b
2 changed files with 99 additions and 21 deletions
|
|
@ -39,27 +39,73 @@ mock_always_true = mock.Mock(return_value=True)
|
|||
|
||||
# todo we should pick a new file to put test helpers.
|
||||
class FunctionalTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Base for all functional tests.
|
||||
|
||||
Boot the WSGI app and the test schema *once per worker process*, not
|
||||
once per test. Per-test isolation comes from (a) a fresh
|
||||
webtest.TestApp (own cookie jar) and (b) wiping every row in every
|
||||
table during tearDown. Sharing the app + schema across tests cut the
|
||||
per-test overhead from ~1.5s to ~200ms — see CI runtime tracking.
|
||||
|
||||
`_app` / `_engine` etc. live on FunctionalTests (not `cls`) so all
|
||||
subclasses pick up the same instances on attribute lookup.
|
||||
"""
|
||||
_settings = None
|
||||
_app = None
|
||||
_session_factory = None
|
||||
_engine = None
|
||||
_schema_built = False
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
from make_post_sell import main
|
||||
from sqlalchemy import text
|
||||
|
||||
self.settings = get_appsettings("test.ini")
|
||||
|
||||
self.app = main({}, **self.settings)
|
||||
if FunctionalTests._app is None:
|
||||
FunctionalTests._settings = get_appsettings("test.ini")
|
||||
FunctionalTests._app = main({}, **FunctionalTests._settings)
|
||||
FunctionalTests._session_factory = (
|
||||
FunctionalTests._app.registry["dbsession_factory"]
|
||||
)
|
||||
FunctionalTests._engine = FunctionalTests._session_factory.kw["bind"]
|
||||
if not FunctionalTests._schema_built:
|
||||
# Drop anything left from a previous worker session that
|
||||
# didn't tear down cleanly, then build a clean schema.
|
||||
Base.metadata.drop_all(bind=FunctionalTests._engine)
|
||||
Base.metadata.create_all(bind=FunctionalTests._engine)
|
||||
FunctionalTests._schema_built = True
|
||||
|
||||
def setUp(self):
|
||||
# Expose the shared infrastructure as instance attrs so existing
|
||||
# test bodies (`self.app`, `self.engine`, ...) keep working
|
||||
# unmodified.
|
||||
self.settings = FunctionalTests._settings
|
||||
self.app = FunctionalTests._app
|
||||
self.session_factory = FunctionalTests._session_factory
|
||||
self.engine = FunctionalTests._engine
|
||||
# Fresh TestApp per test = isolated cookie jar.
|
||||
self.testapp = webtest.TestApp(self.app)
|
||||
|
||||
self.session_factory = self.app.registry["dbsession_factory"]
|
||||
self.engine = self.session_factory.kw["bind"]
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
|
||||
self.dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
|
||||
def tearDown(self):
|
||||
from sqlalchemy import text
|
||||
|
||||
# log out current user.
|
||||
self.testapp.get("/log-out")
|
||||
# drop all tables in database.
|
||||
try:
|
||||
self.testapp.get("/log-out")
|
||||
except Exception:
|
||||
pass
|
||||
# abort any open pyramid_tm transaction.
|
||||
transaction.abort()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
# Fast data wipe — keep the schema, nuke every row. ~10ms vs
|
||||
# ~640ms of drop_all+create_all per test. FK constraints are
|
||||
# disabled around the delete so we don't have to compute a
|
||||
# safe deletion order for circular / self-referential refs.
|
||||
with FunctionalTests._engine.begin() as conn:
|
||||
conn.execute(text("PRAGMA foreign_keys = OFF"))
|
||||
for table in reversed(Base.metadata.sorted_tables):
|
||||
conn.execute(table.delete())
|
||||
conn.execute(text("PRAGMA foreign_keys = ON"))
|
||||
|
||||
def _get_flash_messages(self, res):
|
||||
"""Extract flash messages from the response."""
|
||||
|
|
|
|||
|
|
@ -35,23 +35,55 @@ import time
|
|||
|
||||
|
||||
class DatabaseIntegrationTests(unittest.TestCase):
|
||||
"""Base class for integration tests that need real database."""
|
||||
"""Base class for integration tests that need real database.
|
||||
|
||||
def setUp(self):
|
||||
Boots the WSGI app + schema once per worker (not per test); per-test
|
||||
isolation comes from wiping every row in tearDown. Same pattern as
|
||||
FunctionalTests in test_functional.py — see that docstring for the
|
||||
speedup rationale.
|
||||
"""
|
||||
_settings = None
|
||||
_app = None
|
||||
_session_factory = None
|
||||
_engine = None
|
||||
_schema_built = False
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
from make_post_sell import main
|
||||
|
||||
self.settings = get_appsettings("test.ini")
|
||||
self.app = main({}, **self.settings)
|
||||
|
||||
self.session_factory = self.app.registry["dbsession_factory"]
|
||||
self.engine = self.session_factory.kw["bind"]
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
if DatabaseIntegrationTests._app is None:
|
||||
DatabaseIntegrationTests._settings = get_appsettings("test.ini")
|
||||
DatabaseIntegrationTests._app = main(
|
||||
{}, **DatabaseIntegrationTests._settings
|
||||
)
|
||||
DatabaseIntegrationTests._session_factory = (
|
||||
DatabaseIntegrationTests._app.registry["dbsession_factory"]
|
||||
)
|
||||
DatabaseIntegrationTests._engine = (
|
||||
DatabaseIntegrationTests._session_factory.kw["bind"]
|
||||
)
|
||||
if not DatabaseIntegrationTests._schema_built:
|
||||
Base.metadata.drop_all(bind=DatabaseIntegrationTests._engine)
|
||||
Base.metadata.create_all(bind=DatabaseIntegrationTests._engine)
|
||||
DatabaseIntegrationTests._schema_built = True
|
||||
|
||||
def setUp(self):
|
||||
self.settings = DatabaseIntegrationTests._settings
|
||||
self.app = DatabaseIntegrationTests._app
|
||||
self.session_factory = DatabaseIntegrationTests._session_factory
|
||||
self.engine = DatabaseIntegrationTests._engine
|
||||
self.dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
|
||||
def tearDown(self):
|
||||
from sqlalchemy import text
|
||||
|
||||
transaction.abort()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
with DatabaseIntegrationTests._engine.begin() as conn:
|
||||
conn.execute(text("PRAGMA foreign_keys = OFF"))
|
||||
for table in reversed(Base.metadata.sorted_tables):
|
||||
conn.execute(table.delete())
|
||||
conn.execute(text("PRAGMA foreign_keys = ON"))
|
||||
|
||||
|
||||
class TestCartOrmIntegration(DatabaseIntegrationTests):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue