Módulo 07: TDD, Mocking y BDD

Objetivos del módulo

  • Practicar TDD (Test Driven Development) paso a paso
  • Comprender y aplicar mocking con Moq/NSubstitute
  • Conocer los principios de BDD (Behavior Driven Development)
  • Escribir tests que prueben comportamiento, no implementación

1. TDD — Test Driven Development

El ciclo Red-Green-Refactor

graph LR
    R["🔴 RED<br/>Escribir test<br/>que falla"] --> G["🟢 GREEN<br/>Escribir código<br/>mínimo para pasar"]
    G --> RF["🔵 REFACTOR<br/>Mejorar código<br/>sin romper tests"]
    RF --> R
    style R fill:#f44336,color:white
    style G fill:#4CAF50,color:white
    style RF fill:#2196F3,color:white
Fase Qué hacer Duración
RED Escribir un test que falle Minutos
GREEN Escribir el código mínimo para que pase Minutos
REFACTOR Limpiar código manteniendo tests en verde Minutos

📘 Concepto: En TDD escribes el test ANTES que el código. Esto garantiza que todo el código está testeado y solo escribes lo necesario.

Ejemplo paso a paso: FizzBuzz

Reglas: Dado un número:

  • Si es múltiplo de 3 → “Fizz”
  • Si es múltiplo de 5 → “Buzz”
  • Si es múltiplo de 3 y 5 → “FizzBuzz”
  • En caso contrario → el número como string

Paso 1 — RED: Primer test

public class FizzBuzzTests
{
    [Fact]
    public void Convertir_NumeroNormal_RetornaNumeroComoString()
    {
        Assert.Equal("1", FizzBuzz.Convertir(1));
    }
}
dotnet test  # ❌ FALLA — FizzBuzz no existe

Paso 2 — GREEN: Código mínimo

public class FizzBuzz
{
    public static string Convertir(int numero) => numero.ToString();
}
dotnet test  # ✅ PASA

Paso 3 — RED: Siguiente test

[Fact]
public void Convertir_MultiploDe3_RetornaFizz()
{
    Assert.Equal("Fizz", FizzBuzz.Convertir(3));
}
dotnet test  # ❌ FALLA — retorna "3" en vez de "Fizz"

Paso 4 — GREEN

public static string Convertir(int numero)
{
    if (numero % 3 == 0) return "Fizz";
    return numero.ToString();
}

Paso 5 — RED: Test para múltiplo de 5

[Fact]
public void Convertir_MultiploDe5_RetornaBuzz()
{
    Assert.Equal("Buzz", FizzBuzz.Convertir(5));
}

Paso 6 — GREEN

public static string Convertir(int numero)
{
    if (numero % 3 == 0) return "Fizz";
    if (numero % 5 == 0) return "Buzz";
    return numero.ToString();
}

Paso 7 — RED: Test para múltiplo de 3 y 5

[Fact]
public void Convertir_MultiploDe3y5_RetornaFizzBuzz()
{
    Assert.Equal("FizzBuzz", FizzBuzz.Convertir(15));
}

Paso 8 — GREEN

public static string Convertir(int numero)
{
    if (numero % 15 == 0) return "FizzBuzz";
    if (numero % 3 == 0) return "Fizz";
    if (numero % 5 == 0) return "Buzz";
    return numero.ToString();
}

Paso 9 — REFACTOR

[Theory]
[InlineData(1, "1")]
[InlineData(2, "2")]
[InlineData(3, "Fizz")]
[InlineData(5, "Buzz")]
[InlineData(6, "Fizz")]
[InlineData(10, "Buzz")]
[InlineData(15, "FizzBuzz")]
[InlineData(30, "FizzBuzz")]
public void Convertir_Numeros_RetornaResultadoCorrecto(int numero, string esperado)
{
    Assert.Equal(esperado, FizzBuzz.Convertir(numero));
}

2. Mocking — Simular dependencias

