DEV_NET_CORE
GET_STARTED
.NETTesting strategy and integration testing

Unit Tests vs Integration Tests vs End-to-End Tests

Overview

Unit tests, integration tests, and end-to-end tests are three important categories of automated tests. They all help verify software quality, but they operate at different levels of the system and answer different questions.

A unit test verifies a small piece of code in isolation, such as a method, class, validator, domain service, or command handler. Unit tests should be fast, deterministic, and focused on business logic or edge cases.

An integration test verifies that multiple components work together correctly. In a .NET application, this might mean testing an ASP.NET Core API through WebApplicationFactory, testing EF Core against a real or test database, testing middleware, dependency injection, configuration, authentication, or the request-response pipeline.

An end-to-end test verifies a complete user or business flow through the system from the outside. For a web app, this often means using a browser automation tool such as Playwright to simulate a real user clicking buttons, filling forms, navigating pages, and verifying visible behavior.

This topic matters because a strong test strategy balances confidence, speed, maintainability, and cost. Unit tests are fast and precise but cannot prove the full system works. Integration tests catch wiring and infrastructure problems but are slower and more complex. End-to-end tests provide high business confidence but are the slowest, most expensive, and most fragile.

In real full-stack .NET applications, all three test types are useful:

  • Unit tests for domain logic, validation, mapping, calculations, and handler behavior.
  • Integration tests for API endpoints, EF Core persistence, dependency injection, middleware, authentication, authorization, background jobs, and external service boundaries.
  • End-to-end tests for critical user journeys such as login, checkout, payment, document upload, reporting, approval workflows, and account management.

This topic is important for interviews because it tests practical engineering judgment. Interviewers often ask:

  • What is the difference between unit, integration, and end-to-end tests?
  • What should be mocked and what should be real?
  • Why should most test suites not rely only on E2E tests?
  • How do you test an ASP.NET Core API?
  • How do you test EF Core code?
  • When should you use WebApplicationFactory?
  • When should you use Testcontainers or a real database?
  • How do you keep tests fast and reliable?
  • How do you avoid flaky tests?
  • How do tests fit into CI/CD?
  • What belongs in each layer of the testing pyramid?

A strong answer should explain that the purpose is not to choose one test type. The goal is to use the right level of test for the risk being tested.

Core Concepts

The Main Difference

The simplest comparison is:

Test TypeMain QuestionScopeSpeedConfidenceTypical Tools
Unit testDoes this small piece of logic work?One method/class/componentFastestLow to mediumxUnit, NUnit, MSTest, Moq, NSubstitute
Integration testDo these components work together?Multiple components or infrastructure boundaryMediumMedium to highxUnit, WebApplicationFactory, TestServer, EF Core, Testcontainers
End-to-end testDoes the full user/business flow work?Whole system from outsideSlowestHighest for user flowPlaywright, Selenium, Cypress, browser automation

The trade-off is important:

  • The lower the test level, the faster and more precise the test usually is.
  • The higher the test level, the more realistic and business-facing the test usually is.
  • Higher-level tests are usually slower, more brittle, harder to debug, and more expensive to maintain.

A healthy test strategy normally uses all three, but not in equal amounts.

Unit Tests

A unit test verifies a small unit of behavior in isolation.

Examples of good unit test targets:

  • Domain entity methods.
  • Value objects.
  • Validators.
  • Calculation logic.
  • Mapping logic.
  • Pure functions.
  • Command/query handlers with mocked dependencies.
  • Authorization requirement handlers.
  • Business rules.
  • Error handling branches.
  • Edge cases.

Example domain class:

Code
public sealed class Order
{
    private readonly List<OrderLine> _lines = new();

    public IReadOnlyCollection<OrderLine> Lines => _lines;
    public decimal Total => _lines.Sum(line => line.Quantity * line.UnitPrice);

    public void AddLine(string productName, int quantity, decimal unitPrice)
    {
        if (string.IsNullOrWhiteSpace(productName))
        {
            throw new ArgumentException("Product name is required.", nameof(productName));
        }

        if (quantity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(quantity));
        }

        if (unitPrice < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(unitPrice));
        }

        _lines.Add(new OrderLine(productName, quantity, unitPrice));
    }
}

public sealed record OrderLine(string ProductName, int Quantity, decimal UnitPrice);

Unit test:

Code
using Xunit;

