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:
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:
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:
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:
Example with a mock dependency:
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:
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:
[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:
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:
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.
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:
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:
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:
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:
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:
// Brittle
await page.locator('.css-abc123 > div:nth-child(2)').click();
Prefer user-facing locators:
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:
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:
Test Scope and Test Speed
A useful way to classify tests is by size:
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:
Example HTTP client unit test with fake message handler:
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:
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:
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:
Test 1 creates customer
Test 2 edits customer created by Test 1
Test 3 deletes customer edited by Test 2
Better pattern:
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:
await page.waitForTimeout(3000);
await expect(page.getByText('Saved')).toBeVisible();
Better:
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:
[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:
MethodName_StateUnderTest_ExpectedBehavior
Example:
[Fact]
public void AddLine_WhenQuantityIsZero_ThrowsException()
{
}
Another readable style:
[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:
[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:
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:
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.