<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:taxo="http://purl.org/rss/1.0/modules/taxonomy/" version="2.0">
  <channel>
    <title>article Unit Testing TransformWithState Logic with TwsTester in Technical Blog</title>
    <link>https://community.databricks.com/t5/technical-blog/unit-testing-transformwithstate-logic-with-twstester/ba-p/156085</link>
    <description>&lt;P&gt;You've spent the afternoon building a &lt;CODE&gt;StatefulProcessor&lt;/CODE&gt; for your TransformWithState streaming job. It tracks per-user sessions, accumulates running totals, or deduplicates events. Now you want to know if it actually works.&lt;/P&gt;
&lt;P&gt;So you wire up a streaming source, start a query, push some test rows, wait for a micro-batch to fire, check the sink, squint at the logs, tweak something, restart, and do it all again. The feedback loop is slow, the state is invisible, and reproducing a specific sequence of inputs feels like guesswork.&lt;/P&gt;
&lt;P&gt;TwsTester changes that. It's a small test helper, available in both PySpark and Scala starting in Spark 4.2.0 (DBR 18.2). You hand it your &lt;CODE&gt;StatefulProcessor&lt;/CODE&gt;, feed it rows, and inspect outputs and internal state instantly, without standing up a streaming query.&lt;/P&gt;
&lt;H2 id="what-is-twstester"&gt;What is TwsTester?&lt;/H2&gt;
&lt;P&gt;TwsTester is a unit-testing harness for &lt;CODE&gt;StatefulProcessor&lt;/CODE&gt; implementations used with the &lt;CODE&gt;transformWithState&lt;/CODE&gt; operator in Structured Streaming. You hand it your processor and some input rows; it drives the processor the same way Spark would during a real micro-batch and returns the resulting output rows.&lt;/P&gt;
&lt;P&gt;It's available at:&lt;/P&gt;
&lt;UL&gt;
&lt;LI&gt;&lt;STRONG&gt;PySpark&lt;/STRONG&gt;: &lt;CODE&gt;pyspark.sql.streaming.TwsTester&lt;/CODE&gt;&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;Scala&lt;/STRONG&gt;: &lt;CODE&gt;org.apache.spark.sql.streaming.TwsTester&lt;/CODE&gt;&lt;/LI&gt;
&lt;/UL&gt;
&lt;P&gt;The emphasis is on small, focused tests you can run in a notebook cell or a CI pipeline, not full end-to-end streaming tests with sources and sinks. Think of it as the difference between calling a function in a unit test and spinning up a full integration environment to exercise the same code path.&lt;/P&gt;
&lt;H2 id="why-twstester"&gt;Why TwsTester?&lt;/H2&gt;
&lt;P&gt;Three pain points come up repeatedly when developing stateful streaming logic:&lt;/P&gt;
&lt;OL type="1"&gt;
&lt;LI&gt;&lt;STRONG&gt;Slow feedback loops.&lt;/STRONG&gt; Every change to your processor means restarting a streaming query, wiring up sources and sinks, and waiting for data to flow through. For logic that's still taking shape, this is expensive.&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;Hard-to-observe state and timers.&lt;/STRONG&gt; In a running query, inspecting state mid-flight isn't straightforward. The &lt;A href="https://www.databricks.com/blog/announcing-state-reader-api-new-statestore-data-source" target="_blank" rel="noopener"&gt;State Reader API&lt;/A&gt; helps here; it lets you read state from a query's checkpoint directory after the fact, which is valuable for debugging production workloads and understanding &lt;A href="https://www.databricks.com/blog/announcing-simplified-state-tracking-apache-sparktm-structured-streaming" target="_blank" rel="noopener"&gt;state evolution over time&lt;/A&gt;. But the State Reader API requires access to the checkpoint directory, and it's oriented toward inspecting state &lt;EM&gt;after&lt;/EM&gt; a query has run, not toward feeding controlled inputs and verifying outputs as part of a development loop.&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;Limited reproducibility.&lt;/STRONG&gt; Some bugs only surface with specific input orderings or timing. Reproducing those conditions end-to-end is tedious at best.&lt;/LI&gt;
&lt;/OL&gt;
&lt;P&gt;TwsTester addresses all three:&lt;/P&gt;
&lt;UL&gt;
&lt;LI&gt;Process a &lt;STRONG&gt;controlled sequence of input rows&lt;/STRONG&gt; per key and inspect the exact outputs.&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;Peek at and manipulate state&lt;/STRONG&gt; directly, including value state, list state, and map state, at any point during a test.&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;Advance processing time or the watermark&lt;/STRONG&gt; on demand, and verify that your timer logic fires correctly.&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;Inject initial state&lt;/STRONG&gt; so you can test mid-stream scenarios like "this user already has an active session."&lt;/LI&gt;
&lt;/UL&gt;
&lt;P&gt;This all runs locally against a regular &lt;CODE&gt;SparkSession&lt;/CODE&gt;. No cluster infrastructure, no streaming sources, no sinks.&lt;/P&gt;
&lt;H2 id="how-it-works"&gt;How it works&lt;/H2&gt;
&lt;P&gt;The workflow is straightforward:&lt;/P&gt;
&lt;OL type="1"&gt;
&lt;LI&gt;&lt;STRONG&gt;Implement your StatefulProcessor&lt;/STRONG&gt; as you normally would for &lt;CODE&gt;transformWithState&lt;/CODE&gt;: define &lt;CODE&gt;init&lt;/CODE&gt;, &lt;CODE&gt;handleInputRows&lt;/CODE&gt;, and optionally &lt;CODE&gt;handleExpiredTimer&lt;/CODE&gt; and &lt;CODE&gt;close&lt;/CODE&gt;.&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;Construct a TwsTester&lt;/STRONG&gt; with your processor, choosing a time mode (&lt;CODE&gt;"None"&lt;/CODE&gt;, &lt;CODE&gt;"ProcessingTime"&lt;/CODE&gt;, or &lt;CODE&gt;"EventTime"&lt;/CODE&gt;) and output mode (&lt;CODE&gt;"Append"&lt;/CODE&gt;, &lt;CODE&gt;"Update"&lt;/CODE&gt;, or &lt;CODE&gt;"Complete"&lt;/CODE&gt;).&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;Call &lt;CODE&gt;test(key, rows)&lt;/CODE&gt;&lt;/STRONG&gt; with a grouping key and a list of input rows. TwsTester drives your processor and returns the output rows.&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;Inspect state&lt;/STRONG&gt; with &lt;CODE&gt;peekValueState&lt;/CODE&gt;, &lt;CODE&gt;peekListState&lt;/CODE&gt;, or &lt;CODE&gt;peekMapState&lt;/CODE&gt;. Seed or override state with the corresponding &lt;CODE&gt;update*&lt;/CODE&gt; methods.&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;Advance time&lt;/STRONG&gt; with &lt;CODE&gt;setProcessingTime&lt;/CODE&gt; or &lt;CODE&gt;setWatermark&lt;/CODE&gt; to fire expired timers and collect their output.&lt;/LI&gt;
&lt;/OL&gt;
&lt;P&gt;Steps 3-5 can be repeated in any order, which makes it easy to build up multi-step test scenarios.&lt;/P&gt;
&lt;H2 id="example-catching-a-bug-with-twstester"&gt;Example: catching a bug with TwsTester&lt;/H2&gt;
&lt;P&gt;Let's walk through a realistic development scenario. We'll build a processor that maintains a running sum of transaction amounts per user, write tests for it, discover a bug, fix it, and verify the fix. This is exactly the kind of tight iteration loop that TwsTester enables.&lt;/P&gt;
&lt;H3 id="the-processor-first-attempt"&gt;The processor (first attempt)&lt;/H3&gt;
&lt;P&gt;Here's our first pass at a &lt;CODE&gt;RunningSumProcessor&lt;/CODE&gt;. It takes input rows with an &lt;CODE&gt;amount&lt;/CODE&gt; field, accumulates a per-user total, and emits the updated running sum with each row.&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;from pyspark.sql import Row, SparkSession
from pyspark.sql.streaming import StatefulProcessor, StatefulProcessorHandle
from pyspark.sql.streaming import TwsTester
from pyspark.sql.types import StructType, StructField, LongType

