๐Ÿงช

Test Architecture and Strategy

Testing Advanced 7 min read 1200 words

Comprehensive guide to testing strategy, test architecture, and quality assurance approaches for .NET applications

System Design Testing

Test Architecture and Strategy

Overview

Test architecture defines how tests are organized, executed, and maintained. A well-designed testing strategy ensures adequate coverage while optimizing for speed, reliability, and maintainability.


Table of Contents


Testing Pyramid

The testing pyramid is a metaphor for the ideal distribution of different test types.

                    /\
                   /  \
                  / E2E\        < Few, slow, expensive
                 /------\
                /  Integ \      < Some, medium speed
               /----------\
              /    Unit    \    < Many, fast, cheap
             /--------------\

Distribution Guidelines

Test Type % of Tests Execution Time Scope
Unit 70% Milliseconds Single function/class
Integration 20% Seconds Component interactions
E2E 10% Minutes Full user workflows

Unit Tests

// Fast, isolated, many
[Fact]
public void CalculateDiscount_PremiumCustomer_Returns20Percent()
{
    var calculator = new DiscountCalculator();
    var result = calculator.Calculate(100m, CustomerType.Premium);
    Assert.Equal(20m, result);
}

Characteristics:

  • Test single units (methods, functions)
  • No external dependencies (database, network)
  • Run in milliseconds
  • Provide immediate feedback

Integration Tests

// Test component interactions
[Fact]
public async Task CreateOrder_SavesOrderToDatabase()
{
    await using var context = CreateTestDbContext();
    var repository = new OrderRepository(context);
    var service = new OrderService(repository);

    var order = new Order { CustomerId = 1, Total = 100m };
    await service.CreateAsync(order);

    var saved = await context.Orders.FindAsync(order.Id);
    Assert.NotNull(saved);
}

Characteristics:

  • Test multiple components together
  • May use real database (in-memory or containers)
  • Run in seconds
  • Catch integration bugs

E2E Tests

// Test complete user workflows
[Fact]
public async Task Checkout_CompletePurchaseFlow()
{
    await Page.GotoAsync("/products");
    await Page.GetByTestId("product-1").ClickAsync();
    await Page.GetByRole(AriaRole.Button, new() { Name = "Add to Cart" }).ClickAsync();
    await Page.GetByRole(AriaRole.Link, new() { Name = "Checkout" }).ClickAsync();
    await FillShippingInfo();
    await FillPaymentInfo();
    await Page.GetByRole(AriaRole.Button, new() { Name = "Place Order" }).ClickAsync();
    await Expect(Page.GetByText("Order Confirmed")).ToBeVisibleAsync();
}

Characteristics:

  • Test full user journeys
  • Use real browser automation
  • Run in minutes
  • Verify end-to-end behavior

Testing Trophy

An alternative to the pyramid, emphasizing integration tests.

            _______
           / Static\
          /   Types \
         /___________\
        |             |
        | Integration |    < Primary focus
        |_____________|
       /               \
      /      Unit       \
     /___________________|
    |                     |
    |        E2E          |
    |_____________________|

Trophy Distribution

Test Type Emphasis Rationale
Static High Types prevent many bugs at compile time
Integration Highest Best confidence-to-effort ratio
Unit Medium For complex business logic
E2E Low Critical paths only

When to Prefer Trophy

  1. Full-stack applications with complex integrations
  2. Microservices where boundaries matter
  3. Rapid development where integration bugs are common
  4. Brownfield projects with limited test coverage

Test Categories

Functional Testing

// Tests what the system does
[Fact]
public void CreateOrder_WithValidData_ReturnsOrderId()
{
    var service = new OrderService();
    var order = new CreateOrderRequest { CustomerId = 1 };

    var result = service.Create(order);

    Assert.NotEqual(Guid.Empty, result.OrderId);
}

Non-Functional Testing

Performance Testing

[Fact]
public void CalculateTotal_LargeOrder_CompletesWithinThreshold()
{
    var calculator = new OrderCalculator();
    var items = Enumerable.Range(1, 10000)
        .Select(i => new OrderItem { Price = 10m, Quantity = 1 })
        .ToList();

    var sw = Stopwatch.StartNew();
    calculator.CalculateTotal(items);
    sw.Stop();

    Assert.True(sw.ElapsedMilliseconds < 100, "Should complete in under 100ms");
}

