How to Use Differential Testing During a Legacy Migration — Opportunihub
Course Remote

How to Use Differential Testing During a Legacy Migration

Hugo Teijiz · Remote

At a glance

Type
Course
Organisation
Hugo Teijiz
Location
Remote
Work mode
Remote
Deadline
Rolling / not stated
Posted
14 Sep 2026

About this course

<p>The most dangerous moment in a legacy migration isn't necessarily when you start writing the new implementation. It's when the new implementation looks finished.</p> <p>The code compiles, the tests pass, the architecture is cleaner, and the new service responds faster.</p> <p>Then everybody starts asking the same question:</p> <blockquote> <p>Can we switch traffic now?</p> </blockquote> <p>That's where confidence becomes difficult.</p> <p>A new implementation can pass its own test suite and still behave differently from the system it is replacing.</p> <p>Maybe rounding changed, or null values are handled differently, or an error became a successful response.</p> <p>Maybe records are sorted differently, or a side effect happens in a different order, or a business rule you never documented was lost during the migration.</p> <p>This is why, during a legacy migration, I like having another source of evidence: <strong>run the old and new implementations with the same inputs and compare what they do.</strong></p> <p>That's the basic idea behind differential testing. Instead of asking only if the new system passes its tests, you also ask: given the same input, where does the new system behave differently from the old one?</p> <p>Those differences become evidence.</p> <p>Some are bugs, some are intentional improvements, some are harmless representation differences, and some reveal behavior nobody knew existed.</p> <p>In this tutorial, I'll show you how to use differential testing during a legacy migration to:</p> <ul> <li><p>Compare old and new implementations</p> </li> <li><p>Define what should be considered equivalent</p> </li> <li><p>Normalize outputs before comparing them</p> </li> <li><p>Handle timestamps and other nondeterministic values</p> </li> <li><p>Compare errors and side effects</p> </li> <li><p>Run differential tests automatically</p> </li> <li><p>Introduce tolerances where exact equality doesn't make sense</p> </li> <li><p>Analyze mismatches</p> </li> <li><p>Use shadow traffic in production safely</p> </li> <li><p>Use AI to classify divergences without letting it decide correctness</p> </li> <li><p>Determine when the new implementation is ready for cutover</p> </li> </ul> <p>The examples use TypeScript and Vitest, but the approach applies to most languages and migration strategies.</p> <p>The goal isn't to prove that two implementations are internally identical. It's to obtain evidence that they are <strong>behaviorally equivalent where equivalence matters</strong>.</p> <h2 id="heading-prerequisites">Prerequisites</h2> <p>To follow along here, you should be comfortable with:</p> <ul> <li><p>TypeScript or a similar language</p> </li> <li><p>unit and integration testing</p> </li> <li><p>asynchronous code</p> </li> <li><p>API and service boundaries</p> </li> <li><p>legacy modernization</p> </li> <li><p>basic observability concepts</p> </li> </ul> <p>You should also already have some understanding of the capability being migrated.</p> <p>Ideally, you know its inputs, outputs, important business rules, external contracts, side effects, and known areas of uncertainty.</p> <p>Differential testing works best after you've already created a boundary around the capability you want to migrate.</p> <h2 id="heading-table-of-contents">Table of Contents</h2> <ul> <li><p><a href="#heading-prerequisites">Prerequisites</a></p> </li> <li><p><a href="#heading-what-differential-testing-actually-tells-you">What Differential Testing Actually Tells You</a></p> </li> <li><p><a href="#heading-start-with-one-observable-boundary">Start with One Observable Boundary</a></p> </li> <li><p><a href="#heading-run-the-legacy-and-new-implementations-with-the-same-input">Run the Legacy and New Implementations with the Same Input</a></p> </li> <li><p><a href="#heading-dont-compare-raw-output-blindly">Don't Compare Raw Output Blindly</a></p> </li> <li><p><a href="#heading-normalize-values-before-comparing-them">Normalize Values Before Comparing Them</a></p> </li> <li><p><a href="#heading-handle-timestamps-and-other-nondeterministic-values">Handle Timestamps and Other Nondeterministic Values</a></p> </li> <li><p><a href="#heading-compare-business-meaning-not-just-json">Compare Business Meaning, Not Just JSON</a></p> </li> <li><p><a href="#heading-compare-errors-as-part-of-the-contract">Compare Errors as Part of the Contract</a></p> </li> <li><p><a href="#heading-compare-side-effects-too">Compare Side Effects, Too</a></p> </li> <li><p><a href="#heading-use-tolerances-when-exact-equality-is-wrong">Use Tolerances When Exact Equality Is Wrong</a></p> </li> <li><p><a href="#heading-build-a-reusable-differential-test-harness">Build a Reusable Differential Test Harness</a></p> </li> <li><p><a href="#heading-generate-test-cases-from-real-behavior">Generate Test Cases from Real Behavior</a></p> </li> <li><p><a href="#heading-classify-every-difference">Classify Every Difference</a></p> </li> <li><p><a href="#heading-how-to-use-ai-to-investigate-differential-failures">How to Use AI to Investigate Differential Failures</a></p> </li> <li><p><a href="#heading-how-to-use-shadow-traffic-safely">How to Use Shadow Traffic Safely</a></p> </li> <li><p><a href="#heading-measure-divergence-instead-of-waiting-for-perfection">Measure Divergence Instead of Waiting for Perfection</a></p> </li> <li><p><a href="#heading-how-to-know-when-youre-ready-for-cutover">How to Know When You're Ready for Cutover</a></p> </li> <li><p><a href="#heading-a-practical-differential-testing-workflow">A Practical Differential Testing Workflow</a></p> </li> <li><p><a href="#heading-what-differential-testing-cant-prove">What Differential Testing Can't Prove</a></p> </li> <li><p><a href="#heading-differential-testing-turns-migration-risk-into-evidence">Differential Testing Turns Migration Risk into Evidence</a></p> </li> <li><p><a href="#heading-conclusion">Conclusion</a></p> </li> </ul> <h2 id="heading-what-differential-testing-actually-tells-you">What Differential Testing Actually Tells You</h2> <p>Imagine that your legacy application calculates the final price of an order.</p> <p>The legacy implementation looks like this:</p> <pre><code class="language-typescript">type Order = { subtotal: number; customerType: "STANDARD" | "PREMIUM"; country: string; }; function legacyCalculateTotal(order: Order): number { let total = order.subtotal; if (order.customerType === "PREMIUM") { total *= 0.9; } if (order.country === "AR") { total -= 500; } return Math.max(total, 0); } </code></pre> <p>During the migration, you create a new implementation:</p> <pre><code class="language-typescript">function newCalculateTotal(order: Order): number { const premiumDiscount = order.customerType === "PREMIUM" ? order.subtotal * 0.1 : 0; const countryAdjustment = order.country === "AR" ? 500 : 0; return Math.max( order.subtotal - premiumDiscount - countryAdjustment, 0 ); } </code></pre> <p>The implementations look different. And that's fine. What matters is whether they produce equivalent behavior.</p> <p>A simple differential test can run both:</p> <pre><code class="language-typescript">import { describe, expect, it } from "vitest"; describe("order total migration", () =&gt; { it("matches the legacy implementation", () =&gt; { const order: Order = { subtotal: 10000, customerType: "PREMIUM", country: "AR", }; const legacy = legacyCalculateTotal(order); const migrated = newCalculateTotal(order); expect(migrated).toBe(legacy); }); }); </code></pre> <p>For this input:</p> <pre><code class="language-text">legacy → 8500 new → 8500 </code></pre> <p>Good. But one matching example proves very little.</p> <p>The value comes from systematically asking:</p> <pre><code class="language-text">same input ↓ legacy implementation ──→ result A same input ↓ new implementation ─────→ result B compare A and B </code></pre> <p>Every mismatch gives you something to investigate.</p> <h2 id="heading-start-with-one-observable-boundary">Start with One Observable Boundary</h2> <p>Don't begin by comparing entire applications. To start, choose one capability.</p> <p>For example:</p> <pre><code class="language-text">Calculate Order Total Generate Invoice Approve Customer Renew Subscription Calculate Commission Create Shipment </code></pre> <p>Suppose the migration boundary is:</p> <pre><code class="language-typescript">interface OrderProcessor { process(order: Order): Promise&lt;ProcessedOrder&gt;; } </code></pre> <p>Now you have two implementations:</p> <pre><code class="language-text">LegacyOrderProcessor NewOrderProcessor </code></pre> <p>That is a useful differential boundary, because both receive the same conceptual input, and both produce the same conceptual output.</p> <p>You can compare them without requiring their internal architecture to match.</p> <p>That matters because migrations often change structure intentionally.</p> <p>The legacy implementation might be:</p> <pre><code class="language-text">controller → service → SQL → provider SDK </code></pre> <p>while the new implementation might be:</p> <pre><code class="language-text">use case → repository → gateway → events </code></pre> <p>Differential testing shouldn't care. It should care about observable behavior.</p> <h2 id="heading-run-the-legacy-and-new-implementations-with-the-same-input">Run the Legacy and New Implementations with the Same Input</h2> <p>Suppose both implementations expose:</p> <pre><code class="language-typescript">interface OrderProcessor { process(order: Order): Promise&lt;ProcessedOrder&gt;; } </code></pre> <p>You can create:</p> <pre><code class="language-typescript">const legacyProcessor = new LegacyOrderProcessor(); const newProcessor = new NewOrderProcessor(); </code></pre> <p>Then:</p> <pre><code class="language-typescript">it("produces the same processed order", async () =&gt; { const input: Order = { id: "order-1", subtotal: 10000, customerType: "PREMIUM", country: "US", }; const legacy = await legacyProcessor.process( structuredClone(input) ); const migrated = await newProcessor.process( structuredClone(input) ); expect(migrated).toEqual(legacy); }); </code></pre> <p>Notice the use of:</p> <pre><code class="language-typescript">structuredClone(input) </code></pre> <p>That matters if either implementation mutates its input.</p> <p>Without separate copies, the first execution could influence the second.</p> <p>You want:</p> <pre><code class="language-text">same initial state </code></pre> <p>not:</p> <pre><code class="language-text">new implementation receives state modified by legacy implementation </code></pre> <p>That kind of contamination can create misleading results.</p> <h2 id="heading-dont-compare-raw-output-blindly">Don't Compare Raw Output Blindly</h2> <p>The first version of a differential test is often:</p> <pre><code class="language-typescript">expect(newResult).toEqual(legacyResult); </code></pre> <p>Sometimes that's exactly right. But other times it's wrong.</p> <p>Imagine the legacy system returns:</p> <pre><code class="language-json">{ "id": "order-1", "total": 9000, "status": "PROCESSED", "generatedAt": "2026-09-09T10:00:01.231Z", "requestId": "legacy-f93a" } </code></pre> <p>The new system returns:</p> <pre><code class="language-json">{ "requestId": "new-b517", "status": "PROCESSED", "generatedAt": "2026-09-09T10:00:01.416Z", "total": 9000, "id": "order-1" } </code></pre> <p>A raw object comparison may fail because:</p> <pre><code class="language-text">requestId differs timestamp differs </code></pre> <p>But the business behavior might be equivalent.</p> <p>You need to decide which fields are part of the meaningful contract.</p> <p>Maybe:</p> <pre><code class="language-text">id total status </code></pre> <p>matter.</p> <p>While:</p> <pre><code class="language-text">generatedAt requestId </code></pre> <p>don't need exact equivalence.</p> <p>That leads to normalization.</p> <h2 id="heading-normalize-values-before-comparing-them">Normalize Values Before Comparing Them</h2> <p>Normalization means transforming outputs into a common representation before comparing them.</p> <p>The goal isn't to change the business meaning of the data. It's to remove differences that are expected and irrelevant to the comparison, such as generated request IDs or timestamps, so the test can focus on the fields that actually define the behavior you care about.</p> <p>In practice, that often means creating a canonical representation: a smaller, stable shape that contains only the meaningful fields you want to compare.</p> <p>For example:</p> <pre><code class="language-typescript">type ProcessedOrder = { id: string; total: number; status: string; generatedAt: string; requestId: string; }; function normalizeOrder( order: ProcessedOrder ) { return { id: order.id, total: order.total, status: order.status, }; } </code></pre> <p>Here, <code>ProcessedOrder</code> contains both business-relevant fields and values that may legitimately differ between executions.</p> <p>The <code>normalizeOrder()</code> function keeps <code>id</code>, <code>total</code>, and <code>status</code>, while leaving out <code>generatedAt</code> and <code>requestId</code>. That means two results can still be considered equivalent even if they were generated at slightly different times or used different request identifiers.</p> <p>Now compare:</p> <pre><code class="language-typescript">expect( normalizeOrder(migrated) ).toEqual( normalizeOrder(legacy) ); </code></pre> <p>This makes your equivalence rule explicit.</p> <p>You're saying:</p> <blockquote> <p>These fields define relevant behavior for this comparison.</p> </blockquote> <p>Normalization can also handle:</p> <ul> <li><p>ordering</p> </li> <li><p>casing</p> </li> <li><p>optional fields</p> </li> <li><p>timestamps</p> </li> <li><p>generated identifiers</p> </li> <li><p>numeric formatting</p> </li> <li><p>provider-specific metadata</p> </li> </ul> <p>But normalization must be deliberate. If you remove too much, you can hide real migration bugs.</p> <h2 id="heading-handle-timestamps-and-other-nondeterministic-values">Handle Timestamps and Other Nondeterministic Values</h2> <p>Legacy systems contain many nondeterministic values.</p> <p>For example:</p> <pre><code class="language-text">timestamps UUIDs random tokens request IDs trace IDs database-generated IDs unordered collections provider-generated references </code></pre> <p>If you compare those values exactly, your differential suite may fail constantly.</p> <p>One option is dependency control.</p> <p>Dependency control means moving a nondeterministic source, such as the current time or an ID generator, behind an interface that you can replace during tests.</p> <p>Instead of letting each implementation read the real clock independently, you inject the same controlled clock into both. That gives them the same value and removes time itself as a source of meaningless divergence.</p> <p>Suppose the code uses:</p> <pre><code class="language-typescript">new Date() </code></pre> <p>You can replace that dependency with a clock:</p> <pre><code class="language-typescript">interface Clock { now(): Date; } </code></pre> <p>Then both implementations receive:</p> <pre><code class="language-typescript">const clock = { now: () =&gt; new Date( "2026-09-09T10:00:00.000Z" ), }; </code></pre> <p>Now time becomes deterministic.</p> <p>The same technique can work for ID generation:</p> <pre><code class="language-typescript">interface IdGenerator { next(): string; } </code></pre> <p>Then tests can provide:</p> <pre><code class="language-typescript">const ids = { next: () =&gt; "fixed-id", }; </code></pre> <p>If controlling nondeterminism is impractical, normalize it out only when it's not part of the behavior you need to protect.</p> <h2 id="heading-compare-business-meaning-not-just-json">Compare Business Meaning, Not Just JSON</h2> <p>Two systems can return different representations while expressing the same business state.</p> <p>Imagine you have this in your legacy system:</p> <pre><code class="language-json">{ "status": 2 } </code></pre> <p>And this in your new one:</p> <pre><code class="language-json">{ "status": "APPROVED" } </code></pre> <p>Raw comparison says:</p> <pre><code class="language-text">different </code></pre> <p>Business comparison may say:</p> <pre><code class="language-text">equivalent </code></pre> <p>You can create a semantic normalizer:</p> <pre><code class="language-typescript">function normalizeStatus( status: number | string ) { if (status === 2) { return "APPROVED"; } return status; } </code></pre> <p>Here, the normalizer translates the legacy numeric value <code>2</code> into the business meaning used by the new implementation: <code>"APPROVED"</code>.</p> <p>It doesn't claim that every number and string are interchangeable. It encodes one explicit equivalence rule that you've already decided is valid for this migration.</p> <p>Then:</p> <pre><code class="language-typescript">expect( normalizeStatus(newResult.status) ).toBe( normalizeStatus(legacyResult.status) ); </code></pre> <p>This is especially useful when migration intentionally changes:</p> <pre><code class="language-text">database schema API representation enumerations provider-specific formats internal identifiers </code></pre> <p>The important question becomes:</p> <blockquote> <p>Does the observable business meaning remain equivalent?</p> </blockquote> <p>Not:</p> <blockquote> <p>Are the bytes identical?</p> </blockquote> <h2 id="heading-compare-errors-as-part-of-the-contract">Compare Errors as Part of the Contract</h2> <p>Success responses aren't the whole behavior. Errors matter too.</p> <p>Suppose the legacy implementation rejects a missing customer:</p> <pre><code class="language-typescript">throw new Error("Customer not found"); </code></pre> <p>The new implementation accidentally returns:</p> <pre><code class="language-typescript">return null; </code></pre> <p>These two implementations behave very differently for the same invalid input.</p> <p>The legacy version fails explicitly, while the new version silently returns a value that a caller may interpret as a successful result.</p> <p>If your differential tests only exercise cases where a valid customer exists, both implementations may appear equivalent and this contract change will remain invisible.</p> <p>That's why failure behavior has to be compared too.</p> <p>Create cases that capture errors:</p> <pre><code class="language-typescript">async function captureResult&lt;T&gt;( operation: () =&gt; Promise&lt;T&gt; ) { try { return { type: "success" as const, value: await operation(), }; } catch (error) { return { type: "error" as const, error: error instanceof Error ? error.message : String(error), }; } } </code></pre> <p>The helper wraps an asynchronous operation and converts both possible outcomes into data.</p> <p>If the operation succeeds, it returns an object with <code>type: "success"</code> and the returned value. If the operation throws, the <code>catch</code> block converts that exception into an object with <code>type: "error"</code> and a readable error message.</p> <p>This gives both implementations the same comparison shape, so the test can compare success versus failure explicitly instead of letting an exception stop the test before the two behaviors can be evaluated.</p> <p>Now:</p> <pre><code class="language-typescript">const legacy = await captureResult(() =&gt; legacyProcessor.process(input) ); const migrated = await captureResult(() =&gt; newProcessor.process(input) ); expect(migrated.type).toBe(legacy.type); </code></pre> <p>If errors are contractually important, compare:</p> <pre><code class="language-text">error category HTTP status error code retryability validation details </code></pre> <p>Don't necessarily compare exact wording unless clients depend on it.</p> <h2 id="heading-compare-side-effects-too">Compare Side Effects, Too</h2> <p>One of the easiest migration mistakes is preserving the return value while losing a side effect.</p> <p>Suppose both implementations return:</p> <pre><code class="language-json">{ "status": "PROCESSED" } </code></pre> <p>But the legacy version also:</p> <pre><code class="language-text">persists the order publishes an event creates a payment writes an audit entry </code></pre> <p>and the new version forgets the audit entry.</p> <p>Response-level differential testing won't catch that. So you'll want to capture side effects.</p> <p>For example:</p> <pre><code class="language-typescript">type Effect = | { type: "payment"; orderId: string; amount: number; } | { type: "event"; name: string; orderId: string; }; </code></pre> <p>A test adapter can record them:</p> <pre><code class="language-typescript">class RecordingPaymentGateway { effects: Effect[] = []; async charge( orderId: string, amount: number ) { this.effects.push({ type: "payment", orderId, amount, }); } } </code></pre> <p>Instead of sending a real payment request, this adapter records what the application attempted to do in the <code>effects</code> array.</p> <p>You can apply the same idea to event publication:</p> <pre><code class="language-typescript">class RecordingEvents { effects: Effect[] = []; async publish( name: string, orderId: string ) { this.effects.push({ type: "event", name, orderId, }); } } </code></pre> <p>The application still calls its payment and event dependencies as usual. The test doubles simply capture those calls as structured data instead of performing the real external actions.</p> <p>After running the legacy and migrated implementations with their own recording adapters, you can compare the two recorded effect lists and verify that both systems attempted the same observable side effects.</p> <p>Now the differential test can compare:</p> <pre><code class="language-typescript">expect(newEffects).toEqual(legacyEffects); </code></pre> <p>Again, exact ordering should only be required if ordering matters.</p> <h2 id="heading-use-tolerances-when-exact-equality-is-wrong">Use Tolerances When Exact Equality Is Wrong</h2> <p>Some domains shouldn't use exact equality.</p> <p>Imagine a migrated calculation produces:</p> <pre><code class="language-text">legacy → 34.333333333 new → 34.333333334 </code></pre> <p>Is that a migration bug? Maybe not.</p> <p>Floating-point calculations may justify a tolerance.</p> <p>For example:</p> <pre><code class="language-typescript">expect(newResult).toBeCloseTo( legacyResult, 6 ); </code></pre> <p>Or define an explicit comparator:</p> <pre><code class="language-typescript">function withinTolerance( a: number, b: number, tolerance: number ) { return Math.abs(a - b) &lt;= tolerance; } </code></pre> <p>Then:</p> <pre><code class="language-typescript">expect( withinTolerance( migrated.total, legacy.total, 0.01 ) ).toBe(true); </code></pre> <p>But tolerances should come from domain requirements. Don't use them just to make failing tests disappear.</p> <p>For financial systems, one cent can matter. For scientific calculations, a much smaller numerical difference may matter.</p> <p>Equivalence is a business and engineering decision.</p> <h2 id="heading-build-a-reusable-differential-test-harness">Build a Reusable Differential Test Harness</h2> <p>Once you compare more than a few cases, you can create a reusable harness.</p> <p>For example:</p> <pre><code class="language-typescript">type DifferentialResult&lt;T&gt; = { input: T; equivalent: boolean; legacy: unknown; migrated: unknown; }; async function compareImplementations&lt; TInput, TOutput &gt;( input: TInput, legacy: ( input: TInput ) =&gt; Promise&lt;TOutput&gt;, migrated: ( input: TInput ) =&gt; Promise&lt;TOutput&gt;, normalize: ( output: TOutput ) =&gt; unknown ): Promise&lt; DifferentialResult&lt;TInput&gt; &gt; { const legacyResult = await legacy( structuredClone(input) ); const migratedResult = await migrated( structuredClone(input) ); const normalizedLegacy = normalize(legacyResult); const normalizedMigrated = normalize(migratedResult); return { input, equivalent: JSON.stringify( normalizedLegacy ) === JSON.stringify( normalizedMigrated ), legacy: normalizedLegacy, migrated: normalizedMigrated, }; } </code></pre> <p>The harness does four things.</p> <p>First, it runs the legacy and migrated implementations with separate clones of the same input, so one execution can't mutate the data seen by the other.</p> <p>Second, it passes both outputs through the same <code>normalize()</code> function. That applies the equivalence rules in one place instead of repeating them in every test.</p> <p>Third, it compares the normalized results and records whether they're equivalent.</p> <p>Finally, it returns the input and both normalized outputs together. That makes a failed comparison easier to inspect because the test report can show exactly which case diverged and what each implementation produced.</p> <p>Then:</p> <pre><code class="language-typescript">const result = await compareImplementations( input, legacyProcessor.process.bind( legacyProcessor ), newProcessor.process.bind( newProcessor ), normalizeOrder ); expect(result.equivalent).toBe(true); </code></pre> <p>For real systems, I would usually avoid relying on <code>JSON.stringify()</code> as the final equality mechanism.</p> <p>The example keeps the harness readable.</p> <p>In production-quality tooling, use a proper structural or domain-specific comparator.</p> <p>The important part is that comparison logic becomes centralized.</p> <h2 id="heading-generate-test-cases-from-real-behavior">Generate Test Cases from Real Behavior</h2> <p>Hand-written examples are useful. But migrations often fail on cases nobody thought to write manually.</p> <p>Useful sources of inputs include:</p> <pre><code class="language-text">existing test fixtures historical incidents production-safe request samples database records boundary values previous bug reports known customer scenarios </code></pre> <p>Suppose production shows these order shapes:</p> <pre><code class="language-typescript">const cases: Order[] = [ { subtotal: 0, customerType: "STANDARD", country: "US", }, { subtotal: 500, customerType: "PREMIUM", country: "AR", }, { subtotal: 10000, customerType: "STANDARD", country: "AR", }, ]; </code></pre> <p>The first block is the test data. It captures a small set of representative input shapes that you've observed in real usage or reconstructed safely from production behavior.</p> <p>The next block is the test itself. <code>it.each(cases)</code> tells Vitest to run the same differential comparison once for every input in that array.</p> <p>That separates two concerns: defining realistic cases and defining how every case should be evaluated.</p> <p>Now:</p> <pre><code class="language-typescript">it.each(cases)( "matches legacy behavior", async (input) =&gt; { const legacy = await legacyProcessor.process( structuredClone(input) ); const migrated = await newProcessor.process( structuredClone(input) ); expect( normalizeOrder(migrated) ).toEqual( normalizeOrder(legacy) ); } ); </code></pre> <p>Real examples help expose assumptions that synthetic test data often misses. But production data must be handled carefully.</p> <p>Remove or anonymize:</p> <pre><code class="language-text">personal data credentials tokens financial identifiers confidential business data </code></pre> <p>The objective is to preserve useful behavioral shapes, not copy sensitive production information into test fixtures.</p> <h2 id="heading-classify-every-difference">Classify Every Difference</h2> <p>A differential failure doesn't automatically mean that the new implementation is wrong.</p> <p>Suppose you find 200 mismatches. Classify them.</p> <p>I like categories such as:</p> <pre><code class="language-text">migration defect legacy defect intentionally preserved intentional behavior change representation difference nondeterministic difference test/comparator defect unknown </code></pre> <p>For example:</p> <pre><code class="language-text">Input: subtotal = 5000 Legacy: discount = 0 New: discount = 500 Classification: unknown </code></pre> <p>Investigation reveals that the new implementation changed:</p> <pre><code class="language-typescript">amount &gt; 5000 </code></pre> <p>to:</p> <pre><code class="language-typescript">amount &gt;= 5000 </code></pre> <p>Now you need a decision.</p> <p>Was that:</p> <pre><code class="language-text">accidental migration change </code></pre> <p>or:</p> <pre><code class="language-text">intentional bug fix </code></pre> <p>Differential testing exposes the decision. It doesn't make the decision for you.</p> <p>That's one of its greatest benefits.</p> <h2 id="heading-how-to-use-ai-to-investigate-differential-failures">How to Use AI to Investigate Differential Failures</h2> <p>Large migrations can produce hundreds or thousands of differences. And AI can help triage them.</p> <p>Suppose you have:</p> <pre><code class="language-json">{ "input": { "subtotal": 5000, "country": "AR" }, "legacy": { "total": 4500 }, "new": { "total": 4000 } } </code></pre> <p>You can give the model:</p> <ul> <li><p>the input</p> </li> <li><p>both outputs</p> </li> <li><p>relevant legacy code</p> </li> <li><p>relevant migrated code</p> </li> <li><p>the comparator rules</p> </li> </ul> <p>Then ask:</p> <pre><code class="language-text">Analyze this differential test failure. Identify the smallest behavioral difference that could explain the mismatch. Compare the legacy and migrated implementations. Return: 1. observed difference, 2. relevant legacy branch, 3. relevant migrated branch, 4. likely cause, 5. evidence supporting the cause, 6. additional test cases that could confirm it. Do not decide which behavior is correct. Do not modify the code yet. </code></pre> <p>That last instruction matters. AI can be very useful for locating why two implementations diverge. It shouldn't silently turn that diagnosis into a business decision.</p> <h3 id="heading-dont-let-ai-decide-which-behavior-is-correct">Don't Let AI Decide Which Behavior Is Correct</h3> <p>Imagine the legacy system does this:</p> <pre><code class="language-text">Customer age 65 → no discount Customer age 66 → discount </code></pre> <p>The new system does:</p> <pre><code class="language-text">Customer age 65 → discount Customer age 66 → discount </code></pre> <p>AI may look at the code and say:</p> <blockquote> <p>The new implementation appears more logical because senior discounts typically begin at age 65.</p> </blockquote> <p>That's irrelevant.</p> <p>The business rule might be:</p> <pre><code class="language-text">age &gt; 65 </code></pre> <p>for a reason. Or the legacy behavior might contain a bug.</p> <p>You need evidence.</p> <p>Use:</p> <pre><code class="language-text">requirements existing tests production behavior business owners historical tickets commit history contracts </code></pre> <p>AI can help gather and summarize that evidence. It shouldn't invent the rule.</p> <p>Differential testing is valuable because it tells you that there's a difference before you accidentally turn that difference into production behavior.</p> <h2 id="heading-how-to-use-shadow-traffic-safely">How to Use Shadow Traffic Safely</h2> <p>Once offline differential tests look good, you can sometimes compare behavior with real traffic. This is often called shadowing or traffic mirroring.</p> <p>The pattern looks like:</p> <pre><code class="language-text">real request │ ├────────────→ legacy system │ │ │ ↓ │ real response │ └────────────→ new system │ ↓ shadow result </code></pre> <p>The user still receives:</p> <pre><code class="language-text">legacy response </code></pre> <p>while the new system processes a copy of the request.</p> <p>Then you compare:</p> <pre><code class="language-text">legacy output vs. shadow output </code></pre> <p>This can reveal cases that your test suite never captured.</p> <p>For example:</p> <pre><code class="language-text">unexpected null combinations rare customer states unusual international data old records large values unusual sequence patterns </code></pre> <p>But shadow execution requires careful design, especially when the operation has side effects.</p> <h3 id="heading-how-to-prevent-shadow-execution-from-duplicating-side-effects">How to Prevent Shadow Execution from Duplicating Side Effects</h3> <p>Imagine shadowing:</p> <pre><code class="language-text">POST /payments </code></pre> <p>If both systems really execute the payment, you have a serious problem.</p> <p>The same applies to:</p> <pre><code class="language-text">send email create shipment charge card modify inventory publish event write external record </code></pre> <p>The shadow implementation shouldn't perform destructive or externally visible effects unless they're safely isolated.</p> <p>One approach is to replace real gateways with recording adapters:</p> <pre><code class="language-typescript">class ShadowPaymentGateway implements PaymentGateway { calls: PaymentRequest[] = []; async charge( request: PaymentRequest ) { this.calls.push(request); return { paymentId: "shadow", }; } } </code></pre> <p>The new implementation still tries to execute:</p> <pre><code class="language-text">payment </code></pre> <p>but instead of charging a real card, the shadow adapter records:</p> <pre><code class="language-text">what would have been sent </code></pre> <p>You can then compare that intent with the legacy side effect.</p> <p>This distinction is important:</p> <pre><code class="language-text">compare behavior </code></pre> <p>does not mean:</p> <pre><code class="language-text">duplicate production effects </code></pre> <h2 id="heading-measure-divergence-instead-of-waiting-for-perfection">Measure Divergence Instead of Waiting for Perfection</h2> <p>When running thousands of comparisons, a binary:</p> <pre><code class="language-text">pass / fail </code></pre> <p>may not tell the whole story.</p> <p>You can measure divergence.</p> <p>For example:</p> <pre><code class="language-text">Requests compared: 100,000 Equivalent: 99,620 Different: 380 Divergence rate: 0.38% </code></pre> <p>Then classify those 380:</p> <pre><code class="language-text">250 timestamp differences 80 known intentional changes 30 comparator problems 15 migration defects fixed 5 still unexplained </code></pre> <p>After normalization:</p> <pre><code class="language-text">meaningful unresolved divergence: 5 / 100,000 = 0.005% </code></pre> <p>Now the conversation becomes much more concrete.</p> <p>Instead of:</p> <blockquote> <p>I think the migration is ready.</p> </blockquote> <p>you can say:</p> <blockquote> <p>We compared 100,000 representative executions and have five unresolved behavioral differences.</p> </blockquote> <p>Whether that's acceptable depends on what those five cases are.</p> <p>One incorrect financial transaction can matter more than 100 harmless formatting differences.</p> <p>So don't evaluate only the percentage. Evaluate the severity.</p> <h2 id="heading-how-to-know-when-youre-ready-for-cutover">How to Know When You're Ready for Cutover</h2> <p>Differential testing doesn't give you a universal threshold. But it can give you evidence.</p> <p>Before cutover, I would want to answer questions such as:</p> <h3 id="heading-have-important-input-classes-been-compared">Have Important Input Classes Been Compared?</h3> <p>Not only happy paths.</p> <p>Include:</p> <pre><code class="language-text">boundaries errors historical bugs large values missing values rare states </code></pre> <h3 id="heading-are-meaningful-differences-classified">Are Meaningful Differences Classified?</h3> <p>Avoid:</p> <pre><code class="language-text">we have 47 unexplained mismatches </code></pre> <h3 id="heading-are-critical-differences-resolved">Are Critical Differences Resolved?</h3> <p>Especially:</p> <pre><code class="language-text">money authorization state transitions data integrity external contracts idempotency </code></pre> <h3 id="heading-are-intentional-differences-documented">Are Intentional Differences Documented?</h3> <p>If the new behavior intentionally differs, that should be explicit.</p> <h3 id="heading-are-side-effects-equivalent">Are Side Effects Equivalent?</h3> <p>Not only responses.</p> <h3 id="heading-have-production-like-cases-been-tested">Have Production-like Cases Been Tested?</h3> <p>Synthetic fixtures alone may not be enough.</p> <h3 id="heading-can-the-migration-be-rolled-back">Can the Migration Be Rolled Back?</h3> <p>Differential confidence reduces risk. It doesn't eliminate the need for rollback.</p> <p>If you can answer these questions, you're much closer to a controlled cutover.</p> <h2 id="heading-a-practical-differential-testing-workflow">A Practical Differential Testing Workflow</h2> <p>Here's the workflow I would use.</p> <h3 id="heading-1-pick-one-capability">1. Pick One Capability</h3> <p>For example:</p> <pre><code class="language-text">Process Order Calculate Invoice Approve Customer </code></pre> <p>Don't compare the whole platform at once.</p> <h3 id="heading-2-define-the-observable-contract">2. Define the Observable Contract</h3> <p>List what matters:</p> <pre><code class="language-text">return value status error database state events external calls </code></pre> <h3 id="heading-3-create-legacy-and-new-adapters">3. Create Legacy and New Adapters</h3> <p>Expose both implementations through the same conceptual interface.</p> <h3 id="heading-4-define-normalization-rules">4. Define Normalization Rules</h3> <p>Decide how to handle:</p> <pre><code class="language-text">timestamps generated IDs ordering representation changes optional values </code></pre> <p>Do this before looking at lots of failures. Otherwise you may weaken the comparator simply to make results pass.</p> <h3 id="heading-5-compare-known-cases">5. Compare Known Cases</h3> <p>Begin with:</p> <pre><code class="language-text">existing tests characterization cases edge cases historical bugs </code></pre> <h3 id="heading-6-capture-side-effects">6. Capture Side Effects</h3> <p>Use recording or fake adapters where necessary.</p> <h3 id="heading-7-automate-the-harness">7. Automate the Harness</h3> <p>Produce structured output for every mismatch.</p> <p>For example:</p> <pre><code class="language-json">{ "caseId": "case-493", "equivalent": false, "legacy": {}, "migrated": {}, "difference": {} } </code></pre> <h3 id="heading-8-classify-differences">8. Classify Differences</h3> <p>Use categories:</p> <pre><code class="language-text">defect intentional change normalization issue nondeterminism unknown </code></pre> <h3 id="heading-9-add-representative-real-world-cases">9. Add Representative Real-World Cases</h3> <p>Use anonymized or safely reconstructed production patterns.</p> <h3 id="heading-10-shadow-real-traffic-when-appropriate">10. Shadow Real Traffic When Appropriate</h3> <p>Only after controlling side effects and privacy risk.</p> <h3 id="heading-11-measure-divergence">11. Measure Divergence</h3> <p>Track both:</p> <pre><code class="language-text">frequency severity </code></pre> <h3 id="heading-12-resolve-unknowns-before-cutover">12. Resolve Unknowns Before Cutover</h3> <p>The most dangerous category is often not:</p> <pre><code class="language-text">different </code></pre> <p>It is:</p> <pre><code class="language-text">different and nobody knows why </code></pre> <h2 id="heading-what-differential-testing-cant-prove">What Differential Testing Can't Prove</h2> <p>Differential testing has an important limitation: it compares the new system against the old one.</p> <p>That means the legacy system becomes a behavioral reference. But the legacy system may already be wrong.</p> <p>Suppose:</p> <pre><code class="language-text">legacy output = wrong new output = same wrong result </code></pre> <p>The differential test passes, but that doesn't make the behavior correct.</p> <p>This is why differential testing should complement:</p> <pre><code class="language-text">specification tests characterization tests business requirements security testing performance testing contract testing domain review </code></pre> <p>It answers:</p> <blockquote> <p>Did behavior change?</p> </blockquote> <p>It doesn't automatically answer:</p> <blockquote> <p>Is this the right behavior?</p> </blockquote> <p>That distinction matters. The legacy application is evidence, it's not absolute truth.</p> <h2 id="heading-differential-testing-turns-migration-risk-into-evidence">Differential Testing Turns Migration Risk into Evidence</h2> <p>There's another reason I like this technique. Without differential testing, migration discussions can become subjective.</p> <p>One person says:</p> <blockquote> <p>The new implementation looks ready.</p> </blockquote> <p>Another says:</p> <blockquote> <p>I do not trust it yet.</p> </blockquote> <p>Both may have reasonable instincts, but neither statement is very measurable.</p> <p>Differential testing changes the conversation.</p> <p>Now you can say:</p> <pre><code class="language-text">12,000 cases compared 47 differences found 31 representation differences 9 intentional behavior changes 6 migration defects fixed 1 unresolved </code></pre> <p>That is a much better engineering discussion. You're converting uncertainty into observable differences. Then you can decide what to do with them.</p> <h2 id="heading-conclusion">Conclusion</h2> <p>A legacy migration is not complete because the new implementation passes its own tests.</p> <p>The harder question is whether it preserves the behavior that matters from the system it is replacing.</p> <p>Differential testing gives you another way to answer that question.</p> <p>Run both implementations with the same inputs.</p> <p>Compare outputs.</p> <p>Compare errors.</p> <p>Compare side effects.</p> <p>Normalize only the differences that truly do not matter.</p> <p>Investigate everything else.</p> <p>And when possible, use representative production behavior to discover cases your test suite did not anticipate.</p> <p>The migration sequence now becomes:</p> <pre><code class="language-text">Understand ↓ Characterize ↓ Refactor ↓ Migrate ↓ Compare ↓ Cut over </code></pre> <p>AI can accelerate this process too.</p> <p>It can help build comparators, analyze failures, group similar divergences, inspect code paths, and suggest additional test cases.</p> <p>But it should not decide which implementation is correct.</p> <p>That still requires evidence, domain knowledge, and engineering judgment.</p> <p>The purpose of differential testing is not to eliminate uncertainty completely.</p> <p>It is to make uncertainty visible <strong>before</strong> you switch production traffic.</p> <p>Because during a migration, discovering that the new system behaves differently is useful.</p> <p>Discovering it after the old system has been turned off is much more expensive.</p>

How to apply

  1. 1 Read the full details above and confirm you meet the eligibility criteria.
  2. 2 Prepare your documents — an updated CV, and any cover letter, proposal or certificates required.
  3. 3 Click Apply on official site to complete your application on Hugo Teijiz’s official page.
  4. 4 Submit as early as possible — many close once filled.
Apply on official site

Sourced from freecodecamp. Always verify details on the official website. Opportunihub never charges you to apply.

Frequently asked questions

How do I apply for How to Use Differential Testing During a Legacy Migration?

Review the full details and eligibility on this page, prepare your documents, then use the “Apply on official site” button to complete your application on Hugo Teijiz’s official page.

Is this opportunity remote or location-based?

This opportunity is remote-friendly and open to applicants who can work from anywhere.

Is How to Use Differential Testing During a Legacy Migration free to apply for?

Opportunihub lists this Course for free. Legitimate Courses do not ask for payment to apply — never pay a fee to submit an application.