spark = SparkSession.builder.getOrCreate()


class RunningSumProcessor(StatefulProcessor):
    def init(self, handle: StatefulProcessorHandle) -&amp;gt; None:
        self.handle = handle
        sum_schema = StructType([StructField("sum", LongType())])
        self.sum_state = handle.getValueState("sum", sum_schema)

    def handleInputRows(self, key, rows, timerValues):
        running_sum = 0

        results = []
        for row in rows:
            amount = row["amount"]
            running_sum += amount
            results.append(Row(user_id=key[0], amount=amount, running_sum=running_sum))

        self.sum_state.update((running_sum,))
        return iter(results)

    def close(self) -&amp;gt; None:
        pass&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;import org.apache.spark.sql.streaming._
import org.apache.spark.sql.Encoders

class RunningSumProcessor
    extends StatefulProcessor[String, Long, (String, Long, Long)] {

  @transient private var sumState: ValueState[Long] = _

  override def init(outputMode: OutputMode, timeMode: TimeMode): Unit = {
    sumState = getHandle.getValueState[Long](
      "sum", Encoders.scalaLong, TTLConfig.NONE)
  }

  override def handleInputRows(
      key: String,
      rows: Iterator[Long],
      timerValues: TimerValues
  ): Iterator[(String, Long, Long)] = {
    var runningSum = 0L

    val results = rows.map { amount =&amp;gt;
      runningSum += amount
      (key, amount, runningSum)
    }.toList

    sumState.update(runningSum)
    results.iterator
  }
}&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;Looks reasonable. Let's test it.&lt;/P&gt;
&lt;H3 id="setting-up-twstester"&gt;Setting up TwsTester&lt;/H3&gt;
&lt;LI-CODE lang="python"&gt;tester = TwsTester(
    processor=RunningSumProcessor(),
    timeMode="None",
    outputMode="Append",
)&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;val tester = new TwsTester(
  processor = new RunningSumProcessor(),
  timeMode = TimeMode.None(),
  outputMode = OutputMode.Append()
)&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;No sources, no sinks, no checkpoint directory.&lt;/P&gt;
&lt;H3 id="first-test-single-batch"&gt;First test: single batch&lt;/H3&gt;
&lt;P&gt;We process two transactions for Alice in one call. The running sum should accumulate within the batch.&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;output = tester.test(
    key="alice",
    input=[Row(amount=10), Row(amount=5)],
)
for row in output:
    print(row)