public sealed class OrderTests
{
    [Fact]
    public void AddLine_WhenLineIsValid_AddsLineAndUpdatesTotal()
    {
        // Arrange
        var order = new Order();

        // Act
        order.AddLine("Keyboard", 2, 50m);

        // Assert
        Assert.Single(order.Lines);
        Assert.Equal(100m, order.Total);
    }

    [Fact]
    public void AddLine_WhenQuantityIsZero_ThrowsException()
    {
        // Arrange
        var order = new Order();

        // Act
        var act = () => order.AddLine("Keyboard", 0, 50m);

        // Assert
        Assert.Throws<ArgumentOutOfRangeException>(act);
    }
}

This is a good unit test because it:

  • Does not require a database.
  • Does not require HTTP.
  • Does not require dependency injection.
  • Does not require configuration.
  • Runs quickly.
  • Tests one specific behavior.
  • Gives clear failure feedback.

What Unit Tests Should Usually Avoid

Unit tests should usually avoid real infrastructure.

Avoid in unit tests:

  • Real database calls.
  • Real HTTP calls.
  • Real file system access.
  • Real message queues.
  • Real cloud services.
  • Real email sending.
  • Real browser automation.
  • Large app startup.
  • Complex environment configuration.

If a test needs the real ASP.NET Core pipeline, EF Core provider behavior, or real infrastructure behavior, it is probably an integration test.

Test Doubles

A test double is a replacement for a real dependency.

Common types:

Test DoublePurpose
DummyPassed only because a parameter is required
StubReturns predefined data
FakeWorking simplified implementation, such as an in-memory repository
MockVerifies interactions, such as whether a method was called
SpyRecords information for later assertions

Example with a mock dependency:

Code
public interface IEmailSender
{
    Task SendAsync(string to, string subject, string body, CancellationToken cancellationToken);
}

public sealed class RegisterUserHandler
{
    private readonly IEmailSender _emailSender;

    public RegisterUserHandler(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }

    public async Task HandleAsync(RegisterUserCommand command, CancellationToken cancellationToken)
    {
        // User creation logic omitted for brevity.

        await _emailSender.SendAsync(
            command.Email,
            "Welcome",
            "Thanks for registering.",
            cancellationToken);
    }
}

public sealed record RegisterUserCommand(string Email);

Unit test with Moq:

Code
using Moq;
using Xunit;

public sealed class RegisterUserHandlerTests
{
    [Fact]
    public async Task HandleAsync_WhenUserIsRegistered_SendsWelcomeEmail()
    {
        // Arrange
        var emailSender = new Mock<IEmailSender>();
        var handler = new RegisterUserHandler(emailSender.Object);

        var command = new RegisterUserCommand("[email protected]");

        // Act
        await handler.HandleAsync(command, CancellationToken.None);

        // Assert
        emailSender.Verify(sender => sender.SendAsync(
            "[email protected]",
            "Welcome",
            It.IsAny<string>(),
            CancellationToken.None), Times.Once);
    }
}

Mocking is useful, but over-mocking can make tests brittle. Prefer testing observable behavior rather than internal implementation details.

Unit Test Strengths

Unit tests are valuable because they are:

  • Fast.
  • Deterministic.
  • Easy to run locally.
  • Good for edge cases.
  • Good for business rules.
  • Good for regression protection.
  • Good for guiding design.
  • Easy to debug when focused.
  • Cheap to run in CI.

Unit tests are especially useful for complicated logic with many input combinations.

Example:

Code
[Theory]
[InlineData(100, 0, 100)]
[InlineData(100, 10, 90)]
[InlineData(200, 25, 150)]
public void ApplyDiscount_ReturnsExpectedTotal(
    decimal amount,
    decimal discountPercent,
    decimal expected)
{
    var result = DiscountCalculator.ApplyDiscount(amount, discountPercent);

    Assert.Equal(expected, result);
}

This kind of logic is much better tested with unit tests than with slow end-to-end tests.

Unit Test Limitations

Unit tests cannot prove that the whole application works.

They may miss:

  • Dependency injection misconfiguration.
  • Wrong database mappings.
  • Missing migrations.
  • SQL translation problems.
  • Wrong authentication configuration.
  • Middleware ordering bugs.
  • Serialization issues.
  • Routing issues.
  • CORS issues.
  • External service contract problems.
  • Environment configuration issues.
  • Frontend-backend integration problems.

A unit test can prove that a handler behaves correctly with a mocked repository. It cannot prove that the real repository queries the database correctly.

