Mert Özen Further With Every Line
Developer Tools Debugging and Problem Solving — #02

Reproducing the Bug: Minimal Repro, Environment Differences and a Failing Test First

Mert Özen Nov 8, 2026 11 min 3 views
Reproducing the Bug: Minimal Repro, Environment Differences and a Failing Test First

You can't fix a bug you can't reproduce: get the right information from the reporter, check culture, time zone, configuration and version differences, reduce the bug to a minimal repro and write a failing xUnit test before the fix. With real outputs: 12,45 parsed as 1245 in a container and an order total bug reduced to two lines.

Version note: The code in this post was built and run with .NET 10; the tests were run with xUnit v3 (xunit.v3 4.0.2) and Microsoft.Testing.Platform.

Guess First

You run the four-line console app below in two places: first on your own Mac (Turkish macOS, Istanbul time), then in a Docker container started from the official mcr.microsoft.com/dotnet/aspnet:10.0 image. The same compiled dll runs in both environments.

Console.WriteLine($"culture      : {CultureInfo.CurrentCulture.Name}");
Console.WriteLine($"DateTime.Now : {DateTime.Now:yyyy-MM-dd HH:mm}");
Console.WriteLine($"parse 12,45  : {decimal.Parse("12,45")}");
Console.WriteLine($"\"title\".ToUpper() : {"title".ToUpper()}");

Which lines come out different in the two environments? What does decimal.Parse("12,45") return in the container: 12,45, 12.45, an exception, or something else? You will see the answer and the real output in the section on environment differences.

A Bug You Can't Reproduce Is a Bug You Can't Fix

In the first post of this series we started with the ticket from accounting: for order #10482 the amount charged was 1 kuruş less than the invoice. Putting the two calculations side by side showed that the difference came from the 12.45 TL line with a 10% discount and that midpoint values were rounded differently. Agans' second rule, "Make It Fail", now comes into play: before moving on to the fix we must be able to produce this bug whenever we want, in whichever environment we want, the same way every time.

The reason is simple. If you can't trigger the bug, you can't prove you fixed it either. "I made the change, waited a few days and nobody complained" is not proof; the symptom only showed up "sometimes" anyway. Reproducing also narrows the hypothesis: if you can produce the bug with one piece of data but not with another, the difference between them points you to the cause.

Getting the Right Information from the Reporter

Most bugs arrive through a report from a user, a support team or another team. Nothing can be reproduced from the sentence "the order total is wrong". A good bug report contains:

  • What was done step by step, what was expected and what happened (expected and actual behaviour separately)
  • The time, with its time zone: not "last night" but "2026-09-21 09:42" with its offset from UTC (Coordinated Universal Time), here UTC+3
  • Which user, which order, which request: the order number or the traceId in the ProblemDetails response
  • The app version, the browser and its version, the device and the operating system
  • A screenshot and, if needed, a sanitized HAR (HTTP Archive) file

Putting this list into an issue template saves you from asking the reporter the same questions every time. Our ticket is a lucky one: accounting gave us the order number and both amounts. With the order number we can pull the lines, the discount rates and the app version that created the order from the database.

Be careful with HAR files. A HAR file records every request and response from the browser's Network panel, with cookies and headers, as JSON (JavaScript Object Notation); if it contains a session cookie or an Authorization header, whoever receives the file can use that user's session. Since Chrome 130, DevTools' "Export HAR" option produces a sanitized file by default, leaving out the Cookie, Set-Cookie and Authorization headers. Exporting with sensitive data requires turning on a separate setting. Still, query strings in URLs (Uniform Resource Locators), request bodies and personal data in responses stay in the file; open the HAR and look through it before attaching it to a ticket. The details of the Network panel are in the upcoming Browser DevTools series.

An Environment Differences Checklist

If the bug happens in one environment and not in another, one of the differences between them is either the cause or the trigger. Let's run the full version of the program from the start in both environments. First on my own machine, with Turkish culture and Istanbul time:

using System.Globalization;

Console.WriteLine($"culture      : {CultureInfo.CurrentCulture.Name}");
Console.WriteLine($"time zone    : {TimeZoneInfo.Local.Id}");
Console.WriteLine($"DateTime.Now : {DateTime.Now:yyyy-MM-dd HH:mm}");
Console.WriteLine($"UtcNow       : {DateTime.UtcNow:yyyy-MM-dd HH:mm}");
Console.WriteLine($"parse 12,45  : {decimal.Parse("12,45")}");
Console.WriteLine($"\"title\".ToUpper() : {"title".ToUpper()}");
$ LANG=tr_TR.UTF-8 TZ=Europe/Istanbul dotnet bin/Debug/net10.0/envcheck.dll
culture      : tr-TR
time zone    : Europe/Istanbul
DateTime.Now : 2026-11-08 11:56
UtcNow       : 2026-11-08 08:56
parse 12,45  : 12,45
"title".ToUpper() : TİTLE