# Row(user_id='alice', amount=10, running_sum=10)
# Row(user_id='alice', amount=5, running_sum=15)&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;val output = tester.test("alice", List(10L, 5L))
println(output)
// List(("alice", 10, 10), ("alice", 5, 15))&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;Looks right. Alice's running sum goes 10, then 15.&lt;/P&gt;
&lt;H3 id="second-test-another-batch-for-the-same-key"&gt;Second test: another batch for the same key&lt;/H3&gt;
&lt;P&gt;Now Alice makes another transaction. She already has a running sum of 15, so the new total should be 18.&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;output2 = tester.test(
    key="alice",
    input=[Row(amount=3)],
)
for row in output2:
    print(row)
# Expected: Row(user_id='alice', amount=3, running_sum=18)
# Actual:   Row(user_id='alice', amount=3, running_sum=3)   &amp;lt;- wrong!&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;val output2 = tester.test("alice", List(3L))
println(output2)
// Expected: List(("alice", 3, 18))
// Actual:   List(("alice", 3, 3))   &amp;lt;- wrong!&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;The running sum is 3, not 18. The processor reset Alice's total instead of continuing from where it left off.&lt;/P&gt;
&lt;H3 id="what-went-wrong"&gt;What went wrong&lt;/H3&gt;
&lt;P&gt;Look at the first line of &lt;CODE&gt;handleInputRows&lt;/CODE&gt;:&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;running_sum = 0  # Always starts from zero, ignoring existing state&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;var runningSum = 0L  // Always starts from zero, ignoring existing state&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;The processor initializes &lt;CODE&gt;running_sum&lt;/CODE&gt; to zero on every call instead of reading the existing value from state. Within a single batch this doesn't matter because the accumulation happens in a loop. But across batches, the state from previous calls is silently discarded.&lt;/P&gt;
&lt;P&gt;Without TwsTester, this bug might not surface until production, where it would show up as totals that periodically "reset" at unpredictable intervals (whenever a new micro-batch starts for that key).&lt;/P&gt;
&lt;H3 id="fixing-the-processor"&gt;Fixing the processor&lt;/H3&gt;
&lt;P&gt;The fix is two lines: read existing state before accumulating.&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;class RunningSumProcessor(StatefulProcessor):
    def init(self, handle: StatefulProcessorHandle) -&amp;gt; None:
        self.handle = handle
        sum_schema = StructType([StructField("sum", LongType())])
        self.sum_state = handle.getValueState("sum", sum_schema)

    def handleInputRows(self, key, rows, timerValues):
        # Fixed: read existing state instead of starting from zero
        existing = self.sum_state.get()
        running_sum = existing[0] if existing is not None else 0

        results = []
        for row in rows:
            amount = row["amount"]
            running_sum += amount
            results.append(Row(user_id=key[0], amount=amount, running_sum=running_sum))

        self.sum_state.update((running_sum,))
        return iter(results)

    def close(self) -&amp;gt; None:
        pass&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;class RunningSumProcessor
    extends StatefulProcessor[String, Long, (String, Long, Long)] {

  @transient private var sumState: ValueState[Long] = _

  override def init(outputMode: OutputMode, timeMode: TimeMode): Unit = {
    sumState = getHandle.getValueState[Long](
      "sum", Encoders.scalaLong, TTLConfig.NONE)
  }

  override def handleInputRows(
      key: String,
      rows: Iterator[Long],
      timerValues: TimerValues
  ): Iterator[(String, Long, Long)] = {
    // Fixed: read existing state instead of starting from zero
    var runningSum = if (sumState.exists()) sumState.get() else 0L

    val results = rows.map { amount =&amp;gt;
      runningSum += amount
      (key, amount, runningSum)
    }.toList

    sumState.update(runningSum)
    results.iterator
  }
}&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;H3 id="re-running-the-tests"&gt;Re-running the tests&lt;/H3&gt;
&lt;LI-CODE lang="python"&gt;tester = TwsTester(
    processor=RunningSumProcessor(),  # fixed version
    timeMode="None",
    outputMode="Append",
)