Integration Tests

An integration test verifies that multiple parts of the application work together.

Examples:

  • API endpoint plus routing plus model binding plus validation.
  • Controller plus dependency injection plus middleware.
  • EF Core repository plus real database provider.
  • Authentication handler plus authorization policy.
  • Message consumer plus database update.
  • File upload endpoint plus storage abstraction.
  • Application service plus real database transaction.
  • Configuration binding plus options validation.

Integration test scope can vary. It does not always mean the whole system. It means the test crosses a boundary between components.

ASP.NET Core Integration Tests with WebApplicationFactory

ASP.NET Core integration tests often use WebApplicationFactory<TEntryPoint>.

Example minimal setup:

Code
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using Xunit;

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

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

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

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

For top-level statements in Program.cs, the web app may expose a partial Program class:

Code
public partial class Program
{
}

This lets the test project reference Program as the entry point.

Customizing WebApplicationFactory

A real integration test often replaces production services with test services.

Example: replace SQL Server with SQLite for integration tests.

Code
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

public sealed class CustomWebApplicationFactory
    : WebApplicationFactory<Program>
{
    private SqliteConnection? _connection;

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDbContext>>();

            _connection = new SqliteConnection("Data Source=:memory:");
            _connection.Open();

            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseSqlite(_connection);
            });

            using var scope = services.BuildServiceProvider().CreateScope();
            var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            context.Database.EnsureCreated();
            SeedTestData(context);
        });
    }

    private static void SeedTestData(AppDbContext context)
    {
        context.Customers.Add(new Customer
        {
            Name = "Test Customer"
        });

        context.SaveChanges();
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        _connection?.Dispose();
    }
}

Test:

Code
public sealed class CustomersApiTests
    : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;

    public CustomersApiTests(CustomWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

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

        response.EnsureSuccessStatusCode();

        var json = await response.Content.ReadAsStringAsync();

        Assert.Contains("Test Customer", json);
    }
}

This test checks more than a unit test. It verifies HTTP routing, middleware, dependency injection, EF Core, serialization, and endpoint behavior.

Integration Testing EF Core

EF Core code should often be integration-tested against a real relational provider or a provider close to production.

Why:

  • LINQ translation depends on the provider.
  • SQL behavior differs by provider.
  • Null handling can differ.
  • Transactions can differ.
  • Constraints can differ.
  • Case sensitivity can differ.
  • Migrations are relational behavior.
  • EF Core InMemory provider does not behave like a relational database.

Example repository integration test:

Code
public sealed class CustomerRepositoryTests : IAsyncLifetime
{
    private SqliteConnection _connection = null!;
    private AppDbContext _context = null!;

    public async Task InitializeAsync()
    {
        _connection = new SqliteConnection("Data Source=:memory:");
        await _connection.OpenAsync();

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

        _context = new AppDbContext(options);
        await _context.Database.EnsureCreatedAsync();
    }

    public async Task DisposeAsync()
    {
        await _context.DisposeAsync();
        await _connection.DisposeAsync();
    }

    [Fact]
    public async Task AddAsync_WhenCustomerIsValid_SavesCustomer()
    {
        var customer = new Customer
        {
            Name = "Contoso"
        };

        _context.Customers.Add(customer);
        await _context.SaveChangesAsync();

        var saved = await _context.Customers
            .SingleAsync(c => c.Name == "Contoso");

        Assert.Equal("Contoso", saved.Name);
    }
}

For more production-like tests, use the same database engine as production, often through Docker/Testcontainers.

Integration Tests with Testcontainers

Testcontainers can start real infrastructure in Docker for tests.

Example concept:

Code
public sealed class SqlServerIntegrationTests : IAsyncLifetime
{
    private readonly MsSqlContainer _sqlServer = new MsSqlBuilder()
        .WithPassword("yourStrong(!)Password")
        .Build();

    public async Task InitializeAsync()
    {
        await _sqlServer.StartAsync();

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_sqlServer.GetConnectionString())
            .Options;

        await using var context = new AppDbContext(options);
        await context.Database.MigrateAsync();
    }

    public async Task DisposeAsync()
    {
        await _sqlServer.DisposeAsync();
    }
}

This is slower than a pure unit test but catches real database behavior, provider differences, migrations, constraints, and SQL translation issues.

Integration Test Strengths

