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 |