Skip to content

Commit fadb51b

Browse files
Make test fixtures clean up the bundle and team rows they create
1 parent f57229b commit fadb51b

2 files changed

Lines changed: 152 additions & 23 deletions

File tree

contributing-docs/testing/unit_tests.rst

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,71 @@ You can also use a fixture to create an object that needs the database.
721721
conn = request.getfixturevalue(conn)
722722
...
723723
724+
Database fixtures and test isolation
725+
------------------------------------
726+
727+
Database tests must not leak rows into the database that other tests can see. A test that
728+
depends on rows left behind by an earlier test, or that breaks because of them, is
729+
order-dependent and flaky. The shared fixtures below own their rows and remove them when the
730+
test finishes, so individual tests should not need defensive pre-cleaning such as
731+
``clear_db_dag_bundles()`` or ``clear_db_teams()`` at the top of a test.
732+
733+
Why these fixtures exist
734+
........................
735+
736+
``dag_maker`` was introduced so that every database test has one consistent way to create a Dag
737+
together with all the rows that hang off it, and one consistent way to remove them again. A Dag in
738+
the metadata database is not a single row: it spans ``DagModel``, ``SerializedDagModel``,
739+
``DagVersion``, ``DagRun``, ``TaskInstance``, and a ``DagBundleModel`` it belongs to, all tied
740+
together by foreign keys. Building those by hand is easy to get subtly wrong: you forget a
741+
dependent row, or you create one and forget to delete it on the way out, and the leftover row is
742+
what the next test trips over. Routing setup and teardown through ``dag_maker`` keeps that wiring
743+
in one place instead of copy-pasted across hundreds of tests.
744+
745+
When bundles and teams were added later, they were not folded into ``dag_maker``. Each came with
746+
its own ``clear_db_*`` helper instead, because creating them separately was faster and more
747+
controllable at the time. Over the years that pushed the cleanup responsibility onto callers, so
748+
tests accumulated defensive ``clear_db_dag_bundles()`` / ``clear_db_teams()`` calls to protect
749+
themselves from rows other tests had leaked. The fixtures now clean up after themselves, which lets
750+
that defensive pre-cleaning go away and standardises database tests on the ``dag_maker`` approach.
751+
752+
``dag_maker``
753+
.............
754+
755+
``dag_maker`` is the primary fixture for tests that need a Dag in the database. It is a context
756+
manager that builds a Dag, serializes it, and writes the ``DagModel``, ``DagRun``,
757+
``SerializedDagModel``, and ``DagVersion`` rows for you:
758+
759+
.. code-block:: python
760+
761+
def test_something(dag_maker):
762+
with dag_maker("my_dag") as dag:
763+
EmptyOperator(task_id="task")
764+
dr = dag_maker.create_dagrun()
765+
...
766+
767+
On teardown ``dag_maker`` removes everything it created and, because the ``dag_maker`` bundle is
768+
shared across tests, drops that ``DagBundleModel`` row only once no Dag still references it
769+
(``DagModel.bundle_name`` is a foreign key with no ``ON DELETE`` action, so deleting a bundle that
770+
is still referenced would fail). Prefer ``dag_maker`` over constructing ``DagBag``,
771+
``DagBundleModel``, or ``DagModel`` rows by hand, since hand-built rows are exactly what leaks.
772+
773+
``testing_dag_bundle`` and ``testing_team``
774+
...........................................
775+
776+
For tests that need a bundle or a team but do not go through ``dag_maker``, use the
777+
``testing_dag_bundle`` and ``testing_team`` fixtures. Each one lazily creates a shared
778+
``"testing"`` row only if it does not already exist, and tears that row down on exit only when
779+
this fixture is the one that created it, so overlapping usage does not delete a row another
780+
fixture still needs. ``testing_dag_bundle`` drops the ``"testing"`` bundle only once nothing
781+
references it, leaving the cleanup of the test's own dags to whichever fixture owns them.
782+
``testing_team`` deletes its row directly, because every foreign key to ``team.name`` is
783+
``ON DELETE CASCADE`` or ``ON DELETE SET NULL``.
784+
785+
If you find yourself adding ``clear_db_*`` calls at the start of a test to work around rows left
786+
by another test, that is a sign the other test's fixture is not cleaning up after itself. Fix the
787+
fixture rather than spreading defensive cleanup across tests.
788+
724789
Running Unit tests
725790
------------------
726791

devel-common/src/tests_common/pytest_plugin.py