Integration tests are valuable because they catch problems that unit tests miss:

  • Dependency injection misconfiguration.
  • Incorrect EF Core mapping.
  • Missing services.
  • Middleware ordering bugs.
  • Routing problems.
  • Model binding problems.
  • Serialization problems.
  • Authentication and authorization misconfiguration.
  • Database constraint problems.
  • Query translation issues.
  • Transaction behavior.
  • Configuration binding issues.
  • External service contract problems.

Integration tests provide higher confidence than unit tests for infrastructure-heavy code.

Integration Test Limitations

Integration tests are slower and more expensive than unit tests.

They can be harder to maintain because they may require:

  • Test databases.
  • Test data setup.
  • Environment variables.
  • Docker containers.
  • Authentication setup.
  • Service replacement.
  • Cleanup between tests.
  • More complex fixtures.
  • More CI time.

Integration tests should be focused. Do not test every small business-rule permutation through integration tests when a unit test would be faster and clearer.

End-to-End Tests

An end-to-end test verifies a complete flow through the system from the user's or external client's perspective.

Examples:

  • User logs in, creates an order, checks out, and sees confirmation.
  • Admin creates a user, assigns a role, and the user can access a protected page.
  • Customer uploads a file, backend processes it, and result appears in the UI.
  • User submits a form, receives validation feedback, and saved data appears after refresh.
  • Payment flow completes through frontend, backend, payment provider sandbox, and database.

For a web application, an E2E test often uses browser automation.

Example Playwright test:

Code
import { test, expect } from '@playwright/test';

test('user can create a customer', async ({ page }) => {
  await page.goto('/customers');

  await page.getByRole('button', { name: 'New Customer' }).click();
  await page.getByLabel('Name').fill('Contoso');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByText('Customer created successfully')).toBeVisible();
  await expect(page.getByText('Contoso')).toBeVisible();
});

This test verifies visible behavior, not implementation details.

E2E Tests Should Focus on User-Visible Behavior

A good E2E test interacts with the application like a user:

  • Clicks buttons.
  • Fills form fields.
  • Uses labels and roles.
  • Verifies visible text.
  • Verifies navigation.
  • Verifies important outcomes.

Avoid testing implementation details:

Code
// Brittle
await page.locator('.css-abc123 > div:nth-child(2)').click();

Prefer user-facing locators:

Code
await page.getByRole('button', { name: 'Save' }).click();
await page.getByLabel('Email').fill('[email protected]');

User-visible tests are usually more resilient to refactoring.

E2E Test Strengths

E2E tests are valuable because they catch issues across the whole stack:

  • Frontend routing issues.
  • Broken API calls.
  • Authentication flow problems.
  • Browser-specific behavior.
  • Real validation behavior.
  • Incorrect UI state after API responses.
  • Deployment configuration problems.
  • Environment issues.
  • CORS problems.
  • Broken JavaScript bundles.
  • Integration between frontend and backend.
  • Critical business flow regressions.

They provide strong confidence for important user journeys.

E2E Test Limitations

E2E tests are expensive.

They are often:

  • Slower than unit and integration tests.
  • More brittle.
  • Harder to debug.
  • More dependent on environment stability.
  • More sensitive to data setup.
  • More likely to fail because of timing or infrastructure issues.
  • Harder to run on every local change.
  • More expensive in CI.

Use E2E tests for critical flows, not every business rule permutation.

The Testing Pyramid

The testing pyramid is a common way to think about test distribution.

A typical test strategy has:

Code
        End-to-End Tests
       /                \
      /  Integration     \
     /      Tests         \
    /                      \
   /       Unit Tests       \
  /__________________________\

The general idea:

  • Many unit tests.
  • A smaller number of integration tests.
  • A smaller number of E2E tests.

This is not a strict mathematical rule. Some CRUD-heavy applications may need many integration tests. Some domain-heavy applications may benefit from many unit tests. The key principle is to test behavior at the lowest level that gives enough confidence.

Do not test a simple pure function through a browser if a unit test can verify it faster and more precisely.

Do not mock EF Core behavior if the real risk is SQL translation or database constraints.

Test Trophy and Practical Balance

Some modern teams use a "test trophy" idea instead of a strict pyramid, especially frontend-heavy applications. It emphasizes many integration/component tests because they provide better confidence than shallow unit tests while still being cheaper than full E2E tests.

For full-stack .NET applications, a practical balance is often:

  • Unit tests for business rules and edge cases.
  • Integration tests for API and persistence behavior.
  • E2E tests for a small number of critical user journeys.