¿Por qué mocking?

Las pruebas unitarias deben ser aisladas. Si tu clase depende de una base de datos, servicio web o sistema de archivos, usas mocks para simularlos.

graph LR
    A[Test] --> B[Clase bajo prueba]
    B --> C[Mock de repositorio<br/>Simula la BD]
    B --> D[Mock de email<br/>Simula el envío]
    style C fill:#FF9800,color:white
    style D fill:#FF9800,color:white

Tipos de dobles de prueba

Tipo Descripción Ejemplo
Mock Verifica que se llamó un método ¿Se llamó EnviarEmail()?
Stub Devuelve datos predefinidos GetUsuario() retorna un usuario fake
Fake Implementación simplificada BD en memoria en vez de SQL Server
Spy Registra las llamadas Verificar cuántas veces se llamó

Instalar NSubstitute

dotnet add MiApp.Tests package NSubstitute

NSubstitute es más moderno y sencillo que Moq. Usaremos ambos.


3. Mocking con NSubstitute

Ejemplo: Servicio de pedidos

// Interfaces (en MiApp)
public interface IRepositorioPedido
{
    Task<Pedido?> ObtenerPorIdAsync(int id);
    Task GuardarAsync(Pedido pedido);
}

public interface IServicioEmail
{
    Task EnviarConfirmacionAsync(string email, int pedidoId);
}

// Modelo
public class Pedido
{
    public int Id { get; set; }
    public string ClienteEmail { get; set; } = "";
    public decimal Total { get; set; }
    public bool Confirmado { get; set; }
}

// Servicio bajo prueba
public class PedidoService
{
    private readonly IRepositorioPedido _repo;
    private readonly IServicioEmail _email;

    public PedidoService(IRepositorioPedido repo, IServicioEmail email)
    {
        _repo = repo;
        _email = email;
    }

    public async Task<bool> ConfirmarAsync(int pedidoId)
    {
        var pedido = await _repo.ObtenerPorIdAsync(pedidoId);
        if (pedido is null) return false;

        pedido.Confirmado = true;
        await _repo.GuardarAsync(pedido);
        await _email.EnviarConfirmacionAsync(pedido.ClienteEmail, pedidoId);

        return true;
    }
}

Tests con NSubstitute

using NSubstitute;

public class PedidoServiceTests
{
    private readonly IRepositorioPedido _repo;
    private readonly IServicioEmail _email;
    private readonly PedidoService _servicio;

    public PedidoServiceTests()
    {
        _repo = Substitute.For<IRepositorioPedido>();   // Mock del repo
        _email = Substitute.For<IServicioEmail>();       // Mock del email
        _servicio = new PedidoService(_repo, _email);   // Inyectar mocks
    }

    [Fact]
    public async Task Confirmar_PedidoExiste_RetornaTrue()
    {
        // Arrange: configurar el stub
        var pedido = new Pedido { Id = 1, ClienteEmail = "a@b.com", Total = 100 };
        _repo.ObtenerPorIdAsync(1).Returns(pedido);

        // Act
        bool resultado = await _servicio.ConfirmarAsync(1);

        // Assert
        Assert.True(resultado);
    }

    [Fact]
    public async Task Confirmar_PedidoExiste_GuardaEnRepo()
    {
        var pedido = new Pedido { Id = 1, ClienteEmail = "a@b.com" };
        _repo.ObtenerPorIdAsync(1).Returns(pedido);

        await _servicio.ConfirmarAsync(1);

        // Verificar que se llamó a GuardarAsync con el pedido confirmado
        await _repo.Received(1).GuardarAsync(
            Arg.Is<Pedido>(p => p.Confirmado == true)
        );
    }

    [Fact]
    public async Task Confirmar_PedidoExiste_EnviaEmail()
    {
        var pedido = new Pedido { Id = 1, ClienteEmail = "a@b.com" };
        _repo.ObtenerPorIdAsync(1).Returns(pedido);

        await _servicio.ConfirmarAsync(1);

        // Verificar que se envió el email correcto
        await _email.Received(1).EnviarConfirmacionAsync("a@b.com", 1);
    }

