ETL Testing¶
loom.etl.testing provides in-memory harnesses for testing ETLStep
subclasses without spinning up a real catalog or writing to Delta Lake.
Test a single step with Polars¶
PolarsStepRunner seeds source tables as plain Python tuples, runs one step,
and returns a StepResult for assertions.
from loom.etl.testing import PolarsStepRunner
def test_clean_orders():
runner = PolarsStepRunner()
runner.seed("raw.orders", [(1, 10.0), (2, -5.0)], ["id", "amount"])
result = runner.run(CleanOrdersStep, NoParams())
result.assert_count(1)
result.assert_schema({"id": "Int64", "amount": "Float64"})
StepResult methods: assert_schema, assert_count, assert_not_empty,
show, to_polars.
Steps that declare FromConfig values¶
with_config gives the runner the config a step’s FromConfig values resolve
from, so a test passes a fake token instead of setting an environment variable.
The keys are checked when the step compiles, as ETLRunner does.
SparkStepRunner has the same method.
def test_fetch_uses_the_token():
runner = PolarsStepRunner().with_config({"respondio": {"api_token": "fake"}})
result = runner.run(FetchMessages, DailyParams(run_date=date(2026, 9, 25)))
result.assert_not_empty()
A step whose target the harness cannot capture (an IntoTemp, say) can be
called directly: FetchMessages().execute(params, api_token="fake", ...).
Sources that declare parse_json¶
The harness passes seeded frames to the step as they are; it does not decode
the columns a source declares with parse_json. To give the step what the
Polars reader would, decode the seed with decode_json_columns, which runs
the reader’s own decoding (including the normalisation of fields whose JSON
type changes between rows) on an eager or lazy frame:
from loom.etl.backends.polars import decode_json_columns
events = decode_json_columns(raw_events, EnrichEvents.events.json_columns)
result = EnrichEvents().execute(params, events=events.lazy())
json_columns is the tuple of declarations on a FromTable or FromFile.
Targets that declare replace(allow_empty=False)¶
PolarsStepRunner captures the step’s result instead of writing it, and
StubTargetWriter stores whatever it receives, so neither applies
allow_empty: an empty result passes without EmptyReplaceError. Test the
declaration on the compiled spec, and the guard itself with
PolarsTargetWriter on a temporary directory if needed:
from loom.etl.compiler import ETLCompiler
from loom.etl.declarative.target import ReplaceSpec
def test_dim_dealer_refuses_an_empty_replace():
spec = ETLCompiler().compile_step(BuildDimDealer).target_binding.spec
assert isinstance(spec, ReplaceSpec)
assert spec.allow_empty is False
Reusable seed datasets with ETLScenario¶
ETLScenario stores seed data as plain tuples so the same dataset can be used
with any backend runner.
from loom.etl.testing import ETLScenario
ORDERS = (
ETLScenario()
.with_table("raw.orders", [(1, 10.0), (2, 20.0)], ["id", "amount"])
)
def test_double_amount(loom_polars_runner):
ORDERS.apply(loom_polars_runner)
result = loom_polars_runner.run(DoubleAmountStep, NoParams())
result.assert_count(2)
Pytest fixtures: loom_polars_runner. Import loom.etl.testing.spark or
register it explicitly in Spark-enabled suites to use loom_spark_runner.
Spark integration tests¶
Use SparkTestSession for a real PySpark + Delta session:
from loom.etl.testing.spark import SparkTestSession
with SparkTestSession.start() as spark:
runner = SparkStepRunner(spark)
runner.seed("raw.orders", [(1, 10.0)], ["id", "amount"])
result = runner.run(CleanOrdersStep, NoParams())
result.assert_count(1)
Compile-time stubs¶
StubCatalog— in-memory catalog.StubSourceReader/StubTargetWriter— in-memory I/O.StubRunObserver— records pipeline lifecycle events.