🔄 Proceso Paso a Paso
Objetivo: Implementar paso a paso la metodología de testing, desde la creación del proyecto hasta patrones avanzados, siguiendo las prácticas validadas del equipo.
🏗️ Fase 1: Setup del Proyecto de Testing
Section titled “🏗️ Fase 1: Setup del Proyecto de Testing”Paso 1.1: Crear Proyecto
Section titled “Paso 1.1: Crear Proyecto”1. Right-click en Solution → Add → New Project2. Buscar "xUnit Test Project"3. Nombre: TuProyecto.Tests4. Target Framework: Multiple (net8.0;net48)5. Right-click en Dependencies → Add Project Reference6. Seleccionar TuProyecto.CorePaso 1.2: Configurar .csproj
Section titled “Paso 1.2: Configurar .csproj”<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <!-- Multi-targeting para compatibilidad legacy y modern --> <TargetFrameworks>net48;net8.0</TargetFrameworks> <LangVersion>latest</LangVersion>
<!-- Calidad de código estricta --> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <NoWarn>NU1803, CS1591, SA1600</NoWarn> <IsPackable>false</IsPackable> </PropertyGroup>
<!-- Generación automática de documentación XML --> <PropertyGroup> <DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile> </PropertyGroup>
<ItemGroup> <!-- StyleCop configuration --> <AdditionalFiles Include="../../stylecop.json" />
<!-- Core testing packages --> <PackageReference Include="coverlet.msbuild" Version="6.0.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.1"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference>
<!-- Code quality --> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference>
<!-- Conditional packages --> <PackageReference Include="Moq.Dapper" Version="1.0.3" Condition="'$(UseDapper)' == 'true'" /> </ItemGroup>
</Project>Paso 1.3: Estructura de Carpetas Estándar
Section titled “Paso 1.3: Estructura de Carpetas Estándar”📁 TuProyecto.Tests/├── 📁 Services/ # Business logic testing│ ├── 📄 CacheServiceTests.cs│ ├── 📄 UserServiceTests.cs│ └── 📄 PaymentServiceTests.cs├── 📁 Repositories/ # Data access testing│ ├── 📄 UserRepositoryTests.cs│ └── 📄 ProductRepositoryTests.cs├── 📁 Controllers/ # API endpoint testing│ ├── 📄 UsersControllerTests.cs│ └── 📄 ProductsControllerTests.cs├── 📁 Helpers/ # Utilities testing│ ├── 📄 ValidationHelperTests.cs│ └── 📄 StringExtensionsTests.cs├── 📁 TestData/ # Test data builders│ ├── 📄 UserTestData.cs│ ├── 📄 ProductTestData.cs│ └── 📄 CacheTestData.cs├── 📁 Extensions/ # Testing utilities│ ├── 📄 MockLoggerExtensions.cs│ └── 📄 MockSetupHelpers.cs├── 📄 GlobalUsings.cs # Global using statements└── 📄 TuProyecto.Tests.csprojPaso 1.4: Configurar GlobalUsings.cs
Section titled “Paso 1.4: Configurar GlobalUsings.cs”global using Xunit;global using Moq;global using System;global using System.Collections.Generic;global using System.Linq;global using System.Threading;global using System.Threading.Tasks;global using Microsoft.Extensions.Logging;
// Project-specific usings (opcional)global using TuProyecto.Core.Services;global using TuProyecto.Core.Repositories;global using TuProyecto.Core.Models;🧪 Fase 2: Patrones de Testing por Tipo
Section titled “🧪 Fase 2: Patrones de Testing por Tipo”Patrón Base: Test Class Setup
Section titled “Patrón Base: Test Class Setup”Estándar observado en código real del equipo:
public class CacheServiceTests{ // 1. Mocks como readonly fields - inmutables private readonly Mock<ICacheBackendResolver> resolverMock = new Mock<ICacheBackendResolver>(); private readonly Mock<ILogger<CacheService>> loggerMock = new Mock<ILogger<CacheService>>(); private readonly CancellationToken cancellationToken = CancellationToken.None;
// 2. SUT (System Under Test) y configuración como fields private CacheService service; private CacheOptions defaultOptions;
public CacheServiceTests() { // 3. Setup en constructor - ejecutado antes de cada test this.defaultOptions = new CacheOptions { CacheType = CacheType.InMemory, SerializerType = SerializerType.None, ThrowOnError = true, Expiration = new CacheExpirationOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }, };
// 4. Instanciar SUT con dependencies inyectadas this.service = new CacheService( this.resolverMock.Object, this.loggerMock.Object, this.defaultOptions ); }}¿Por qué este patrón funciona?
- ✅ Estado limpio en cada test (nuevo constructor por test)
- ✅ Configuración centralizada evita duplicación
- ✅ Mocks inmutables previenen interferencia entre tests
- ✅ CancellationToken reutilizable para consistency
Testing de Services (Business Logic)
Section titled “Testing de Services (Business Logic)”[Fact]public async Task GetAsync_WithValidKey_ReturnsValue(){ // Arrange const string key = "test-key"; const string expectedValue = "cached-value";
var backend = new Mock<ICacheBackend<object>>(); backend.Setup(x => x.GetAsync(key, this.cancellationToken)) .ReturnsAsync(expectedValue);
this.resolverMock .Setup(x => x.GetBackend<object>(CacheType.InMemory)) .Returns(backend.Object);
// Act var result = await this.service.GetOrSetAsync(key, () => Task.FromResult(factoryValue));
// Assert Assert.Equal(factoryValue, result); backend.Verify(x => x.SetAsync( key, factoryValue, It.IsAny<CacheExpirationOptions>(), It.IsAny<string[]>(), It.IsAny<CancellationToken>()), Times.Once);}Elementos clave:
- Arrange: Setup específico, no genérico
- Act: Una sola acción siendo probada
- Assert: Verificación del resultado Y del comportamiento
[Fact]public async Task GetAsync_TypeMismatch_LogsWarning_ReturnsDefault(){ // Arrange var backend = new Mock<ICacheBackend<object>>(); backend.Setup(x => x.GetAsync("key", this.cancellationToken)) .ReturnsAsync(123); // Wrong type: int instead of string
this.resolverMock.Setup(x => x.GetBackend<object>(CacheType.InMemory)) .Returns(backend.Object);
// Act var result = await this.service.GetAsync<string>("key");
// Assert Assert.Null(result); // Should return default for type mismatch this.loggerMock.VerifyLogging( "Type mismatch in cache for key key. Expected String, found Int32", LogLevel.Warning, Times.Once());}Patrón para edge cases:
- Type mismatches: Cuando casting falla
- Null handling: Valores nulos o vacíos
- Boundary conditions: Límites de parámetros
[Theory][InlineData(null)][InlineData("")][InlineData(" ")]public async Task GetAsync_InvalidKey_ThrowsArgumentException(string invalidKey){ // Act & Assert var exception = await Assert.ThrowsAsync<ArgumentException>( () => this.service.GetAsync<string>(invalidKey) );
Assert.Equal("key", exception.ParamName); Assert.Contains("Key cannot be null or empty", exception.Message);}
[Fact]public async Task SetAsync_NullValue_ThrowsArgumentNullException(){ // Act & Assert var exception = await Assert.ThrowsAsync<ArgumentNullException>( () => this.service.SetAsync<string>("valid-key", null) );
Assert.Equal("value", exception.ParamName);}Patrón Theory para validación:
- Theory + InlineData para múltiples valores inválidos
- Verificar ParamName específico en exceptions
- Verificar mensaje para UX consistency
[Fact]public async Task GetAsync_BackendThrows_ThrowOnErrorFalse_LogsAndReturnsDefault(){ // Arrange this.defaultOptions.ThrowOnError = false; // Flag de configuración
this.resolverMock .Setup(x => x.GetBackend<object>(CacheType.InMemory)) .Throws<InvalidOperationException>();
// Act var result = await this.service.GetAsync<string>("key", this.defaultOptions);
// Assert Assert.Null(result); // Debe retornar default en lugar de exception this.loggerMock.VerifyLogging( "Error getting cache item with key key", LogLevel.Error, Times.Once());}
[Fact]public async Task GetAsync_BackendThrows_ThrowOnErrorTrue_PropagatesException(){ // Arrange this.defaultOptions.ThrowOnError = true; // Comportamiento diferente
this.resolverMock .Setup(x => x.GetBackend<object>(CacheType.InMemory)) .Throws<InvalidOperationException>();
// Act & Assert await Assert.ThrowsAsync<InvalidOperationException>( () => this.service.GetAsync<string>("key", this.defaultOptions) );
// No debe loggear error cuando re-throw this.loggerMock.VerifyLogging( "Error getting cache item with key key", LogLevel.Error, Times.Never);}Patrón conditional error handling:
- Test ambos comportamientos (throw vs no-throw)
- Verificar logging diferencial
- Configuración específica por test
Testing de Repositories (Data Access)
Section titled “Testing de Repositories (Data Access)”public class UserRepositoryTests{ private readonly Mock<IDbConnection> connectionMock = new Mock<IDbConnection>(); private readonly UserRepository repository;
public UserRepositoryTests() { this.repository = new UserRepository(this.connectionMock.Object); }
[Fact] public async Task GetByIdAsync_WithValidId_ReturnsUser() { // Arrange const int userId = 1; var expectedUser = CreateTestUser(userId); const string expectedSql = @" SELECT Id, Email, FirstName, LastName, IsActive, CreatedAt FROM Users WHERE Id = @Id";
this.connectionMock .SetupDapperAsync(c => c.QueryFirstOrDefaultAsync<User>( It.Is<string>(sql => NormalizeSql(sql) == NormalizeSql(expectedSql)), It.Is<object>(p => ((dynamic)p).Id == userId), null, null, null)) .ReturnsAsync(expectedUser);
// Act var result = await this.repository.GetByIdAsync(userId);
// Assert Assert.NotNull(result); Assert.Equal(expectedUser.Id, result.Id); Assert.Equal(expectedUser.Email, result.Email); }
private static string NormalizeSql(string sql) { return sql?.Trim().Replace("\r\n", " ").Replace("\n", " ") ?? string.Empty; }
private static User CreateTestUser(int id) => new User { Id = id, Email = $"user{id}@test.com", FirstName = $"FirstName{id}", LastName = $"LastName{id}", IsActive = true, CreatedAt = DateTime.UtcNow };}Elementos específicos para Dapper:
- SetupDapperAsync para métodos Dapper
- SQL normalization para comparación reliable
- Parameter verification específica
- Mock.Dapper package requerido
[Fact]public async Task CreateAsync_WithValidUser_ReturnsTrue(){ // Arrange var user = CreateTestUser(0); // ID 0 para nuevo usuario const string expectedSql = @" INSERT INTO Users (Email, FirstName, LastName, IsActive, CreatedAt) VALUES (@Email, @FirstName, @LastName, @IsActive, @CreatedAt)";
this.connectionMock .SetupDapperAsync(c => c.ExecuteAsync( It.Is<string>(sql => NormalizeSql(sql) == NormalizeSql(expectedSql)), It.Is<User>(u => u.Email == user.Email), null, null, null)) .ReturnsAsync(1); // 1 fila afectada = éxito
// Act var result = await this.repository.CreateAsync(user);
// Assert Assert.True(result);}
[Fact]public async Task CreateAsync_WhenDatabaseFails_ReturnsFalse(){ // Arrange var user = CreateTestUser(0);
this.connectionMock .SetupDapperAsync(c => c.ExecuteAsync( It.IsAny<string>(), It.IsAny<User>(), null, null, null)) .ReturnsAsync(0); // 0 filas afectadas = fallo
// Act var result = await this.repository.CreateAsync(user);
// Assert Assert.False(result);}🔄 Workflow de Desarrollo
Section titled “🔄 Workflow de Desarrollo”Naming Convention para Tests
Section titled “Naming Convention para Tests”Patrón estándar observado en el equipo:
[MethodName]_[Scenario]_[ExpectedResult]Ejemplos correctos:
GetAsync_WithValidKey_ReturnsValue()GetAsync_WithInvalidKey_ThrowsArgumentException()GetAsync_TypeMismatch_LogsWarning_ReturnsDefault()GetAsync_BackendThrows_ThrowOnErrorFalse_LogsAndReturnsDefault()SetAsync_NullValue_ThrowsArgumentNullException()GetOrSetAsync_ValueInCache_ReturnsCached()GetOrSetAsync_CacheMiss_UsesFactoryAndSets()Elementos del naming:
- MethodName: Método siendo probado
- Scenario: Condición específica o input
- ExpectedResult: Comportamiento esperado
✅ Checklist de Implementación
Section titled “✅ Checklist de Implementación”Setup Inicial
Section titled “Setup Inicial”- Proyecto creado con xUnit template
- .csproj configurado con multi-targeting y packages
- Estructura de carpetas estándar creada
- GlobalUsings.cs configurado
- Extensions y helpers implementados
Por Cada Clase a Testear
Section titled “Por Cada Clase a Testear”- Test class creada siguiendo naming convention
- Constructor setup con mocks readonly
- Test data builders disponibles
- Happy path cubierto
- Edge cases identificados y probados
- Parameter validation con Theory tests
- Error handling ambos modos (throw/no-throw)
- Async patterns correctamente implementados
Verificación de Calidad
Section titled “Verificación de Calidad”- Naming convention consistente
- Arrange/Act/Assert claramente separados
- Mock verification específica, no genérica
- Assertions específicas sobre el resultado
- Logging verification donde aplique
Pre-Commit
Section titled “Pre-Commit”- Todas las pruebas pasan localmente
- Coverage mínimo alcanzado (90% líneas, 85% branches)
- StyleCop sin warnings
- Build exitoso en ambos frameworks
🚀 Siguiente Paso
Section titled “🚀 Siguiente Paso”Con el proceso de implementación completado, continuamos con:
➡️ Herramientas y Scripts - Automatización completa para desarrollo y CI/CD