Skip to main content

What Automated Tests Should Protect First in a Legacy .NET Modernization

When you inherit a legacy .NET system with no automated tests, the instinct is to start writing unit tests everywhere. Under time pressure that is the wrong move. The real question is which tests give you enough safety to refactor confidently, without months spent on coverage that never catches a real bug. A useful answer is to protect the places where the system exchanges value with the outside world first, then work inward only as far as the risk demands.

A priority ladder

  1. Critical user journeys. A handful of end-to-end smoke tests for the flows the business cannot afford to lose.
  2. Integration points. APIs, databases, file parsers and external connectors.
  3. Business rules with a history of bugs. The logic that has caused customer-visible errors or manual reconciliation.
  4. Contracts between internal services as you begin splitting the system.
  5. Speed and reliability of the suite itself. A suite nobody runs protects nothing.

1. Start with a few critical journeys

Before touching anything, pick the three to five flows that matter most (place an order, issue an invoice, import a partner file) and automate one test for each at the outermost level you can. They are slow and coarse, but they tell you in minutes whether a refactor broke something that matters. If the code is too tangled for fine-grained tests, these are your net.

2. Protect integration points next

Integration points are where failures hide behind optimistic assumptions. A missing null check in a SOAP or WCF integration does not fail in development; it returns a 500 in production months later, when a partner sends an empty element nobody expected.

  • Databases: run tests against a real database, not a mock. A containerized SQL Server (for example with the Testcontainers library) works in CI on any operating system, whereas LocalDB is Windows-only.
  • External APIs: call your adapter code with recorded response payloads, including edge cases the vendor documentation never mentions: empty collections, nulls, unexpected enum values, error bodies.
  • Stubs for what you own: use test doubles for internal services, and real infrastructure for dependencies you do not control.
[TestFixture]
public class OrderRepositoryTests
{
    private readonly MsSqlContainer _db = new MsSqlBuilder().Build();

    [OneTimeSetUp]
    public async Task StartDatabase() => await _db.StartAsync();

    [OneTimeTearDown]
    public async Task StopDatabase() => await _db.DisposeAsync();

    [Test]
    public async Task Save_Then_Load_ReturnsSameOrder()
    {
        var repo = new OrderRepository(_db.GetConnectionString());
        // arrange, act, assert against a real database
    }
}

These tests are slower than unit tests, but they catch connection handling, schema drift and null values, and they push you to design a testable integration layer, which you need before extracting services anyway.

3. Cover business rules that have burned you

Do not write unit tests for every private method. Write them for the logic that has already caused trouble: an invoice calculation that was wrong twice last year, a report that silently dropped records, a date filter that mishandles time zones. Parameterized tests are well suited to rounding rules and edge cases:

[TestCase(100.00, 0.10, 8.0, 97.20)]
[TestCase(0.00,   0.10, 8.0, 0.00)]
[TestCase(19.99,  0.00, 8.0, 21.59)]
public void CalculateTotal_AppliesDiscountThenTax(
    decimal subtotal, decimal discount, decimal taxPercent, decimal expected)
{
    var total = PricingCalculator.CalculateTotal(subtotal, discount, taxPercent);
    Assert.That(total, Is.EqualTo(expected));
}

Legacy business logic often hides in stored procedures, thick controllers or static helpers. Do not try to test it in place. Extract it into small functions with clear inputs and outputs, and if you cannot extract it safely without tests, write characterization tests first (see characterization tests).

4. Contract tests for internal service boundaries

Splitting a monolith adds new ways to break things: changed request schemas, missing error handling, different assumptions about idempotency. Contract tests catch these early. In consumer-driven contract testing (for example with Pact), the consumer records what it expects from the provider, and the provider verifies that it still satisfies those expectations in its own build. A lighter alternative is to validate requests and responses against a shared OpenAPI or JSON schema in CI.

Contract tests need no shared environment and give fast feedback, especially when different teams own different services. They complement, not replace, integration tests: a failing contract test says the schema is broken; a failing integration test says runtime behavior is broken.

5. Make the suite fast enough to be run

A suite that takes 45 minutes and needs manual setup will not be run. Aim for the following:

  • Run unit and contract tests on every pull request.
  • Run integration tests on merge to the main branch or nightly, with containerized dependencies so nobody has to install SQL Server locally.
  • Tag slow or destructive tests with categories and make a fast subset trivial to run: dotnet test --filter "Category!=Slow" should give a new developer a green result in minutes.
  • Fix or quarantine flaky tests quickly; a test that fails randomly teaches everyone to ignore failures.

Measuring whether tests are any good

Line coverage rewards tests of trivial getters and constructors. If you want a metric, mutation testing (for example Stryker.NET) asks a better question: if the code is deliberately broken, does a test fail? Use it on the modules that matter most rather than across the whole system.

Common pitfalls

  • Testing private methods instead of public contracts.
  • Stubbing the database in tests meant to verify data access.
  • Chasing coverage numbers on trivial code.
  • Skipping error paths: external failures, timeouts and unexpected data are where legacy code is weakest.
  • Building heavyweight test infrastructure nobody maintains. If a setup breaks every few months, simplify it or delete it.

The takeaway

In a legacy modernization, automated tests are less a coverage target than a forcing function for better architecture. Protect the journeys and the boundaries first, cover the business rules that have hurt you, add contract tests as services appear, and keep the suite fast enough that people run it voluntarily. You cannot test your way out of a legacy architecture, but you can build enough safety to change it without fear.

Comments