    [Fact]
    public async Task Confirmar_PedidoNoExiste_RetornaFalse()
    {
        _repo.ObtenerPorIdAsync(99).Returns((Pedido?)null);

        bool resultado = await _servicio.ConfirmarAsync(99);

        Assert.False(resultado);
    }

    [Fact]
    public async Task Confirmar_PedidoNoExiste_NoEnviaEmail()
    {
        _repo.ObtenerPorIdAsync(99).Returns((Pedido?)null);

        await _servicio.ConfirmarAsync(99);

        // Verificar que NO se llamó a enviar email
        await _email.DidNotReceive().EnviarConfirmacionAsync(
            Arg.Any<string>(), Arg.Any<int>()
        );
    }
}

4. Mocking con Moq (alternativa)

dotnet add MiApp.Tests package Moq
using Moq;

public class PedidoServiceMoqTests
{
    private readonly Mock<IRepositorioPedido> _repoMock;
    private readonly Mock<IServicioEmail> _emailMock;
    private readonly PedidoService _servicio;

    public PedidoServiceMoqTests()
    {
        _repoMock = new Mock<IRepositorioPedido>();
        _emailMock = new Mock<IServicioEmail>();
        _servicio = new PedidoService(_repoMock.Object, _emailMock.Object);
    }

    [Fact]
    public async Task Confirmar_PedidoExiste_RetornaTrue()
    {
        // Arrange: configurar stub
        var pedido = new Pedido { Id = 1, ClienteEmail = "a@b.com" };
        _repoMock.Setup(r => r.ObtenerPorIdAsync(1))
                 .ReturnsAsync(pedido);

        // Act
        bool resultado = await _servicio.ConfirmarAsync(1);

        // Assert
        Assert.True(resultado);

        // Verify: se llamó a GuardarAsync exactamente 1 vez
        _repoMock.Verify(r => r.GuardarAsync(
            It.Is<Pedido>(p => p.Confirmado)), Times.Once);

        // Verify: se envió email
        _emailMock.Verify(e => e.EnviarConfirmacionAsync(
            "a@b.com", 1), Times.Once);
    }
}

NSubstitute vs Moq

Aspecto NSubstitute Moq
Crear mock Substitute.For<T>() new Mock<T>()
Configurar .Returns(valor) .Setup().Returns()
Verificar llamada .Received(1).Metodo() .Verify(m => m.Metodo(), Times.Once)
Verificar no llama .DidNotReceive() .Verify(..., Times.Never)
Cualquier arg Arg.Any<T>() It.IsAny<T>()
Arg condicional Arg.Is<T>(x => ...) It.Is<T>(x => ...)
Estilo Más conciso Más explícito

5. BDD — Behavior Driven Development

Concepto

BDD describe el comportamiento del software en lenguaje natural que tanto técnicos como no técnicos entienden.

Gherkin — Lenguaje de especificación

Feature: Login de usuario
  Como usuario registrado
  Quiero hacer login
  Para acceder a mi cuenta

  Scenario: Login exitoso
    Given un usuario registrado con email "ana@test.com" y password "Pass123!"
    When intenta hacer login con email "ana@test.com" y password "Pass123!"
    Then el login es exitoso
    And se muestra el mensaje "Bienvenida, Ana"

  Scenario: Login fallido por contraseña incorrecta
    Given un usuario registrado con email "ana@test.com" y password "Pass123!"
    When intenta hacer login con email "ana@test.com" y password "incorrecta"
    Then el login falla
    And se muestra el error "Credenciales incorrectas"

  Scenario Outline: Login con datos inválidos
    When intenta hacer login con email "<email>" y password "<password>"
    Then el login falla

    Examples:
      | email          | password |
      |                | Pass123! |
      | ana@test.com   |          |
      | noexiste@x.com | Pass123! |

