Job tests¶
A job test makes a throwaway copy of a bundle job, runs it one or more times, and asserts on the outcome. This page covers the core flow: lakeflow.job(), run(), and the JobRun and TaskRun results. The recipes cover everything you can change about the copy and the run.
Make a copy of a job¶
from pytest_databricks_lakeflow import Lakeflow
def test_job_succeeds(lakeflow: Lakeflow) -> None:
with lakeflow.job("my_example_job", params={"param1": "hello", "param2": "world"}) as job:
run = job.run()
assert run.succeeded, run
assert run.task("transform").output == "hello world"
keyis the bundle resource key of the job, the name thatdatabricks bundle runtakes.paramsare job parameters for every run of the copy. The job must declare them: the Jobs API accepts an undeclared parameter silently, so the plugin raisesLakeflowErrorinstead.Lakeflow(allow_undeclared_params=True)lets them through. Names starting with__pytest_are reserved and raiseValueError.- Entering the
withblock creates the job. Leaving it deletes the job, unless the test failed andkeep_failedis on, which is the default.pytest.skipandpytest.xfaildo not count as failures. - Without
with, the firstrun()creates the job and the plugin deletes it at the end of the test:
def test_without_with(lakeflow: Lakeflow) -> None:
assert lakeflow.job("my_example_job").run().succeeded
Everything after params is keyword-only. Each option has a recipe:
| Option | Changes | Recipe |
|---|---|---|
name |
The job name, [pytest] <node id> by default |
Share one run between tests |
disable |
Switches tasks off in the copy | Disable a task |
task_params |
The parameters of single tasks | Override one task's parameters |
task_overrides |
The timeout and retries of single tasks | Test retries and timeouts |
force_conditions |
The outcome of if/else condition tasks | Force a branch |
run_as |
Who the copy runs as | Run as another identity |
children |
Warn or refuse when the copy would run a deployed child job | Jobs that run other jobs |
capture_task_values |
Adds probe tasks that read task values | Read task values |
upstream_task_values |
Replaces tasks with stubs that set task values | Run the downstream half of a job |
for_each_inputs |
The list a for-each task iterates over | For-each tasks |
trigger |
The values of {{job.trigger.*}} references |
Simulate a trigger |
setup, teardown, teardown_at |
Tasks that seed and clean up data | Seed data with a setup task |
extra_dependencies |
Test-only packages | Add test-only dependencies |
environments |
The copy's serverless environments | Change a serverless environment |
pipelines, schema |
Copies the pipelines that its pipeline tasks update | Copy the pipelines of a job |
saved_queries |
Runs saved query tasks as SQL files, so their rows come back | Read the rows of a saved query |
run_as, disable, extra_dependencies, environments, children, setup, teardown and saved_queries take their default from the Lakeflow argument of the same name, see defaults for per-call arguments.
Run it¶
job.run() starts a run, waits for it to finish and returns a JobRun. Running is explicit so that a test can run one copy more than once:
def test_run_twice_in_one_job(lakeflow: Lakeflow) -> None:
with lakeflow.schema() as schema, lakeflow.job("my_example_job", params={"schema": schema}) as job:
first = job.run()
second = job.run(params={"mode": "incremental"})
assert first.succeeded and second.succeeded
assert second.duration < first.duration
paramsare merged over the job's parameters for this run only.onlylists the tasks to run, see Run only some tasks.timeoutis atimedelta, by defaultLakeflow'stimeoutof 30 minutes. When it expires the run is cancelled andLakeflowTimeoutis raised, naming the run's page and the state of each task, such asTasks when cancelled: extract SUCCESS, transform RUNNING, load PENDING.- A copy allows one run at a time.
job.runsholds every finishedJobRun, andjob.job_idandjob.urlidentify the job in the workspace. - Every call sends a fresh idempotency token, so a request that the SDK retries cannot start a second run.
The keyword-only options each have a recipe:
| Option | Changes | Recipe |
|---|---|---|
stop_after |
Cancels the run after a fixed time and returns it | Stop a run that does not end |
performance_target, queue |
The serverless performance mode and queueing of this run | Speed up a run |
full_refresh and the refresh selections |
How the run's pipeline tasks refresh | Choose how a pipeline task refreshes |
replay |
Stubs the tasks that only leaves out with a previous run's task values |
Replay a run's task values |
dbt_commands |
The commands of every dbt task in this run | dbt tasks |
stream |
Prints the run's progress to the terminal | Watch a run live |
job.start() starts a run without waiting and returns a RunHandle, see Run jobs side by side.
Check the outcome¶
run.succeeded is true only when every one of your tasks that was part of the run ended SUCCESS. Tasks the plugin injected do not count, nor do tasks that ended EXCLUDED because their run_if or if/else condition was not met. run.failed is not run.succeeded.
Do not use run.result_state, the parent run's state, as the verdict: a job whose probe runs after a failed task ends SUCCESS_WITH_FAILURES.
def test_job_surfaces_permission_error(lakeflow: Lakeflow) -> None:
with lakeflow.job("my_example_job", params={"param1": "forbidden", "param2": "world"}) as job:
run = job.run()
assert run.failed
assert run.termination_code == "RUN_EXECUTION_ERROR"
assert run.task("transform").raised(PermissionError)
assert run.task("load").result_state == "UPSTREAM_FAILED"
run.task(key)returns the task'sTaskRun. For a task that was not part of the run it raises aKeyErrorthat lists the available keys, or saysSkipped by only: [...]orDisabled in the job: [...].run.tasksholds your tasks that were part of the run,run.skipped_tasksthose left out byonly,run.disabled_tasksthose disabled in the job and the tasks downstream of them, andrun.injected_tasksthe plugin's stubs, probes, setup and teardown tasks.run.executed_tasksis the sorted list of keys inrun.tasksthat ran.run.parametersis the run's job parameters by name: the value passed for the run, or the default. The plugin's own parameters are left out.run.timed_outis true when the run or any of its tasks endedTIMEDOUT, andrun.cancelledwhen the run endedCANCELED. Both count as failed.
Get the whole failure in one call¶
run.raise_for_failure() returns the run when it succeeded, so run = job.run().raise_for_failure() works, and otherwise raises LakeflowRunFailed. Its message is the run's repr, then one line per failing task with its whole error, its root cause and its run URL, then the resolved parameters:
LakeflowRunFailed: JobRun('etl', FAILED, https://.../runs/77, failing=['extract: ValueError: bad limit'])
params: {"date": "2026-10-01"}
extract: ValueError: bad limit https://.../runs/7701
params: {"date": "2026-10-01", "limit": "10"}
The exception's run attribute holds the JobRun:
import pytest
from pytest_databricks_lakeflow import Lakeflow, LakeflowRunFailed
def test_failure_detail(lakeflow: Lakeflow) -> None:
with lakeflow.job("my_example_job", params={"param1": "forbidden", "param2": "world"}) as job:
with pytest.raises(LakeflowRunFailed, match="PermissionError") as excinfo:
job.run().raise_for_failure()
assert excinfo.value.run.task("transform").raised(PermissionError)
assert run.succeeded, run is the shorter form: repr(run) names the failing tasks with the first line of their errors, links to the run, and adds params={...} when the run failed.
Read a task's result¶
Each TaskRun describes one task of the run, or the last attempt of a retried task. The fields you will reach for most:
| Field | Holds |
|---|---|
result_state, succeeded, ran |
The platform's state, such as SUCCESS, FAILED or UPSTREAM_FAILED, and whether the task ran at all. excluded, upstream_failed, disabled, cancelled and timed_out each test for one state. |
output, output_json |
What a notebook passed to dbutils.notebook.exit(), as text or parsed as JSON. |
logs, log_lines |
stdout and stderr of a Python script, wheel or JAR task. Notebook tasks have no logs; what they printed is in notebook. |
notebook |
The rendered notebook of a notebook task that ran and did not succeed, with each cell's output and stdout. See Read what a notebook printed. |
error |
A TaskError with type, message, trace, root_cause and code, or None. |
parameters |
The task's parameters after job parameters and {{...}} references were resolved: a dict for notebook, SQL, run-job and named wheel parameters, a list for positional parameters and dbt commands. |
task_values, missing_task_values |
Task values a probe captured, see Read task values. |
attempts, attempt |
Every attempt of a retried task, in order, and the number of this one. |
duration, timing, effective_performance_target |
How long the task took, split into queue, setup, execution and cleanup, and the serverless performance mode it ran with. |
run_url, run_id |
The task run's page and id. |
sql_result, alert_state, dbt, pipeline_update, child_run, condition_outcome, iterations |
The result of a SQL file, alert, dbt, pipeline, run-job, condition or for-each task. See Task types. |
The API reference lists every field. When you compare parameters, remember two platform rules: a job parameter wins over a task parameter of the same name, and every job parameter is pushed to notebook tasks, SQL file tasks and wheel tasks with named parameters. Compare the entries you set rather than the whole dict.
Assert on a failure¶
def test_root_cause_follows_the_exception_chain(lakeflow: Lakeflow) -> None:
with lakeflow.job("my_example_job", params={"param1": "chained", "param2": "world"}) as job:
run = job.run()
error = run.task("transform").error
assert error is not None
assert error.type == "RuntimeError"
assert error.message == "wrapper failed"
assert error.root_cause == "KeyError: 'root cause'"
task.raised(SomeError)compares class names, because the exception happened in another process.error.typekeeps the name as Python printed it, sojson.decoder.JSONDecodeErrormatchesraised(json.JSONDecodeError).error.root_causeis the lastCaused by:line of a Spark or Py4J trace, or the first exception of a Python chain (raise ... from e).error.typeanderror.messagestill describe the outer exception.error.codeis the Spark error class, such asTABLE_OR_VIEW_NOT_FOUND, when the message or the root cause starts with one in square brackets.task.output_jsonparses the value ofdbutils.notebook.exit(json.dumps({...})). It raises aValueErrornaming the task when the notebook never calledexit(), when the output was cut at 5 MB, or when it is not JSON.- The Jobs API returns at most 5 MB of output and of logs. When a task's output or logs were cut, building the run emits a
UserWarningnaming the task, andoutput_truncatedorlogs_truncatedis true.
What a failed notebook printed is in task.notebook.stdout, see Read what a notebook printed.
Read task values¶
Task values are not exposed by the Jobs API. capture_task_values adds a probe task after each task you name. The probe depends on the task, runs even when the task failed, and reports the values it finds:
def test_values_captured_even_on_failure(lakeflow: Lakeflow) -> None:
with lakeflow.job(
"my_example_job", params={"param1": "explode"}, capture_task_values={"transform": ["stage"]}
) as job:
run = job.run()
assert run.failed
assert run.task("transform").task_values["stage"] == "validation"
Reading a value that was not captured raises a KeyError whose message says why:
Task 'transform' never set value 'never_set': the task ran and did not set it.Task 'load' did not run (UPSTREAM_FAILED), so value 'anything' was not captured: the task was skipped.Value 'x' of task 't' was not captured: probe task __pytest_probe__t ended FAILED ...: the probe failed or its output could not be read.
missing_task_values lists the keys that were not captured. Each probe is a serverless notebook task and costs 7 to 30 seconds, even when its task did not run. A for-each task, or the task nested in one, cannot be named, because an iteration cannot set task values.
Task tests explain the probe and stub mechanics. A run that captured values can feed them to a task test or to a later run, see Replay a task from a previous run and Replay a run's task values.
Use a temporary schema¶
lakeflow.schema() creates a schema named <catalog>.pytest_<run id>_<random> and drops it on leaving the block, even when the block failed, unless keep or --lakeflow-keep is set. It needs a catalog, from Lakeflow(catalog=...) or schema(catalog=...). Pass the schema to the job as a parameter:
def test_writes_table(lakeflow: Lakeflow) -> None:
with lakeflow.schema() as schema, lakeflow.job("my_example_job", params={"schema": schema}) as job:
run = job.run()
assert run.succeeded, run
assert lakeflow.ws.tables.exists(f"{schema}.example_rows").table_exists
lakeflow.ws is the WorkspaceClient, for whatever else the test needs to check. To seed the schema before the run, see Test data.
Run only some tasks¶
only lists the task keys to run. +key adds the tasks upstream of key, key+ the tasks downstream, and +key+ both, as the Jobs API's run-now only does.
def test_subset_of_job(lakeflow: Lakeflow) -> None:
with lakeflow.job("my_example_job") as job:
run = job.run(only=["+transform"])
assert run.succeeded
assert "load" not in run.tasks
assert sorted(run.skipped_tasks) == ["load", "my_wheel_task"]
Two things are checked before the run starts, because the platform would otherwise start the run and fail it:
- A selected task that refers to
{{tasks.<key>.values.<name>}}or{{tasks.<key>.output.<path>}}of a task left out raisesLakeflowErrornaming both tasks. Select that task too, as+transformdoes, or stub it, see Run the downstream half of a job. Other references to a left-out task, such as{{tasks.extract.result_state}}, resolve against itsDISABLEDrun and are not checked. onlycannot name a task that is disabled in the copy, or one downstream of a disabled task, because the platform runs a disabled task thatonlynames. A+that only reaches a disabled task leaves it out.
When the job has stubs, probes, setup or teardown tasks, only is extended with the ones the selected tasks need.
When a test fails¶
The job is kept, with its runs, so you can open it in the workspace. The job and run URLs appear in the test's lakeflow report section and as JUnit XML properties, and a lakeflow block at the end of the session lists every kept job. pytest --lakeflow-sweep deletes kept jobs older than sweep_hours at the start of a later session.
Debug a failure covers reading a kept run again, replaying a failed notebook task in your workspace, and keeping every run's output as a CI artefact.