Skip to content
academia.sh

Lesson 10 / 16

Testable Transformations

Counting what expectations placed on the transformation's own output catch and what they do not: six expectations never fire on the sound pipeline and catch four of the six corruption classes, while duplication and type drift are swallowed by the transformation itself and never appear in the output. The unit change is caught by an expectation on the output at the third stage, and no baseline is needed for it, although the serving stays exactly the same. A rule error that shifts the period label back by one period leaves 2200 rows, 55 cells and five serving values exactly the same, so it trips none of the six expectations; only a unit test on a fixed input catches it.

Published Updated

In this lesson

  • Defines the condition that makes a transformation testable and writes a unit test with a fixed input against a hand-computed output.
  • Runs the six expectations placed on the transformation's own output over the six corruption classes and counts what is caught and what is not.
  • Writes by name the residue no expectation sees and shows that a rule error that shifts labels leaves the serving exactly the same.
Contents

Three lessons wrote down the place of the transformation: whether it is done at the source or at the target, in which order it runs, and in which layer it stops. The answer to all three questions was an arrangement, and all three assumed the same thing — that the rule itself is correct and the data arrives in good shape. At no stage was it tested that the transformation produces the correct result.

This lesson asks that question and puts two separate answer instruments side by side: a unit test that runs on a hand-written fixed input, and an expectation placed on the actual output of the run. What they catch is different and, more importantly, there is a residue that neither of them can see.

  • TF41. The source, the five-stage pipeline and the corruption classes are fictional; no real store, transformation framework or data test library is called. The seed is 20260218, and the output does not depend on the run.
  • TF42. In this lesson the transformation is the composition of the first three stages: s1_screen, s2_dedupe and consumption from the period difference. Its input is the raw row, its output the consumption row.
  • TF43. An expectation is a routine that returns, as true or false, an invariant the output must carry. Here True means broken.
  • TF44. The unit test routine was measured in the Unit Testing and Test-Driven Development course of the Software Quality and Testing curriculum, and the translation of quality dimensions into checks in the Introduction to Data Engineering course; neither is repeated. The difference here is at a single point: those checks looked at the source, these look at the output of the transformation.
  • TF45. The baseline is the transformation without expectations: broken expectations 0, because no expectation has been written.
  • TF46. The corruption classes are not attacks but accidents on the producer side, and each corrupts a single period (2026-07).
  • TF47. The consumption ceiling 200 is a physical choice; it is not the 49 known from the source’s generator. The one who writes the expectation knows the meter, not the generator.
  • TF48. Resolution: the step of the consumption row ratio is 1/2200 = 0.0005, the summary 55 cells, the serving 5 values. No ratio is printed over the serving output, the value itself is written.

The Condition of a Testable Transformation

A transformation is testable only when it is a pure function: for the same input it gives the same output on every run, carries no hidden state, does not look at the clock, reads nothing from outside. This condition is the direct counterpart of the rule of the course — a transformation that does not give the same result when rerun has no result to test, because every run makes a different answer defensible.

Once the condition is met, two separate kinds of tests become possible, and they look at different questions:

Type Input Oracle: where the expected result comes from Catches
unit test hand-written fixed input hand-computed output an error in the rule
data test (expectation) actual output of the run invariant the output must carry a corruption of the data

The distinction lives in the oracle column. In a unit test the correct answer comes from the person writing the test, and since the input is kept small it can be computed by hand. An expectation has no such answer; nobody can know by hand the correct total of two thousand small rows. An expectation can only write what must be true of the output itself on every run: the key is unique, consumption cannot be negative, the cell count is complete.

The block below sets up the source, the five stages and the six corruption classes. It is the definition used throughout the lesson.

# FICTIONAL. The meter readings of the measurement network, the five-stage pipeline and the corruption
# classes are defined in the lesson; no real store, framework or orchestrator is called.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


REGION = ["east", "west", "north", "south", "central"]
TYPE = ["residential", "commercial"]
PERIODS = [f"2026-{a:02d}" for a in range(1, 13)]
SUBSCRIBERS = {}
for i in range(200):
    r = rng(SEED + 37 * i)
    SUBSCRIBERS[f"A{i + 1:03d}"] = {"region": REGION[int(r() * 5)], "type": TYPE[int(r() * 2)],
                                    "start": 10000 + int(r() * 60000)}


