docs/en/Community-Articles/2025-09-17-Integration-Testing-Best-Practices-for-Building-a-Robust-Application-Layer/article.md
For software development purposes, it is not sufficient to validate individual components individually in a vacuum. In practical usage, programs consist of holistic systems comprising a myriad of components such as databases, services, APIs, and existing tools, which must work together. An application would fail testing parameters for an individual component sufficiently but fail as a whole if such components fail to interact appropriately.
Whereas unit tests verify individual parts one by one, integration tests confirm software reliability with integrated parts. Integration testing guarantees that the overall system performs as per design parameters whenever parts of the system are interfederated.
Why Integration Testing Is Important:
Example: Detailed Integration Test with Dependency Injection
[Fact]
public async Task OrderCreation_WithValidInput_ShouldPersistAndReturnSuccess()
{
// Set up: Use a complete Test Server
var factory = new WebApplicationFactory<Startup>();
var client = factory.CreateClient();
// Do:
var request = new CreateOrderRequest { ProductId = 1, Quantity = 2 };
var response = await client.PostAsJsonAsync("/api/orders", request);
// Check
response.EnsureSuccessStatusCode(); // Stops if the code isn't successful
var order = await response.Content.ReadFromJsonAsync<Order>();
Assert.NotNull(order);
Assert.Equal(2, order.Quantity);
}
This example uses a memory TestServer to act like the full HTTP process, from the controller to the database, for a more real test.
Integration tests should happen in their own area to prevent:
Tips for Keeping Tests Separate:
Example: Using Actions to Keep Things Separate
[Fact]
public async Task OrderCreation_RollsBack_AfterTest()
{
// Set up: Start an action that will be undone later
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
var service = new OrderService(_dbContext);
var result = service.CreateOrder(1, 2);
Assert.True(result.IsSuccess);
// Do & Check
var orderCount = await _dbContext.Orders.CountAsync();
Assert.Equal(1, orderCount);
// Clean up: Undo the action to reset all changes
await transaction.RollbackAsync();
}
Using actions makes sure tests stay separate and can run at the same time safely.
Start data gives a steady base for tests. Include complex links for real situations.
// Use a special way to get the data started
private static async Task SeedData(AppDbContext dbContext)
{
await dbContext.Products.AddRangeAsync(
new Product { Id = 1, Name = "Coffee", Price = 5, Stock = 50 },
new Product { Id = 2, Name = "Tea", Price = 3, Stock = 20 }
);
await dbContext.Users.AddAsync(new User { Id = 1, Name = "Alice", IsActive = true });
await dbContext.Orders.AddAsync(new Order { Id = 1, UserId = 1, ProductId = 1, Quantity = 2 });
await dbContext.SaveChangesAsync();
}
Tip: Use the Builder style or a special TestSeeder class to make detailed, reusable data setups.
Integration tests should copy what real users do to check the whole request-response process.
// Detailed API Test with CancellationToken and Custom Headers
var request = new CreateOrderRequest { ProductId = 1, Quantity = 2 };
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/orders")
{
Content = JsonContent.Create(request)
};
requestMessage.Headers.Add("X-Request-Id", Guid.NewGuid().ToString());
// Act like a timeout is happening
var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
await Assert.ThrowsAsync<TaskCanceledException>(async () =>
{
await _client.SendAsync(requestMessage, cts.Token);
});
Test both normal uses and unusual ones like timeouts, bad requests, and too many users at once for a truly reliable API.
Use copies or fake versions for services like payment systems, emails, or other APIs to keep your application logic separate.
// Set up: Copy an outside payment service
var paymentServiceMock = new Mock<IPaymentService>();
paymentServiceMock.Setup(x => x.ProcessPayment(It.IsAny<decimal>(), It.IsAny<string>()))
.ThrowsAsync(new TimeoutException("Payment gateway timeout"));
// Do: Put the copy into the test area
services.AddScoped(_ => paymentServiceMock.Object);
// Check: See how the system handles the timeout
var response = await _client.PostAsJsonAsync("/api/payments", new { Amount = 100 });
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
Assert.Contains("Payment service unavailable", await response.Content.ReadAsStringAsync());
Mimicking lets you act like things are failing (like APIs timing out or services being down) and check that your application handles these issues well without using real network calls.
A good test set has full coverage of both successful actions and error handling.
// Normal use: A successful order creation
var validOrder = new CreateOrderRequest { ProductId = 1, Quantity = 1 };
var successResponse = await _client.PostAsJsonAsync("/api/orders", validOrder);
Assert.Equal(HttpStatusCode.Created, successResponse.StatusCode);
// Bad request: Wrong product ID
var invalidProductOrder = new CreateOrderRequest { ProductId = 999, Quantity = 1 };
var badProductResponse = await _client.PostAsJsonAsync("/api/orders", invalidProductOrder);
Assert.Equal(HttpStatusCode.NotFound, badProductResponse.StatusCode);
// Conflict: Not enough stock
var largeOrder = new CreateOrderRequest { ProductId = 1, Quantity = 1000 };
var stockResponse = await _client.PostAsJsonAsync("/api/orders", largeOrder);
Assert.Equal(HttpStatusCode.Conflict, stockResponse.StatusCode);
Testing everything makes sure your application layer gives helpful and correct error messages to users.
Keep test results steady by making sure each test is totally separate from others.
// Use a special cleanup method or a test setup
public class OrderTests : IDisposable
{
private readonly AppDbContext _dbContext;
public OrderTests()
{
_dbContext = new AppDbContext(GetDbContextOptions());
// Setup logic here
}
// Cleanup logic
public void Dispose()
{
_dbContext.Database.EnsureDeleted();
_dbContext.Dispose();
}
}
Clean up automatically for tests that run at the same time using IDisposable or by putting each test in a database action.
Automate tests in CI/CD lines for regular, consistent checking.
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x
- name: Build
run: dotnet build --configuration Release
- name: Run Integration Tests
run: dotnet test --filter Category=Integration --logger trx;LogFileName=testresults.trx
Regular testing finds integration problems early, saving effort over time.
[Fact]
public async Task CompleteOrderWorkflow_ShouldPersistAllSteps()
{
// Set up: Start complex data for a full action
await SeedData(_dbContext);
// Do: Act like a real user request
var request = new CreateOrderRequest { ProductId = 1, Quantity = 2, UserId = 1 };
var response = await _client.PostAsJsonAsync("/api/orders", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
// Check 1: See the database state
var order = await _dbContext.Orders.FirstOrDefaultAsync(o => o.UserId == 1 && o.ProductId == 1);
Assert.NotNull(order);
Assert.Equal(2, order.Quantity);
// Check 2: See side effects (like stock going down)
var product = await _dbContext.Products.FirstOrDefaultAsync(p => p.Id == 1);
Assert.Equal(48, product.Stock);
}
šŖ Cookie Comparison
This shows how a good application layer uses new and old data well together.