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. But modernizing production systems under time pressure forces a harder question: which tests give you enough safety to refactor confidently without spending months writing coverage that never catches real bugs? I've modernized legacy .NET systems into testable architectures using .NET 6, microservices, and CI/CD pipelines, and the answer is not "test everything equally." The answer is to protect the boundaries where your system exchanges value with the outside world, then work inward only as far as velocity demands.

Test the Integration Points That Break Silently

The highest-value tests in a legacy modernization protect integration points: REST APIs, database queries, WCF service calls, file parsers, and external connectors. These are the places where production failures hide behind optimistic assumptions. A missing null check in a WCF integration does not fail loudly during development. It returns a 500 in production three months later when a partner sends an unexpected empty element.

Start with integration tests that exercise real external boundaries using test doubles for dependencies you control and real infrastructure for dependencies you do not. Use nUnit or xUnit with Moq to stub internal services, but run your database tests against a real SQL Server container or LocalDB instance. If your legacy system talks to an external REST API, write tests that call your adapter code with recorded response payloads, including edge cases the vendor documentation does not mention.

These tests are slower and more brittle than pure unit tests, but they catch the bugs that matter: connection timeouts, schema drift, unexpected null values, and authentication token expiration. They also force you to design testable integration layers, which is the architecture work you need to do anyway before you can safely extract microservices.

Cover Business Logic Only After the Boundaries Are Safe

Once integration points have test coverage, move to business logic that has burned you in production. Do not write unit tests for every private method. Write tests for the domain logic that has caused customer-visible bugs, regulatory violations, or manual reconciliation work. If your invoicing calculation has been wrong twice in the past year, write parameterized tests that cover edge cases, rounding behavior, and timezone handling. If your reporting pipeline has silently dropped records, write tests that assert row counts and key integrity constraints.

Legacy systems often bury business logic inside stored procedures, thick controllers, or static utility classes. Do not try to unit-test these in place. Extract the logic into small, testable functions with clear inputs and outputs, then write the tests. If extracting the logic safely requires integration tests first, write those integration tests. The goal is not perfect coverage. The goal is to make the next refactoring safer than the last one.

Use Contract Tests for Internal Service Boundaries

When you start splitting a legacy monolith into microservices, you create new failure modes: breaking changes in request schemas, missing error handling, and mismatched assumptions about idempotency. Contract tests catch these failures early. A contract test runs against both the client and the server, asserting that the client sends requests the server expects and that the server returns responses the client can parse.

For .NET microservices communicating over REST, write contract tests using the same testing framework you already use. Serialize a request DTO from the client, deserialize it on the server, and assert that required fields are present and types match. Do the same in reverse for responses. These tests are fast, require no infrastructure, and catch breaking changes before deployment. They are especially valuable when teams own different services and coordinate through pull requests rather than shared standups.

Contract tests do not replace integration tests, but they give you a faster feedback loop. If a contract test fails, you know the schema is broken. If an integration test fails, you know the runtime behavior is broken. Both matter, but schema breaks are cheaper to catch early.

Automate the Tests You Actually Run

A test suite that takes 45 minutes to run and requires manual setup will not get run. Optimize for fast feedback and zero-friction execution. Run unit and contract tests on every pull request using GitHub Actions or Azure DevOps pipelines. Run integration tests nightly or on merge to main, using containerized dependencies so developers do not need to install SQL Server and Redis locally.

If your integration tests are too slow, parallelize them or split them into critical-path tests that run on every commit and extended tests that run nightly. Use test categories or tags to mark destructive tests, slow tests, and tests that require production-like infrastructure. Make it trivial for a new developer to check out the repository, run dotnet test --filter Category!=Slow, and see green results in under two minutes.

When tests are fast and reliable, they become a refactoring tool. When they are slow or flaky, they become a checkbox exercise that everyone routes around.

Common Pitfalls

  • Writing unit tests for private methods instead of public contracts. Private methods change frequently during refactoring. Test the public API and let the internals evolve.
  • Mocking database calls in integration tests. You cannot trust an integration test that stubs the database. Use a real instance with test data and clean up after each test.
  • Achieving high coverage on trivial getter and setter logic. Coverage metrics reward testing property accessors and constructors. Focus on logic that has caused production bugs.
  • Skipping tests for error paths and edge cases. Legacy systems often lack error handling. Write tests that assert behavior when external services fail, return unexpected data, or time out.
  • Building test infrastructure no one maintains. If your integration tests require a complex Docker Compose setup that breaks every three months, simplify or delete them. Tests that do not run have no value.

The Honest Takeaway

Automated tests in a legacy modernization are a forcing function for better architecture, not a coverage target. Protect integration points first because they hide the most expensive production failures. Cover business logic that has burned you, not every method in the codebase. Use contract tests to catch breaking changes between services. Make the test suite fast enough that developers run it voluntarily, not because CI forces them to. You cannot test your way out of legacy architecture, but you can build enough safety to refactor without fear. Start with the boundaries where your system meets the world, and work inward only as far as production risk demands.

Comments