Transcript Testing: Helping Humans Verify AI Code

Andrew A Lamb

2026/11/13

Software tests in the AI era should be explicitly written for humans to read, to help us verify and understand the systems we are building. This is even more important as AI tools mean we write less code in programming languages. This post proposes “Transcript Testing” to help.

Introduction

There is much speculation about the future role of humans in implementing and maintaining software systems, but at least in the short term, the consensus is that humans need to verify AI output. Traditionally, a substantial part of software engineering was the creation and maintenance of software tests, both to prevent regressions and to precisely document expected system behavior.

Reviewing human-created PRs included ensuring adequate test coverage (often there was not). I haven’t seen any PR with inadequate tests in the last 6 months (thanks to AI coding tools). Instead, I now have the opposite problem: PRs include many hundreds of lines of hard-to-understand tests. In fact, these tests are so hard to read and understand that I suspect they are often skimmed, if not outright skipped, during human review (I admit to doing this occasionally).

If we don’t understand (or can’t summon the mental energy to read) the tests our agents write, how can we verify the system we are responsible for 🤔?

Tests no one reads

Here is a single typical AI-generated test, from the initial commit of a recent PR to Apache DataFusion optimizing how the Parquet reader reads Bloom filters (don’t worry if you don’t know what that means).1 The PR included four test functions (~400 total lines) that covered combinations of row group contents, writer settings, and predicates.

