DEV_NET_CORE
GET_STARTED
.NETTesting strategy and integration testing

Test doubles, mocking boundaries, and integration risks in .NET

Overview

Test doubles are replacement objects used in tests instead of real dependencies such as databases, file systems, message queues, HTTP APIs, identity providers, clocks, payment gateways, or email services. They help developers test code quickly, deterministically, and safely without relying on slow or unpredictable external systems.

In C# and .NET applications, test doubles are commonly used with unit testing frameworks such as xUnit, NUnit, and MSTest, and with mocking libraries such as Moq, NSubstitute, and FakeItEasy. They are especially common in applications built with dependency injection, where services depend on interfaces such as IEmailSender, IPaymentGateway, IClock, IRepository<T>, or IHttpClientFactory.

This topic matters because testing is not only about making tests pass. Good tests should give confidence that the real application works. Test doubles are useful when they isolate business logic, remove external instability, and make edge cases easy to simulate. However, excessive mocking can hide real integration problems. A test can pass because the mock behaves exactly as the developer configured it, while the real database query, real HTTP call, real serializer, real authentication middleware, real transaction, or real dependency injection configuration fails in production.

This topic is important for interviews because it reveals whether a developer understands the difference between isolated unit tests and integration tests. Interviewers often ask about mocks, stubs, and fakes to evaluate practical judgment: what should be mocked, what should be tested with real infrastructure, how to avoid brittle tests, and how to design code that is testable without over-abstracting everything.

A strong interview answer should explain that test doubles are tools, not the goal. The goal is useful feedback. Unit tests with test doubles should validate business logic quickly, while integration tests should verify real wiring, framework behavior, database behavior, serialization, authentication, authorization, middleware, configuration, and external contracts.

Core Concepts

What Is a Test Double?

A test double is a substitute used in a test in place of a real dependency. The name comes from the idea of a stunt double in a movie: the double stands in for the real object during a controlled scenario.

Common reasons to use a test double include:

  • Avoiding calls to external services.
  • Making tests faster.
  • Making tests deterministic.
  • Simulating rare error cases.
  • Avoiding destructive actions such as sending real emails or charging real cards.
  • Testing business logic in isolation.
  • Controlling dependency behavior for edge cases.

Example dependency:

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

A real implementation may use SMTP, SendGrid, Azure Communication Services, or another provider. In a unit test, calling the real provider would be slow, unreliable, and potentially dangerous. A test double can replace it.

Dummy, Stub, Fake, Mock, and Spy

Testing terminology is sometimes used inconsistently, but interviewers usually expect the following practical distinctions.

Dummy

A dummy is passed only because a method requires a parameter. The test does not use it meaningfully.

Code
public sealed class NullLogger : ILogger
{
    public void Log(string message)
    {
        // Intentionally does nothing.
    }
}

Use a dummy when the dependency is required by the constructor or method signature but is irrelevant to the specific test.

Stub

A stub provides pre-defined data or behavior. It helps the system under test reach a specific path.

Code
public interface IExchangeRateProvider
{
    Task<decimal> GetRateAsync(string fromCurrency, string toCurrency);
}

public sealed class StubExchangeRateProvider : IExchangeRateProvider
{
    public Task<decimal> GetRateAsync(string fromCurrency, string toCurrency)
    {
        return Task.FromResult(25_000m);
    }
}

A stub is useful when the test needs a known response.

Code
public sealed class PriceCalculator
{
    private readonly IExchangeRateProvider _exchangeRateProvider;

    public PriceCalculator(IExchangeRateProvider exchangeRateProvider)
    {
        _exchangeRateProvider = exchangeRateProvider;
    }

    public async Task<decimal> ConvertToVndAsync(decimal usdAmount)
    {
        var rate = await _exchangeRateProvider.GetRateAsync("USD", "VND");
        return usdAmount * rate;
    }
}
Code
[Fact]
public async Task ConvertToVndAsync_ReturnsConvertedAmount()
{
    var calculator = new PriceCalculator(new StubExchangeRateProvider());

    var result = await calculator.ConvertToVndAsync(10m);

    Assert.Equal(250_000m, result);
}

This test checks the calculator logic. It does not check the real exchange rate provider.

Fake

A fake is a working but simplified implementation. It usually has behavior close to the real dependency but is simpler, faster, and safer.

Code
public interface IUserRepository
{
    Task<User?> GetByEmailAsync(string email);
    Task AddAsync(User user);
}