def generate_source():
    records = []
    for i, (sub, a) in enumerate(SUBSCRIBERS.items()):
        e = a["start"]
        for p in PERIODS:
            r = rng(SEED + 101 * i + 7 * PERIODS.index(p))
            e += 4 + int(r() * 46)
            records.append({"subscriber": sub, "period": p, "region": a["region"], "type": a["type"],
                            "index": e, "unit": "m3"})
    return records


CORRUPTIONS = ["late_arrival", "duplicated", "schema_changed", "unit_changed", "missing_partition", "type_drift"]


def corrupt(records, cls, period="2026-07"):
    """Corrupts a single period , the remaining periods stay intact."""
    out, counter = [], 0
    for k in records:
        if k["period"] != period:
            out.append(k)
            continue
        if cls == "late_arrival":
            if k["subscriber"] <= "A040":           # 40 subscribers never arrived in this period
                counter += 1
                continue
            out.append(k)
        elif cls == "duplicated":
            out.append(k)
            if k["subscriber"] <= "A060":           # 60 records arrived twice
                out.append(dict(k))
                counter += 1
        elif cls == "schema_changed":
            y = dict(k)
            y["index_value"] = y.pop("index")       # the field name changed
            counter += 1
            out.append(y)
        elif cls == "unit_changed":
            y = dict(k)
            y["index"] = k["index"] * 1000          # liters instead of m3
            y["unit"] = "m3"                        # THE LABEL DID NOT CHANGE , silent corruption
            counter += 1
            out.append(y)
        elif cls == "missing_partition":
            if k["region"] == "north":              # one region's partition was never written
                counter += 1
                continue
            out.append(k)
        elif cls == "type_drift":
            y = dict(k)
            y["index"] = str(k["index"])            # the number turned into text
            counter += 1
            out.append(y)
    return out, counter


def s1_screen(records):
    """A row that does not carry the required fields is dropped."""
    REQUIRED = ("subscriber", "period", "region", "index", "unit")
    keep = [k for k in records if all(a in k for a in REQUIRED)]
    return keep, len(records) - len(keep)


def s2_dedupe(records):
    seen, keep = set(), []
    for k in records:
        key = (k["subscriber"], k["period"])
        if key in seen:
            continue
        seen.add(key)
        keep.append(k)
    return keep, len(records) - len(keep)


def s3_consumption(records):
    """Consumption from the period difference. If the previous period does not exist , the row cannot be computed."""
    table = {(k["subscriber"], k["period"]): k for k in records}
    out, dropped = [], 0
    for k in records:
        i = PERIODS.index(k["period"])
        if i == 0:
            continue
        prev = table.get((k["subscriber"], PERIODS[i - 1]))
        if prev is None:
            dropped += 1
            continue
        try:
            f = int(k["index"]) - int(prev["index"])
        except (TypeError, ValueError):
            dropped += 1
            continue
        out.append(dict(k, m3=f))
    return out, dropped


def s4_summary(records):
    totals = {}
    for k in records:
        key = (k["region"], k["period"])
        totals[key] = totals.get(key, 0) + k["m3"]
    return totals, 0


def s5_serve(summary):
    """Serving: the period average per region."""
    region = {}
    for (b, d), v in summary.items():
        region.setdefault(b, []).append(v)
    return {b: round(sum(v) / len(v), 2) for b, v in region.items()}, 0

Unit Test on a Fixed Input

The second block writes an eight-row input and computes the correct output by hand. The input has two subscribers, three periods each, one duplicated row and one row missing a required field; therefore all three stages have to do their work. Next to it a rule is placed that does the same calculation but writes the result with the label of the previous period.

def transform(records):
    """From raw row to consumption row: screen , dedupe , period difference."""
    v, _ = s1_screen(records)
    v, _ = s2_dedupe(v)
    v, _ = s3_consumption(v)
    return v


def transform_shifted(records):
    """THE SAME calculation , the only difference: the result is written with the previous period's label."""
    return [dict(k, period=PERIODS[PERIODS.index(k["period"]) - 1]) for k in transform(records)]


def row(sub, p, e, type="residential"):
    return {"subscriber": sub, "period": p, "region": "east", "type": type, "index": e, "unit": "m3"}


# Fixed input: eight rows , one duplicate and one row with a missing field.
FIXED = [row("T1", "2026-01", 100), row("T1", "2026-02", 112),
         row("T1", "2026-02", 112), row("T1", "2026-03", 130),
         row("T2", "2026-01", 500, "commercial"), row("T2", "2026-02", 540, "commercial"),
         row("T2", "2026-03", 545, "commercial"), {"subscriber": "T3", "period": "2026-02"}]