The exact mix depends on architecture:

Application TypeLikely Test Emphasis
Domain-heavy systemMore unit tests around domain rules
CRUD-heavy APIMore integration tests around API/database behavior
Frontend-heavy SPAMore component and E2E tests around user behavior
MicroservicesContract and integration tests around service boundaries
Legacy systemMore characterization and higher-level regression tests first

Test Scope and Test Speed

A useful way to classify tests is by size:

SizeSimilar CategoryCharacteristics
SmallUnit testIn-process, no network, no database, very fast
MediumIntegration testCrosses process/component boundary or uses test infrastructure
LargeE2E/system testUses real app, browser, network, deployed environment, or multiple services

Smaller tests are easier to run frequently. Larger tests provide more realistic coverage but should be fewer and more carefully selected.

What to Mock

Mocking should be based on the test type and risk.

In unit tests, mock dependencies that are not the focus:

  • Email sender.
  • Clock/time provider.
  • External API client.
  • File storage.
  • Message bus.
  • Repository interface.
  • Current user provider.

In integration tests, use more real components:

  • Real DI container.
  • Real middleware.
  • Real EF Core provider.
  • Real validation pipeline.
  • Real authorization policies.
  • Test database.

In E2E tests, avoid mocking core application behavior unless using a controlled fake for unstable third-party systems.

Example:

  • Unit test checkout calculation: mock payment gateway.
  • Integration test payment service boundary: use fake payment provider or sandbox.
  • E2E test checkout flow: use payment provider sandbox or stable test double configured at environment level.

Testing External Services

External services make tests harder because they add network dependency, cost, rate limits, credentials, and instability.

Strategies:

StrategyUse Case
MockUnit tests of logic using the service
Fake serverIntegration tests of HTTP client behavior
SandboxE2E test of real provider flow
Contract testVerify request/response compatibility
Recorded responseStable tests where live service is too expensive

Example HTTP client unit test with fake message handler:

Code
public sealed class FakeHttpMessageHandler : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var response = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("{ \"status\": \"ok\" }")
        };

        return Task.FromResult(response);
    }
}

Use real external systems sparingly and usually outside the fast pull-request test suite.

Test Data Management

Good tests require reliable data setup.

Common approaches:

  • Arrange data inside each test.
  • Use builders or object mothers.
  • Use database transactions and rollback.
  • Recreate database per test class.
  • Use unique test data per test.
  • Use Testcontainers per test suite.
  • Use API calls to set up E2E state.
  • Use seeded baseline data.

Example test data builder:

Code
public sealed class CustomerBuilder
{
    private string _name = "Default Customer";

    public CustomerBuilder WithName(string name)
    {
        _name = name;
        return this;
    }

    public Customer Build()
    {
        return new Customer
        {
            Name = _name
        };
    }
}

Usage:

Code
var customer = new CustomerBuilder()
    .WithName("Contoso")
    .Build();

Tests should not depend on other tests leaving data behind.

Test Isolation

Test isolation means each test should be able to run independently.

A good test should:

  • Run alone.
  • Run with other tests.
  • Run in any order.
  • Run repeatedly.
  • Not depend on shared mutable state.
  • Clean up after itself or use isolated data.
  • Not require another test to run first.

Bad E2E pattern:

Code
Test 1 creates customer
Test 2 edits customer created by Test 1
Test 3 deletes customer edited by Test 2

Better pattern:

Code
Each test creates or obtains its own customer data.
Each test can run independently.

Test dependency causes cascading failures and makes parallel execution difficult.

Flaky Tests

A flaky test sometimes passes and sometimes fails without a code change.

Common causes:

  • Timing assumptions.
  • Race conditions.
  • Shared test data.
  • Tests depending on execution order.
  • Unstable external services.
  • Fixed sleeps.
  • Environment differences.
  • Browser rendering timing.
  • Database cleanup issues.
  • Parallel tests modifying the same data.

Bad Playwright pattern:

Code
await page.waitForTimeout(3000);
await expect(page.getByText('Saved')).toBeVisible();

Better:

Code
await expect(page.getByText('Saved')).toBeVisible();

Modern E2E tools usually have auto-waiting and retrying assertions. Prefer condition-based waits over fixed delays.

AAA Pattern

AAA means Arrange, Act, Assert.

Example:

Code
[Fact]
public void CalculateTotal_WhenOrderHasLines_ReturnsSum()
{
    // Arrange
    var order = new Order();
    order.AddLine("Mouse", 2, 25m);
    order.AddLine("Keyboard", 1, 50m);

    // Act
    var total = order.Total;

    // Assert
    Assert.Equal(100m, total);
}

Benefits:

  • Clear structure.
  • Easy to read.
  • Easy to debug.
  • Easier to review.
  • Helps avoid testing multiple behaviors at once.

Naming Tests

Good test names describe behavior.

Common pattern:

Code
MethodName_StateUnderTest_ExpectedBehavior

Example:

Code
[Fact]
public void AddLine_WhenQuantityIsZero_ThrowsException()
{
}

Another readable style:

Code
[Fact]
public void Cannot_add_order_line_with_zero_quantity()
{
}

Choose one convention and keep it consistent.

Code Coverage

Code coverage measures how much code was executed by tests.

Coverage is useful, but it does not prove correctness.

A test can execute code without meaningful assertions:

Code
[Fact]
public void BadTest()
{
    var order = new Order();
    order.AddLine("Mouse", 1, 10m);

    // No meaningful assertion.
}

Good coverage strategy:

  • Use coverage to find untested areas.
  • Focus on meaningful assertions.
  • Prioritize critical business logic.
  • Do not chase 100% coverage blindly.
  • Combine coverage with mutation testing if needed.
  • Review high-risk uncovered code.

CI/CD Test Strategy

A practical CI pipeline often separates tests by speed and cost.

Example:

Code
Pull Request:
- Build
- Unit tests
- Fast integration tests
- Lint/static analysis

Main branch:
- Full integration tests
- Database tests
- Contract tests

Nightly or pre-release:
- Full E2E suite
- Cross-browser tests
- Performance smoke tests
- Security scans

Not every test must run at every stage. Fast feedback is important for developers, while deeper confidence can run later or on release branches.

Choosing the Right Test Type

Use this decision guide:

ScenarioBest Test Type
Pure calculation logicUnit test
Validation ruleUnit test
Domain entity behaviorUnit test
Command handler with mocked repositoryUnit test
EF Core mapping/query behaviorIntegration test
API endpoint routing/model binding/validationIntegration test
Middleware behaviorIntegration test
Authentication and authorization pipelineIntegration test
Frontend button calls API and updates UIE2E or component/integration test
Complete checkout flowE2E test
Cross-service workflowIntegration, contract, or E2E depending on scope
Browser-specific behaviorE2E test

Principle:

Test at the lowest level that gives enough confidence.

Common Mistakes

Common mistakes include:

  • Testing everything through E2E tests.
  • Mocking everything and missing real integration failures.
  • Unit testing implementation details instead of behavior.
  • Writing tests with no meaningful assertions.
  • Letting tests depend on execution order.
  • Sharing mutable test data between tests.
  • Using fixed sleeps in E2E tests.
  • Using EF Core InMemory provider to test relational behavior.
  • Not testing database constraints or migrations.
  • Returning false confidence from over-mocked tests.
  • Ignoring flaky tests.
  • Writing large integration tests for every small business rule.
  • Running slow tests on every small local change without separation.
  • Not including tests in CI.
  • Not cleaning up test data.
  • Using production services or production data in tests.
  • Treating code coverage as the only quality metric.

Best Practices

Use unit tests for fast feedback on business logic.

Use integration tests for real component collaboration and infrastructure behavior.

Use E2E tests for critical user journeys, not every edge case.

Prefer meaningful assertions over high coverage numbers.

Keep tests isolated and deterministic.

Avoid test dependencies and shared mutable data.

Use test data builders for readable setup.

Use realistic infrastructure for integration tests when provider behavior matters.

Prefer WebApplicationFactory for ASP.NET Core API integration tests.

Use Testcontainers or real test databases when relational database behavior matters.

Avoid EF Core InMemory provider for relational behavior tests.

Use Playwright-style E2E tests for user-visible browser behavior.

Avoid fixed sleeps; use condition-based waits and retrying assertions.

Separate fast and slow test suites in CI.

Run the fastest useful tests on every pull request.

Run broader integration and E2E suites at appropriate pipeline stages.

Treat flaky tests as bugs.

Interview Practice

PreviousUnit testing frameworks, naming, AAA, and test data setup in .NETNext UpWebApplicationFactory, TestServer, HttpClient, and Full ASP.NET Core Pipeline Testing