TDD in .NET: Working with Dependencies (Mocks, Stubs, and Fakes)
🎯 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 dependencyHow 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 exampleFix: 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 exampleFix: 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 setupFix: 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:
- Dependencies are design feedback
If your code is hard to test, it's telling you the design needs improvement. Listen to that feedback.
- 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
- Great tests focus on behavior, not implementation
Verify outcomes and contracts, not internal mechanics.
- 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:
- Write tests using mocks to verify behavior
- Create a fake implementation for integration-style testing
- 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!