Then the same dll in the official ASP.NET Core runtime image, as in production:

$ docker run --rm -v "$PWD/bin/Debug/net10.0:/app:ro" mcr.microsoft.com/dotnet/aspnet:10.0 dotnet /app/envcheck.dll
culture      :
time zone    : UTC
DateTime.Now : 2026-11-08 08:57
UtcNow       : 2026-11-08 08:57
parse 12,45  : 1245
"title".ToUpper() : TITLE

The answer to the opening question: in the container decimal.Parse("12,45") doesn't throw, it silently returns 1245. With no LANG set in the container, CurrentCulture is the invariant culture, whose name is empty; in the invariant culture the comma is the thousands separator and the decimal separator is a dot. The same code produces 12.45 TL on the developer's machine and 1,245 TL on the server. The Turkish I turning ToUpper into "TİTLE" belongs to the same family; the details are in the C# Fundamentals series post "Strings in C#: Interpolation, StringBuilder and the Turkish I Trap". And DateTime.Now is exactly three hours apart in the two environments.

This is the list I go through when checking environment differences:

DifferenceTypical symptomHow to check
CultureA parsed number is 100 times too big, an upper-cased key doesn't matchLog CultureInfo.CurrentCulture, run the test under two cultures
Time zoneA report is shifted by 3 hours, a campaign starting at midnight hasn't started on the serverLog TimeZoneInfo.Local, pin the time with TimeProvider
Configuration and feature flagsBehaviour seen only for one customer group or in one environmentDump the effective configuration (GetDebugView()), compare flag values
DataOnly for a particular product, price or customerAnonymise the same data and bring it into your own environment
VersionA bug believed to be fixed is still thereRead the version and commit hash from a health endpoint
Browser and cacheSome users see the old screenPrivate window, disabling the cache in the Network panel, browser version

Environment or Code, in This Case?

Seeing the output above, a hypothesis jumps to mind: "In the production container the culture is invariant; maybe rounding also depends on the culture." It's a nice hypothesis, because it could also explain the word "sometimes": maybe some servers run with a different culture. But a hypothesis stays a hypothesis until its prediction is tested. The prediction: if rounding depends on the culture, the same test should give different results under different cultures.

To run the experiment we first need to write the bug as a test; we'll look at the test in detail below. For now, let's run it under two cultures:

$ LANG=tr_TR.UTF-8 dotnet test | grep -E "^failed|Expected|Actual|failed:"
failed Bookstore.Ordering.Tests.PriceCalculatorTests.DiscountedUnitPrice_rounds_midpoint_away_from_zero(unitPrice: 12,45, discountRate: 0,1, expected: 11,21) (0ms)
  Expected: 11,21
  Actual:   11,20
  failed: 1

$ LANG=en_US.UTF-8 dotnet test | grep -E "^failed|Expected|Actual|failed:"
failed Bookstore.Ordering.Tests.PriceCalculatorTests.DiscountedUnitPrice_rounds_midpoint_away_from_zero(unitPrice: 12.45, discountRate: 0.1, expected: 11.21) (0ms)
  Expected: 11.21
  Actual:   11.20
  failed: 1

How the values are written changes with the culture (12,45 and 12.45), but the result doesn't: in both environments the expected value is 11.21 and the actual one 11.20. The culture hypothesis is eliminated; one more line in the journal. decimal arithmetic and Math.Round are culture-independent; culture only comes into play when converting to and parsing from strings. In this case the environment difference isn't the cause, but had we skipped the checklist we wouldn't know that.

So where does "sometimes" come from? Not from the environment but from the data. The scan in the previous post showed that the difference appears only for amounts whose raw value is exactly a midpoint and whose second decimal is even. The trigger is the price and discount combination in the order.

Minimal Repro: Shrinking the Code and the Data