BDD con Reqnroll (sucesor de SpecFlow para .NET)

dotnet add package Reqnroll
dotnet add package Reqnroll.xUnit
// Steps/LoginSteps.cs
using Reqnroll;

[Binding]
public class LoginSteps
{
    private LoginService _servicio = null!;
    private LoginResult _resultado = null!;

    [Given(@"un usuario registrado con email ""(.*)"" y password ""(.*)""")]
    public void DadoUsuarioRegistrado(string email, string password)
    {
        _servicio = new LoginService();
        _servicio.Registrar(email, password, "Ana");
    }

    [When(@"intenta hacer login con email ""(.*)"" y password ""(.*)""")]
    public void CuandoIntentaLogin(string email, string password)
    {
        _resultado = _servicio.Login(email, password);
    }

    [Then(@"el login es exitoso")]
    public void EntoncesLoginExitoso()
    {
        Assert.True(_resultado.Exitoso);
    }

    [Then(@"el login falla")]
    public void EntoncesLoginFalla()
    {
        Assert.False(_resultado.Exitoso);
    }

    [Then(@"se muestra el mensaje ""(.*)""")]
    public void EntoncesMuestraMensaje(string mensaje)
    {
        Assert.Equal(mensaje, _resultado.Mensaje);
    }

    [Then(@"se muestra el error ""(.*)""")]
    public void EntoncesMuestraError(string error)
    {
        Assert.Equal(error, _resultado.Error);
    }
}

Cuándo usar BDD

Usar BDD cuando… No usar BDD cuando…
Hay stakeholders no técnicos Solo desarrolladores leen los tests
Requisitos complejos de negocio Lógica técnica pura
Documentación viva es importante Tests unitarios simples
Equipos grandes multidisciplinares Equipo pequeño ágil

6. Buenas prácticas de testing

Principios FIRST

Principio Descripción
Fast Rápidos (milisegundos)
Independent No dependen unos de otros
Repeatable Mismo resultado siempre
Self-validating Pass/Fail automático
Timely Se escriben a tiempo (con el código)

Qué mockear y qué no

Mockear No mockear
Bases de datos La clase bajo prueba
APIs externas Valores de retorno simples
Servicios de email Clases de utilidad puras
Sistema de archivos (opcional) Lógica interna de la clase

Antipatrones

Antipatrón Problema Solución
Test frágil Se rompe con cambios internos Testear comportamiento, no implementación
Test acoplado Depende de otros tests Usar SetUp para estado limpio
Test lento Accede a BD/red Usar mocks
Test trivial Testea obviedades Enfocarse en lógica de negocio
Lógica en test If/for en el test Datos estáticos, sin lógica

7. Ejercicios

Ejercicio 1: TDD

Implementa usando TDD (Red-Green-Refactor) una clase RomanConverter que convierta números romanos a enteros:

  • I=1, V=5, X=10, L=50, C=100, D=500, M=1000
  • IV=4, IX=9, XL=40, XC=90, CD=400, CM=900

Ejercicio 2: Mocking

Crea un NotificacionService que dependa de IRepositorioUsuario y IServicioSMS. Escribe tests que verifiquen que se envía SMS solo a usuarios activos.

Ejercicio 3: BDD

Escribe escenarios Gherkin para un sistema de reservas de hotel con: buscar disponibilidad, reservar, cancelar, modificar fecha.


Resumen

Concepto Descripción
TDD Red → Green → Refactor, test antes que código
Mock Simula una dependencia
Stub Mock que devuelve datos predefinidos
NSubstitute Substitute.For<T>(), .Returns(), .Received()
Moq new Mock<T>(), .Setup(), .Verify()
BDD Describe comportamiento en lenguaje natural (Gherkin)
FIRST Fast, Independent, Repeatable, Self-validating, Timely