From c5e966c33b8568597b6f9b8ca887a90566371415 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Wed, 13 May 2026 12:33:44 -0400 Subject: [PATCH] perf(tests): boot app + schema once per worker, wipe rows per test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- make_post_sell/tests/test_functional.py | 70 ++++++++++++++++++++---- make_post_sell/tests/test_integration.py | 50 ++++++++++++++--- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 36ee914..f46d81f 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -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.""" diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py index 88fbc4f..bb67402 100644 --- a/make_post_sell/tests/test_integration.py +++ b/make_post_sell/tests/test_integration.py @@ -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):