public sealed class FakeUserRepository : IUserRepository
{
    private readonly List<User> _users = new();

    public Task<User?> GetByEmailAsync(string email)
    {
        var user = _users.SingleOrDefault(x => x.Email == email);
        return Task.FromResult(user);
    }

    public Task AddAsync(User user)
    {
        _users.Add(user);
        return Task.CompletedTask;
    }
}

A fake is useful when the dependency has enough behavior that configuring many mocks would make the test hard to read.

Example:

Code
[Fact]
public async Task RegisterAsync_AddsUser_WhenEmailIsUnique()
{
    var repository = new FakeUserRepository();
    var service = new RegistrationService(repository);

    await service.RegisterAsync("[email protected]");

    var user = await repository.GetByEmailAsync("[email protected]");
    Assert.NotNull(user);
}

This fake repository is simple and fast, but it does not behave exactly like a real database. It does not enforce all database constraints, transactions, collation rules, query translation rules, tracking behavior, or concurrency behavior.

Mock

A mock is a test double used to verify interactions. It usually answers the question: "Did the system call this dependency correctly?"

Example using Moq:

Code
[Fact]
public async Task CompleteOrderAsync_SendsConfirmationEmail()
{
    var emailSender = new Mock<IEmailSender>();

    var service = new OrderService(emailSender.Object);

    await service.CompleteOrderAsync(
        orderId: 123,
        customerEmail: "[email protected]");

    emailSender.Verify(x => x.SendAsync(
            "[email protected]",
            "Order completed",
            It.IsAny<string>(),
            It.IsAny<CancellationToken>()),
        Times.Once);
}

This test verifies that the service asks the email sender to send a confirmation email.

Mocks are most valuable when the observable outcome is an interaction with an external boundary, such as:

  • Sending an email.
  • Publishing an event.
  • Calling a payment gateway.
  • Writing an audit log.
  • Invalidating a cache.
  • Triggering a notification.

Mocks become risky when they verify internal implementation details that users do not care about.

Spy

A spy records what happened so the test can inspect it later. Some mocking libraries can create spies, but a hand-written spy is often clearer.

Code
public sealed class SpyEmailSender : IEmailSender
{
    public List<string> Recipients { get; } = new();

    public Task SendAsync(
        string to,
        string subject,
        string body,
        CancellationToken cancellationToken = default)
    {
        Recipients.Add(to);
        return Task.CompletedTask;
    }
}
Code
[Fact]
public async Task CompleteOrderAsync_RecordsEmailRecipient()
{
    var emailSender = new SpyEmailSender();
    var service = new OrderService(emailSender);

    await service.CompleteOrderAsync(123, "[email protected]");

    Assert.Contains("[email protected]", emailSender.Recipients);
}

A spy is useful when a mocking library would make the test more complex than necessary.

Unit Tests vs Integration Tests

A unit test usually tests a small unit of behavior in isolation. It often replaces external dependencies with test doubles.

An integration test verifies that multiple parts of the system work together. It may use a real ASP.NET Core pipeline, dependency injection container, database provider, HTTP serialization, authentication scheme, middleware, configuration, or external service emulator.

Example unit test scope:

Code
OrderService + mocked IEmailSender + fake repository

Example integration test scope:

Code
HTTP request
-> ASP.NET Core middleware
-> endpoint routing
-> model binding
-> validation
-> controller/minimal API
-> EF Core
-> real test database
-> response serialization

Both are valuable. A healthy test suite usually contains many fast unit tests and enough integration tests to prove that the real application wiring works.

Why Test Doubles Are Useful

Test doubles help with several practical problems.

They make tests fast:

Code
Real payment provider call: slow and unreliable
Mock payment provider: immediate and deterministic

They make tests safe:

Code
Real email sender: may send an actual customer email
Spy email sender: records the email without sending it

They make rare cases easy to simulate:

Code
paymentGateway
    .Setup(x => x.ChargeAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
    .ThrowsAsync(new TimeoutException("Payment provider timed out."));

They isolate business rules:

Code
Test the discount calculation without needing a database, message broker, or HTTP server.

They improve design feedback:

Code
If a class is impossible to test without a huge amount of setup, it may have too many responsibilities.

When Mocking Is the Right Choice

Mocking is usually appropriate for dependencies at system boundaries.

Good candidates for mocks include:

  • Email services.
  • SMS services.
  • Push notification services.
  • Payment gateways.
  • External HTTP APIs.
  • Message publishers.
  • File storage abstractions.
  • Cache invalidation services.
  • Clock/time providers.
  • Random number providers.
  • Feature flag providers.
  • Authorization or identity abstractions in pure unit tests.

Example:

Code
public interface IOrderEventPublisher
{
    Task PublishOrderCompletedAsync(int orderId, CancellationToken cancellationToken);
}

[Fact]
public async Task CompleteOrderAsync_PublishesOrderCompletedEvent()
{
    var publisher = new Mock<IOrderEventPublisher>();
    var service = new OrderService(publisher.Object);

    await service.CompleteOrderAsync(123, CancellationToken.None);

    publisher.Verify(x => x.PublishOrderCompletedAsync(123, It.IsAny<CancellationToken>()), Times.Once);
}

This is reasonable because publishing an event is an important observable interaction.

When a Fake Is Better Than a Mock

A fake is often better when the dependency has behavior that many tests need and mocking each interaction would create noisy tests.

Good fake candidates include:

  • In-memory repositories for simple domain tests.
  • In-memory queues for command handlers.
  • Fake clocks.
  • Fake current-user providers.
  • Fake file storage for simple save/read behavior.
  • Fake feature flag stores.

Example fake clock:

Code
public interface IClock
{
    DateTimeOffset UtcNow { get; }
}

public sealed class FakeClock : IClock
{
    public DateTimeOffset UtcNow { get; set; }
}
Code
[Fact]
public void IsExpired_ReturnsTrue_WhenExpirationIsInPast()
{
    var clock = new FakeClock
    {
        UtcNow = new DateTimeOffset(2026, 5, 17, 0, 0, 0, TimeSpan.Zero)
    };

    var token = new AccessToken(
        expiresAt: new DateTimeOffset(2026, 5, 16, 0, 0, 0, TimeSpan.Zero));

    Assert.True(token.IsExpired(clock));
}

This is easier to read than mocking a property call.

When Not to Mock

Do not mock something just because it is possible.

Avoid mocking:

  • Simple value objects.
  • Domain entities.
  • Pure functions.
  • LINQ behavior.
  • Framework code that should be tested through the framework.
  • EF Core query behavior when the real concern is SQL translation.
  • ASP.NET Core model binding, filters, middleware, routing, or authorization when the real concern is API behavior.
  • AutoMapper mappings when the real concern is mapping configuration.
  • Serialization when the real concern is JSON contract compatibility.
  • Internal private method calls through artificial abstractions.

Bad example:

Code
discountCalculator
    .Verify(x => x.CalculateDiscount(order), Times.Once);

If the test only cares about the final price, verify the final price instead:

Code
Assert.Equal(90m, result.Total);

Interaction verification should be used when the interaction itself is the behavior.

State Verification vs Interaction Verification

State verification checks the final result.

Code
Assert.Equal(OrderStatus.Completed, order.Status);
Assert.Equal(90m, order.Total);

Interaction verification checks how collaborators were used.

Code
emailSender.Verify(x => x.SendAsync(
    customerEmail,
    "Order completed",
    It.IsAny<string>(),
    It.IsAny<CancellationToken>()),
    Times.Once);

Prefer state verification when possible because it is usually less coupled to implementation details. Use interaction verification when the behavior is an interaction with another component.

Example:

Code
Good interaction verification:
"An email should be sent after order completion."

Risky interaction verification:
"The service should call repository.GetByIdAsync before repository.UpdateAsync."

The first describes business-observable behavior. The second may describe implementation details.

Over-Mocking and Brittle Tests

Over-mocking means replacing too many collaborators or verifying too many internal calls.

Symptoms of over-mocking include:

  • Tests fail after harmless refactoring.
  • Tests duplicate the implementation.
  • Tests contain more setup than assertions.
  • Tests verify exact method call order unnecessarily.
  • Tests pass even though the real app is broken.
  • The team has high unit-test coverage but low production confidence.
  • Developers avoid refactoring because many mocks must be updated.

Example of a brittle test:

Code
repository.Verify(x => x.GetByIdAsync(id), Times.Once);
discountService.Verify(x => x.ApplyDiscount(order), Times.Once);
repository.Verify(x => x.SaveAsync(order), Times.Once);
emailSender.Verify(x => x.SendAsync(email, subject, body, default), Times.Once);

This test may fail if the implementation changes from GetByIdAsync to GetByNumberAsync, even if the user-visible behavior is still correct.

A more resilient test verifies important outcomes:

Code
Assert.Equal(OrderStatus.Completed, order.Status);
emailSender.Verify(x => x.SendAsync(
    order.CustomerEmail,
    "Order completed",
    It.IsAny<string>(),
    It.IsAny<CancellationToken>()),
    Times.Once);

How Mocking Can Hide Real Integration Problems

Mocking can hide problems when the mock does not behave like the real dependency.

Common examples include:

Database Query Translation Problems

A mocked repository may return expected data, but the real EF Core query may fail because it cannot be translated to SQL.

Risky unit test:

Code
repository
    .Setup(x => x.SearchAsync("abc"))
    .ReturnsAsync(new List<Product>
    {
        new Product { Name = "ABC Product" }
    });

This test does not prove that the real query works.

The real code might contain:

Code
var products = await dbContext.Products
    .Where(x => Normalize(x.Name).Contains(searchText))
    .ToListAsync();

If Normalize cannot be translated to SQL, the real query can fail or behave differently. A unit test with a mocked repository would not catch this. An integration test using the real provider is needed.

EF Core Tracking and State Problems

A fake repository may not reproduce EF Core behavior such as:

  • Change tracking.
  • Entity states.
  • Relationship fix-up.
  • Required properties.
  • Concurrency tokens.
  • Transactions.
  • Unique constraints.
  • Cascade deletes.
  • Query translation.
  • Database collation.
  • Null comparison semantics.

For EF Core-heavy behavior, use integration tests with SQLite, SQL Server LocalDB, containers, or a real test database where possible.

Serialization and Contract Problems

A mocked service may directly return a C# object, but the real API may fail due to JSON serialization settings.

Problems mocks may hide include:

  • Wrong property names.
  • Missing required fields.
  • Enum serialization mismatch.
  • Date/time format issues.
  • Circular references.
  • Nullability contract mismatch.
  • Case sensitivity issues.

An API integration test should send an HTTP request and verify the real serialized response.

Dependency Injection and Configuration Problems

A unit test can manually construct a service and pass mocks. That does not prove the real application can resolve the service.

Mock-based tests may miss:

  • Missing service registrations.
  • Wrong service lifetimes.
  • Invalid options binding.
  • Missing configuration keys.
  • Incorrect named HttpClient.
  • Incorrect authentication scheme registration.

A startup or API integration test can catch these problems.

Authentication and Authorization Problems

Mocking ICurrentUser can be useful for domain service tests, but it does not prove real authentication and authorization work.

Mocks may hide:

  • Missing [Authorize].
  • Wrong policy name.
  • Wrong role requirement.
  • Incorrect claim mapping.
  • Middleware ordering problems.
  • Token validation configuration issues.

Security-sensitive behavior should have integration tests that exercise the real authorization pipeline.

HTTP Client Problems

Mocking an external API client can hide problems in:

  • Base address configuration.
  • Request headers.
  • Authentication tokens.
  • JSON body shape.
  • Status code handling.
  • Timeout behavior.
  • Retry policies.
  • Error response parsing.

A better approach is often to test your adapter against a fake HTTP server or mocked HttpMessageHandler, and separately test application behavior with a fake adapter.

Message Queue and Event Problems

Mocking an event publisher can verify that publishing was attempted, but it does not verify:

  • Topic or queue name.
  • Message schema.
  • Serialization.
  • Dead-letter behavior.
  • Retry behavior.
  • Idempotency.
  • Consumer compatibility.

For event-driven systems, add contract tests or integration tests for message shape and consumer behavior.

Mocking External Systems vs Mocking Your Own Adapter

A useful design habit is to wrap external systems behind your own adapter interface.

Instead of mocking HttpClient everywhere:

Code
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken cancellationToken);
}

