
Every developer, sooner or later, faces the challenge of refactoring a project that started as a proof of concept but ended up in production.
For me, this is an exciting task. It gives me the opportunity to learn new things, complain about “who the hell wrote this code?”, and apply my knowledge to make the code cleaner and more maintainable.
At the same time, refactoring can be quite tricky—especially when the project is already in production and “works fine”, but adding new features has become difficult, the service is struggling to scale, and worst of all, there are no tests.
Where we begin
This series is built around practical examples. You can find the full code on GitHub.
The OrderService from Lesson-00 has several issues. Despite being a small service, the code is already quite intricate. Technical concerns and business logic are not properly separated into clear abstractions, and cognitive complexity has not been considered at all.
But the most alarming issue? There are absolutely no tests—of any kind.
How can you ensure that the OrderService will still work the same way after refactoring? Would you take the risk of refactoring, merging the Pull Request, and deploying to production?
The importance of Tests
Tests are the key to confidence. A well-designed test suite allows you to develop new features or refactor existing code with the assurance that you can always run the tests to verify whether the code still behaves as expected.
In fact, a failing test can sometimes be reassuring—it means the tests are doing their job, catching issues before they reach production, and giving you time to fix them.
Now that we understand the importance of tests, we should already know the first step before refactoring OrderService: write tests! But what kind of tests?
Unit tests
Unit tests verify the behavior of individual components or units of code in isolation. In object-oriented programming, this usually means testing a single class or method. They’re invaluable during development—they catch bugs early, document how the code works, and make refactoring safer.
Unit tests can also be used with Test-Driven Development (TDD). The idea is simple:
- Write a test before writing any code.
- Run the test—it should fail because the functionality doesn’t exist yet.
- Implement the minimal code needed to make the test pass.
- Refactor if necessary, then repeat.
In TDD, even a compilation error counts as a failure. For example, if you’re testing a class named OrderController, your first test will fail immediately because the class doesn’t exist yet. This forces you to define it, guiding the development process step by step.
Why use unit tests?
- Run tests fast and provide immediate feedback
- Pinpoint exactly where failures occur
- Serve as documentation for expected behavior
- Make refactoring safer by catching regressions
However, unit tests can become a burden when doing major refactoring. Since they’re tightly coupled to implementation details, you might spend more time fixing tests than improving the actual code. That’s why, for this refactoring, we’ll take a different approach.
Integration tests
Integration tests verify the behavior of an entire service, including its interactions with external components such as databases, service buses, and distributed caches.
A common example is, when developing a .NET Web API, an integration test ensures that the entire pipeline, from the HTTP request to the controller, including AspNet middleware, works correctly.
In our case, OrderService.Api requires a significant refactor. As mentioned earlier, writing unit tests in this scenario could be more of a burden than a benefit. However, since OrderService.Api is already in production, we must ensure that its external behavior, particularly its public contracts (REST APIs), remains unchanged during the refactoring process.
This is where integration tests excel. Rather than testing internal implementation details, we test OrderService.Api through its public contracts, ensuring it behaves correctly from the outside. The process can be summarized as follows:
- Invoke a REST API endpoint in OrderService.Api.
- Verify the API response.
- Validate any side effects, such as checking if an order was persisted in the database after making a POST /api/order request.
With integration tests, we treat OrderService.Api as a black box, focusing solely on its external behavior. This allows us to confidently refactor the internal implementation while ensuring that the service continues to function as expected.
Essential .NET libraries for Integration Tests
Here the libraries used in the OrderService.Api example to write OrderService.Api.Integration.Tests
- Test Containers for .NET : used to startup containers with services required for the integration test – in our case Sql Server
- Autofixture : helpfull to create test data
- Respawn : used to reset the database content at each test
- Fluent Assertions : provides fluent methods to assert test results
Setup test environment
Using TestContainers for .NET, we can spin up a lightweight test environment for our integration tests.
Our integration tests are built using XUnit, which provides a mechanism to share context between tests: Fixtures. In this case, the shared context is the test environment itself.
To achieve this, we create a Class Fixture that ensures all test cases in a class use the same test environment. This fixture will:
- Start all required containers
- Boot up the web service, which is our System Under Test (SUT)
- Provide reusable resources like HTTP clients and database connections
Integration Test Fixture
The IntegrationTestFixture manages the lifecycle of the test environment. It initializes the SQL Server container and the OrderService API instance.
namespace OrderService.Api.Integration.Tests.Fixtures;
public sealed class IntegrationTestFixture : IAsyncLifetime
{
public OrderServiceFixture? OrderServiceFixture { get; private set; }
public HttpClient? Client { get; private set; }
public SqlServerFixture SqlServerFixture { get; }
public IntegrationTestFixture()
{
SqlServerFixture = new SqlServerFixture();
}
public async Task InitializeAsync()
{
await SqlServerFixture.InitializeAsync();
OrderServiceFixture = new OrderServiceFixture(SqlServerFixture.GetConnectionString());
Client = OrderServiceFixture.CreateClient();
}
public async Task DisposeAsync()
{
await SqlServerFixture.DisposeAsync();
await (OrderServiceFixture?.DisposeAsync() ?? ValueTask.CompletedTask);
Client?.Dispose();
}
}
Bootstrapping the API for Testing
The OrderServiceFixture is responsible for launching the API in memory using WebApplicationFactory. This is a built-in .NET feature that enables full end-to-end testing without needing a live web server.
One of its key benefits is that it allows us to override configurations—for example, injecting a database connection string from our SQL Server test container.
namespace OrderService.Api.Integration.Tests.Fixtures;
public class OrderServiceFixture : WebApplicationFactory<Program>
{
private readonly string connectionString;
public OrderServiceFixture(string connectionString)
{
this.connectionString = connectionString;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = connectionString
});
});
base.ConfigureWebHost(builder);
}
}
Code language: HTML, XML (xml)
Sql Server Container instance
Sql Server is started in a container instance using Test Containers for .NET
In short, the container is built starting from official image and the SqlServerFixture offers methods to startup and shutdown the container, implementing IAsyncLifetime interface
/// <summary>
/// Represents a fixture that provides a SQL Server instance for testing.
/// Startup SQL Server docker container, create a database, and run migrations.
/// </summary>
public class SqlServerFixture : IAsyncLifetime
{
private readonly MsSqlContainer _sqlContainer;
private const string DATABASE_PASSWORD = "yourStrong(!)Password";
private const string DATABASE_IMAGE = "mcr.microsoft.com/mssql/server:2022-latest";
private const string DATABASE_NAME = "OrderService";
private const string DATABASE_CREATE_SCRIPT = "order-service.sql";
public SqlServerFixture()
{
_sqlContainer = new MsSqlBuilder()
.WithImage(DATABASE_IMAGE)
.WithPassword(DATABASE_PASSWORD)
.Build();
}
public string GetConnectionString()
{
SqlConnectionStringBuilder builder = new(_sqlContainer.GetConnectionString());
builder.InitialCatalog = DATABASE_NAME;
return builder.ConnectionString;
}
public async Task InitializeAsync()
{
await _sqlContainer.StartAsync();
await InitializeDatabaseAsync();
}
private async Task InitializeDatabaseAsync()
{
//Create database, schema and tables
}
public async Task DisposeAsync()
{
await _sqlContainer.StopAsync();
await _sqlContainer.DisposeAsync();
}
}
Code language: HTML, XML (xml)
Implement Integration Tests
Now, let’s implement the integration tests. Our test class requires the IntegrationTestFixture, which is responsible for setting up the test environment. Additionally, since the tests persist data in the database, we use the Respawn library to reset the database after each test execution.
public class OrderControllerIntegrationTests : IClassFixture<IntegrationTestFixture>, IAsyncLifetime
{
private readonly IntegrationTestFixture fixture;
private Respawner? respawner;
private readonly Fixture autoFixture = new();
public OrderControllerIntegrationTests(IntegrationTestFixture fixture)
{
this.fixture = fixture;
}
public async Task DisposeAsync()
{
await respawner!.ResetAsync(fixture.SqlServerFixture.GetConnectionString());
}
public async Task InitializeAsync()
{
respawner = await Respawner.CreateAsync(fixture.SqlServerFixture.GetConnectionString(), new RespawnerOptions(){
TablesToInclude = ["Orders"],
SchemasToInclude = ["Order"]
});
}
//Tests
}
Code language: HTML, XML (xml)
Next, the tests verify all the endpoints implemented in OrderController, aiming to cover all possible cases.
[Fact]
public async Task GetOrders_WhenOrdersListIsEmpty_ThenReturnsOkWithEmptyResult()
{
// Arrange
var response = await fixture.Client!.GetAsync("/api/order");
response.EnsureSuccessStatusCode();
// Act
var orders = await response.Content.ReadFromJsonAsync<List<Order>>();
// Assert
orders.Should().NotBeNull();
orders.Should().BeEmpty();
}
Code language: JavaScript (javascript)
Conclusions
Refactoring a service that is already in production can be challenging—and without proper testing, even risky.
Writing tests might seem like a waste of time at first. Instead of jumping straight into developing a new feature or refactoring code, you have to invest significant effort in creating tests. But this effort is not wasted; it’s an investment. Once tests are in place, every future change becomes faster and safer, allowing for smooth deployments through CI/CD pipelines.
If you ever feel like it’s too late to start writing tests for a production service, remember a common piece of financial advice: The best time to start investing was yesterday. The second-best time is now.