When you inherit a legacy .NET system that needs modernization, the instinct is to read the code until you understand it, then write tests. That sequence is backward. If the system works in production, you need to lock down its behavior before you refactor anything, and you need to do it without knowing why the code does what it does. Characterization tests solve this problem. They document observed behavior, not intended behavior, and they catch regressions while you're still learning the system.
I've used characterization tests to modernize several legacy .NET systems where the original developers were gone, documentation was sparse, and the only source of truth was production. The goal was not to prove the code was correct. The goal was to prevent silent breakage while I replaced tightly coupled classes, swapped out WCF endpoints for REST APIs, and introduced dependency injection. Characterization tests are the safety net that makes incremental modernization possible.
The Problem With Writing Tests After You Understand the Code
Traditional test-driven development assumes you understand the requirements before you write the test. In a greenfield project, that works. In a legacy system, the requirements are often implicit, encoded in business logic spread across service classes, stored procedures, and configuration files. Reading the code gives you hypotheses, not certainty. If you write tests based on your interpretation, you're testing your assumptions, not the system's actual behavior.
Characterization tests invert this. You call a method with real inputs, observe the output, and encode that output as the expected result. If the output is wrong, the test still passes, because the test is not asserting correctness; it's asserting consistency. Once the test is in place, any change that alters the output will fail the test, forcing you to decide whether the change is intentional. This is especially valuable when modernizing a system that has been patched repeatedly and whose current behavior is the result of years of bug fixes and edge-case handling.
Writing a Characterization Test for a Legacy Method
Start with a method you need to refactor. Don't try to understand it first. Call it with a realistic input, capture the output, and write a test that asserts the output matches. Here's a simplified example from a legacy invoice calculation service I worked with. The method was over 200 lines, touched three databases, and had been modified by at least four developers. No one on the team was confident they understood all the edge cases.
using NUnit.Framework;
using Moq;
using LegacyBilling;
[TestFixture]
public class InvoiceServiceCharacterizationTests
{
[Test]
public void CalculateTotalAmount_WithStandardInvoice_ReturnsObservedValue()
{
// Arrange: Use production-like input
var invoice = new Invoice
{
InvoiceId = 12345,
LineItems = new List<LineItem>
{
new LineItem { Quantity = 2, UnitPrice = 50.00m, TaxRate = 0.08m },
new LineItem { Quantity = 1, UnitPrice = 120.00m, TaxRate = 0.08m }
},
DiscountCode = "SUMMER10"
};
var service = new InvoiceService();
// Act: Call the legacy method
var result = service.CalculateTotalAmount(invoice);
// Assert: Lock down the observed behavior
Assert.AreEqual(216.00m, result.TotalAmount);
Assert.AreEqual(16.00m, result.TaxAmount);
Assert.AreEqual(20.00m, result.DiscountAmount);
}
}
This test does not explain why the total is 216.00. It does not validate that the tax calculation is correct or that the discount logic matches the business rules. It simply records what the method returns today. If you refactor the method and the total changes to 215.50, the test fails, and you have to investigate. Maybe the change is correct, maybe it's a regression. The test forces the decision to be explicit.
Handling Dependencies Without Full Understanding
Legacy methods often depend on services, repositories, or static helpers that make characterization tests difficult to write. You can't easily call the method if it requires a live database or an external API. The temptation is to mock everything, but that requires understanding what the dependencies do, which defeats the purpose of characterization testing.
The pragmatic approach is to use minimal mocking and accept some integration-test characteristics. If the method calls a repository, run the test against a local SQL Server instance with a known dataset. If it calls an external service, record the HTTP response and use a stub. The goal is to isolate the method enough to make the test repeatable, not to achieve perfect unit-test purity. As you modernize the code, you'll replace these integration-like tests with true unit tests, but in the meantime, they prevent regressions.
Here's an example where I used Moq to stub a repository method without fully understanding the data access layer. The legacy code used a static database helper, which I wrapped in an interface so I could inject a mock:
[Test]
public void CalculateTotalAmount_WithDatabaseDiscount_ReturnsObservedValue()
{
// Arrange: Stub the repository to return a known discount
var mockRepo = new Mock<IDiscountRepository>();
mockRepo.Setup(r => r.GetDiscountByCode("SUMMER10"))
.Returns(new Discount { Code = "SUMMER10", Percentage = 0.10m });
var service = new InvoiceService(mockRepo.Object);
var invoice = new Invoice
{
InvoiceId = 12345,
LineItems = new List<LineItem>
{
new LineItem { Quantity = 2, UnitPrice = 50.00m, TaxRate = 0.08m }
},
DiscountCode = "SUMMER10"
};
// Act
var result = service.CalculateTotalAmount(invoice);
// Assert
Assert.AreEqual(97.20m, result.TotalAmount);
}
The mock is shallow. I'm only stubbing the method I know the service calls. If the discount repository has side effects or internal state, the test won't catch them. That's acceptable at this stage. As I refactor, I'll expand the test coverage and remove the integration dependencies, but the characterization test gives me a baseline.
When Characterization Tests Reveal Bugs
Sometimes a characterization test locks down a bug. You write the test, it passes, and then you realize the observed behavior is wrong. The discount calculation rounds incorrectly, the tax rate is applied twice, or the method returns a cached value when it should recompute. This is an uncomfortable moment. You've just written a test that asserts a bug is correct.
The decision is whether to fix the bug now or defer it. If the bug is critical and you have stakeholder approval, fix it and update the test. If the bug has been in production for months and no one has complained, document it in a comment and move on. The purpose of the characterization test is to prevent unintentional changes, not to fix everything at once. You can file a separate issue for the bug and address it in a controlled way after the refactor is complete.
In one legacy modernization, I found a discount calculation that applied the discount after tax instead of before. The characterization test locked down the incorrect behavior. I flagged it in a code comment, continued the refactor, and then worked with the business team to decide when to fix it. The fix eventually required a data migration and customer communication. Trying to fix it during the initial refactor would have derailed the modernization.
Building a Test Suite That Supports Incremental Refactoring
Characterization tests are not the end state. They're scaffolding. As you refactor the legacy code into smaller, testable methods, you replace the characterization tests with unit tests that assert correct behavior, not just observed behavior. The lifecycle looks like this:
- Write characterization tests to lock down the current behavior of a legacy method.
- Refactor the method, keeping the characterization tests green.
- Extract smaller methods and write focused unit tests for them.
- Once the unit tests provide full coverage, delete or reduce the characterization tests.
The key is to keep the characterization tests passing during the refactor. If you change the behavior intentionally, update the test. If the test fails unexpectedly, stop and investigate. The test suite becomes a ratchet that prevents backsliding while you improve the design.
Practical Guidelines for Characterization Testing
Characterization tests work best when you follow a few operational rules. First, use realistic inputs. If the legacy method processes invoices, use an invoice structure that matches production data. Synthetic test data often misses edge cases. Second, start with the methods you plan to refactor first. Don't try to characterize the entire system upfront. Write tests for the seams you're about to change. Third, accept some integration-test characteristics early. You can't fully isolate a legacy method without understanding it, and the whole point is to defer that understanding until the tests are in place.
Fourth, run characterization tests in CI/CD alongside your other tests. If a characterization test fails in a pull request, investigate before merging. Finally, document why the test exists. A comment explaining that the test locks down observed behavior, not intended behavior, prevents future developers from assuming the test validates correctness.
Characterization tests are not elegant. They're not the tests you'd write if you were building the system from scratch. But they're the tests that make legacy modernization safe. They let you refactor with confidence before you fully understand the code, and they turn implicit behavior into explicit contracts. When you're modernizing a .NET system that has been running in production for years, that safety net is the difference between incremental progress and a rewrite that never ships.
Comments
Post a Comment