Test Doubles en .NET: Dummies, Stubs, Spies, Mocks y Fakes explicados con un solo ejemplo

Los test doubles son el término paraguas para cualquier dependencia falsa en un test. En esta guía verás la diferencia real entre dummies, stubs, spies, mocks y fakes, cuándo usar cada uno, cómo implementarlos a mano o con NSubstitute/Moq, y los errores más comunes al usarlos — todo con un único escenario de ejemplo en C#.


1. ¿Qué es un test double?

Un test double es cualquier objeto falso que sustituye a una dependencia real dentro de un test. El nombre viene del cine: igual que un stunt double (doble de acción) sustituye al actor en las escenas peligrosas, un test double sustituye al objeto real en las escenas "peligrosas" de tus pruebas: bases de datos, APIs externas, envío de emails, el reloj del sistema...

¿Por qué no usar las dependencias reales? Porque son difíciles de configurar de forma consistente: necesitarías una base de datos con datos conocidos, un servidor SMTP que no envíe emails de verdad, una API de pagos que no cobre... Un double te da aislamiento, velocidad y determinismo: el test solo ejercita la unidad bajo prueba (System Under Test, SUT) y siempre se comporta igual.

El detalle importante: no todos los doubles son iguales. Hay cinco tipos, y elegir mal produce tests frágiles o tests que no prueban nada útil.

2. La taxonomía: cinco tipos, dos familias

Los doubles se agrupan según el tipo de interacción que sustituyen:

  • Interacciones entrantes (incoming): el SUT consulta información a la dependencia (leer de BD, obtener configuración). No cambian estado fuera del SUT → aquí viven stubs y fakes.
  • Interacciones salientes (outgoing): el SUT ordena algo a la dependencia (enviar email, guardar, cobrar). Cambian estado en el sistema externo → aquí viven mocks y spies. Cuando los mocks se crean manualmente, se les suele llamar spies.
Tipo Familia Qué hace Puede fallar el test por...
Dummy Ninguna Rellena un parámetro requerido; nunca se usa Nada (si se usa, es que sobra)
Stub Entrante Devuelve respuestas fijas y predefinidas No falla: solo prepara el escenario
Spy Saliente (manual) Stub que además registra las llamadas recibidas Lo que registró, verificado al final
Mock Saliente Interacción pre-programada que debe ocurrir Llamadas que no ocurrieron (o ocurrieron mal)
Fake Entrante Implementación real simplificada que funciona Nada por sí mismo: es código real

Dos reglas mnemotécnicas:

  1. Stub = entrada, Mock = salida. Si tu test pregunta "¿qué devuelve la dependencia?", es un stub. Si pregunta "¿la dependencia fue llamada correctamente?", es un mock.
  2. El stub no afecta al resultado del test (solo monta el escenario); el mock sí puede hacerlo fallar, porque su expectativa forma parte de la aserción.

3. Verificación de estado vs verificación de comportamiento

La taxonomía conecta con las dos formas de asertar:

  • Verificación de estado: asserts sobre el valor devuelto o el estado visible del SUT. Estilo natural con stubs y fakes.
  • Verificación de comportamiento: asserts sobre las interacciones entre el SUT y sus colaboradores. Estilo natural con mocks y spies.

Si recuerdas CQS (Command-Query Separation): las queries devuelven datos sin efectos secundarios → se sustituyen con stubs y se verifica por estado. Los commands cambian estado → se sustituyen con mocks y se verifican por interacción. Mockear una query suele ser señal de que estás probando implementación, no comportamiento.

4. El escenario: un caso, cinco dobles

Usaremos un único servicio durante todo el artículo: restablecer la contraseña de un usuario. Necesita leer de un repositorio (interacción entrante), guardar el cambio y enviar un email (salientes).

public interface IUserRepository
{
    User? GetById(int id);
    void Save(User user);
}

public interface IEmailSender
{
    bool SendEmail(string to, string subject, string body);
}

public class UserService(IUserRepository repository, IEmailSender emailSender)
{
    public bool ResetPassword(int userId, string newPassword)
    {
        var user = repository.GetById(userId);      // query  → stub/fake
        if (user is null) return false;

        user.PasswordHash = PasswordHasher.Hash(newPassword);
        repository.Save(user);                      // command → mock/spy

        return emailSender.SendEmail(               // command → mock/spy
            user.Email, "Tu contraseña cambió", "Si no fuiste tú, contacta soporte.");
    }
}