Click to see the test code (117 lines)
#[tokio::test]
async fn incremental_bloom_filter_pruning_short_circuits_remaining_columns() {
    let store = Arc::new(InMemory::new()) as Arc<dyn ObjectStore>;
    // Two row groups, each with columns a and b:
    //   RG-0: a = [99, 98, 97],  b = [900, 980, 970]  — no predicate values present
    //   RG-1: a = [10, 20, 30],  b = [100, 200, 300]  — contains predicate match
    //
    // Statistics pruning is disabled throughout; Bloom filters are the only pruning
    // mechanism.  Decoder-level filter push-down is enabled so matching rows are
    // physically filtered out of RG-1 where possible.
    let batch_rg0 = record_batch!(
        ("a", Int32, vec![99_i32, 98, 97]),
        ("b", Int32, vec![900_i32, 980, 970])
    )
    .unwrap();
    let batch_rg1 = record_batch!(
        ("a", Int32, vec![10_i32, 20, 30]),
        ("b", Int32, vec![100_i32, 200, 300])
    )
    .unwrap();
    let schema = batch_rg0.schema();
    let props = WriterProperties::builder()
        // Force each batch into its own row group.
        .set_max_row_group_row_count(Some(3))
        .set_bloom_filter_enabled(true)
        .set_statistics_enabled(EnabledStatistics::None)
        .build();
    let data_size = write_parquet_batches(
        Arc::clone(&store),
        "multi_col_bloom.parquet",
        vec![batch_rg0, batch_rg1],
        Some(props),
    )
    .await;
    let file =
        PartitionedFile::new("multi_col_bloom.parquet".to_string(), data_size as u64);

    // ── Query 1: a = 999 AND b = 100 ─────────────────────────────────────────────
    // 'a' Bloom filter is checked first for each row group.  999 is absent from
    // both, so both groups are pruned after reading only the 'a' filter — the 'b'
    // filter is never fetched (short-circuit).
    let pred1 =
        logical2physical(&col("a").eq(lit(999)).and(col("b").eq(lit(100))), &schema);
    let metrics1 = ExecutionPlanMetricsSet::new();
    let morselizer1 = ParquetMorselizerBuilder::new()
        .with_store(Arc::clone(&store))
        .with_schema(Arc::clone(&schema))
        .with_predicate(Arc::clone(&pred1))
        .with_pushdown_filters(true)
        .with_row_group_stats_pruning(false)
        .with_enable_bloom_filter(true)
        .with_metrics(metrics1.clone())
        .build();
    let stream1 = open_file(&morselizer1, file.clone()).await.unwrap();
    let bytes_scanned1 = counter_metric_value(&metrics1, "bytes_scanned");
    assert_eq!(collect_int32_values(stream1).await, Vec::<i32>::new());
    let (pruned1, matched1) = bloom_filter_pruning_metrics(&metrics1);
    // Both row groups pruned; no row group survived Bloom evaluation.
    assert_eq!(pruned1, 2);
    assert_eq!(matched1, 0);

    // ── Query 2: a = 10 AND b = 999 ──────────────────────────────────────────────
    // RG-0: 'a' filter doesn't contain 10 → pruned immediately (no 'b' read).
    // RG-1: 'a' filter contains 10 → must read 'b'; 'b' filter doesn't contain 999
    //        → pruned on the second column.
    let pred2 =
        logical2physical(&col("a").eq(lit(10)).and(col("b").eq(lit(999))), &schema);
    let metrics2 = ExecutionPlanMetricsSet::new();
    let morselizer2 = ParquetMorselizerBuilder::new()
        .with_store(Arc::clone(&store))
        .with_schema(Arc::clone(&schema))
        .with_predicate(Arc::clone(&pred2))
        .with_pushdown_filters(true)
        .with_row_group_stats_pruning(false)
        .with_enable_bloom_filter(true)
        .with_metrics(metrics2.clone())
        .build();
    let stream2 = open_file(&morselizer2, file.clone()).await.unwrap();
    let bytes_scanned2 = counter_metric_value(&metrics2, "bytes_scanned");
    assert_eq!(collect_int32_values(stream2).await, Vec::<i32>::new());
    let (pruned2, matched2) = bloom_filter_pruning_metrics(&metrics2);
    assert_eq!(pruned2, 2);
    assert_eq!(matched2, 0);

    // Query 2 must read both 'a' and 'b' filters for RG-1, so it scans strictly
    // more Bloom filter bytes than Query 1 (which short-circuits on 'a' alone).
    assert!(bytes_scanned1 > 0, "expected non-zero bloom filter I/O");
    assert!(
        bytes_scanned2 > bytes_scanned1,
        "query 2 should scan more bloom filter bytes due to loading 'b' filter for RG-1"
    );

    // ── Query 3: a = 10 (one row group pruned, one matched) ───────────────────────
    // RG-0: 'a' filter doesn't contain 10 → pruned.
    // RG-1: 'a' filter contains 10 → matched (not pruned); the actual matching
    //        rows [10, 20, 30] are returned through decoder-level filtering.
    let pred3 = logical2physical(&col("a").eq(lit(10)), &schema);
    let metrics3 = ExecutionPlanMetricsSet::new();
    let morselizer3 = ParquetMorselizerBuilder::new()
        .with_store(Arc::clone(&store))
        .with_schema(Arc::clone(&schema))
        .with_predicate(Arc::clone(&pred3))
        .with_pushdown_filters(true)
        .with_row_group_stats_pruning(false)
        .with_enable_bloom_filter(true)
        .with_metrics(metrics3.clone())
        .build();
    let stream3 = open_file(&morselizer3, file.clone()).await.unwrap();
    // The matching row (a=10) must be returned; decoder filtering removes a=20,30.
    assert_eq!(collect_int32_values(stream3).await, vec![10_i32]);
    let (pruned3, matched3) = bloom_filter_pruning_metrics(&metrics3);
    assert_eq!(pruned3, 1, "RG-0 should be pruned by Bloom filter");
    assert_eq!(
        matched3, 1,
        "RG-1 passed Bloom evaluation and must count as matched"
    );
}

By human-written PR standards, the tests were excellent. They fully covered the new functionality and followed a classic Arrange Act Assert pattern. My own agent double-checked the tests and gave them a ✅.

However, to truly verify the PR I needed to understand what is the I/O before and after this change? Despite the comprehensive test coverage, this was extremely mentally taxing, as I had to wade through 400 lines of code, mostly setup unrelated to the change, and mentally translate asserts on low-level pruning counters into effects on I/O.

Transcript Tests: designed for humans to understand

To help understand the change and thus verify the PR, mohitgurav20 and I implemented what I call Transcript Tests in this PR. As the name implies, a Transcript Test creates a human-readable transcript (a string) of the system’s behavior. The actual test then asserts that the transcript matches an expected transcript (we used the amazing insta library to compare inline).