# Test 1: single batch
output = tester.test(key="alice", input=[Row(amount=10), Row(amount=5)])
for row in output:
    print(row)
# Row(user_id='alice', amount=10, running_sum=10)
# Row(user_id='alice', amount=5, running_sum=15)

# Test 2: second batch for the same key
output2 = tester.test(key="alice", input=[Row(amount=3)])
for row in output2:
    print(row)
# Row(user_id='alice', amount=3, running_sum=18)  PASS&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;val tester = new TwsTester(
  processor = new RunningSumProcessor(),  // fixed version
  timeMode = TimeMode.None(),
  outputMode = OutputMode.Append()
)

// Test 1: single batch
val output = tester.test("alice", List(10L, 5L))
println(output)
// List(("alice", 10, 10), ("alice", 5, 15))

// Test 2: second batch for the same key
val output2 = tester.test("alice", List(3L))
println(output2)
// List(("alice", 3, 18))  PASS&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;Both tests pass. From bug to fix to verification in seconds, not the minutes (or longer) it would take to redeploy a streaming query and push test data through a source.&lt;/P&gt;
&lt;H3 id="inspecting-state-directly"&gt;Inspecting state directly&lt;/H3&gt;
&lt;P&gt;After calling &lt;CODE&gt;test()&lt;/CODE&gt;, the processor's state is still live inside the tester. We can peek at it:&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;alice_sum = tester.peekValueState("sum", "alice")
print(alice_sum)  # (18,)&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;val aliceSum = tester.peekValueState[Long]("sum", "alice")
println(aliceSum)  // Some(18)&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;We can also process rows for a different key and verify that state is isolated:&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;output_bob = tester.test(key="bob", input=[Row(amount=7)])
print(output_bob)
# [Row(user_id='bob', amount=7, running_sum=7)]

# Bob's state is independent
print(tester.peekValueState("sum", "bob"))    # (7,)
print(tester.peekValueState("sum", "alice"))   # (18,) -- unchanged&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;val outputBob = tester.test("bob", List(7L))
println(outputBob)
// List(("bob", 7, 7))

// Bob's state is independent
println(tester.peekValueState[Long]("sum", "bob"))    // Some(7)
println(tester.peekValueState[Long]("sum", "alice"))   // Some(18) -- unchanged&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;H3 id="seeding-state-for-mid-stream-scenarios"&gt;Seeding state for mid-stream scenarios&lt;/H3&gt;
&lt;P&gt;Sometimes you want to test behavior starting from a known state rather than building up from zero. &lt;CODE&gt;updateValueState&lt;/CODE&gt; lets you seed state before processing:&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;tester.updateValueState("sum", "carol", (100,))

output_carol = tester.test(key="carol", input=[Row(amount=25)])
print(output_carol)
# [Row(user_id='carol', amount=25, running_sum=125)]&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;tester.updateValueState[Long]("sum", "carol", 100L)

