What is new in .NET 11, and should you move from .NET 10?
A fair tour of .NET 11 and C# 15 as released on 10 November 2026, the breaking changes to read first, and a decision guide for production teams on .NET 10.
· 7 min readHow characterisation tests record what your untested C# code does today, so you can change it, or upgrade beneath it, with a safety net.

You have a working application and no tests. It earns its keep, it has been running for years, and everyone on the team has a story about the last time a small change broke something far away. So the changes that would help, such as upgrading a framework, fixing a long-standing bug or adding a feature, keep being put off.
That reluctance is reasonable. Without tests, you find out what a change broke when a user tells you. This article walks through one practical way out: characterisation tests, which record what the code does today so you can change it with a safety net. The examples are C# and .NET, because that is what many of the systems we see are written in, but the idea works in any language. Tool details are as of October 2026.
Michael Feathers, in Working Effectively with Legacy Code (2004), uses a deliberately blunt working definition of legacy code: code without tests. The age of the code is beside the point. What matters is that you cannot change it and know quickly whether you broke it.
The usual advice is to write tests first. The problem is that nobody is quite sure what the code is supposed to do. The original developer has gone, the specification, if one existed, is out of date, and the code has accumulated years of fixes for situations nobody remembers.
Characterisation tests sidestep that. Feathers' book has a chapter on the problem ("I Need to Make a Change, but I Don't Know What Tests to Write"), and the technique is this: write tests that describe what the code does now, not what it should do. If the code has a bug, the test records the bug. Judging the behaviour can wait. For now you are taking a photograph of it.
Then, when you change the code, any difference from the photograph is a deliberate decision rather than an accident.
Here is an invented piece of legacy code. It is illustrative, not from a real system.
public record Customer(bool IsTrade);
public record OrderLine(int Quantity, decimal UnitPrice);
public record Order(Customer Customer, string DeliveryCountry, IReadOnlyList<OrderLine> Lines);
public class InvoiceCalculator
{
public decimal Total(Order order)
{
decimal total = 0;
foreach (var line in order.Lines)
total += line.Quantity * line.UnitPrice;
if (order.Customer.IsTrade && total > 500m)
total *= 0.92m;
if (order.DeliveryCountry != "GB")
total += 18.50m;
return Math.Round(total, 2);
}
}
Nobody has written down why the trade discount is 8% or why it only applies above £500. You do not need to know yet. The first characterisation test is written like this, with a deliberately wrong expected value:
using Xunit;
public class InvoiceCalculatorTests
{
[Fact]
public void Trade_customer_over_500_pounds_in_the_UK()
{
var order = new Order(
new Customer(IsTrade: true),
"GB",
new[] { new OrderLine(Quantity: 10, UnitPrice: 60m) });
var total = new InvoiceCalculator().Total(order);
Assert.Equal(0m, total); // placeholder: run it, then replace with the actual value
}
}
Run it. It fails and tells you the code returned 552.00. You replace the placeholder with that value and the test passes. You have recorded one fact about the system. Repeat it for a handful of cases that exercise different branches: a non-trade customer, an order just under £500, delivery outside the UK.
A common surprise comes from a total such as 0.125. Math.Round in .NET rounds half to even by default, so Math.Round(0.125m, 2) gives 0.12, not 0.13. Whether that is right for your business is a question for later. The characterisation test records it, and now you will notice if a change alters it.
This code has not been compiled or run for this article. It is an illustration of the approach, so check it in your own project before relying on it.
Writing expected values by hand is slow when the output is large, such as a generated report, a JSON response or a long list of results. Snapshot or approval testing helps: the tool saves the output to a file, a person reviews and approves it, and later runs compare against the approved file.
In .NET, Verify is a widely used snapshot tool. Its documentation lists packages for several test frameworks, including Verify.XunitV3, Verify.NUnit and Verify.MSTest; choose the one that matches your framework. A minimal test looks like this (illustrative, assuming Verify.XunitV3 with implicit usings enabled; SampleOrders stands in for your own test data):
[Fact]
public Task Totals_for_a_spread_of_orders()
{
var calculator = new InvoiceCalculator();
var results = SampleOrders.All
.Select(o => new { o.Name, Total = calculator.Total(o.Order) });
return Verify(results);
}
On the first run there is no approved file, so the test fails and writes a .received.txt file. You read it, and if it matches your understanding of what the system does today, you accept it as the .verified.txt file. Commit the verified files, and keep the received ones out of source control, as Verify's documentation advises. From then on, any change in output shows up as a readable difference.
A snapshot test is only as good as the review of the first approval. Do not accept a file you have not read.
You cannot cover everything, and you do not need to. Start where a mistake costs the most:
Pick one, write ten or twenty tests, and look at what they tell you. That alone often exposes surprising behaviour.
Often the code cannot be tested because it reaches straight out to the clock, the database or a web service. Feathers calls the places where you can change behaviour without editing the code in that place a seam. The aim is to introduce one safely, in the smallest change you can, and without altering what the code does.
A frequent example is the current date. Code that calls DateTime.Now directly gives different results on different days. Since .NET 8, there is a built-in TimeProvider class you can pass in instead:
public class OverdueChecker(TimeProvider time)
{
public bool IsOverdue(DateTimeOffset dueDate) => time.GetUtcNow() > dueDate;
}
In production you pass TimeProvider.System. In tests, you pass a fixed or fake clock. Make the change, run the existing behaviour through it, and check nothing moved.
For databases and web services, the same idea applies: put a small interface around the call, so a test can supply known data. Keep each step small and commit after each one.
A framework or package upgrade is the clearest payoff. The code has not changed, but the thing underneath it has. With characterisation tests, you upgrade, run the tests and see exactly which behaviours moved. Without them, you are relying on someone clicking through the application. We will return to upgrading frameworks and keeping dependencies current in later articles.
AI coding assistants are good at drafting this kind of test quickly: reading a method, suggesting the inputs worth trying and writing the scaffolding. That is a real time-saver.
The risk is that they write tests that assert whatever the code does without any thought for whether it is meaningful, and then you hold a large number of tests nobody has read. A person must read every one, run them and decide which behaviours are worth keeping. Treat generated tests as a draft, and check that each would fail if the behaviour changed.
Once characterisation tests exist, the next step is to turn the important ones into written behaviour specifications that the business can read. We will cover that in articles on behaviour-driven development.
Choose one calculation or one integration that would hurt most if it broke. This week, write five characterisation tests for it, using the approach above. Run them, read what they tell you, and keep them. You will have started a safety net, and learned something about the system.
If you would rather have an engineer do this for a system you rely on, that is part of how we take on software through our software care service, which puts automated tests around a system as part of onboarding it.

A fair tour of .NET 11 and C# 15 as released on 10 November 2026, the breaking changes to read first, and a decision guide for production teams on .NET 10.
· 7 min read
As of 10 November 2026, .NET 8 and .NET 9 no longer get security fixes. What has changed, what has not, and a calm 30-day plan for software on them.
· 4 min read
How a non-technical owner can decide what comes first on a product roadmap: plain criteria, simple scoring, outcome-based thinking, and saying no with reasons.
· 6 min read