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:
russell@unturf.com 2026-05-13 12:33:44 -04:00
parent ffdcbf41d5
commit c5e966c33b
No known key found for this signature in database
2 changed files with 99 additions and 21 deletions

View file

@ -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."""

View file

@ -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):