This is an automated email from the ASF dual-hosted git repository.

amoghrajesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 297b0c7abc1 Update the docs recommending dag.test() for custom 
operator tests (#71542)
297b0c7abc1 is described below

commit 297b0c7abc16a6c11570944559e1b427ffdff987
Author: Amogh Desai <[email protected]>
AuthorDate: Fri Aug 14 11:22:27 2026 +0530

    Update the docs recommending dag.test() for custom operator tests (#71542)
---
 airflow-core/docs/best-practices.rst        | 69 ++++++++++++++++++++++-------
 airflow-core/docs/core-concepts/debug.rst   | 13 ++++++
 airflow-core/docs/howto/custom-operator.rst | 23 ++++++++++
 airflow-core/docs/tutorial/fundamentals.rst |  6 ++-
 4 files changed, 92 insertions(+), 19 deletions(-)

diff --git a/airflow-core/docs/best-practices.rst 
b/airflow-core/docs/best-practices.rst
index 52011031cd9..cc4c31bc2b6 100644
--- a/airflow-core/docs/best-practices.rst
+++ b/airflow-core/docs/best-practices.rst
@@ -733,6 +733,8 @@ the example_python_operator.py above so the actual parsing 
time is about ~ 0.62
 
 You can look into :ref:`Testing a Dag <testing>` for details on how to test 
individual operators.
 
+.. _best_practices:unit_tests:
+
 Unit tests
 -----------
 
@@ -786,29 +788,62 @@ This is an example test want to verify the structure of a 
code-generated Dag aga
 
 **Unit test for custom operator:**
 
+To unit test an operator or a sensor, call it directly. You do not need a Dag 
run, a Dag, or a
+metadata database — instantiate the operator and call ``execute()`` with the 
context keys your
+code actually reads:
+
+.. code-block:: python
+
+    def test_my_custom_operator_execute():
+        op = MyCustomOperator(task_id="my_custom_operator_task", 
prefix="s3://bucket/some/prefix")
+
+        assert op.execute(context={}) == "expected return value"
+
+For a sensor, call ``poke()`` and assert on the returned boolean:
+
+.. code-block:: python
+
+    def test_my_custom_sensor_poke():
+        sensor = MyCustomSensor(task_id="my_custom_sensor_task", 
key="some-key")
+
+        assert sensor.poke(context={}) is True
+
+If your operator renders templated fields, render them before asserting:
+
+.. code-block:: python
+
+    op.render_template_fields(context={"ds": "2021-09-13"})
+    assert op.prefix == "s3://bucket/2021-09-13"
+
+For a deferrable operator, assert that it defers with the trigger you expect, 
then drive the
+resume path directly:
+
 .. code-block:: python
 
-    import pendulum
+    from airflow.sdk.exceptions import TaskDeferred
+
+
+    def test_my_custom_operator_defers():
+        op = MyCustomOperator(task_id="my_custom_operator_task", 
deferrable=True)
 
-    from airflow.sdk import DAG, TaskInstanceState
+        with pytest.raises(TaskDeferred) as exc:
+            op.execute(context={})
+        assert isinstance(exc.value.trigger, MyCustomTrigger)
 
+        # Drive the method the trigger resumes into.
+        getattr(op, exc.value.method_name)(context={}, event={"status": 
"success"})
+
+.. note::
 
-    def test_my_custom_operator_execute_no_trigger(dag):
-        TEST_TASK_ID = "my_custom_operator_task"
-        with DAG(
-            dag_id="my_custom_operator_dag",
-            schedule="@daily",
-            start_date=pendulum.datetime(2021, 9, 13, tz="UTC"),
-        ) as dag:
-            MyCustomOperator(
-                task_id=TEST_TASK_ID,
-                prefix="s3://bucket/some/prefix",
-            )
+    ``TaskInstance.run()`` and ``TaskInstance.render_templates()`` were 
removed in Airflow 3.2 —
+    ``TaskInstance`` has been an internal class since Airflow 3.0. Replace 
``ti.run()`` with
+    ``op.execute(context)`` and ``ti.render_templates(context)`` with
+    ``op.render_template_fields(context)`` as shown above.
 
-        dagrun = dag.test()
-        ti = dagrun.get_task_instance(task_id=TEST_TASK_ID)
-        assert ti.state == TaskInstanceState.SUCCESS
-        # Assert something related to tasks results: ti.xcom_pull()
+To exercise a whole Dag run rather than a single operator, see
+:ref:`Testing Dags with dag.test() <concepts:debugging>`. That is an 
integration test: it needs a
+metadata database and a Dag that Airflow can serialize, so it is not a 
substitute for the unit
+tests above.
 
 
 Self-Checks
diff --git a/airflow-core/docs/core-concepts/debug.rst 
b/airflow-core/docs/core-concepts/debug.rst
index 0bed4ec3f2e..3ee12bea395 100644
--- a/airflow-core/docs/core-concepts/debug.rst
+++ b/airflow-core/docs/core-concepts/debug.rst
@@ -29,6 +29,19 @@ serialized python process.
 This approach can be used with any supported database (including a local 
SQLite database) and will
 *fail fast* as all tasks run in a single process.
 
+``dag.test()`` executes a real Dag run, so it has two prerequisites:
+
+* an initialized metadata database (``airflow db migrate``), and
+* a Dag that Airflow can serialize, which means the Dag must be defined in a 
file inside a
+  configured :doc:`Dag bundle </administration-and-deployment/dag-bundles>` — 
by default, your
+  Dags folder. ``dag.test()`` re-parses the bundle and serializes the Dag for 
you.
+
+A Dag built in memory — for instance inside a pytest function, in a file 
outside your Dags folder —
+is not in any bundle, so nothing gets serialized and the call fails with
+``Cannot create DagRun for DAG <dag_id> because the dag is not serialized``. 
To test operator
+behaviour without a Dag run at all, call the operator directly as described in
+:ref:`Unit tests <best_practices:unit_tests>`.
+
 To set up ``dag.test``, add these two lines to the bottom of your Dag file:
 
 .. code-block:: python
diff --git a/airflow-core/docs/howto/custom-operator.rst 
b/airflow-core/docs/howto/custom-operator.rst
index b5e14128ca0..f6793d4e8d0 100644
--- a/airflow-core/docs/howto/custom-operator.rst
+++ b/airflow-core/docs/howto/custom-operator.rst
@@ -149,6 +149,8 @@ Override ``custom_operator_name`` to change the displayed 
name to something othe
             custom_operator_name = "Howdy"
             # ...
 
+.. _custom-operator/template-fields:
+
 Templating
 ----------
 You can use :ref:`Jinja templates <concepts:jinja-templating>` to parameterize 
your operator.
@@ -449,3 +451,24 @@ An example of a sensor that keeps internal state and 
cannot be used with resched
 is 
:class:`airflow.providers.google.cloud.sensors.gcs.GCSUploadSessionCompleteSensor`.
 It polls the number of objects at a prefix (this number is the internal state 
of the sensor)
 and succeeds when there a certain amount of time has passed without the number 
of objects changing.
+
+Testing your operator
+---------------------
+
+Instantiate your operator and call ``execute()`` — or ``poke()`` for a sensor 
— with the context
+keys your code reads. This needs no Dag, no Dag run and no metadata database:
+
+.. code-block:: python
+
+    def test_hello_operator():
+        op = HelloOperator(task_id="hello", name="Bob")
+
+        assert op.execute(context={}) == "Hello Bob"
+
+Use ``op.render_template_fields(context)`` if you need to assert on rendered
+:ref:`template fields <custom-operator/template-fields>`.
+
+Reach for :ref:`dag.test() <concepts:debugging>` only when you want to 
exercise a whole Dag run,
+which is an integration test: it requires a metadata database and a Dag 
defined in a file that
+Airflow can serialize. See :ref:`Unit tests <best_practices:unit_tests>` for 
the full set of
+patterns, including deferrable operators.
diff --git a/airflow-core/docs/tutorial/fundamentals.rst 
b/airflow-core/docs/tutorial/fundamentals.rst
index 749008e36be..14ecb5bf77c 100644
--- a/airflow-core/docs/tutorial/fundamentals.rst
+++ b/airflow-core/docs/tutorial/fundamentals.rst
@@ -293,8 +293,10 @@ This command will provide detailed logs and execute your 
bash command.
 Keep in mind that the ``airflow tasks test`` command runs task instances 
locally, outputs their logs to stdout, and
 doesn't track state in the database. This is a handy way to test individual 
task instances.
 
-Similarly, ``airflow dags test`` runs a single Dag run without registering any 
state in the database, which is useful
-for testing your entire Dag locally.
+Similarly, ``airflow dags test`` runs a single Dag run locally, which is 
useful for testing your entire Dag. Unlike
+``airflow tasks test``, it creates a real Dag run and records task state in 
the metadata database, so it needs an
+initialized database and a Dag that Airflow can serialize from your Dags 
folder. See
+:ref:`Testing Dags with dag.test() <concepts:debugging>`.
 
 What's Next?
 -------------

Reply via email to