val outputCarol = tester.test("carol", List(25L))
println(outputCarol)
// List(("carol", 25, 125))&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;This is useful for testing edge cases: what happens at counter rollover, after a long idle period, or when the user already has existing state from a previous session.&lt;/P&gt;
&lt;H2 id="adding-timer-logic"&gt;Adding timer logic&lt;/H2&gt;
&lt;P&gt;Timers let a &lt;CODE&gt;StatefulProcessor&lt;/CODE&gt; react to the passage of time: session timeouts, periodic flushes, TTL-based cleanup. TwsTester supports testing timer logic by letting you advance time manually.&lt;/P&gt;
&lt;P&gt;Here's a processor that registers a processing-time timer to expire idle sessions:&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;class SessionTimeoutProcessor(StatefulProcessor):
    TIMEOUT_MS = 60_000  # 1 minute

    def init(self, handle: StatefulProcessorHandle) -&amp;gt; None:
        self.handle = handle
        schema = StructType([StructField("event_count", LongType())])
        self.session = handle.getValueState("session", schema)

    def handleInputRows(self, key, rows, timerValues):
        existing = self.session.get()
        event_count = existing[0] if existing is not None else 0

        for row in rows:
            event_count += 1

        self.session.update((event_count,))

        # Reset the timeout: register a timer at current time + timeout
        current_time = timerValues.getCurrentProcessingTimeInMs()
        self.handle.registerTimer(current_time + self.TIMEOUT_MS)

        return iter([Row(user_id=key[0], event_count=event_count, status="active")])

    def handleExpiredTimer(self, key, timerValues, expiredTimerInfo):
        # Timer fired, session timed out. Clear state and emit a closure event.
        event_count = self.session.get()
        self.session.clear()
        count = event_count[0] if event_count is not None else 0
        return iter([Row(user_id=key[0], event_count=count, status="expired")])

    def close(self) -&amp;gt; None:
        pass&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;class SessionTimeoutProcessor
    extends StatefulProcessor[String, String, (String, Long, String)] {

  private val TimeoutMs = 60000L
  @transient private var sessionState: ValueState[Long] = _

  override def init(outputMode: OutputMode, timeMode: TimeMode): Unit = {
    sessionState = getHandle.getValueState[Long](
      "session", Encoders.scalaLong, TTLConfig.NONE)
  }

  override def handleInputRows(
      key: String,
      rows: Iterator[String],
      timerValues: TimerValues
  ): Iterator[(String, Long, String)] = {
    var eventCount = if (sessionState.exists()) sessionState.get() else 0L
    rows.foreach(_ =&amp;gt; eventCount += 1)
    sessionState.update(eventCount)

    val currentTime = timerValues.getCurrentProcessingTimeInMs()
    getHandle.registerTimer(currentTime + TimeoutMs)

    Iterator((key, eventCount, "active"))
  }

  override def handleExpiredTimer(
      key: String,
      timerValues: TimerValues,
      expiredTimerInfo: ExpiredTimerInfo
  ): Iterator[(String, Long, String)] = {
    val count = if (sessionState.exists()) sessionState.get() else 0L
    sessionState.clear()
    Iterator((key, count, "expired"))
  }
}&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;Now test it with TwsTester in processing-time mode:&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;tester = TwsTester(
    processor=SessionTimeoutProcessor(),
    timeMode="ProcessingTime",
    outputMode="Append",
)

# Set an initial processing time
tester.setProcessingTime(1_000)

# Process some events for alice
output = tester.test(
    key="alice",
    input=[Row(event="click"), Row(event="scroll")],
)
print(output)
# [Row(user_id='alice', event_count=2, status='active')]

# Advance time past the timeout threshold
expired_output = tester.setProcessingTime(62_000)
print(expired_output)
# [Row(user_id='alice', event_count=2, status='expired')]

# State has been cleared
print(tester.peekValueState("session", "alice"))
# None&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show Scala equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;val tester = new TwsTester(
  processor = new SessionTimeoutProcessor(),
  timeMode = TimeMode.ProcessingTime(),
  outputMode = OutputMode.Append()
)

// Set an initial processing time
tester.setProcessingTime(1000L)

// Process some events for alice
val output = tester.test("alice", List("click", "scroll"))
println(output)
// List(("alice", 2, "active"))

