TDD in .NET: Working with Dependencies (Mocks, Stubs, and Fakes)

TDD in .NET: Working with Dependencies (Mocks, Stubs, and Fakes)
Author(s): Ajay Kumar
Last updated: 01 Nov 2025

🎯 When the Real World Gets in the Way of Your Tests


You've just written a beautiful test. It's clear, focused, and follows the Red-Green-Refactor cycle perfectly. Then you realize your code needs to send an email, call an external API, or query a database. Suddenly, your tests become:

  • 🐢 Slow — waiting for database queries or API responses
  • 🎲 Unreliable — network issues, rate limits, or external service downtime
  • 🎭 Non-deterministic — data changes between test runs
  • 🔗 Tightly coupled — difficult to swap or replace dependencies
"The pain of testing dependencies is feedback. It's your code telling you that your design could be cleaner."

This article will help you understand why dependencies make TDD tricky, what test doubles are (mocks, stubs, fakes), and how they can actually improve your design—not just your tests.

🪄 The Problem: Why Real Dependencies Break TDD


Let's look at a common scenario. You're building a user registration system that sends a welcome email:


public class UserService
{
    private readonly EmailService _emailService;
    
    public UserService(EmailService emailService)
    {
        _emailService = emailService;
    }

    public void RegisterUser(string email, string username)
    {
        // ... validation and user creation logic ...
        
        _emailService.SendWelcomeEmail(email, username);
    }
}
        
Code Sample #1 : User registration with email dependency

How do you test this without actually sending emails? More importantly, should your test care about the email at all, or just that the registration logic works correctly?

⚠️ The Testing Dilemma:

  • If you use a real EmailService, tests become slow and may spam real email addresses
  • If you don't test the email integration, you miss a critical part of the feature
  • If you tightly couple to EmailService, changing email providers breaks all your tests

This is where Test Doubles come to the rescue. Think of them as stunt performers—they step in when the real thing is too risky, expensive, or slow for practice.

🧩 Meet the Test Doubles Family


Just like Hollywood has different types of stand-ins (stunt doubles, body doubles, CGI), testing has different types of doubles for different purposes. Let's meet the family:

Type What it does Example Use Case When to use
Dummy Passed but never actually used A null logger passed to satisfy a constructor Required parameter but unused in test
Stub Provides canned (pre-programmed) responses IUserRepo.GetUser() returns a fake user To control test data and setup
Fake Has working but simplified implementation In-memory database for testing Fast alternative for complex systems
Mock Verifies interactions and behavior Verify(x => x.SendEmail(...)) To assert that methods were called correctly
Spy Records calls for later inspection Capturing all API calls made during a test Debugging or detailed behavior verification

