← Back to DevBytes

Testing Strategies for C# Applications

Introduction to Testing Strategies for C# Applications

Testing is a cornerstone of modern software development. In the C# ecosystem, a well-defined testing strategy ensures that your applications are reliable, maintainable, and scalable. A testing strategy is not just about writing unit tests; it encompasses a holistic approach that includes unit testing, integration testing, end-to-end testing, and everything in between. This tutorial will guide you through the essential testing strategies for C# applications, providing practical code examples and best practices along the way.

What Is a Testing Strategy?

A testing strategy is a comprehensive plan that defines how an application will be tested at various levels. It outlines the types of tests to be written, the tools to be used, the scope of testing, and the processes for maintaining test quality. In C# applications, a typical testing strategy leverages frameworks like xUnit, NUnit, and MSTest, along with mocking libraries such as Moq and NSubstitute.

The most widely adopted model is the Testing Pyramid, which suggests that you should have a large number of fast, isolated unit tests at the base, fewer integration tests in the middle, and a small number of slow, comprehensive end-to-end tests at the top.

Why Testing Strategies Matter

Without a clear testing strategy, teams often fall into common traps: writing too many slow tests, testing the wrong things, or neglecting critical edge cases. A deliberate strategy provides several key benefits:

How to Use Testing Strategies in C#

Implementing a testing strategy in C# involves choosing the right tools and writing tests at the appropriate levels. Let's explore each layer of the testing pyramid with practical examples.

1. Unit Testing

Unit tests verify the behavior of individual methods or classes in isolation. Dependencies are typically replaced with mocks or stubs. xUnit is currently the most popular testing framework in the .NET ecosystem.

Consider a simple DiscountCalculator class:

public class DiscountCalculator
{
    public decimal CalculateDiscount(decimal orderTotal, bool isLoyalCustomer)
    {
        if (orderTotal < 0)
            throw new ArgumentException("Order total cannot be negative.");

        decimal discountRate = isLoyalCustomer ? 0.15m : 0.05m;
        decimal discount = orderTotal * discountRate;

        return Math.Round(discount, 2);
    }
}

Here is how you would write unit tests for this class using xUnit:

using Xunit;

public class DiscountCalculatorTests
{
    private readonly DiscountCalculator _calculator = new DiscountCalculator();

    [Fact]
    public void CalculateDiscount_LoyalCustomer_AppliesFifteenPercent()
    {
        // Arrange
        decimal orderTotal = 100m;
        bool isLoyalCustomer = true;

        // Act
        decimal result = _calculator.CalculateDiscount(orderTotal, isLoyalCustomer);

        // Assert
        Assert.Equal(15m, result);
    }

    [Fact]
    public void CalculateDiscount_NewCustomer_AppliesFivePercent()
    {
        // Arrange
        decimal orderTotal = 100m;
        bool isLoyalCustomer = false;

        // Act
        decimal result = _calculator.CalculateDiscount(orderTotal, isLoyalCustomer);

        // Assert
        Assert.Equal(5m, result);
    }

    [Theory]
    [InlineData(-50)]
    [InlineData(-1)]
    public void CalculateDiscount_NegativeOrderTotal_ThrowsException(decimal orderTotal)
    {
        // Act & Assert
        Assert.Throws<ArgumentException>(() =>
            _calculator.CalculateDiscount(orderTotal, false));
    }
}

Notice the use of [Fact] for single test cases and [Theory] with [InlineData] for parameterized tests. The Arrange-Act-Assert (AAA) pattern keeps tests readable and consistent.

2. Mocking Dependencies with Moq

Real-world classes often depend on external services like databases or APIs. To isolate the unit under test, you mock these dependencies. Moq is the most widely used mocking library in C#.

Suppose you have an OrderService that depends on an IOrderRepository:

public interface IOrderRepository
{
    Order GetById(int id);
    void Save(Order order);
}

public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public void ProcessOrder(int orderId)
    {
        var order = _repository.GetById(orderId);
        if (order == null)
            throw new InvalidOperationException("Order not found.");

        order.Status = "Processed";
        _repository.Save(order);
    }
}

You can test OrderService by mocking the repository:

using Moq;
using Xunit;

public class OrderServiceTests
{
    private readonly Mock<IOrderRepository> _repoMock;
    private readonly OrderService _service;

    public OrderServiceTests()
    {
        _repoMock = new Mock<IOrderRepository>();
        _service = new OrderService(_repoMock.Object);
    }

    [Fact]
    public void ProcessOrder_ValidOrder_UpdatesStatusAndSaves()
    {
        // Arrange
        var order = new Order { Id = 1, Status = "Pending" };
        _repoMock.Setup(r => r.GetById(1)).Returns(order);

        // Act
        _service.ProcessOrder(1);

        // Assert
        Assert.Equal("Processed", order.Status);
        _repoMock.Verify(r => r.Save(order), Times.Once);
    }