// Advance time past the timeout threshold
val expiredOutput = tester.setProcessingTime(62000L)
println(expiredOutput)
// List(("alice", 2, "expired"))

// State has been cleared
println(tester.peekValueState[Long]("session", "alice"))
// None&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;We just tested a complete session lifecycle (activity, timeout, cleanup) in a few lines, with full control over time.&lt;/P&gt;
&lt;H2 id="wrapping-tests-for-ci"&gt;Wrapping tests for CI&lt;/H2&gt;
&lt;P&gt;The examples above work well in a notebook for interactive exploration. To lock in behavior for CI, wrap them in your test framework of choice.&lt;/P&gt;
&lt;P&gt;Here's a pytest example. The same pattern works in ScalaTest; expand the spoiler below for the Scala version.&lt;/P&gt;
&lt;LI-CODE lang="python"&gt;import pytest
from pyspark.sql import Row, SparkSession
from pyspark.sql.streaming import TwsTester


@pytest.fixture(scope="module")
def spark():
    return SparkSession.builder.master("local[*]").getOrCreate()


def test_running_sum_single_batch(spark):
    tester = TwsTester(
        processor=RunningSumProcessor(),
        timeMode="None",
        outputMode="Append",
    )

    output = tester.test("alice", [Row(amount=10), Row(amount=5)])

    assert len(output) == 2
    assert output[0]["running_sum"] == 10
    assert output[1]["running_sum"] == 15


def test_running_sum_across_batches(spark):
    tester = TwsTester(
        processor=RunningSumProcessor(),
        timeMode="None",
        outputMode="Append",
    )

    tester.test("alice", [Row(amount=10), Row(amount=5)])
    output = tester.test("alice", [Row(amount=3)])

    assert output[0]["running_sum"] == 18


def test_state_isolation_across_keys(spark):
    tester = TwsTester(
        processor=RunningSumProcessor(),
        timeMode="None",
        outputMode="Append",
    )

    tester.test("alice", [Row(amount=10)])
    tester.test("bob", [Row(amount=20), Row(amount=5)])

    assert tester.peekValueState("sum", "alice") == (10,)
    assert tester.peekValueState("sum", "bob") == (25,)&lt;/LI-CODE&gt;&lt;DETAILS style="margin: 8px 0 16px 0; border-left: 3px solid #FF3621; padding-left: 12px;" open="open"&gt;
&lt;SUMMARY style="cursor: pointer; color: #1f6feb; font-weight: 500; padding: 4px 0; user-select: none;"&gt;▸ Show ScalaTest equivalent&lt;/SUMMARY&gt;
&lt;LI-CODE lang="scala"&gt;import org.scalatest.funsuite.AnyFunSuite
import org.apache.spark.sql.streaming._

class RunningSumProcessorTest extends AnyFunSuite {

  test("running sum accumulates within a single batch") {
    val tester = new TwsTester(
      processor = new RunningSumProcessor(),
      timeMode = TimeMode.None(),
      outputMode = OutputMode.Append()
    )

    val output = tester.test("alice", List(10L, 5L))

    assert(output === List(("alice", 10L, 10L), ("alice", 5L, 15L)))
  }

  test("running sum accumulates across batches") {
    val tester = new TwsTester(
      processor = new RunningSumProcessor(),
      timeMode = TimeMode.None(),
      outputMode = OutputMode.Append()
    )

    tester.test("alice", List(10L, 5L))
    val output = tester.test("alice", List(3L))

    assert(output === List(("alice", 3L, 18L)))
  }