Your application unit tests can mock IPaymentGateway.

Then write focused integration tests for the real PaymentGateway implementation:

Code
PaymentGateway
-> HttpClient
-> JSON serialization
-> headers
-> status code mapping
-> fake HTTP server or sandbox API

This gives both fast business tests and confidence in the external integration layer.

Mocking Repositories: Useful but Dangerous

Mocking repositories can be useful when the test is about business decisions, not persistence.

Example:

Code
[Fact]
public async Task CreateOrderAsync_RejectsDuplicateOrderNumber()
{
    var repository = new Mock<IOrderRepository>();

    repository
        .Setup(x => x.ExistsByOrderNumberAsync("ORD-001", It.IsAny<CancellationToken>()))
        .ReturnsAsync(true);

    var service = new OrderService(repository.Object);

    await Assert.ThrowsAsync<DuplicateOrderException>(() =>
        service.CreateOrderAsync("ORD-001", CancellationToken.None));
}

This test checks business behavior: duplicate order numbers are rejected.

However, it does not prove the real duplicate check query works. You still need an integration test for the repository or EF Core query.

A good rule:

Code
Mock repositories in application service unit tests.
Do not rely only on mocked repositories for persistence behavior.

EF Core InMemory Provider vs Real Database Provider

The EF Core InMemory provider is convenient, but it is not a relational database. It may not catch relational database behavior such as:

  • Foreign key constraints.
  • Unique indexes.
  • SQL translation issues.
  • Transactions.
  • Raw SQL behavior.
  • Database-specific functions.
  • Collation and case sensitivity differences.