    [Fact]
    public void ProcessOrder_OrderNotFound_ThrowsException()
    {
        // Arrange
        _repoMock.Setup(r => r.GetById(99)).Returns((Order)null);

        // Act & Assert
        Assert.Throws<InvalidOperationException>(() => _service.ProcessOrder(99));
        _repoMock.Verify(r => r.Save(It.IsAny<Order>()), Times.Never);
    }
}

The Verify method ensures that the Save method was called the expected number of times, which is crucial for validating side effects.

3. Integration Testing

Integration tests verify that multiple components work together correctly, often including real databases, file systems, or external services. In ASP.NET Core, the WebApplicationFactory class allows you to run your API in-memory for integration testing.

Here is an example of integration testing an ASP.NET Core API endpoint using WebApplicationFactory:

using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using Xunit;

public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductsApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsOkWithProductList()
    {
        // Act
        var response = await _client.GetAsync("/api/products");

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var products = await response.Content.ReadFromJsonAsync<List<Product>>();
        Assert.NotNull(products);
        Assert.NotEmpty(products);
    }

    [Fact]
    public async Task GetProduct_WithValidId_ReturnsProduct()
    {
        // Act
        var response = await _client.GetAsync("/api/products/1");

        // Assert
        response.EnsureSuccessStatusCode();
        var product = await response.Content.ReadFromJsonAsync<Product>();
        Assert.Equal(1, product.Id);
    }
}

For database integration tests, it is best practice to use a real database in a container (such as SQL Server in Docker via Testcontainers) rather than an in-memory provider, because in-memory databases do not accurately replicate relational database behavior.

4. Testing with Entity Framework Core

When testing code that uses EF Core, you can use the in-memory database for simple scenarios or SQLite in-memory mode for closer-to-production behavior. Here is an example using SQLite:

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Xunit;

public class ProductRepositoryTests : IDisposable
{
    private readonly SqliteConnection _connection;
    private readonly AppDbContext _context;

    public ProductRepositoryTests()
    {
        _connection = new SqliteConnection("DataSource=:memory:");
        _connection.Open();

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite(_connection)
            .Options;

        _context = new AppDbContext(options);
        _context.Database.EnsureCreated();
    }

    [Fact]
    public async Task GetById_ExistingProduct_ReturnsProduct()
    {
        // Arrange
        _context.Products.Add(new Product { Id = 1, Name = "Laptop", Price = 999m });
        await _context.SaveChangesAsync();

        var repository = new ProductRepository(_context);

        // Act
        var result = await repository.GetById(1);

        // Assert
        Assert.NotNull(result);
        Assert.Equal("Laptop", result.Name);
    }

    public void Dispose()
    {
        _context.Dispose();
        _connection.Dispose();
    }
}

5. End-to-End Testing

End-to-end (E2E) tests validate entire user flows from start to finish. For web applications, tools like Playwright or Selenium are commonly used. Here is a simple Playwright example for a C# web application:

using Microsoft.Playwright;
using Xunit;

public class UserRegistrationE2ETests
{
    [Fact]
    public async Task UserCanRegister_WithValidData()
    {
        using var playwright = await Playwright.CreateAsync();
        await using var browser = await playwright.Chromium.LaunchAsync();
        var page = await browser.NewPageAsync();

        // Navigate to registration page
        await page.GotoAsync("https://localhost:5001/register");

        // Fill in the form
        await page.FillAsync("#email", "testuser@example.com");
        await page.FillAsync("#password", "SecurePass123!");
        await page.FillAsync("#confirmPassword", "SecurePass123!");

        // Submit
        await page.ClickAsync("#registerButton");

        // Verify success
        await page.WaitForSelectorAsync(".success-message");
        var successText = await page.TextContentAsync(".success-message");
        Assert.Contains("Registration successful", successText);
    }
}

E2E tests are powerful but slow and brittle, so they should be kept to a minimum and focused on critical user journeys.

Best Practices for C# Testing

To get the most out of your testing strategy, follow these established best practices:

Conclusion

A well-crafted testing strategy is essential for building robust C# applications. By layering your tests according to the testing pyramid—starting with fast, isolated unit tests, adding meaningful integration tests, and finishing with a small set of end-to-end tests—you create a safety net that catches bugs early while keeping your feedback loop fast. Leveraging tools like xUnit, Moq, WebApplicationFactory, and Playwright, you can cover every layer of your application from individual methods to full user journeys. Remember that testing is an ongoing investment: as your application evolves, so should your tests. By adhering to best practices and continuously refining your approach, you will build software that is not only functional today but also maintainable and trustworthy for years to come.

— Ad —

Google AdSense will appear here after approval

← Back to all articles