# Hand-computed correct output: the oracle of the test.
EXPECTED = [("T1", "2026-02", 12), ("T1", "2026-03", 18),
            ("T2", "2026-02", 40), ("T2", "2026-03", 5)]


def essence(v):
    return sorted((k["subscriber"], k["period"], k["m3"]) for k in v)


def report(name, v):
    print(f"{name:<11}", " ".join(f"{a}/{d[-2:]}={m}" for a, d, m in essence(v)),
          " passed:", essence(v) == sorted(EXPECTED))


print("fixed input:", len(FIXED), "rows , expected output:", len(EXPECTED), "rows")
report("correct", transform(FIXED))
report("shifted", transform_shifted(FIXED))
print("same input run twice , is the output the same:",
      essence(transform(FIXED)) == essence(transform(FIXED)))
fixed input: 8 rows , expected output: 4 rows
correct     T1/02=12 T1/03=18 T2/02=40 T2/03=5  passed: True
shifted     T1/01=12 T1/02=18 T2/01=40 T2/02=5  passed: False
same input run twice , is the output the same: True

Eight rows fall to four: the duplicated row in the s2_dedupe stage, the row with the missing field in the s1_screen stage, and the first period of each subscriber by the definition of consumption. All four numbers can be verified by hand: for T1, 112 − 100 = 12 and 130 − 112 = 18; for T2, 540 − 500 = 40 and 545 − 540 = 5.

The shifted rule produces the same four numbers and fails the test immediately, because the labels have slipped back by one period. This is the strength of the test: the calculation itself is right, only the period it is written to is wrong, and this distinction is visible to the eye on a fixed input. The last line verifies the rule of the course on the fixed input — when the same input is run twice, the output is exactly the same.

Expectations on the Output

The third block defines six expectations. All of them look at the transformation’s own output; none goes back to the source and none needs a correct result known in advance. The expectations are run on the six corruption classes one by one.

import statistics

# All the expectations look at the transformation's OWN output , not at the source.
ROWS, CELLS, CEILING = 2200, 55, 200


def e_key_unique(v, sm):
    key = [(k["subscriber"], k["period"]) for k in v]
    return len(key) != len(set(key))


def e_no_negative(v, sm):
    return any(k["m3"] < 0 for k in v)


def e_ceiling(v, sm):
    return any(k["m3"] > CEILING for k in v)


def e_row_ratio(v, sm):
    return abs(len(v) - ROWS) / ROWS > 0.02


def e_cell_completeness(v, sm):
    return len(sm) != CELLS


def e_volatility(v, sm):
    """A region's period cells must not stray far from their own median."""
    for b in REGION:
        vals = [x for (bb, _), x in sm.items() if bb == b]
        median = statistics.median(vals) if vals else 0
        if not vals or any(abs(x - median) / abs(median) > 0.30 for x in vals):
            return True
    return False


EXPECTATIONS = [("key_unique", e_key_unique), ("no_negative", e_no_negative),
                ("ceiling", e_ceiling), ("row_ratio", e_row_ratio),
                ("cell_completeness", e_cell_completeness), ("volatility", e_volatility)]


def run_checks(v):
    """Names of the broken expectations , summary and serving."""
    sm = s4_summary(v)[0]
    return [a for a, f in EXPECTATIONS if f(v, sm)], sm, s5_serve(sm)[0]


SOURCE = generate_source()
CLEAN, _, SERVING = run_checks(transform(SOURCE))
print("expectations:", len(EXPECTATIONS), " broken on the sound pipeline:", len(CLEAN))
print(f"{'corruption':<19}{'rows':>6}{'cells':>7}{'broken expectations':>34}{'serving':>9}")
for s in CORRUPTIONS:
    v = transform(corrupt(SOURCE, s)[0])
    bz, sm, sn = run_checks(v)
    print(f"{s:<19}{len(v):>6}{len(sm):>7}{','.join(bz) or 'none':>34}"
          f"{'same' if sn == SERVING else 'changed':>9}")
expectations: 6  broken on the sound pipeline: 0
corruption           rows  cells               broken expectations  serving
late_arrival         2120     55              row_ratio,volatility  changed
duplicated           2200     55                              none     same
schema_changed       1800     45       row_ratio,cell_completeness  changed
unit_changed         2200     55    no_negative,ceiling,volatility     same
missing_partition    2108     53       row_ratio,cell_completeness  changed
type_drift           2200     55                              none     same