In this case, since I wanted to verify the I/O behavior, the test records human-readable descriptions of I/O rather than indirect signals such as counter values. For example, instead of (just) the requested byte range, it records the corresponding part of the Parquet file:

--- Query: SELECT * FROM t WHERE val = 7 (1 rows) ---
Request 1
    Read Parquet footer (offsets 0..1402)
Request 2
    Read dictionary page and data page 0 for column 'val' (rg 0) (offsets 51..98)
Request 3
    Read dictionary page and data page 0 for column 'val' (rg 1) (offsets 145..192)
Request 4
    Read dictionary page for column 'id' (rg 1) (offsets 98..124)
    Read data page 0 for column 'id' (rg 1) (offsets 124..145)
Produce batch (1 rows)
Request 5
    Read dictionary page and data page 0 for column 'val' (rg 2) (offsets 239..286)

You don’t have to understand the details of reading Parquet files or the existing code to understand this transcript. It is like a story: “The scan reads the filter column for row group 0, finds nothing, reads it for row group 1, finds a match, etc.” It is much easier to understand than the equivalent asserts:

assert_eq!(pruned3, 1, "RG-0 should be pruned by Bloom filter");
assert_eq!(matched3, 1, "RG-1 passed Bloom evaluation and must count as matched");

We then updated the PR, and the transcript differences illustrate exactly the part of the behavior that changed:

 /// Column `a` does not match, so the row group is pruned.
 #[tokio::test]
 async fn test_parquet_bloom_filter_io_pruned() {
     let test = bloom_filter_test().await;
-    // Currently the Bloom filters for all candidate columns (`a` and `b`) are
-    // loaded upfront. With incremental pruning
-    // (https://github.com/apache/datafusion/issues/25811), `a` would be
-    // evaluated first and the Bloom filter for `b` would not be loaded.
+    // Column `a` does not match, so the row group is pruned immediately
+    // and the Bloom filter for column `b` is not loaded.
     assert_snapshot!(test.query("SELECT * FROM t WHERE a = 999 AND b = 100").await, @r"
     --- Query: SELECT * FROM t WHERE a = 999 AND b = 100 (0 rows) ---
     Request 1
         Head (object size and metadata)
     Request 2
         Read Parquet footer (offsets 213..678)
     Request 3
         Read OffsetIndex for columns 'a', 'b' (rg 0) (offsets 192..213)
     Request 4
         Read Bloom filter for column 'a' (rg 0) (offsets 98..145)
-    Request 5
-        Read Bloom filter for column 'b' (rg 0) (offsets 145..192)
     ");
 }

Note that this testing style may actually require more total lines, as the code to generate the transcript is non-trivial. However, an agent wrote it super quickly, and the resulting test output is far more readable and understandable.

Conclusion

I strongly believe (hope?) that humans will continue to design and maintain software systems. I think the most likely future is one of programming at a higher level of abstraction (e.g. natural language rather than classical programming languages). As this transition happens, we must also increase the level of abstraction used for verification (aka tests) to keep up. I think Transcript Tests can help with this transition.

Appendix: Is this approach really new?

No. I first encountered a version of this testing strategy (an internal tool called ORATEST) when I worked at Oracle more than 20 years ago. We use a (slightly) more modern version called sqllogictests (slt tests), based on SQLite’s sqllogictest, in DataFusion. These tools show a transcript of SQL queries and their expected results. Snapshot tests are also nothing new, and organizations such as Jane Street have written about this style for a decade under the name “expect tests”. Comparing against expected output and “golden” output files is also well known.

However, the use of transcript-style testing used to be limited to cases where the overhead of writing a transcript generator could be amortized over many tests (e.g. running 1000s of SQL queries and comparing to expected results). Today, it is just as easy to prompt an AI to make a Transcript Test as a traditional unit test, so I think we should be using them much more often.

Footnotes


  1. I am not picking on mohitgurav20; they have been super great to work with, and I very much value their contributions. I chose this PR as an example because it happened to appear when I was thinking about this post, and mohitgurav20 was willing to work with me to make a Transcript Test. ↩︎