💡 Key Distinction: Mocks verify behavior (did you call this method?), while Stubs control state (here's the data you need). Understanding this difference is crucial for writing clean tests.

💡 Hands-On: Email Notification on User Signup


Let's solve our email problem step-by-step using TDD principles and test doubles.

Step 1: Identify the Problem

Our UserService is tightly coupled to EmailService. This makes it hard to test and violates the Dependency Inversion Principle. Let's fix the design first.


public interface IEmailService
{
    void SendWelcomeEmail(string email, string username);
}

public class UserService
{
    private readonly IEmailService _emailService;
    
    public UserService(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public void RegisterUser(string email, string username)
    {
        // ... validation logic ...
        _emailService.SendWelcomeEmail(email, username);
    }
}
        
Code Sample #2 : Introducing an interface for email service

🎨 Design Insight: By introducing IEmailService, we've made our code more flexible. Now we can swap email providers, use different implementations for testing, or even disable emails entirely without changing UserService.

Step 2: Write a Test with a Mock

Now let's write a test that verifies the email is sent, without actually sending it:


using Moq;
using Xunit;

public class UserServiceTests
{
    [Fact]
    public void RegisterUser_SendsWelcomeEmail_WithCorrectDetails()
    {
        // Arrange
        var mockEmailService = new Mock<IEmailService>();
        var userService = new UserService(mockEmailService.Object);

        // Act
        userService.RegisterUser("ajay@example.com", "Ajay");

        // Assert
        mockEmailService.Verify(
            e => e.SendWelcomeEmail("ajay@example.com", "Ajay"), 
            Times.Once
        );
    }
}
        
Code Sample #3 : Testing email sending with Moq

This test is fast, deterministic, and focused. It verifies that UserService correctly calls the email service without actually sending any emails.

💡 TDD Benefit: This test documents the behavior: "When registering a user, a welcome email should be sent with the user's details." Anyone reading this test understands the feature immediately.

Step 3: What About Stubs?

Stubs are useful when you need to control the return values of dependencies. Let's add a feature: users can't register twice with the same email. We need a repository to check for existing users.


public interface IUserRepository
{
    User? FindByEmail(string email);
    void Save(User user);
}

public class UserService
{
    private readonly IEmailService _emailService;
    private readonly IUserRepository _userRepository;
    
    public UserService(IEmailService emailService, IUserRepository userRepository)
    {
        _emailService = emailService;
        _userRepository = userRepository;
    }

    public void RegisterUser(string email, string username)
    {
        var existingUser = _userRepository.FindByEmail(email);
        if (existingUser != null)
            throw new InvalidOperationException("User already exists");
            
        var newUser = new User { Email = email, Username = username };
        _userRepository.Save(newUser);
        _emailService.SendWelcomeEmail(email, username);
    }
}

[Fact]
public void RegisterUser_ThrowsException_WhenEmailAlreadyExists()
{
    // Arrange
    var mockEmailService = new Mock<IEmailService>();
    var mockUserRepo = new Mock<IUserRepository>();
    
    // Stub: Control what FindByEmail returns
    mockUserRepo
        .Setup(r => r.FindByEmail("ajay@example.com"))
        .Returns(new User { Email = "ajay@example.com" });
    
    var userService = new UserService(mockEmailService.Object, mockUserRepo.Object);

    // Act & Assert
    Assert.Throws<InvalidOperationException>(
        () => userService.RegisterUser("ajay@example.com", "Ajay")
    );
}
        
Code Sample #4 : Using a stub for repository responses

Here, we're using Setup() to create a stub—we're controlling what data the repository returns, not verifying that it was called.

🧰 Beyond Basics: When to Use Fakes


Sometimes you want more realistic behavior without the overhead of real dependencies. That's where Fakes shine. A fake has a working implementation, but it's simplified and in-memory.


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

    public User? FindByEmail(string email)
    {
        return _users.FirstOrDefault(u => u.Email == email);
    }

    public void Save(User user)
    {
        _users.Add(user);
    }
}

[Fact]
public void RegisterUser_SavesUser_WhenEmailIsNew()
{
    // Arrange
    var mockEmailService = new Mock<IEmailService>();
    var fakeUserRepo = new InMemoryUserRepository(); // Using a fake instead of a mock
    var userService = new UserService(mockEmailService.Object, fakeUserRepo);

    // Act
    userService.RegisterUser("ajay@example.com", "Ajay");

    // Assert
    var savedUser = fakeUserRepo.FindByEmail("ajay@example.com");
    Assert.NotNull(savedUser);
    Assert.Equal("Ajay", savedUser.Username);
}
        
Code Sample #5 : Creating an in-memory fake repository

🎨 When to Choose Fakes vs Mocks:

  • Use Mocks when you care about how a dependency is called (behavior verification)
  • Use Stubs when you just need to control return values (state setup)
  • Use Fakes when you want realistic behavior without external dependencies (integration-like tests)

Fakes offer a middle ground:

  • ✅ More realistic than mocks—actual logic, not just assertions
  • ✅ Faster than real dependencies—everything stays in memory
  • ✅ Reusable across multiple tests

🔍 Design Feedback: What Your Mocks Are Telling You


Here's the secret most developers miss: Mocks don't just help you test—they reveal design flaws.

⚠️ Warning Signs in Your Tests:

Test Smell What It Reveals Design Fix
Too many mocks in one test Class has too many dependencies → violates Single Responsibility Principle Split the class into smaller, focused services
Deep mock chains like mock.Object.Property.Method() Tight coupling, Law of Demeter violation Introduce facades or aggregate interfaces
Repeating the same mock setup everywhere Common abstraction missing Create a test fixture or helper methods
Tests break when internal implementation changes Testing implementation details, not behavior Focus tests on public API and outcomes
"If your code is hard to test, it's not your tests' fault—it's your design asking for help."

⚖️ Common Testing Gotchas and Anti-Patterns


Let's call out some testing sins that even experienced developers fall into:

❌ Anti-Pattern 1: Mocking Everything


[Fact]
public void BadTest_MocksValueObjects()
{
    var mockUser = new Mock<User>(); // User is a value object!
    var mockEmail = new Mock<Email>(); // Email is a value type!
    // ... you're testing mocks, not code
}
            
Over-mocking example

Fix: Only mock interfaces and external dependencies. Use real value objects and domain models.

❌ Anti-Pattern 2: Verifying Too Much


[Fact]
public void BrittleTest_VerifiesInternals()
{
    var mock = new Mock<IEmailService>();
    // ... act ...
    
    // Verifying every single interaction makes tests brittle
    mock.Verify(e => e.Connect(), Times.Once);
    mock.Verify(e => e.Authenticate(), Times.Once);
    mock.Verify(e => e.SendEmail(...), Times.Once);
    mock.Verify(e => e.Disconnect(), Times.Once);
}
            
Over-verification example

Fix: Only verify behavior that matters to the test. Internal orchestration should be an implementation detail.

❌ Anti-Pattern 3: Inconsistent Stubs


[Fact]
public void ConfusingTest_InconsistentStubs()
{
    var mockRepo = new Mock<IUserRepository>();
    mockRepo.Setup(r => r.FindByEmail("test@example.com")).Returns(null);
    mockRepo.Setup(r => r.FindByEmail("ajay@example.com")).Returns(new User());
    
    // Test gets confusing—which email are we actually testing?
}
            
Inconsistent stub setup

Fix: Keep test data clear and focused. One scenario per test.

🎓 Practical Comparison: Mocks vs Stubs vs Fakes


Let's compare all three approaches for the same scenario: testing a PaymentProcessor that depends on a IPaymentGateway:


// === Using a Mock (Behavior Verification) ===
[Fact]
public void WithMock_VerifiesPaymentWasCalled()
{
    var mockGateway = new Mock<IPaymentGateway>();
    var processor = new PaymentProcessor(mockGateway.Object);
    
    processor.ProcessPayment(100);
    
    mockGateway.Verify(g => g.Charge(100), Times.Once);
}

// === Using a Stub (State Setup) ===
[Fact]
public void WithStub_ControlsGatewayResponse()
{
    var stubGateway = new Mock<IPaymentGateway>();
    stubGateway.Setup(g => g.Charge(It.IsAny<decimal>())).Returns(true);
    var processor = new PaymentProcessor(stubGateway.Object);
    
    var result = processor.ProcessPayment(100);
    
    Assert.True(result);
}

// === Using a Fake (Realistic Implementation) ===
public class FakePaymentGateway : IPaymentGateway
{
    public List<decimal> ChargedAmounts { get; } = new();
    
    public bool Charge(decimal amount)
    {
        ChargedAmounts.Add(amount);
        return amount > 0; // Simple validation logic
    }
}

[Fact]
public void WithFake_RealisticBehavior()
{
    var fakeGateway = new FakePaymentGateway();
    var processor = new PaymentProcessor(fakeGateway);
    
    processor.ProcessPayment(100);
    processor.ProcessPayment(50);
    
    Assert.Equal(2, fakeGateway.ChargedAmounts.Count);
    Assert.Equal(150, fakeGateway.ChargedAmounts.Sum());
}
        
Code Sample #6 : Comparison of test double approaches

💡 Choosing the Right Approach:

  • Unit tests → Fast and isolated → Use mocks and stubs
  • Integration tests → Realistic but fast → Use fakes
  • End-to-end tests → Full realism → Use real implementations (sparingly)

💬 Key Takeaways: Dependency Isolation = Design Clarity


"Mocks and fakes aren't testing tricks—they're design feedback tools."

Here's what we've learned about working with dependencies in TDD:

  1. Dependencies are design feedback

    If your code is hard to test, it's telling you the design needs improvement. Listen to that feedback.

  2. Test doubles serve different purposes
    • Mocks → Verify behavior ("Did you call this?")
    • Stubs → Control state ("Here's your data")
    • Fakes → Provide realistic but simplified implementations
  3. Great tests focus on behavior, not implementation

    Verify outcomes and contracts, not internal mechanics.

  4. Design for testability is good design

    Dependency injection, interface segregation, and single responsibility emerge naturally when you practice TDD.

🎨 Final Design Insight: When you find yourself creating many mocks for a single class, it's not a testing problem—it's a code smell. Your class is likely doing too much. Break it down, introduce clear boundaries, and watch both your code and tests become clearer.

🪶 Challenge: Refactor with Test Doubles


Try This:

Take one of your existing services that talks to a database or external API. Refactor it to use dependency injection with an interface. Then:

  1. Write tests using mocks to verify behavior
  2. Create a fake implementation for integration-style testing
  3. Compare the test speed and readability

Does it feel faster, more predictable, more readable? Did you discover any design issues along the way?

"Great TDD isn't about avoiding the real world. It's about learning to simulate it—gracefully."

🔜 What's Next?


Now that you understand how to handle dependencies, you're ready to tackle more complex scenarios. In the next article, we'll explore:

  • TDD in a Web API context with practical examples
  • Testing controllers, middleware, and request pipelines
  • Balancing unit tests, integration tests, and end-to-end tests
  • Building a complete feature using TDD from start to finish

Until then, practice isolating your dependencies and let your tests guide your design!


Have you struggled with testing dependencies before? What strategies worked for you? Share your experiences and questions in the comments below!

Copyright Š 2025 Dev Codex

An unhandled error has occurred. Reload 🗙