The first line shows there is no cost: on the sound pipeline none of the six expectations fires, so no good row is wrongly blocked. Six expectations catch four of the six classes.

The unit_changed row is the most important number of this lesson. In the Introduction to Data Engineering course the same corruption was visible only through the distribution check that looks at the summary of the fourth stage; here the no_negative and ceiling expectations catch it in the output of the transformation, at the third stage. The difference is not merely one stage: the distribution check needs a sound baseline summary to work, these three expectations need no baseline at all. A newly built pipeline has no history to compare against, whereas an invariant can be written on the first run. The serving column, on the other hand, still says same for unit_changed — although the five served values stay exactly in place, two periods are garbage, and the only thing that says so is the expectation.

The remaining three catches are ordinary: late_arrival and missing_partition lower the row count and break the cell band, while schema_changed makes the whole period vanish at the s1_screen stage and leaves 1800 rows and 45 cells.

The Residue No Expectation Sees

Two lines in the table are empty: duplicated and type_drift. The fourth block writes two rules next to them — one faulty, one deliberate — and runs the same six expectations on them.

SHIFTED = transform_shifted(SOURCE)
FACTORED = [dict(k, m3=k["m3"] * (1.05 if k["type"] == "commercial" else 1.0))
            for k in transform(SOURCE)]
for name, v in (("shifted rule", SHIFTED), ("factor rule", FACTORED)):
    bz, sm, sn = run_checks(v)
    print(f"{name:<17} rows={len(v)} cells={len(sm)} broken={','.join(bz) or 'none'}")
    print("  serving:", sn)
print("  baseline:", SERVING)
shifted rule      rows=2200 cells=55 broken=none
  serving: {'central': 1321.45, 'north': 1212.55, 'east': 881.36, 'west': 833.09, 'south': 1065.55}
factor rule       rows=2200 cells=55 broken=none
  serving: {'central': 1357.32, 'north': 1235.57, 'east': 902.13, 'west': 859.22, 'south': 1095.81}
  baseline: {'central': 1321.45, 'north': 1212.55, 'east': 881.36, 'west': 833.09, 'south': 1065.55}

The residue has three parts, and the mechanism of each is separate.

The two classes the transformation swallows. duplicated is repaired in the s2_dedupe stage and type_drift in the type conversion; an expectation looking at the output cannot tell a repaired corruption from a corruption that never happened. The emptiness of these two lines is proof of the transformation’s soundness, but nobody learns that the producer is still sending text, and the text of a coming period may not be convertible to a number. An expectation placed on the output does not take the place of a check placed on the input; the two look at different questions.

The label-shifting rule. The shifted rule produces 2200 rows and 55 cells, breaks no expectation and leaves the serving exactly the same. The reason is structural: the set of calculated numbers has not changed at all, only the periods they are written to have slipped; the average of the eleven cells per region is the average of the same numbers. No invariant that can be written about the output itself can see this, because the output itself is flawless — what is wrong is which fact it corresponds to. The only thing that catches this error is the fixed-input test at the start of the lesson.

The deliberate rule change. The factor rule reports commercial consumption multiplied by 1.05. None of the six expectations breaks, because there is no corruption; the serving, however, moves up in all five values. Expectations cannot say whether this difference is a wanted change or an accident. The only thing that makes the distinction is writing the rule version to the record.

Summary

  • A transformation is testable only if it is a pure function; a rule that does not give the same output for the same input on every run has no result to test.
  • A unit test catches an error in the rule, an expectation catches a corruption of the data; the distinction lies in where the oracle comes from, and the two do not take each other’s place.
  • On the sound pipeline none of the six expectations fires, and they catch four of the six corruption classes; duplicated and type_drift are swallowed by the transformation itself and never appear in the output.
  • unit_changed is caught by an expectation on the output at the third stage and without any baseline, although the five served values stay exactly the same.
  • The rule that shifts the period label back by one period produces 2200 rows, 55 cells and the same five serving values, so it trips none of the six expectations; only the unit test with a fixed input sees it.

Next Step

The factor rule of the last block opens the question this lesson cannot close. The rule changed and the new runs will produce the correct result — but eleven of the twelve periods stand computed with the old rule. Recomputing all the periods means run cost, while recomputing only a few periods leaves one part of the report with the new rule and the rest with the old one. The next lesson measures backfill over these two options: how many periods are recomputed, how far the mixed report deviates from the full application, and which checks that report passes.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close