Everything that decides whether a green suite is evidence lives in one file and five settings. Get them wrong and the suite is a ritual that runs in eleven seconds and proves nothing.
The whole harness is one file, and it decides exactly two things: which Spark the tests run against, and which defaults are pinned. SPARK_TEST_MODE is the switch — local for the pull-request suite, connect for the nightly parity run. No test knows which one it got. Everything else in the file is three fixtures: seeded FX rates, the catalog name the remote suites qualify against, and the cleanup that keeps one session from leaking between tests.
Why
Engineers run this suite forty times a day. A harness that takes ninety seconds to reach the first assertion gets run once, just before pushing — at which point it is not a test suite, it is a slow linter.
The sharper reason: a session built by a bare SparkSession.builder.getOrCreate() is not the engine this warehouse runs on. Every setting nobody pinned is a place where the test and production disagree, and the disagreement surfaces at 03:00 in silver.sales_order_line rather than in a pull request.
How
pythontests/conftest.py — the entire harness
import os
import pytest
MODE = os.environ.get("SPARK_TEST_MODE", "local")
@pytest.fixture(scope="session")
def spark():
"""Session-scoped: the JVM starts once per worker, not once per test."""
if MODE == "connect":
from databricks.connect import DatabricksSession
session = DatabricksSession.builder.serverless(True).getOrCreate()
elif MODE == "local":
from delta import configure_spark_with_delta_pip
from pyspark.sql import SparkSession
builder = (
SparkSession.builder.master("local[2]")
.appName("dwh-tests")
# pinned because production pins them
.config("spark.sql.ansi.enabled", "true")
.config("spark.sql.session.timeZone", "UTC")
# pinned because 200 shuffle tasks over six rows is absurd
.config("spark.sql.shuffle.partitions", "1")
# STATIC configs — applied only when the session is created
.config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
.config(
"spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog",
)
.config("spark.ui.enabled", "false")
)
# puts the io.delta JARs matching the installed delta-spark wheel on the
# classpath; the two configs above name classes that are absent without it
session = configure_spark_with_delta_pip(builder).getOrCreate()
else:
raise ValueError(f"SPARK_TEST_MODE must be local or connect, got {MODE!r}")
yield session
if MODE == "local":
session.stop()
@pytest.fixture(scope="session")
def catalog() -> str:
"""The catalog the remote suites qualify against: dwh_test in CI, dwh_prod
on the scheduled read-only run. Resolved lazily, so the local suite, which
never requests it, needs no environment at all."""
return os.environ["DWH_CATALOG"]
@pytest.fixture(scope="session")
def rates(spark):
"""silver.fx_rate, seeded once: EUR at 1, CHF at 0.94 on the factory's date."""
from tests.factories import FX_RATE_SCHEMA, fx_rate_rows
return spark.createDataFrame(fx_rate_rows(), FX_RATE_SCHEMA)
@pytest.fixture(autouse=True)
def _isolate(spark):
"""One session for the whole run means one test can poison the next."""
yield
for table in spark.catalog.listTables():
if table.isTemporary:
spark.catalog.dropTempView(table.name)
spark.catalog.clearCache()
The default path needs no workspace, no token and no grant. A new joiner clones the repo, runs pytest, and has a green suite before anyone has provisioned them anything — the one dependency is a first run that resolves the Delta JARs, which the CI image should have already cached.
The catalog fixture is the one line here that is not for this suite. The SQL-assertion suite and the warehouse-behaviour suite both request it, and both build fully qualified names from it, so DWH_CATALOG is the single place the whole test estate learns whether it is pointed at dwh_test or at dwh_prod. Read it from the environment in exactly one place — the constraint-generator in the warehouse suite reads the same variable directly at collection time, because pytest fixtures do not exist yet at collection, and the two must never disagree.
Doing it
Keep conftest.py to one screen — under eighty linesA harness longer than that has bugs of its own and no tests of its own.
Pin every config that can change a resultLeave performance tuning out, with the single exception of shuffle partitions.
Pin delta-spark to the Delta version shipped with the runtime production uses, not to whatever pip resolves today
Fail loudly on an unknown SPARK_TEST_MODEA typo that silently falls back to local is a parity run that stops running and keeps reporting green.
Embedding it
Time a cold clone-to-green run with a new joiner watchingThat number is the harness's real quality score, and the team owns it once they have seen it.
When someone proposes a helper in conftest.py, ask which test it makes readableHarness code grows until it needs its own test suite and nobody notices the moment it did.
Name an owner for conftest.py at handoverShared ownership of the harness means nobody upgrades PySpark.
Defaults
Session-scoped Spark, function-scoped cleanup, nothing in between
SPARK_TEST_MODE defaults to local; pull-request CI never talks to a workspace and needs no credentials
Every config the warehouse depends on is explicit, never inherited from a library default
Gotchas
getOrCreate returning an EXISTING session, to which static configs are not appliedIt returns the session already up in the process. Runtime SQL settings are applied, so ANSI and the timezone look fine while the Delta extension is silently missing — and the symptom is a parse or analysis error on MERGE INTO, which reads like a syntax bug. It happens when any imported module builds a session at import time.
Setting spark.sql.extensions without putting the Delta JARs on the classpathThe two config lines name Java classes; pip-installing delta-spark ships only the Python side, so the session build dies with ClassNotFoundException on DeltaSparkSessionExtension. configure_spark_with_delta_pip resolves the matching JARs — but it resolves them from Maven, so on an offline or egress-restricted CI runner the first session hangs and then fails inside Ivy with a resolution timeout that never mentions Delta. Bake the JARs into the image or pre-populate the Ivy cache.
Local PySpark on Windows needs winutils.exe, hadoop.dll and HADOOP_HOME before it can write a Delta tableWithout them the failure is NativeIO$Windows.access0 deep in a stack trace. Standardise on a devcontainer or WSL and say so in the README; a JAVA_HOME mismatch has the same shape, green on every machine but one.
Session-scoped Spark without the cleanup fixture: a temp view created in one test is visible to the nextThe suite passes, then fails when someone adds a test above it, and the bisect blames an innocent commit.
depends on your pyspark pin — off on 3.5, on from 4.0
depends on the compute: on for serverless and SQL warehouses, and on classic from DBR 17.0; OFF on a classic cluster running DBR 13.3–16.x
a bad cast returns NULL in the test and raises in production, or the reverse
spark.sql.session.timeZone
the JVM zone — Europe/Zurich on a laptop, UTC in the container
UTC
to_date(order_ts) lands on a different day; date_sk differs by one between laptop and CI
spark.sql.shuffle.partitions
200
200, with AQE coalescing at real volumes
199 empty tasks per shuffle; a six-second suite becomes six minutes
ANSI is the one that changes numbers rather than timings. The webshop sends quantities as strings, and German decimal commas arrive in them:
Why
These settings are the difference between a suite that models production and a suite that models your laptop. The failure they prevent is the expensive kind: not a red build, but a green one that was never asking the right question.
Runtime belongs in the same topic because it is a correctness lever, not a comfort one. A suite that takes eleven minutes is run less often, then marked @pytest.mark.skip during a deadline, then deleted in the cleanup sprint.
How
pythonThe same cast, two behaviours
from pyspark.sql.functions import col
bad = spark.createDataFrame([("2,5",)], "quantity STRING")
bad.select(col("quantity").cast("decimal(12,3)")).collect()
# ansi.enabled = false -> [Row(quantity=None)]
# ansi.enabled = true -> [CAST_INVALID_INPUT] The value '2,5' of the type
# "STRING" cannot be cast to "DECIMAL(12,3)"
The NULL is the dangerous outcome, not the exception. It propagates: quantity NULL makes amount_eur NULL, the line contributes nothing to revenue, and no job fails. Business rule 5 — amount_eur must be greater than 0 for non-cancelled lines — is what eventually catches it, one layer and several hours downstream.
Which is why the ANSI row is qualified rather than stated flat. Production is ANSI is true of serverless compute, of SQL warehouses, and of classic clusters from DBR 17.0 — but a classic cluster on DBR 13.3 through 16.x defaults spark.sql.ansi.enabled to false, and the standards route only mandates 13.3 LTS or newer. So a team running batch on a DBR 15.4 job cluster has a test session stricter than the thing it models: the bad cast raises in CI and returns NULL in production, which is exactly the direction this route calls the dangerous one. Pin ANSI on in the builder either way — then pin it on in the cluster's Spark config too, or move that workload to serverless, so the harness is not the only place the rule holds.
textpyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
# src layout: dwh.* and tests.* both import without an editable install
pythonpath = ["src", "."]
addopts = "-n 4 --dist loadfile -q"
markers = ["classic_only: known Spark Classic behaviour; expires 2026-10-01"]
The other two levers are structural. Session scope on the spark fixture is worth roughly five seconds per test, because that is what starting a JVM costs: three hundred tests function-scoped spend twenty-five minutes doing nothing but starting Spark, and session-scoped they pay it once. And --dist loadfile sends every test in a file to the same xdist worker, so module-scoped fixtures are built once and a file that creates a temp view cannot collide with another worker doing the same. The default, --dist load, scatters tests individually and gives you both problems.
Doing it
Set ansi, timezone and shuffle partitions in the builderNever in an environment variable a CI runner might not export.
Cap xdist explicitly-n 4 on a laptop, -n 4 in CI, tuned only if you have measured it.
Keep money in DECIMAL end to end so no tolerance is ever needed in a comparison
Write one test that asserts the session settingsLet the parity run tell you when a serverless default moves.
Read spark.sql.ansi.enabled off the compute production actually runs on, not off the docsClassic below DBR 17.0 needs it set in the cluster policy, or the harness is the only place ANSI is true.
Embedding it
Flip ansi.enabled to false in front of the team and re-run the suiteWhatever still passes is a test that was not testing what its name says.
Ask whoever adds a config line to write the test that fails without itA config with no failing test is a superstition the next upgrade will keep alive.
Give the team the suite-runtime number in every retroIt only degrades, and only they can defend it.
Configs live in conftest.py, not in a Makefile and not in CI YAML
Never assert a non-ANSI behaviour, such as a bad cast returning NULLThat is encoding a bug as a requirement.
Measure the suiteOver three minutes and it is on its way out of the team's habits.
Gotchas
shuffle.partitions = 1 hides every partition-dependent bugmonotonically_increasing_id looks like a dense sequence from zero, so a surrogate-key test passes; in production the id jumps by 2 to the 33rd at each partition boundary. One partition also makes output order look stable, so assert df.collect()[0] passes locally and is nondeterministic at scale.
spark.conf.set inside a test leaks to every test that runs afterwards, because the session is sharedWhich tests those are depends on collection order, so adding an unrelated test file moves the failure. This is what the autouse cleanup fixture is for.
-n auto on a 64-core CI runner starts 64 JVMsThe container is OOM-killed, pytest reports that a worker crashed, and it does not name the test that was running — so it reads as flakiness rather than as a memory limit.
A missing timezone pin does not failIt shifts order_ts across a midnight boundary, so exactly the tests seeded near 00:00 fail, only in CI, and only for developers in a non-UTC zone.
Assuming 'production is ANSI' because serverless isA classic job cluster on DBR 13.3–16.x has ANSI off by default, so a harness that pins it on is stricter than production: the suite goes red on a cast that production quietly turns into NULL, the team weakens the test to make CI green, and the NULL keeps shipping. Check spark.conf.get('spark.sql.ansi.enabled') on the actual job cluster before you trust the row above.