For tests that need database confidence, prefer a relational provider such as SQLite in-memory mode or the same database engine used in production through containers or test infrastructure.

Example integration-style test shape:

Code
[Fact]
public async Task CreateUserAsync_Fails_WhenEmailAlreadyExists()
{
    await using var dbContext = CreateSqliteDbContext();

    dbContext.Users.Add(new User { Email = "[email protected]" });
    await dbContext.SaveChangesAsync();

    dbContext.Users.Add(new User { Email = "[email protected]" });

    await Assert.ThrowsAsync<DbUpdateException>(() =>
        dbContext.SaveChangesAsync());
}

This type of test catches real database constraint behavior that a fake repository would not catch.

ASP.NET Core Integration Tests and Replacing Services

ASP.NET Core integration tests can run the real application pipeline while replacing selected services with stubs or fakes.

Common approach:

Code
public sealed class CustomWebApplicationFactory
    : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            var descriptor = services.SingleOrDefault(
                x => x.ServiceType == typeof(IEmailSender));

            if (descriptor is not null)
            {
                services.Remove(descriptor);
            }

            services.AddSingleton<IEmailSender, SpyEmailSender>();
        });
    }
}

This keeps the real routing, model binding, validation, filters, middleware, and JSON serialization, while replacing only the email sender.

A good integration test usually mocks fewer things than a unit test.

Contract Tests

A contract test verifies that two components agree on a boundary.

Examples:

  • Your API returns the JSON shape expected by a frontend.
  • Your service sends a message with the schema expected by a consumer.
  • Your client sends the request format expected by a third-party API.
  • Your adapter maps external error codes correctly.

Contract tests are useful when mocks would otherwise make unrealistic assumptions.

Example concern:

Code
Mock says payment provider returns "Approved".
Real provider returns "APPROVED", "approved", or a nested status object.

A contract test catches this mismatch.

Choosing the Right Test Double

Use this practical decision guide:

Code
Need only a required argument?
Use a dummy.

Need a fixed response?
Use a stub.

Need a simple working implementation?
Use a fake.

Need to verify that a dependency was called?
Use a mock.

Need to record calls for later inspection?
Use a spy.

Need to verify framework, database, serialization, DI, or middleware behavior?
Use an integration test.

Designing Code for Testability Without Over-Abstraction

Good testability usually comes from clear boundaries, not from abstracting every class.

Useful boundaries include:

  • External services.
  • Time.
  • Randomness.
  • File system.
  • Network calls.
  • Message brokers.
  • Payment gateways.
  • Email/SMS providers.
  • Current user context.
  • Feature flags.

Avoid creating interfaces for every class only to make mocking possible. This can create unnecessary complexity.

Example of useful abstraction:

Code
public interface ISystemClock
{
    DateTimeOffset UtcNow { get; }
}

Example of questionable abstraction:

Code
public interface IStringTrimmer
{
    string Trim(string value);
}

The first abstracts a hard-to-control dependency: time. The second abstracts a simple framework method and may not add value.

Verifying Behavior Instead of Implementation

Tests should usually describe behavior, not implementation details.

Less useful:

Code
[Fact]
public async Task CreateOrderAsync_CallsRepositorySave()
{
    // This verifies an implementation step.
}

More useful:

Code
[Fact]
public async Task CreateOrderAsync_CreatesPendingOrderForValidCustomer()
{
    // This verifies business behavior.
}

A test name should help future developers understand what behavior must not break.

Mock Setup Should Be Minimal

Avoid configuring mocks for behavior that does not matter to the test.

Noisy setup:

Code
customerRepository.Setup(x => x.GetByIdAsync(id)).ReturnsAsync(customer);
pricingService.Setup(x => x.CalculateAsync(cart)).ReturnsAsync(price);
taxService.Setup(x => x.CalculateAsync(price)).ReturnsAsync(tax);
shippingService.Setup(x => x.CalculateAsync(cart)).ReturnsAsync(shipping);
auditLogger.Setup(x => x.WriteAsync(It.IsAny<string>())).Returns(Task.CompletedTask);

If the test needs all of this setup, the unit may be too large or the test may be trying to cover too much.

Better options:

  • Split the service.
  • Use a fake object that represents a common scenario.
  • Move complex setup into a test data builder.
  • Use an integration test if the behavior depends on many real components.

Handling Errors with Test Doubles

Mocks and stubs are useful for simulating failure paths that are difficult to reproduce with real systems.

Example:

Code
emailSender
    .Setup(x => x.SendAsync(
        It.IsAny<string>(),
        It.IsAny<string>(),
        It.IsAny<string>(),
        It.IsAny<CancellationToken>()))
    .ThrowsAsync(new TimeoutException());

This helps test retry logic, fallback behavior, logging, or user-friendly error handling.

However, a mocked timeout does not prove that real timeout configuration works. For that, use integration or resilience tests around the real HTTP client, database command timeout, or messaging client.

Test Data Builders and Object Mothers

Test doubles often become easier to use when combined with test data builders.

Example:

Code
public sealed class OrderBuilder
{
    private int _id = 1;
    private string _email = "[email protected]";
    private decimal _total = 100m;

    public OrderBuilder WithId(int id)
    {
        _id = id;
        return this;
    }

    public OrderBuilder WithEmail(string email)
    {
        _email = email;
        return this;
    }

    public OrderBuilder WithTotal(decimal total)
    {
        _total = total;
        return this;
    }

    public Order Build()
    {
        return new Order
        {
            Id = _id,
            CustomerEmail = _email,
            Total = _total
        };
    }
}

Usage:

Code
var order = new OrderBuilder()
    .WithTotal(250m)
    .Build();

This keeps tests readable and reduces irrelevant setup noise.

Common Mistakes

Common mistakes include:

  • Calling every test double a mock.
  • Mocking the class under test.
  • Mocking simple data objects.
  • Verifying every internal method call.
  • Writing tests that duplicate implementation logic.
  • Using mocks to hide poor design instead of improving boundaries.
  • Mocking EF Core queries and assuming persistence is tested.
  • Using the EF Core InMemory provider as proof of relational database behavior.
  • Mocking ASP.NET Core authorization and assuming endpoint security is tested.
  • Having only unit tests and no integration tests.
  • Having only integration tests and no fast unit tests.
  • Making tests depend on exact call order without a business reason.
  • Returning unrealistic data from stubs.
  • Letting fake implementations become more complex than production code.
  • Ignoring cancellation tokens, exceptions, and failure scenarios in mocked dependencies.

Best Practices

Use test doubles intentionally.

Good habits include:

  • Mock external boundaries, not simple domain logic.
  • Prefer state-based assertions when possible.
  • Use interaction verification only when the interaction is the behavior.
  • Keep mock setup small and relevant.
  • Use fakes for common, reusable test behavior.
  • Use spies when recording calls is simpler than using a mocking library.
  • Use integration tests for framework behavior, DI, database behavior, serialization, authentication, authorization, and middleware.
  • Test real EF Core queries with a relational provider when query behavior matters.
  • Wrap external systems behind adapter interfaces.
  • Keep test double behavior realistic.
  • Avoid testing implementation details.
  • Use clear names such as FakeUserRepository, StubClock, or SpyEmailSender.
  • Add contract tests for important external boundaries.
  • Combine unit tests and integration tests instead of relying on only one style.
  • Treat high code coverage from heavily mocked tests with caution.
  • Refactor production code when tests require excessive mocking.

Practical Rule of Thumb

A practical interview-ready answer is:

Code
Use mocks to isolate business logic from external boundaries.
Use fakes or stubs when simple controlled behavior is enough.
Use integration tests when the risk is in wiring, database behavior, framework behavior, serialization, security, or external contracts.
Avoid mocking so much that tests only prove the mocks were configured correctly.

Interview Practice

PreviousOverriding services and configuration in .NET testsNext UpTesting ASP.NET Core security, pipeline behavior, validation, and error responses