Our first repro was an order: three lines, a customer, a campaign, free shipping. A minimal repro is the smallest input and the smallest code that still produce the bug. Shrinking has two benefits: it shows what the bug really depends on, and it makes the bug explainable to someone else (a teammate, a library's issue tracker) in five seconds.

Let's shrink the data: remove the customer, the campaign and the shipping, and the difference is still there. Remove the 89.90 and 45.50 lines, and it is still there. One line remains: 12.45 TL, 10% discount, quantity 1. Now shrink the code: we don't even need OrderLine and PriceCalculator; the bug shows up in a single framework call:

decimal raw = 12.45m * (1 - 0.10m);
Console.WriteLine(FormattableString.Invariant($"{raw} -> default: {Math.Round(raw, 2)}, AwayFromZero: {Math.Round(raw, 2, MidpointRounding.AwayFromZero)}"));
11.2050 -> default: 11.20, AwayFromZero: 11.21

What the two-line repro tells us matters: there is no bug in .NET; .NET does what it documents. Math.Round(decimal, int) rounds midpoint values to the nearest even number. The bug is that our code uses this default while the invoice service uses another rule. Had we not shrunk the minimal repro this far, we could have wasted time asking whether something is wrong in the framework.

Shrinking a large input (such as a 5,000-line import file) by hand is tiring and error-prone. We'll see the delta debugging technique that does it automatically in post 03 of this series.

Flaky Tests and Heisenbugs

Not every bug is this well-behaved. Some happen sometimes and sometimes not, with the same input. The cause is usually timing: the order of two threads, the edge of a timeout, a cache being full or empty, the clock passing midnight. The way to reproduce such a bug is to pin the variable. If it depends on time, inject the time with TimeProvider and pin it in the test; if it depends on randomness, pin the seed; if it depends on ordering, run the test hundreds of times in a loop and measure the rate.

A Heisenbug, a term referring to Heisenberg's uncertainty principle, is a bug whose behaviour changes when you try to observe it. When you stop at a breakpoint in the debugger, the race between two threads disappears; in a Debug build optimisations are off, so behaviour seen in Release goes away; the Watch window evaluating a property triggers the lazy initialisation in that property's getter and changes the state. If a bug vanishes in the debugger, move the observation to less intrusive tools: logs, tracepoints, dumps. Post 06 of this series covers async and multi-threaded debugging, and posts 09 and 10 cover diagnosis with dumps and traces.

A Failing Test First

If the minimal repro stays a console line, it gets lost once the fix is in. The way to make it permanent is to write it as a test. The order matters: the test is written before the fix and is seen failing first. A test you've never seen fail doesn't prove it catches the bug; maybe it calls the wrong method, maybe the assertion never runs. We covered xUnit basics in the C# Advanced series post "Unit Testing in C#: xUnit, Theory/InlineData and Testable Code"; here we only apply it to this case:

using Bookstore.Ordering;
using Xunit;

namespace Bookstore.Ordering.Tests;

public class PriceCalculatorTests
{
    // Ticket #4127: order #10482 was charged 178.46, the invoice says 178.47
    [Theory]
    [InlineData(12.45, 0.10, 11.21)]
    [InlineData(45.50, 0.15, 38.68)]
    [InlineData(89.90, 0.00, 89.90)]
    public void DiscountedUnitPrice_rounds_midpoint_away_from_zero(
        decimal unitPrice, decimal discountRate, decimal expected)
    {
        OrderLine line = new("978-605-0000-01-1", unitPrice, 1, discountRate);

        decimal actual = PriceCalculator.DiscountedUnitPrice(line);

        Assert.Equal(expected, actual);
    }
}

All three rows come from the ticket: 12.45, which shows the bug; 45.50, which is a midpoint yet gives the right result; and 89.90 with no discount. The last two are green even today; their job is to show that the fix doesn't break them. The test project uses xUnit v3, and xUnit v3 tests run on Microsoft.Testing.Platform. In the .NET 10 SDK (Software Development Kit), dotnet test runs this platform only if you select it as the runner in a global.json at the solution root; if you don't, the build stops with the error "Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later.":

{
  "sdk": {
    "version": "10.0.402",
    "rollForward": "latestFeature"
  },
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

The test, before the fix:

$ dotnet test
Running tests from ./tests/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64)
failed Bookstore.Ordering.Tests.PriceCalculatorTests.DiscountedUnitPrice_rounds_midpoint_away_from_zero(unitPrice: 12.45, discountRate: 0.1, expected: 11.21) (0ms)
  from ./tests/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64)
  Assert.Equal() Failure: Values differ
  Expected: 11.21
  Actual:   11.20
    at Bookstore.Ordering.Tests.PriceCalculatorTests.DiscountedUnitPrice_rounds_midpoint_away_from_zero(Decimal unitPrice, Decimal discountRate, Decimal expected) in ./tests/Bookstore.Ordering.Tests/PriceCalculatorTests.cs:20
    at InvokeStub_PriceCalculatorTests.DiscountedUnitPrice_rounds_midpoint_away_from_zero(Object, Span`1)
    at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
./tests/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64) failed with 1 error(s) (519ms)
Exit code: 2
Test run summary: Failed!
  total: 3
  failed: 1
  succeeded: 2
  skipped: 0
  duration: 626ms
Test run completed with non-success exit code: 2 (see: https://aka.ms/testingplatform/exitcodes)

Red, and red for the right reason: expected 11.21, actual 11.20. Now the fix is a single argument:

public static decimal DiscountedUnitPrice(OrderLine line) =>
    Math.Round(line.UnitPrice * (1 - line.DiscountRate), 2, MidpointRounding.AwayFromZero);
$ dotnet test
Running tests from ./tests/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64)
./tests/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64) passed (502ms)
Test run summary: Passed!
  total: 3
  failed: 0
  succeeded: 3
  skipped: 0
  duration: 598ms

Green. To complete Agans' ninth rule, revert the fix and see the test turn red again. This test is now a regression test: if this line ever changes again, the CI (Continuous Integration) pipeline turns red before the merge.

On the Angular side the method is the same. The wrong behaviour of a component or a service is first written as a failing spec with Vitest, which Angular sets up as the default test runner in new projects, and then fixed. Bug hunting on the frontend is in post 07 of this series.

A note: this test caught the bug but still doesn't answer one question. When, and with which change, did this line become like this? Accounting had never seen the same difference in the reconciliations of earlier weeks. We'll answer that question in the next post with git bisect run.

In Production

When you bring production data to your own machine to reproduce a bug, anonymise the personal data. The prices and discount rates of the order lines are enough for this bug; the customer's name, address and email are not needed. Taking more data than you need is also an unnecessary risk under KVKK (Kişisel Verilerin Korunması Kanunu, the Turkish Personal Data Protection Law) and the GDPR (General Data Protection Regulation).

If the bug happens in production and can't be reproduced in any way, add the observation that will catch the next occurrence: a structured log line at the relevant spot that writes the raw values, the culture, the time zone and the version. This isn't a fix, it's an experiment; when the next ticket arrives you will have data. Diagnosis with logs is in post 08 of this series; producing logs is covered in the upcoming Logging in .NET series.

Don't leave culture and time zone to the environment. On the server, use CultureInfo.InvariantCulture and UTC for money and date operations; convert to the user's culture and time zone when displaying. Pin these values deliberately in container images so that behaviour doesn't change when the machine does.

Common Pitfalls

The most common pitfall is closing the ticket with "works on my machine". That it works on your machine doesn't mean it works in the reporter's environment or with their data. In this case someone trying with a 12.40 TL book says "everything is correct". "I couldn't reproduce it" is not a conclusion but a status report; the question that should follow is "what's the difference between us?"

The second pitfall is writing the test after the fix. A test written after the fix is green on its first run, and that green proves nothing; you can't tell whether the test really catches the bug.

The third pitfall is copying the test's expected value from what the code produces. The 11.21 in the test comes from the invoice, that is, from the business rule; had we run the code and written the resulting 11.20 into the test, we would have put the bug under the protection of a test.

The fourth pitfall is not shrinking the minimal repro enough. Had we stopped at the three-line order, we would have kept the shipping and campaign code on the suspect list.

The fifth pitfall is attaching a HAR file or a production log to the ticket as is. Ticket systems are usually open to half the company; a file containing a token is a lasting security hole there.

A Small Experiment

★☆☆ Run the environment check program from the start on your own machine and in a container. Which lines differ? Add decimal.Parse("12,45", CultureInfo.InvariantCulture) and decimal.Parse("12,45", new CultureInfo("tr-TR")) and confirm that you get the same results in both environments.

★★☆ Add a price that diverges at a 15% discount to PriceCalculatorTests. Work out on paper which price will diverge first, then see the test go red.

★★★ Imagine a campaign starting at midnight Istanbul time. Write a Campaign.IsActive() method that uses DateTime.Now, then change it to take a TimeProvider, and write a test showing that 21:30 UTC and 00:30 in Istanbul are the same instant.

In the next post we'll use this failing test like a detective: with git bisect run we'll find the commit that first introduced the bug in a range of 31 commits in seven tests, see how to skip a commit that doesn't build, and reduce a 5,000-line import file to three lines with delta debugging.