Saltar al contenido principal

Testing 🟡

Pirámide de Testing

          /\
/ \
/ E2E \ ← Pocos, lentos, costosos
/--------\
/Integration\ ← Algunos, más lentos
/--------------\
/ Unit Tests \ ← Muchos, rápidos, baratos
/------------------\

Unit Testing en .NET con xUnit

// Paquetes: xUnit, FluentAssertions, Moq, Microsoft.NET.Test.Sdk

public class CalculadoraTests
{
private readonly Calculadora _calc = new();

[Fact]
public void Sumar_DosNumeros_RetornaSuma()
{
// Arrange
int a = 5, b = 3;

// Act
int resultado = _calc.Sumar(a, b);

// Assert
Assert.Equal(8, resultado);
// Con FluentAssertions (más legible):
resultado.Should().Be(8);
}

[Theory]
[InlineData(0, 5, 5)]
[InlineData(-1, 1, 0)]
[InlineData(10, -3, 7)]
public void Sumar_VariosScenarios_RetornaCorrecto(int a, int b, int esperado)
{
_calc.Sumar(a, b).Should().Be(esperado);
}

[Fact]
public void Dividir_PorCero_LanzaException()
{
Action accion = () => _calc.Dividir(10, 0);
accion.Should().Throw<DivideByZeroException>();
}
}

Mocking con Moq

public class ProductoServiceTests
{
private readonly Mock<IProductoRepository> _repoMock;
private readonly Mock<IEmailService> _emailMock;
private readonly ProductoService _service;

public ProductoServiceTests()
{
_repoMock = new Mock<IProductoRepository>();
_emailMock = new Mock<IEmailService>();
_service = new ProductoService(_repoMock.Object, _emailMock.Object);
}

[Fact]
public async Task CrearProducto_Valido_RetornaProductoCreado()
{
// Arrange
var dto = new CrearProductoDto("Laptop", 999.99m, 1);
var productoEsperado = new Producto { Id = 1, Nombre = "Laptop", Precio = 999.99m };

_repoMock
.Setup(r => r.CrearAsync(It.IsAny<Producto>()))
.ReturnsAsync(productoEsperado);

// Act
var resultado = await _service.CrearAsync(dto);

// Assert
resultado.Should().NotBeNull();
resultado.Nombre.Should().Be("Laptop");
_repoMock.Verify(r => r.CrearAsync(It.IsAny<Producto>()), Times.Once);
}

[Fact]
public async Task CrearProducto_CategoriaInexistente_LanzaNotFoundException()
{
_repoMock
.Setup(r => r.ExisteCategoriaAsync(It.IsAny<int>()))
.ReturnsAsync(false);

Func<Task> accion = () => _service.CrearAsync(new CrearProductoDto("Test", 10, 999));

await accion.Should().ThrowAsync<NotFoundException>()
.WithMessage("*categoría*");
}
}

Integration Testing en ASP.NET Core

// WebApplicationFactory: levanta la app en memoria para tests
public class ProductosControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;

public ProductosControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Reemplazar DB real con SQLite en memoria
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);

services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
});
})
.CreateClient();
}

[Fact]
public async Task GET_Productos_RetornaOk()
{
var response = await _client.GetAsync("/api/productos");

response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<List<ProductoDto>>();
body.Should().NotBeNull();
}

[Fact]
public async Task POST_ProductoValido_Retorna201()
{
var dto = new { Nombre = "Test", Precio = 99.99, CategoriaId = 1 };
var response = await _client.PostAsJsonAsync("/api/productos", dto);

response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
}
}

Testing en React con Vitest y Testing Library

// Vitest + @testing-library/react + @testing-library/user-event

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { FormularioLogin } from './FormularioLogin';

describe('FormularioLogin', () => {
it('muestra error cuando las credenciales son incorrectas', async () => {
const mockLogin = vi.fn().mockRejectedValue(new Error('Credenciales inválidas'));
const user = userEvent.setup();

render(<FormularioLogin onLogin={mockLogin} />);

await user.type(screen.getByLabelText(/email/i), 'test@test.com');
await user.type(screen.getByLabelText(/contraseña/i), 'incorrecta');
await user.click(screen.getByRole('button', { name: /iniciar sesión/i }));

await waitFor(() => {
expect(screen.getByText(/credenciales inválidas/i)).toBeInTheDocument();
});
});

it('llama a onLogin con las credenciales correctas', async () => {
const mockLogin = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();

render(<FormularioLogin onLogin={mockLogin} />);

await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/contraseña/i), 'password123');
await user.click(screen.getByRole('button', { name: /iniciar sesión/i }));

expect(mockLogin).toHaveBeenCalledWith('user@example.com', 'password123');
});
});

// Mock de módulos
vi.mock('../services/api', () => ({
fetchProductos: vi.fn().mockResolvedValue([
{ id: 1, nombre: 'Laptop', precio: 999 }
])
}));

Preguntas frecuentes de entrevista 🎯

1. ¿Cuál es la diferencia entre unit test e integration test?

Unit test: prueba una unidad aislada (clase, método) con dependencias mockeadas. Rápido. Integration test: prueba múltiples componentes juntos (controller + service + DB). Más lento pero prueba el flujo real.

2. ¿Qué es TDD?

Test-Driven Development: escribes el test primero (falla), luego escribes el código mínimo para pasarlo, luego refactorizas. Ciclo: Red → Green → Refactor.

3. ¿Qué deberías mockear y qué no?

Mockear: dependencias externas (DB, APIs externas, email, sistema de archivos). No mockear: la lógica que estás probando, ni clases del framework que ya tienen tests.

4. ¿Cómo calculas la cobertura de código y qué porcentaje es bueno?

Con dotnet test --collect:"XPlat Code Coverage" o Coverlet. No hay un número mágico — 80% es un objetivo común, pero más importante que el número es cubrir los casos críticos y los edge cases. 100% de cobertura no significa 0 bugs.