  test("state is isolated across keys") {
    val tester = new TwsTester(
      processor = new RunningSumProcessor(),
      timeMode = TimeMode.None(),
      outputMode = OutputMode.Append()
    )

    tester.test("alice", List(10L))
    tester.test("bob", List(20L, 5L))

    assert(tester.peekValueState[Long]("sum", "alice") === Some(10L))
    assert(tester.peekValueState[Long]("sum", "bob") === Some(25L))
  }
}&lt;/LI-CODE&gt;&lt;/DETAILS&gt;
&lt;P&gt;No mock streaming sources, no &lt;CODE&gt;awaitTermination&lt;/CODE&gt;, no checkpoint cleanup. Just direct assertions on processor behavior.&lt;/P&gt;
&lt;H2 id="fitting-twstester-into-your-workflow"&gt;Fitting TwsTester into your workflow&lt;/H2&gt;
&lt;P&gt;TwsTester is most valuable when you're in the thick of building or debugging stateful logic:&lt;/P&gt;
&lt;UL&gt;
&lt;LI&gt;&lt;STRONG&gt;During development&lt;/STRONG&gt;: run tests in a notebook cell after each change. The feedback loop drops from minutes to seconds.&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;For corner cases&lt;/STRONG&gt;: construct specific input sequences that exercise edge conditions such as late arrivals, duplicate keys, empty batches, timer expirations at exact boundaries.&lt;/LI&gt;
&lt;LI&gt;&lt;STRONG&gt;In CI&lt;/STRONG&gt;: wrap the same tests in pytest or ScalaTest so regressions are caught before they reach a streaming job.&lt;/LI&gt;
&lt;/UL&gt;
&lt;P&gt;It's not a replacement for end-to-end integration tests. You'll still want to verify that your processor works correctly within a full &lt;CODE&gt;transformWithState&lt;/CODE&gt; pipeline. But for the business logic inside the processor, TwsTester gives you the tight feedback loop that stateful streaming has always been missing.&lt;/P&gt;
&lt;H2 id="get-started"&gt;Get started&lt;/H2&gt;
&lt;P&gt;Copy the example above into a Databricks Notebook or a local Spark environment running Spark 4.2.0+, replace &lt;CODE&gt;RunningSumProcessor&lt;/CODE&gt; with your own processor, and start testing.&lt;/P&gt;
&lt;P&gt;For the full API reference and more examples:&lt;/P&gt;
&lt;UL&gt;
&lt;LI&gt;&lt;A href="https://spark.apache.org/docs/latest/structured-streaming-programming-guide.html#arbitrary-stateful-operations-with-transformwithstate" target="_blank" rel="noopener"&gt;TransformWithState programming guide&lt;/A&gt; covers &lt;CODE&gt;StatefulProcessor&lt;/CODE&gt;, state variables, and timer semantics.&lt;/LI&gt;
&lt;LI&gt;&lt;A href="https://github.com/apache/spark/pull/53159" target="_blank" rel="noopener"&gt;Scala TwsTester PR&lt;/A&gt;: the original implementation with comprehensive test cases.&lt;/LI&gt;
&lt;LI&gt;&lt;A href="https://github.com/apache/spark/pull/53758" target="_blank" rel="noopener"&gt;PySpark TwsTester PR&lt;/A&gt;: PySpark port with Row and Pandas mode examples.&lt;/LI&gt;
&lt;LI&gt;&lt;A href="https://github.com/apache/spark/pull/53802" target="_blank" rel="noopener"&gt;TwsTester documentation PR&lt;/A&gt;: the programming guide additions for TwsTester.&lt;/LI&gt;
&lt;/UL&gt;
&lt;H2&gt;&lt;STRONG&gt;Frequently asked questions (FAQ)&lt;/STRONG&gt;&lt;/H2&gt;
&lt;P&gt;&lt;STRONG&gt;What is TwsTester?&lt;/STRONG&gt;&lt;BR aria-hidden="true" /&gt;TwsTester is a unit-testing harness for &lt;CODE&gt;StatefulProcessor&lt;/CODE&gt; implementations used with the &lt;CODE&gt;transformWithState&lt;/CODE&gt; operator in Apache Spark Structured Streaming. You hand it your processor and a list of input rows, and it drives the processor the same way Spark would inside a real micro-batch, returning the resulting output rows so you can assert on them directly.&lt;/P&gt;
&lt;P&gt;&lt;BR aria-hidden="true" /&gt;&lt;STRONG&gt;Which Spark and Databricks Runtime versions include TwsTester?&lt;/STRONG&gt;&lt;BR aria-hidden="true" /&gt;TwsTester is available starting in &lt;STRONG&gt;Apache Spark 4.2.0&lt;/STRONG&gt;, which corresponds to &lt;STRONG&gt;Databricks Runtime 18.2&lt;/STRONG&gt; and later.&lt;/P&gt;
&lt;P&gt;&lt;BR aria-hidden="true" /&gt;&lt;STRONG&gt;Is TwsTester available in both PySpark and Scala?&lt;/STRONG&gt;&lt;BR aria-hidden="true" /&gt;Yes. The PySpark entry point is &lt;CODE&gt;pyspark.sql.streaming.TwsTester&lt;/CODE&gt; and the Scala entry point is &lt;CODE&gt;org.apache.spark.sql.streaming.TwsTester&lt;/CODE&gt;. The two surfaces mirror each other, so the same test patterns translate directly between languages.&lt;/P&gt;
&lt;P&gt;&lt;BR aria-hidden="true" /&gt;&lt;STRONG&gt;Does TwsTester replace end-to-end streaming integration tests?&lt;/STRONG&gt;&lt;BR aria-hidden="true" /&gt;No. TwsTester targets the business logic inside a &lt;CODE&gt;StatefulProcessor&lt;/CODE&gt;: per-key state, timers, output rows for given inputs. You should still run end-to-end tests against a real &lt;CODE&gt;transformWithState&lt;/CODE&gt; pipeline to verify source/sink wiring, checkpointing, and fault tolerance. TwsTester compresses the &lt;I&gt;inner&lt;/I&gt; feedback loop; integration tests still validate the &lt;I&gt;outer&lt;/I&gt; one.&lt;/P&gt;
&lt;P&gt;&lt;BR aria-hidden="true" /&gt;&lt;STRONG&gt;Can TwsTester test processing-time and event-time timers?&lt;/STRONG&gt;&lt;BR aria-hidden="true" /&gt;Yes. Construct the tester with &lt;CODE&gt;timeMode="ProcessingTime"&lt;/CODE&gt; or &lt;CODE&gt;timeMode="EventTime"&lt;/CODE&gt; and use &lt;CODE&gt;setProcessingTime(...)&lt;/CODE&gt; or &lt;CODE&gt;setWatermark(...)&lt;/CODE&gt; to advance time on demand. Any expired timers fire and their output is collected, so timeout, session-expiry, and periodic-flush logic can be exercised deterministically.&lt;/P&gt;
&lt;P&gt;&lt;BR aria-hidden="true" /&gt;&lt;STRONG&gt;How is TwsTester different from the State Reader API?&lt;/STRONG&gt;&lt;BR aria-hidden="true" /&gt;The &lt;SPAN draggable="true"&gt;&lt;A href="https://www.databricks.com/blog/announcing-state-reader-api-new-statestore-data-source" target="_blank" rel="noopener noreferrer"&gt;State Reader API&lt;/A&gt;&lt;/SPAN&gt; reads state from a query's checkpoint directory after the fact, which is great for inspecting production state. TwsTester runs locally, drives the processor with controlled inputs, and lets you inspect or override state mid-test with &lt;CODE&gt;peekValueState&lt;/CODE&gt; / &lt;CODE&gt;updateValueState&lt;/CODE&gt; and friends. They are complementary: State Reader for production debugging, TwsTester for development and CI.&lt;/P&gt;
&lt;P&gt;&lt;STRONG&gt;Can I use TwsTester in a CI pipeline?&lt;/STRONG&gt;&lt;BR aria-hidden="true" /&gt;Yes. Wrap each test in your framework of choice (&lt;CODE&gt;pytest&lt;/CODE&gt; for PySpark, &lt;CODE&gt;ScalaTest&lt;/CODE&gt; for Scala) and run them like any other unit test. TwsTester needs only a regular &lt;CODE&gt;SparkSession&lt;/CODE&gt;, no streaming sources, sinks, or checkpoint directory, so it fits naturally into existing test infrastructure.&lt;/P&gt;</description>
    <pubDate>Tue, 12 May 2026 14:00:33 GMT</pubDate>
    <dc:creator>craig_lukasik</dc:creator>
    <dc:date>2026-05-12T14:00:33Z</dc:date>
    <item>
      <title>Unit Testing TransformWithState Logic with TwsTester</title>
      <link>https://community.databricks.com/t5/technical-blog/unit-testing-transformwithstate-logic-with-twstester/ba-p/156085</link>
      <description>&lt;P&gt;TwsTester is a unit-testing harness for StatefulProcessor implementations used with transformWithState. Hand it your processor and some input rows, and it drives them the same way Spark would in a real micro-batch — with full control over state and timers.&lt;/P&gt;</description>
      <pubDate>Tue, 12 May 2026 14:00:33 GMT</pubDate>
      <guid>https://community.databricks.com/t5/technical-blog/unit-testing-transformwithstate-logic-with-twstester/ba-p/156085</guid>
      <dc:creator>craig_lukasik</dc:creator>
      <dc:date>2026-05-12T14:00:33Z</dc:date>
    </item>
  </channel>
</rss>

