Testing
Pipeline tests check that connected transformations produce the expected result. If a
reader, writer, or model cannot run in a test environment, pass a replacement transformation
to Pipeline.run() through transform_overrides.
To inspect outputs and writer inputs from a completed run, see Inspecting Pipeline Runs.
Replacing readers and writers
Pass overrides to Pipeline.run() as a mapping from step name to another transformation. Overrides
apply only to that run. Give steps you plan to replace explicit names with add_step(..., name="...").
This example replaces the reader and writer, then checks the
DataFrame passed to the writer:
from dataclasses import dataclass
from typing import Annotated
import polars as pl
from polars.testing import assert_frame_equal
from data_joinery import Context, Pipeline, Project, transform
@dataclass(frozen=True)
class Storage:
input_table: str
output_table: str
@dataclass(frozen=True)
class OrderPipelineContext:
storage: Storage
@dataclass
class Order:
order_id: int
amount: int
@dataclass
class OrderTotal:
order_id: int
total: int
@transform
def read_orders(
storage: Annotated[Storage, Context()],
) -> Annotated[pl.LazyFrame, Project(Order)]:
return pl.scan_parquet(storage.input_table)
@transform
def calculate_totals(
orders: Annotated[pl.LazyFrame, Project(Order)],
) -> Annotated[pl.LazyFrame, Project(OrderTotal)]:
return orders.select("order_id", (pl.col("amount") * 2).alias("total"))
@transform
def write_totals(
totals: Annotated[pl.LazyFrame, Project(OrderTotal)],
storage: Annotated[Storage, Context()],
) -> None:
totals.sink_parquet(storage.output_table)
def build_pipeline() -> Pipeline[OrderPipelineContext]:
pipeline = Pipeline(OrderPipelineContext)
read = pipeline.add_step(read_orders, name="read")
calculate = pipeline.add_step(calculate_totals, name="calculate")
write = pipeline.add_step(write_totals, name="write")
read >> calculate >> write
return pipeline
def test_order_pipeline_end_to_end():
fixture_orders = pl.LazyFrame({"order_id": [1, 2], "amount": [10, 25]})
@transform
def read_fixture() -> Annotated[pl.LazyFrame, Project(Order)]:
return fixture_orders
@transform
def capture_totals(
totals: Annotated[pl.LazyFrame, Project(OrderTotal)],
) -> None:
pass
result = build_pipeline().run(
OrderPipelineContext(Storage("unused", "unused")),
transform_overrides={
"read": read_fixture,
"write": capture_totals,
},
)
written = result.get_one_input("write", pl.LazyFrame)
expected = pl.LazyFrame({"order_id": [1, 2], "total": [20, 50]})
assert_frame_equal(written.collect(), expected.collect())
Outputs:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/runner/work/data-joinery/data-joinery
configfile: pyproject.toml
plugins: platformdirs-4.12.1, layout-enforcer-0.1.0
collected 1 item
docs_src/learn/pipelines/testing/test_pipeline_read_write_overrides_polars.py . [100%]
============================== 1 passed ===============================
from dataclasses import dataclass
from typing import Annotated
import pytest
from pyspark.sql import DataFrame, SparkSession
from pyspark.testing import assertDataFrameEqual
from data_joinery import Context, Pipeline, Project, transform
@dataclass(frozen=True)
class Storage:
input_table: str
output_table: str
@dataclass(frozen=True)
class OrderPipelineContext:
spark: SparkSession
storage: Storage
@dataclass
class Order:
order_id: int
amount: int
@dataclass
class OrderTotal:
order_id: int
total: int
@transform
def read_orders(
spark: Annotated[SparkSession, Context()],
storage: Annotated[Storage, Context()],
) -> Annotated[DataFrame, Project(Order)]:
return spark.table(storage.input_table)
@transform
def calculate_totals(
orders: Annotated[DataFrame, Project(Order)],
) -> Annotated[DataFrame, Project(OrderTotal)]:
return orders.selectExpr("order_id", "amount * 2 AS total")
@transform
def write_totals(
totals: Annotated[DataFrame, Project(OrderTotal)],
storage: Annotated[Storage, Context()],
) -> None:
totals.write.saveAsTable(storage.output_table)
def build_pipeline() -> Pipeline[OrderPipelineContext]:
pipeline = Pipeline(OrderPipelineContext)
read = pipeline.add_step(read_orders, name="read")
calculate = pipeline.add_step(calculate_totals, name="calculate")
write = pipeline.add_step(write_totals, name="write")
read >> calculate >> write
return pipeline
@pytest.fixture(scope="module")
def spark():
return SparkSession.builder.master("local[1]").getOrCreate()
def test_order_pipeline_end_to_end(spark: SparkSession):
fixture_orders = spark.createDataFrame(
[(1, 10), (2, 25)], "order_id BIGINT, amount BIGINT"
)
@transform
def read_fixture() -> Annotated[DataFrame, Project(Order)]:
return fixture_orders
@transform
def capture_totals(
totals: Annotated[DataFrame, Project(OrderTotal)],
) -> None:
pass
result = build_pipeline().run(
OrderPipelineContext(spark, Storage("unused", "unused")),
transform_overrides={
"read": read_fixture,
"write": capture_totals,
},
)
written = result.get_one_input("write", DataFrame)
expected = spark.createDataFrame(
[(1, 20), (2, 50)], "order_id BIGINT, total BIGINT"
)
assertDataFrameEqual(written, expected)
Outputs:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/runner/work/data-joinery/data-joinery
configfile: pyproject.toml
plugins: platformdirs-4.12.1, layout-enforcer-0.1.0
collected 1 item
docs_src/learn/pipelines/testing/test_pipeline_read_write_overrides_spark.py . [100%]
============================== 1 passed ==============================
The production reader and writer are not called. An override may remove context dependencies but cannot introduce dependencies absent from the original transformation. The pipeline still receives its complete declared context, keeping its public interface stable. Contract parameters must remain compatible, and the override must declare the same output contract as the production transformation.
Replacing a model
Overrides can also replace a model training step with a small dummy model, so the surrounding steps can be tested:
from dataclasses import dataclass
from typing import Annotated
import polars as pl
from polars.testing import assert_frame_equal
from data_joinery import Pipeline, Project, transform
@dataclass
class Observation:
value: int
class Model:
def predict(self, value: int) -> int:
raise NotImplementedError
class DummyModel(Model):
def predict(self, value: int) -> int:
return value * 2
@transform
def read_observations() -> Annotated[pl.LazyFrame, Project(Observation)]:
return pl.LazyFrame({"value": [1, 2]})
@transform
def train_model(
observations: Annotated[pl.LazyFrame, Project(Observation)],
) -> Model:
raise RuntimeError("expensive production training")
@transform
def predict(
observations: Annotated[pl.LazyFrame, Project(Observation)],
model: Model,
) -> Annotated[pl.LazyFrame, Project(Observation)]:
return observations.select(
pl.col("value").map_elements(model.predict, return_dtype=pl.Int64)
)
def build_pipeline() -> Pipeline:
pipeline = Pipeline()
read = pipeline.add_step(read_observations, name="read")
train = pipeline.add_step(train_model, name="train")
prediction = pipeline.add_step(predict, name="predict")
pipeline.connect(read, train)
pipeline.connect_many([read, train], prediction)
return pipeline
def test_model_pipeline_with_dummy_training():
@transform
def train_dummy(
observations: Annotated[pl.LazyFrame, Project(Observation)],
) -> Model:
return DummyModel()
result = build_pipeline().run(
transform_overrides={"train": train_dummy},
)
predicted = result.get_output("predict", pl.LazyFrame)
expected = pl.LazyFrame({"value": [2, 4]})
assert_frame_equal(predicted.collect(), expected.collect())
Outputs:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/runner/work/data-joinery/data-joinery
configfile: pyproject.toml
plugins: platformdirs-4.12.1, layout-enforcer-0.1.0
collected 1 item
docs_src/learn/pipelines/testing/test_pipeline_model_override_polars.py . [100%]
=============================== warnings summary ===============================
docs_src/learn/pipelines/testing/test_pipeline_model_override_polars.py::test_model_pipeline_with_dummy_training
/home/runner/work/data-joinery/data-joinery/docs_src/learn/pipelines/testing/test_pipeline_model_override_polars.py:43: PolarsInefficientMapWarning:
Expr.map_elements is significantly slower than the native expressions API.
Only use if you absolutely CANNOT implement your logic otherwise.
Replace this expression...
- pl.col("value").map_elements(predict)
with this one instead:
+ pl.col("value") * 2
pl.col("value").map_elements(model.predict, return_dtype=pl.Int64)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 1 passed, 1 warning =========================
from dataclasses import dataclass
from typing import Annotated
import pytest
from pyspark.sql import DataFrame, SparkSession
from pyspark.testing import assertDataFrameEqual
from data_joinery import Context, Pipeline, Project, transform
from data_joinery.backends.spark import SparkContext
@dataclass
class Observation:
value: int
class Model:
def predict(self, value: int) -> int:
raise NotImplementedError
class DummyModel(Model):
def predict(self, value: int) -> int:
return value * 2
@transform
def read_observations(
spark: Annotated[SparkSession, Context()],
) -> Annotated[DataFrame, Project(Observation)]:
return spark.createDataFrame([(1,), (2,)], "value BIGINT")
@transform
def train_model(
observations: Annotated[DataFrame, Project(Observation)],
) -> Model:
raise RuntimeError("expensive production training")
@transform
def predict(
spark: Annotated[SparkSession, Context()],
observations: Annotated[DataFrame, Project(Observation)],
model: Model,
) -> Annotated[DataFrame, Project(Observation)]:
predictions = [(model.predict(row.value),) for row in observations.collect()]
return spark.createDataFrame(
predictions,
"value BIGINT",
)
def build_pipeline() -> Pipeline[SparkContext]:
pipeline = Pipeline(SparkContext)
read = pipeline.add_step(read_observations, name="read")
train = pipeline.add_step(train_model, name="train")
prediction = pipeline.add_step(predict, name="predict")
pipeline.connect(read, train)
pipeline.connect_many([read, train], prediction)
return pipeline
@pytest.fixture(scope="module")
def spark():
return SparkSession.builder.master("local[1]").getOrCreate()
def test_model_pipeline_with_dummy_training(spark: SparkSession):
@transform
def train_dummy(
observations: Annotated[DataFrame, Project(Observation)],
) -> Model:
return DummyModel()
result = build_pipeline().run(
SparkContext(spark),
transform_overrides={"train": train_dummy},
)
predicted = result.get_output("predict", DataFrame)
expected = spark.createDataFrame([(2,), (4,)], "value BIGINT")
assertDataFrameEqual(predicted, expected)
Outputs:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/runner/work/data-joinery/data-joinery
configfile: pyproject.toml
plugins: platformdirs-4.12.1, layout-enforcer-0.1.0
collected 1 item
docs_src/learn/pipelines/testing/test_pipeline_model_override_spark.py . [100%]
============================== 1 passed ==============================
An override must declare the same output contract as the production transformation. The dummy
training transformation therefore declares Model, but its runtime value can still be a
DummyModel instance.
Context or an override?
Use Context() when the implementation is suitable for a test and only its values need
to change. Paths, dates, thresholds, and other configuration usually belong in the pipeline's context
dataclass.
Use an override when the implementation itself should not run, such as:
- a streaming sink that should be replaced with a memory sink
- a reader that depends on unavailable external infrastructure
- an expensive model-training implementation
- a writer with effects that the test needs to capture