Lines changed: 87 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,23 @@ def __call__(
878878
def serialized_dag(self) -> SerializedDAG: ...
879879

880880

881+
def _delete_bundle_if_unreferenced(session, bundle_name):
882+
"""Delete a DagBundleModel row, but only once no DagModel still references it.
883+
884+
``DagModel.bundle_name`` is a foreign key with no ``ON DELETE`` action, and the bundle is
885+
shared across tests, so it can only be dropped after the last referencing Dag is gone.
886+
"""
887+
from sqlalchemy import delete, func, select
888+
889+
from airflow.models.dag import DagModel
890+
from airflow.models.dagbundle import DagBundleModel
891+
892+
if not session.scalar(
893+
select(func.count()).select_from(DagModel).where(DagModel.bundle_name == bundle_name)
894+
):
895+
session.execute(delete(DagBundleModel).where(DagBundleModel.name == bundle_name))
896+
897+
881898
@pytest.fixture
882899
def dag_maker(request) -> Generator[DagMaker, None, None]:
883900
"""
@@ -946,6 +963,7 @@ def __init__(self):
946963
self.dagbag = DagBag(os.devnull)
947964
else:
948965
self.dagbag = DagBag(os.devnull, include_examples=False) # type: ignore[call-arg]
966+
self.created_bundle_names: set[str] = set()
949967

950968
def __enter__(self):
951969
self.serialized_model = None
@@ -1380,6 +1398,7 @@ def __call__(
13801398
):
13811399
self.session.add(DagBundleModel(name=self.bundle_name))
13821400
self.session.commit()
1401+
self.created_bundle_names.add(self.bundle_name)
13831402

13841403
return self
13851404

@@ -1424,6 +1443,9 @@ def cleanup(self):
14241443
self.session.execute(delete(DagModel).where(DagModel.dag_id.in_(dag_ids)))
14251444
self.session.execute(delete(TaskMap).where(TaskMap.dag_id.in_(dag_ids)))
14261445
self.session.execute(delete(AssetEvent).where(AssetEvent.source_dag_id.in_(dag_ids)))
1446+
if AIRFLOW_V_3_0_PLUS:
1447+
for bundle_name in self.created_bundle_names:
1448+
_delete_bundle_if_unreferenced(self.session, bundle_name)
14271449
self.session.commit()
14281450
if self._own_session:
14291451
self.session.expunge_all()
@@ -1743,6 +1765,8 @@ def session():
17431765

17441766
@pytest.fixture
17451767
def get_test_dag():
1768+
created = {"bundle": False, "import_error_files": set()}
1769+
17461770
def _get(dag_id: str):
17471771
from airflow import settings
17481772
from airflow.models.serialized_dag import SerializedDagModel
@@ -1784,6 +1808,7 @@ def _get(dag_id: str):
17841808
stacktrace=stacktrace,
17851809
)
17861810
)
1811+
created["import_error_files"].add(str(dag_file))
17871812

17881813
return
17891814

@@ -1798,6 +1823,7 @@ def _get(dag_id: str):
17981823
session = settings.Session()
17991824
if not session.scalar(select(func.count()).where(DagBundleModel.name == "testing")):
18001825
session.add(DagBundleModel(name="testing"))
1826+
created["bundle"] = True
18011827
session.flush()
18021828
SerializedDAG.bulk_write_to_db("testing", None, [dag], session=session)
18031829
session.commit()
@@ -1808,7 +1834,25 @@ def _get(dag_id: str):
18081834

18091835
return dag
18101836

1811-
return _get
1837+
yield _get
1838+
1839+
from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
1840+
1841+
if not AIRFLOW_V_3_0_PLUS:
1842+
return
1843+
1844+
from sqlalchemy import delete
1845+
1846+
from airflow.models.errors import ParseImportError
1847+
from airflow.utils.session import create_session
1848+
1849+
with create_session() as session:
1850+
if created["import_error_files"]:
1851+
session.execute(
1852+
delete(ParseImportError).where(ParseImportError.filename.in_(created["import_error_files"]))
1853+
)
1854+
if created["bundle"]:
1855+
_delete_bundle_if_unreferenced(session, "testing")
18121856

18131857

18141858
@pytest.fixture
@@ -2918,40 +2962,60 @@ def mock_xcom_backend():
29182962
def testing_dag_bundle():
29192963
from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
29202964

2921-
if AIRFLOW_V_3_0_PLUS:
2922-
from sqlalchemy import func, select
2965+
if not AIRFLOW_V_3_0_PLUS:
2966+
yield
2967+
return
2968+
2969+
from sqlalchemy import func, select
29232970

2924-
from airflow.models.dagbundle import DagBundleModel
2925-
from airflow.utils.session import create_session
2971+
from airflow.models.dagbundle import DagBundleModel
2972+
from airflow.utils.session import create_session
29262973

2974+
created = False
2975+
with create_session() as session:
2976+
if (
2977+
session.scalar(
2978+
select(func.count()).select_from(DagBundleModel).where(DagBundleModel.name == "testing")
2979+
)
2980+
== 0
2981+
):
2982+
session.add(DagBundleModel(name="testing"))
2983+
created = True
2984+
2985+
yield
2986+
2987+
if created:
29272988
with create_session() as session:
2928-
if (
2929-
session.scalar(
2930-
select(func.count()).select_from(DagBundleModel).where(DagBundleModel.name == "testing")
2931-
)
2932-
== 0
2933-
):
2934-
testing = DagBundleModel(name="testing")
2935-
session.add(testing)
2989+
_delete_bundle_if_unreferenced(session, "testing")
29362990

29372991

29382992
@pytest.fixture
29392993
def testing_team():
29402994
from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
29412995

2942-
if AIRFLOW_V_3_0_PLUS:
2943-
from sqlalchemy import select
2996+
if not AIRFLOW_V_3_0_PLUS:
2997+
yield None
2998+
return
29442999

2945-
from airflow.models.team import Team
2946-
from airflow.utils.session import create_session
3000+
from sqlalchemy import delete, select
29473001

3002+
from airflow.models.team import Team
3003+
from airflow.utils.session import create_session
3004+
3005+
created = False
3006+
with create_session() as session:
3007+
team = session.scalar(select(Team).where(Team.name == "testing"))
3008+
if not team:
3009+
team = Team(name="testing")
3010+
session.add(team)
3011+
session.commit()
3012+
created = True
3013+
yield team
3014+
3015+
if created:
3016+
# FKs to team.name are CASCADE / SET NULL, so deleting the row is safe.
29483017
with create_session() as session:
2949-
team = session.scalar(select(Team).where(Team.name == "testing"))
2950-
if not team:
2951-
team = Team(name="testing")
2952-
session.add(team)
2953-
session.flush()
2954-
yield team
3018+
session.execute(delete(Team).where(Team.name == "testing"))
29553019

29563020

29573021
@pytest.fixture

0 commit comments

Comments
 (0)