4.1 Dummy: el parámetro que jamás se usa

A veces el constructor exige una dependencia que tu test no ejercita (un logger, un reloj). El dummy existe solo para satisfacer la firma:

var dummyClock = Substitute.For<ISystemClock>();   // nadie lo llamará
var sut = new ReportService(repoReal, dummyClock);

Regla práctica: si un dummy empieza a recibir llamadas relevantes para el test, ya no es un dummy — conviértelo en stub o mock.

4.2 Stub: entrada de datos controlada

Devuelve lo que necesitamos para montar el escenario, y nada más:

var repo = Substitute.For<IUserRepository>();
repo.GetById(42).Returns(new User { Id = 42, Email = "ana@example.com" });

var result = sut.ResetPassword(userId: 42, "Nueva#Clave");

Assert.True(result);   // verificamos el RESULTADO del SUT, no las llamadas

Fíjate: el Assert comprueba el resultado del SUT. El stub no participa en la aserción — eso es exactamente lo que lo hace stub.

4.3 Spy: un stub que recuerda

La versión artesanal: implementas la interfaz a mano y guardas evidencia de las llamadas para inspeccionarla al final:

public class SpyEmailSender : IEmailSender
{
    public List<(string To, string Subject)> Sent { get; } = [];

    public bool SendEmail(string to, string subject, string body)
    {
        Sent.Add((to, subject));
        return true;                               // escenario feliz
    }
}

// en el test
var spy = new SpyEmailSender();
sut.ResetPassword(42, "Nueva#Clave");
Assert.Single(spy.Sent);                           // verificación DESPUÉS del Act
Assert.Equal(("ana@example.com", "Tu contraseña cambió"), spy.Sent[0]);

La diferencia con el mock es el momento: el spy graba ahora, verifica después; el mock declara la expectativa antes y el framework verifica por ti.

4.4 Mock: la interacción saliente obligatoria

Con NSubstitute, Substitute.For<T>() crea el doble y Received() convierte la llamada esperada en aserción:

var emailSender = Substitute.For<IEmailSender>();
emailSender.SendEmail(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
           .Returns(true);

var sut = new UserService(repo, emailSender);
sut.ResetPassword(42, "Nueva#Clave");

emailSender.Received(1).SendEmail(
    "ana@example.com",
    "Tu contraseña cambió",
    Arg.Is<string>(b => b.Contains("Si no fuiste tú")));

Si esa llamada no ocurre exactamente una vez con esos argumentos, el test falla — ese es el rasgo distintivo del mock. También puedes verificar ausencias: emailSender.DidNotReceive().SendEmail(...), ideal para el caso "usuario no existe → no se envía email".

Y si necesitas una reacción con efectos cuando ocurre la llamada, usa When(...).Do(...):

emailSender.When(x => x.SendEmail(
        Arg.Is<string>(to => to.EndsWith("example.com")),
        Arg.Any<string>(), Arg.Any<string>()))
     .Do(_ => output.WriteLine("Email enviado al dominio de ejemplo."));

4.5 Fake: la implementación real pero simple

Un fake funciona de verdad, solo que de forma simplificada. El clásico: repositorio en memoria respaldado por un diccionario:

public class FakeUserRepository(params User[] seed) : IUserRepository
{
    private readonly Dictionary<int, User> _users =
        seed.ToDictionary(u => u.Id);

    public User? GetById(int id) =>
        _users.TryGetValue(id, out var user) ? user : null;

    public void Save(User user) => _users[user.Id] = user;
}

Ventaja frente al stub: el fake mantiene estado entre llamadas, así que puedes probar flujos completos (guardar → recuperar → comprobar que la contraseña nueva está). Para bases de datos existen fakes ya hechos: el proveedor InMemory de EF Core o SQLite in-memory, muy usados en tests de integración ligeros.

El test completo, juntándolo todo

[Fact]
public void ResetPassword_UsuarioExistente_HasheaYNotifica()
{
    // Arrange: fake para datos, mock para el comando saliente
    var repo = new FakeUserRepository(new User { Id = 42, Email = "ana@example.com" });
    var emailSender = Substitute.For<IEmailSender>();
    emailSender.SendEmail(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
               .Returns(true);
    var sut = new UserService(repo, emailSender);

    // Act
    var result = sut.ResetPassword(42, "Nueva#Clave");

    // Assert: estado sobre el fake, comportamiento sobre el mock
    Assert.True(result);
    Assert.NotEqual("old-hash", repo.GetById(42)!.PasswordHash);
    emailSender.Received(1).SendEmail(
        "ana@example.com", "Tu contraseña cambió", Arg.Any<string>());
}

5. ¿A mano o con framework?

Escribe doubles a mano cuando:

  • El fake es trivial (una lista o diccionario en memoria) — será más legible y reutilizable que su equivalente con framework.
  • Necesitas un spy con semántica de dominio (Sent, LastRecipient), que documenta la intención mejor que Received().
  • La interfaz es pequeña y estable.

Usa un framework (NSubstitute, Moq) cuando:

  • Verificas interacciones en interfaces grandes o con muchos miembros.
  • Necesitas argument matching complejo (Arg.Is<T>(x => ...)).
  • Quieres verificar ausencias o número exacto de llamadas sin escribir contadores a mano.

NSubstitute vs Moq, traducción rápida

Necesidad NSubstitute Moq
Crear el doble Substitute.For<T>() new Mock<T>().Object
Programar respuesta sub.Metodo().Returns(x) mock.Setup(m => m.Metodo()).Returns(x)
Lanzar excepción sub.Metodo().Throws(ex) mock.Setup(...).Throws(ex)
Verificar llamada sub.Received(1).Metodo() mock.Verify(m => m.Metodo(), Times.Once)
Cualquier argumento Arg.Any<T>() It.IsAny<T>()
Argumento con condición Arg.Is<T>(a => ...) It.Is<T>(a => ...)

⚠️ Nota sobre Moq: en agosto de 2023, su versión 4.20.0 incluyó SponsorLink, un componente ofuscado que enviaba el email configurado en Git a un servicio externo. Fue retirado días después tras el rechazo de la comunidad, pero muchos equipos migraron entonces a NSubstitute, que hoy es la opción por defecto recomendada en la documentación moderna de .NET.

En este blog usamos NSubstitute en los ejemplos; si vienes de Moq, la tabla anterior es todo el diccionario que necesitas.

6. Errores comunes con test doubles

  1. Over-mocking: sustituir todo con mocks produce tests acoplados a la implementación: cualquier refactor rompe tests aunque el comportamiento siga correcto. Regla: mock solo los commands que cruzan la frontera del sistema.
  2. Verificar queries: asertar que se llamó a GetById() prueba el cómo interno, no el qué. Usa stubs para queries y verifica el resultado del SUT.
  3. Mockear lo que no te pertenece: mockear directamente HttpClient o librerías de terceros acopla tus tests a detalles ajenos. Envuélvelas tras una interfaz propia y hazle fake/spy a tu wrapper.
  4. Confundir stub con mock: si configuras respuestas en un doble y luego además lo verificas con Received(), estás mezclando las dos familias y probablemente probando implementación.
  5. Fakes demasiado simples: un fake que no replica las reglas esenciales (unicidad, validaciones) puede dar falsos positivos. Un fake debe ser correcto aunque limitado.

7. Checklist

  • Identifica si cada dependencia del test es query (entrante) o command (saliente)
  • Queries → stub/fake; commands → mock/spy
  • Dummies solo para parámetros que el escenario no usa
  • Fakes escritos a mano cuando son triviales y reutilizables
  • Framework (NSubstitute) para verificación de interacciones y argument matching
  • Nada de Received() sobre queries ni verificaciones de llamadas internas
  • Cada double tiene una sola razón de existir en el test

Fuentes

  1. Tools and Skills for .NET 8 (Mark J. Price) — cap. 11: definición de test double, mocks como interacciones salientes vs stubs como entrantes, spies como mocks manuales, mocking con NSubstitute
  2. Clean Code with C# (Jason Alls, 2ª ed.) — stubs que preparan el escenario sin afectar al resultado, mocks que pueden hacer fallar el test
  3. Building Modern SaaS Applications with C# and .NET — técnica de stubbing con datos predefinidos y fakes como implementaciones simplificadas
  4. Web API Development with ASP.NET Core 8 — InMemoryDatabase de EF Core y SQLite in-memory como fakes para probar la capa de acceso a datos
  5. Refactoring with C# — verificación de llamadas con Moq (Verify, Times.Once) y comparativa con NSubstitute
  6. Real World Web Development with .NET 9 — API de NSubstitute (Returns, Received, Arg.Any/Arg.Is, When().Do()) y la controversia SponsorLink de Moq