Load Testing (with k6)

// k6 script
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
    vus: 100,
    duration: '5m',
    thresholds: {
        http_req_duration: ['p(95)<500'],
        http_req_failed: ['rate<0.01'],
    },
};

export default function () {
    const res = http.get('https://api.example.com/orders');
    check(res, { 'status is 200': (r) => r.status === 200 });
    sleep(1);
}

Security Testing

[Fact]
public async Task Login_SqlInjectionAttempt_ReturnsUnauthorized()
{
    var client = _factory.CreateClient();
    var payload = new LoginRequest
    {
        Email = "'; DROP TABLE Users; --",
        Password = "password"
    };

    var response = await client.PostAsJsonAsync("/api/auth/login", payload);

    Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    // Verify database wasn't affected
}

Regression Testing

// Automated suite run before releases
[Collection("Regression")]
public class OrderRegressionTests
{
    [Fact]
    public async Task Legacy_OrderCreation_StillWorks() { }

    [Fact]
    public async Task Legacy_Discount_CalculatesCorrectly() { }

    [Fact]
    public async Task Legacy_EmailNotification_Sends() { }
}

Smoke Testing

// Quick sanity checks after deployment
[Collection("Smoke")]
public class SmokeTests
{
    [Fact]
    public async Task HealthEndpoint_ReturnsOk()
    {
        var response = await _client.GetAsync("/health");
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }

    [Fact]
    public async Task Database_IsConnected()
    {
        var response = await _client.GetAsync("/health/db");
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }

    [Fact]
    public async Task Homepage_Loads()
    {
        var response = await _client.GetAsync("/");
        Assert.True(response.IsSuccessStatusCode);
    }
}

When to Write Each Test Type

Decision Matrix

Scenario Unit Integration E2E
Pure business logic Primary - -
Data transformations Primary - -
API endpoint behavior - Primary Backup
Database operations Mock Primary -
External service calls Mock Primary -
Critical user flows - - Primary
UI interactions - - Primary
Cross-service communication - Primary Backup
Authentication flows - Primary Primary

The Test Decision Flowchart

Is it pure business logic?
โ”œโ”€โ”€ Yes โ†’ Unit test
โ””โ”€โ”€ No โ†’ Does it involve external systems?
    โ”œโ”€โ”€ Yes โ†’ Integration test (mock if expensive)
    โ””โ”€โ”€ No โ†’ Is it a critical user journey?
        โ”œโ”€โ”€ Yes โ†’ E2E test
        โ””โ”€โ”€ No โ†’ Integration or unit based on complexity

Examples by Feature

Order Discount Calculation

// Unit test - Pure logic
[Theory]
[InlineData(CustomerType.Regular, 0)]
[InlineData(CustomerType.Premium, 10)]
[InlineData(CustomerType.VIP, 20)]
public void GetDiscountPercent_ByCustomerType(CustomerType type, int expected)
{
    var discount = DiscountCalculator.GetPercent(type);
    Assert.Equal(expected, discount);
}

Order API Endpoint

// Integration test - API + Database
[Fact]
public async Task CreateOrder_ValidOrder_SavesAndReturnsCreated()
{
    var client = _factory.CreateClient();
    var order = new CreateOrderRequest { CustomerId = 1, Items = ... };

    var response = await client.PostAsJsonAsync("/api/orders", order);

    Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}

Complete Checkout Flow

// E2E test - Full user journey
[Fact]
public async Task User_CanCompleteCheckout()
{
    await LoginAsTestUser();
    await AddProductToCart("SKU-123");
    await ProceedToCheckout();
    await CompletePayment();
    await VerifyOrderConfirmation();
}

Test Coverage Strategies

Types of Coverage

Metric Description Target
Line Coverage Lines executed 70-80%
Branch Coverage Decision paths 60-70%
Method Coverage Methods called 80-90%
Condition Coverage Boolean sub-expressions 50-60%
Path Coverage Unique execution paths Impractical for 100%

Coverage Tools

# Using Coverlet
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura

# Generate HTML report
reportgenerator -reports:coverage.cobertura.xml -targetdir:coverage-report

Coverage Configuration

<!-- Directory.Build.props -->
<PropertyGroup>
  <CollectCoverage>true</CollectCoverage>
  <CoverletOutputFormat>cobertura</CoverletOutputFormat>
  <ExcludeByAttribute>
    GeneratedCodeAttribute,
    ExcludeFromCodeCoverageAttribute
  </ExcludeByAttribute>
  <ExcludeByFile>
    **/*.Designer.cs,
    **/Migrations/*.cs
  </ExcludeByFile>
</PropertyGroup>

Coverage Anti-Patterns

// DON'T: Test trivial code just for coverage
public class PersonTests
{
    [Fact]
    public void FullName_ReturnsFirstAndLastName()
    {
        var person = new Person { FirstName = "John", LastName = "Doe" };
        Assert.Equal("John Doe", person.FullName);  // Testing a simple concatenation
    }
}

// DON'T: Write tests that don't assert anything meaningful
[Fact]
public void Constructor_DoesNotThrow()
{
    var service = new OrderService();  // No assertion, just coverage
}

// DO: Focus on behavior, not implementation
[Fact]
public void CalculateShipping_FreeForOrdersOver100()
{
    var result = _calculator.CalculateShipping(orderTotal: 150m);
    Assert.Equal(0m, result);
}

Risk-Based Coverage

Prioritize coverage based on:

  1. Business criticality: Payment processing > user preferences
  2. Change frequency: Often-modified code needs more tests
  3. Complexity: Complex algorithms need thorough testing
  4. Historical bugs: Areas with past issues need regression tests

Contract Testing

What is Contract Testing?

Contract testing verifies that services communicate correctly by testing against a shared contract rather than the actual service.

Consumer            Contract            Provider
   โ”‚                   โ”‚                   โ”‚
   โ”œโ”€โ”€ Generates โ”€โ”€โ”€โ”€โ”€โ–บโ”‚                   โ”‚
   โ”‚                   โ”‚โ—„โ”€โ”€ Validates โ”€โ”€โ”€โ”€โ”€โ”ค
   โ”‚                   โ”‚                   โ”‚

Consumer-Driven Contract Testing with Pact

Consumer Side

// Install: dotnet add package PactNet

public class OrderServiceConsumerTests
{
    private readonly IPactBuilderV4 _pactBuilder;

    public OrderServiceConsumerTests()
    {
        var pact = Pact.V4("OrderAPI-Consumer", "OrderAPI-Provider", new PactConfig());
        _pactBuilder = pact.WithHttpInteractions();
    }

    [Fact]
    public async Task GetOrder_ReturnsOrder()
    {
        var orderId = Guid.Parse("12345678-1234-1234-1234-123456789012");

        _pactBuilder
            .UponReceiving("A request for an order")
            .Given("Order exists")
            .WithRequest(HttpMethod.Get, $"/api/orders/{orderId}")
            .WillRespond()
            .WithStatus(HttpStatusCode.OK)
            .WithJsonBody(new
            {
                id = orderId,
                customerId = 1,
                total = 100.00m,
                status = "Pending"
            });

        await _pactBuilder.VerifyAsync(async ctx =>
        {
            var client = new HttpClient { BaseAddress = ctx.MockServerUri };
            var orderClient = new OrderApiClient(client);

            var order = await orderClient.GetOrderAsync(orderId);

            Assert.Equal(orderId, order.Id);
            Assert.Equal(100.00m, order.Total);
        });
    }
}

Provider Side

public class OrderServiceProviderTests
{
    [Fact]
    public void VerifyPactWithConsumer()
    {
        var config = new PactVerifierConfig();
        var pactVerifier = new PactVerifier(config);

        pactVerifier
            .ServiceProvider("OrderAPI-Provider", new Uri("http://localhost:5000"))
            .WithPactBrokerSource(new Uri("https://pact-broker.example.com"))
            .WithProviderStateUrl(new Uri("http://localhost:5000/provider-states"))
            .Verify();
    }
}

Benefits of Contract Testing

  1. Fast feedback: No need to deploy full environment
  2. Independent deployment: Teams can deploy without coordination
  3. Breaking change detection: Catch incompatibilities early
  4. Documentation: Contracts serve as API documentation

Mutation Testing

What is Mutation Testing?

Mutation testing evaluates test quality by introducing bugs (mutations) and checking if tests detect them.

Original Code          Mutant               Test Result
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€       โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€     โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if (x > 0)        โ†’   if (x >= 0)          Killed/Survived?
return x + y;     โ†’   return x - y;        Killed/Survived?

Using Stryker.NET

# Install
dotnet tool install -g dotnet-stryker

# Run
dotnet stryker --project src/MyProject.csproj

Configuration

// stryker-config.json
{
  "stryker-config": {
    "project": "MyProject.csproj",
    "solution": "MySolution.sln",
    "test-projects": ["MyProject.Tests.csproj"],
    "mutate": ["!**/bin/**", "!**/obj/**"],
    "reporters": ["html", "progress", "dashboard"],
    "thresholds": {
      "high": 80,
      "low": 60,
      "break": 50
    }
  }
}

Mutation Operators

Operator Original Mutant
Arithmetic a + b a - b
Relational a > b a >= b
Logical a && b a || b
Negation if (x) if (!x)
Return return true return false

Interpreting Results

Mutation Score = Killed Mutations / Total Mutations ร— 100%

Score    Interpretation
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
90%+     Excellent test quality
70-90%   Good test quality
50-70%   Needs improvement
<50%     Poor test quality

Testing Microservices

Testing Strategies

                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚           End-to-End Tests               โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚  Contract   โ”‚  โ”‚  Contract   โ”‚
                    โ”‚   Tests     โ”‚  โ”‚   Tests     โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   Service A     โ”‚  โ”‚   Service B     โ”‚  โ”‚   Service C     โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚  โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚  โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ Integration โ”‚ โ”‚  โ”‚ โ”‚ Integration โ”‚ โ”‚  โ”‚ โ”‚ Integration โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚  โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚  โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚  โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚  โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚    Unit     โ”‚ โ”‚  โ”‚ โ”‚    Unit     โ”‚ โ”‚  โ”‚ โ”‚    Unit     โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚  โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚  โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Service Testing

// Test service in isolation with mocked dependencies
public class OrderServiceTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public OrderServiceTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace external service clients with mocks
                services.AddSingleton<IPaymentServiceClient>(
                    Substitute.For<IPaymentServiceClient>());
                services.AddSingleton<IInventoryServiceClient>(
                    Substitute.For<IInventoryServiceClient>());
            });
        });
    }

    [Fact]
    public async Task CreateOrder_CallsPaymentAndInventory()
    {
        var client = _factory.CreateClient();
        // Test service behavior with mocked dependencies
    }
}

Component Testing

// Test component with real database but mocked external services
public class OrderComponentTests : IClassFixture<OrderServiceFactory>
{
    [Fact]
    public async Task OrderFlow_FromCreationToCompletion()
    {
        // Test complete component behavior
        await CreateOrder();
        await ProcessPayment();
        await UpdateInventory();
        await SendNotification();
        await VerifyOrderState();
    }
}

Cross-Service Testing

// Use Docker Compose for multi-service tests
public class CrossServiceTests : IAsyncLifetime
{
    private DockerComposeCompositeService _compose;

    public async Task InitializeAsync()
    {
        _compose = new DockerComposeCompositeService(
            new DirectoryInfo("."),
            new[]
            {
                "docker-compose.yml",
                "docker-compose.test.yml"
            });
        await _compose.StartAsync();
    }

    [Fact]
    public async Task OrderCreation_TriggersInventoryReservation()
    {
        // Test cross-service behavior
    }

    public async Task DisposeAsync()
    {
        await _compose.StopAsync();
    }
}

Testing Event-Driven Systems

Event Testing Patterns

Testing Event Publishers

public class OrderServiceEventTests
{
    [Fact]
    public async Task CreateOrder_PublishesOrderCreatedEvent()
    {
        // Arrange
        var events = new List<IEvent>();
        var eventBus = Substitute.For<IEventBus>();
        eventBus.PublishAsync(Arg.Do<IEvent>(e => events.Add(e)));

        var service = new OrderService(eventBus);

        // Act
        await service.CreateOrderAsync(new CreateOrderRequest());

        // Assert
        Assert.Single(events);
        Assert.IsType<OrderCreatedEvent>(events[0]);
    }
}

Testing Event Handlers

public class PaymentReceivedHandlerTests
{
    [Fact]
    public async Task Handle_UpdatesOrderStatus()
    {
        // Arrange
        var repository = new FakeOrderRepository();
        var handler = new PaymentReceivedHandler(repository);
        var order = new Order { Id = Guid.NewGuid(), Status = OrderStatus.Pending };
        await repository.SaveAsync(order);

        var @event = new PaymentReceivedEvent { OrderId = order.Id };

        // Act
        await handler.HandleAsync(@event);

        // Assert
        var updated = await repository.GetByIdAsync(order.Id);
        Assert.Equal(OrderStatus.Paid, updated.Status);
    }
}

Testing Event Flows

public class EventFlowTests
{
    [Fact]
    public async Task OrderFlow_FromCreationToShipment()
    {
        // Use in-memory event bus for testing
        var eventBus = new InMemoryEventBus();
        var handlers = new EventHandlerRegistry();

        // Register handlers
        handlers.Register<OrderCreatedEvent>(new InventoryHandler());
        handlers.Register<InventoryReservedEvent>(new PaymentHandler());
        handlers.Register<PaymentReceivedEvent>(new ShippingHandler());

        // Trigger flow
        await eventBus.PublishAsync(new OrderCreatedEvent { OrderId = orderId });

        // Wait for processing
        await eventBus.WaitForCompletion(TimeSpan.FromSeconds(5));

        // Verify final state
        var order = await _orderRepository.GetByIdAsync(orderId);
        Assert.Equal(OrderStatus.Shipped, order.Status);
    }
}

Saga Testing

public class OrderSagaTests
{
    [Fact]
    public async Task OrderSaga_PaymentFails_CompensatesInventory()
    {
        // Arrange
        var saga = new OrderSaga(
            _orderService,
            _inventoryService,
            _paymentService);

        _paymentService.ProcessAsync(Arg.Any<Payment>())
            .ThrowsAsync(new PaymentFailedException());

        // Act
        await saga.ExecuteAsync(new CreateOrderCommand());

        // Assert - Compensation was called
        await _inventoryService.Received().ReleaseReservationAsync(Arg.Any<Guid>());
        await _orderService.Received().CancelAsync(Arg.Any<Guid>());
    }
}

QA vs Developer Testing

Responsibility Matrix

Testing Type Developer QA
Unit Tests Primary Review
Integration Tests Primary Augment
API Tests Primary Augment
E2E Tests Support Primary
Exploratory Testing - Primary
Performance Testing Support Primary
Security Testing Support Primary
Accessibility Testing Awareness Primary

Shift-Left Testing

Traditional:
Code โ†’ Code Review โ†’ QA Testing โ†’ Bug Fixes โ†’ Release
                         โ†“
                    Late discovery
                    Expensive fixes

Shift-Left:
Code + Tests โ†’ Code Review โ†’ Automated Tests โ†’ Release
     โ†“
Early discovery
Cheap fixes

Test Ownership

// Developer-owned: Unit and integration tests
namespace OrderService.Tests
{
    public class OrderValidatorTests { }
    public class OrderRepositoryTests { }
    public class OrderApiTests { }
}

// QA-owned: E2E and exploratory test scenarios
namespace OrderService.E2ETests
{
    public class CheckoutFlowTests { }
    public class EdgeCaseScenarios { }
}

// Shared: Regression test suites
namespace OrderService.RegressionTests
{
    public class CriticalPathTests { }
}

Test Organization

Project Structure

Solution/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ OrderService/
โ”‚   โ”‚   โ”œโ”€โ”€ Controllers/
โ”‚   โ”‚   โ”œโ”€โ”€ Services/
โ”‚   โ”‚   โ””โ”€โ”€ Models/
โ”‚   โ””โ”€โ”€ OrderService.Infrastructure/
โ”œโ”€โ”€ tests/
โ”‚   โ”œโ”€โ”€ OrderService.UnitTests/
โ”‚   โ”‚   โ”œโ”€โ”€ Services/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ OrderServiceTests.cs
โ”‚   โ”‚   โ””โ”€โ”€ Validators/
โ”‚   โ”‚       โ””โ”€โ”€ OrderValidatorTests.cs
โ”‚   โ”œโ”€โ”€ OrderService.IntegrationTests/
โ”‚   โ”‚   โ”œโ”€โ”€ Api/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ OrdersControllerTests.cs
โ”‚   โ”‚   โ””โ”€โ”€ Repositories/
โ”‚   โ”‚       โ””โ”€โ”€ OrderRepositoryTests.cs
โ”‚   โ”œโ”€โ”€ OrderService.E2ETests/
โ”‚   โ”‚   โ”œโ”€โ”€ PageObjects/
โ”‚   โ”‚   โ””โ”€โ”€ Flows/
โ”‚   โ””โ”€โ”€ OrderService.PerformanceTests/
โ””โ”€โ”€ test-infrastructure/
    โ”œโ”€โ”€ docker-compose.test.yml
    โ””โ”€โ”€ TestContainerSetup.cs

Test Categorization

// Using traits/categories
[Trait("Category", "Unit")]
public class OrderValidatorTests { }

[Trait("Category", "Integration")]
public class OrderRepositoryTests { }

[Trait("Category", "E2E")]
public class CheckoutFlowTests { }

[Trait("Category", "Smoke")]
public class HealthCheckTests { }

// Running specific categories
// dotnet test --filter "Category=Unit"
// dotnet test --filter "Category!=E2E"

Test Fixtures

// Shared setup for related tests
public class DatabaseFixture : IAsyncLifetime
{
    public ApplicationDbContext Context { get; private set; }

    public async Task InitializeAsync()
    {
        var options = new DbContextOptionsBuilder<ApplicationDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        Context = new ApplicationDbContext(options);
        await SeedDataAsync();
    }

    public Task DisposeAsync()
    {
        Context?.Dispose();
        return Task.CompletedTask;
    }

    private async Task SeedDataAsync()
    {
        Context.Customers.Add(new Customer { Id = 1, Name = "Test Customer" });
        await Context.SaveChangesAsync();
    }
}

[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }

[Collection("Database")]
public class OrderRepositoryTests
{
    private readonly DatabaseFixture _fixture;

    public OrderRepositoryTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }
}

Test Data Management

Test Data Patterns

Builder Pattern

public class OrderBuilder
{
    private readonly Order _order = new()
    {
        Id = Guid.NewGuid(),
        Status = OrderStatus.Pending,
        CreatedAt = DateTime.UtcNow
    };

    public OrderBuilder WithCustomer(int customerId)
    {
        _order.CustomerId = customerId;
        return this;
    }

    public OrderBuilder WithItem(string productId, int quantity, decimal price)
    {
        _order.Items.Add(new OrderItem
        {
            ProductId = productId,
            Quantity = quantity,
            UnitPrice = price
        });
        return this;
    }

    public OrderBuilder AsPaid()
    {
        _order.Status = OrderStatus.Paid;
        _order.PaidAt = DateTime.UtcNow;
        return this;
    }

    public Order Build() => _order;
}

// Usage
var order = new OrderBuilder()
    .WithCustomer(1)
    .WithItem("PROD-1", 2, 10.00m)
    .AsPaid()
    .Build();

Object Mother Pattern

public static class TestOrders
{
    public static Order PendingOrder() =>
        new OrderBuilder().WithItem("PROD-1", 1, 10.00m).Build();

    public static Order PaidOrder() =>
        new OrderBuilder().WithItem("PROD-1", 1, 10.00m).AsPaid().Build();

    public static Order LargeOrder() =>
        new OrderBuilder()
            .WithItem("PROD-1", 100, 10.00m)
            .WithItem("PROD-2", 50, 20.00m)
            .Build();
}

// Usage
var order = TestOrders.PaidOrder();

Fixture Files

// tests/fixtures/orders.json
public class TestDataLoader
{
    public static T Load<T>(string fixture)
    {
        var path = Path.Combine("fixtures", $"{fixture}.json");
        var json = File.ReadAllText(path);
        return JsonSerializer.Deserialize<T>(json)!;
    }
}

// Usage
var orders = TestDataLoader.Load<List<Order>>("orders");

Database Seeding

public class TestDataSeeder
{
    public static async Task SeedAsync(ApplicationDbContext context)
    {
        // Clear existing data
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        // Seed reference data
        var customers = new[]
        {
            new Customer { Id = 1, Name = "Test Customer", Type = CustomerType.Regular },
            new Customer { Id = 2, Name = "Premium Customer", Type = CustomerType.Premium }
        };
        context.Customers.AddRange(customers);

        var products = new[]
        {
            new Product { Id = "PROD-1", Name = "Widget", Price = 10.00m },
            new Product { Id = "PROD-2", Name = "Gadget", Price = 25.00m }
        };
        context.Products.AddRange(products);

        await context.SaveChangesAsync();
    }
}

Data Isolation

// Transaction-based isolation
public class TransactionalTest : IAsyncLifetime
{
    private IDbContextTransaction _transaction;

    public async Task InitializeAsync()
    {
        _transaction = await _context.Database.BeginTransactionAsync();
    }

    public async Task DisposeAsync()
    {
        await _transaction.RollbackAsync();
    }
}

// Database-per-test isolation
[Fact]
public async Task IsolatedTest()
{
    var dbName = Guid.NewGuid().ToString();
    var options = new DbContextOptionsBuilder<ApplicationDbContext>()
        .UseInMemoryDatabase(dbName)
        .Options;

    using var context = new ApplicationDbContext(options);
    // Test with isolated database
}

Interview Questions

1. What is the testing pyramid and when might you deviate from it?

Answer: The testing pyramid suggests having many unit tests, fewer integration tests, and fewest E2E tests. This optimizes for fast feedback and low maintenance.

Deviation scenarios:

  • Testing trophy: When integration tests provide better confidence-to-cost ratio
  • Legacy systems: May need more E2E tests when unit testing is difficult
  • UI-heavy applications: May need more E2E tests for visual validation
  • Microservices: Contract tests may be more valuable than unit tests

2. How do you decide what type of test to write?

Answer:

  1. Pure logic: Unit test
  2. Component interactions: Integration test
  3. Database operations: Integration with real/in-memory database
  4. External services: Integration with mocks, contract tests for verification
  5. Critical user journeys: E2E tests
  6. Complex UI: E2E with visual comparison

Key principle: Test at the lowest level that gives confidence.


3. What is contract testing and when would you use it?

Answer: Contract testing verifies that services communicate correctly by testing against a shared contract rather than the actual service.

Use cases:

  • Microservices: Verify API compatibility between services
  • Team independence: Allow independent deployment without integration testing everything
  • Breaking change detection: Catch API incompatibilities early
  • Documentation: Contracts serve as living API documentation

Tools: Pact, Spring Cloud Contract


4. How do you test event-driven systems?

Answer:

  1. Unit test handlers: Test each handler in isolation
  2. Test event publishing: Verify correct events are published
  3. Test event flows: Use in-memory event bus to verify complete flows
  4. Saga testing: Verify compensating actions on failures
  5. Contract testing: Verify event schema compatibility

Key challenges:

  • Async nature requires waiting/polling
  • Order of events may vary
  • Need to test compensation/rollback scenarios

5. What code coverage target should a project have?

Answer: Coverage targets should be risk-based:

  • 80% for business-critical code (payments, security)
  • 70% for general business logic
  • 50-60% for infrastructure/boilerplate

Important considerations:

  • Coverage measures execution, not correctness
  • Focus on meaningful tests, not coverage numbers
  • Use mutation testing to validate test quality
  • Some code (trivial getters, generated code) shouldnโ€™t need tests

Key Takeaways

  1. Test pyramid: Start with unit tests, add integration and E2E as needed
  2. Risk-based testing: Focus on business-critical paths
  3. Contract testing: Essential for microservices independence
  4. Mutation testing: Validates your tests actually catch bugs
  5. Shift-left: Test early, test often
  6. Test data: Use builders and fixtures for consistent test data
  7. Organization: Clear separation between test types

Further Reading

๐Ÿ“š Related Articles