Tests Unitarios — PlanService
NSubstitute mocks · Arrange-Act-Assert · FluentAssertions
CS PlanServiceTests.cs
tests/NutriTrack.UnitTests/Services/PlanServiceTests.cs
✓ 8 passed
Unit
using FluentAssertions; using NSubstitute; using NutriTrack.Domain.Plans; using NutriTrack.Application.Services; namespace NutriTrack.UnitTests.Services; public sealed class PlanServiceTests { // ── Dependencias mockeadas ──────────────────────────────────── private readonly INutritionPlanRepository _repo = Substitute.For<INutritionPlanRepository>(); private readonly ILogger<PlanService> _logger = Substitute.For<ILogger<PlanService>>(); private readonly PlanService _sut; public PlanServiceTests() => _sut = new PlanService(_repo, _logger); // ── CreatePlanAsync ─────────────────────────────────────────── [Fact] public async Task CreatePlanAsync_ReturnsCreatedPlan_WhenRequestIsValid() { // Arrange var request = new CreatePlanRequest { PatientId = "pat-001", DietitianId = "diet-007", Calories = 1800, Macros = new(180, 60, 200) // g: carbs, fat, protein }; // Act var result = await _sut.CreatePlanAsync(request, CancellationToken.None); // Assert result.Should().NotBeNull(); result.PatientId.Should().Be("pat-001"); result.Status.Should().Be(PlanStatus.Active); } [Fact] public async Task CreatePlanAsync_PersistsPlan_WhenRequestIsValid() { var request = PlanRequestFaker.Valid(); await _sut.CreatePlanAsync(request, CancellationToken.None); // Verifica que el repositorio fue llamado exactamente 1 vez await _repo.Received(1).AddAsync( Arg.Is<NutritionPlan>(p => p.PatientId == request.PatientId), Arg.Any<CancellationToken>()); } [Fact] public async Task CreatePlanAsync_ThrowsDomainException_WhenCaloriesAreZero() { var request = PlanRequestFaker.Valid() with { Calories = 0 }; var act = () => _sut.CreatePlanAsync(request, CancellationToken.None); await act.Should().ThrowAsync<DomainException>() .WithMessage("*calories*must be greater than zero*"); } // ── ArchivePlanAsync ────────────────────────────────────────── [Fact] public async Task ArchivePlanAsync_SetsStatusArchived_WhenPlanExists() { var planId = Guid.NewGuid(); var plan = new NutritionPlanBuilder().WithId(planId).Build(); _repo.FindByIdAsync(planId, Arg.Any<CancellationToken>()) .Returns(plan); await _sut.ArchivePlanAsync(planId, CancellationToken.None); plan.Status.Should().Be(PlanStatus.Archived); } [Fact] public async Task ArchivePlanAsync_ThrowsNotFoundException_WhenPlanDoesNotExist() { _repo.FindByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()) .Returns((NutritionPlan?)null); var act = () => _sut.ArchivePlanAsync(Guid.NewGuid(), CancellationToken.None); await act.Should().ThrowAsync<NotFoundException>(); } }
Tests Paramétricos — MacroValidator
Theory + InlineData + MemberData · 6 casos de prueba
CS MacroValidatorTests.cs
tests/NutriTrack.UnitTests/Validators/MacroValidatorTests.cs
✓ 6 passed
Unit · Theory
namespace NutriTrack.UnitTests.Validators; public sealed class MacroValidatorTests { // InlineData: casos simples directo en el atributo [Theory] [InlineData(180, 60, 150, true)] // balance correcto [InlineData(0, 60, 150, false)] // carbohidratos = 0 [InlineData(180, -5, 150, false)] // grasa negativa [InlineData(500, 500, 500, false)] // ratio imposible public void IsValidMacroBalance_ReturnsExpected( int carbs, int fat, int protein, bool expected) { MacroValidator.IsValid(carbs, fat, protein) .Should().Be(expected); } // MemberData: casos complejos con objetos [Theory] [MemberData(nameof(InvalidPlanCases))] public async Task CreatePlanAsync_RejectsInvalidMacros( CreatePlanRequest req, string expectedError) { var sut = PlanServiceFactory.Create(); var act = () => sut.CreatePlanAsync(req, CancellationToken.None); await act.Should().ThrowAsync<DomainException>() .WithMessage($"*{expectedError}*"); } public static TheoryData<CreatePlanRequest, string> InvalidPlanCases => new() { { new() { PatientId = "", Calories = 1800 }, "PatientId" }, { new() { PatientId = "p1", Calories = 0 }, "calories" }, { new() { PatientId = "p1", Calories = 9999 }, "exceeds maximum" }, }; }
Tests de Integración — API + Repositorio
WebApplicationFactory · Testcontainers PostgreSQL · HttpClient
CS NutritionApiTests.cs
CS FoodLogRepoTests.cs
tests/NutriTrack.IntegrationTests/Api/NutritionApiTests.cs
✓ 5 passed
Integration
namespace NutriTrack.IntegrationTests.Api; public sealed class NutritionApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client; public NutritionApiTests(WebApplicationFactory<Program> factory) { _client = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Reemplaza PostgreSQL real por InMemory en tests services.RemoveAll<DbContextOptions<NutriTrackDbContext>>(); services.AddDbContext<NutriTrackDbContext>(o => o.UseInMemoryDatabase("NutriTrackTestDb")); }); }).CreateClient(); } [Fact] public async Task GetSummary_Returns404_WhenPatientDoesNotExist() { var response = await _client.GetAsync( $"/api/patients/{Guid.NewGuid()}/summary"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] public async Task CreatePlan_Returns201_WithLocation_WhenRequestIsValid() { var request = new CreatePlanRequest { PatientId = "pat-integration-01", DietitianId = "diet-007", Calories = 2000, Macros = new(200, 70, 160) }; var response = await _client.PostAsJsonAsync("/api/plans", request); response.StatusCode.Should().Be(HttpStatusCode.Created); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString() .Should().StartWith("/api/plans/"); } [Fact] public async Task GetPlan_Returns200_WithCorrectData_WhenPlanExists() { // 1. Crear plan var created = await SeedPlanAsync(); var location = created.Headers.Location!.ToString(); // 2. Obtenerlo var response = await _client.GetAsync(location); var dto = await response.Content .ReadFromJsonAsync<NutritionPlanDto>(); response.StatusCode.Should().Be(HttpStatusCode.OK); dto!.Calories.Should().Be(2000); dto.Status.Should().Be("Active"); } private async Task<HttpResponseMessage> SeedPlanAsync() => await _client.PostAsJsonAsync("/api/plans", PlanRequestFaker.Valid()); }
Testcontainers — PostgreSQL Real
IAsyncLifetime · contenedor real · migraciones automáticas
CS FoodLogRepositoryTests.cs
tests/NutriTrack.IntegrationTests/Repositories/FoodLogRepositoryTests.cs
✓ 5 passed
Integration · Postgres
namespace NutriTrack.IntegrationTests.Repositories; public sealed class FoodLogRepositoryTests : IAsyncLifetime { // Levanta un contenedor postgres:16-alpine real en Docker private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder() .WithImage("postgres:16-alpine") .Build(); private NutriTrackDbContext _db = null!; private SqlFoodLogRepository _repo = null!; public async Task InitializeAsync() { await _postgres.StartAsync(); var options = new DbContextOptionsBuilder<NutriTrackDbContext>() .UseNpgsql(_postgres.GetConnectionString()) .Options; _db = new NutriTrackDbContext(options); _repo = new SqlFoodLogRepository(_db); await _db.Database.MigrateAsync(); // aplica migraciones reales } public async Task DisposeAsync() { await _db.DisposeAsync(); await _postgres.DisposeAsync(); } [Fact] public async Task LogFoodAsync_PersistsEntry_WhenDataIsValid() { var entry = new FoodEntry { PatientId = "pat-001", FoodName = "Avena", Grams = 80, Calories = 300, LoggedAt = DateTime.UtcNow }; await _repo.LogFoodAsync(entry, CancellationToken.None); var found = await _repo.GetByPatientAsync("pat-001", CancellationToken.None); found.Should().ContainSingle(e => e.FoodName == "Avena"); } [Fact] public async Task GetDailySummaryAsync_ReturnsAggregatedCalories() { await SeedFoodEntriesAsync("pat-002", ("Pollo", 200, 330), ("Arroz", 150, 210), ("Brócoli", 100, 35)); var summary = await _repo.GetDailySummaryAsync( "pat-002", DateOnly.FromDateTime(DateTime.Today), CancellationToken.None); summary.TotalCalories.Should().Be(575); summary.EntryCount.Should().Be(3); } }
Test Helpers — Builder & Faker
NutritionPlanBuilder · Bogus faker · reutilizable en toda la suite
CS NutritionPlanBuilder.cs
tests/NutriTrack.TestHelpers/Builders/NutritionPlanBuilder.cs
Helper
namespace NutriTrack.TestHelpers.Builders; /// Fluent builder — evita "magic values" dispersos por toda la suite. public sealed class NutritionPlanBuilder { private Guid _id = Guid.NewGuid(); private string _patientId = "pat-default"; private int _calories = 2000; private Macros _macros = new(200, 65, 160); private PlanStatus _status = PlanStatus.Active; public NutritionPlanBuilder WithId(Guid id) { _id = id; return this; } public NutritionPlanBuilder WithPatient(string patientId) { _patientId = patientId; return this; } public NutritionPlanBuilder WithCalories(int kcal) { _calories = kcal; return this; } public NutritionPlanBuilder Archived() { _status = PlanStatus.Archived; return this; } public NutritionPlan Build() => NutritionPlan.Reconstitute(_id, _patientId, _calories, _macros, _status); } /// Faker con datos realistas usando Bogus public static class PlanRequestFaker { private static readonly Faker<CreatePlanRequest> _faker = new Faker<CreatePlanRequest>() .RuleFor(r => r.PatientId, f => $"pat-{f.Random.AlphaNumeric(6)}") .RuleFor(r => r.DietitianId, f => $"diet-{f.Random.AlphaNumeric(4)}") .RuleFor(r => r.Calories, f => f.Random.Int(1200, 3500)) .RuleFor(r => r.Macros, f => new( f.Random.Int(100, 350), f.Random.Int(40, 100), f.Random.Int(80, 200))); public static CreatePlanRequest Valid() => _faker.Generate(); public static IReadOnlyList<CreatePlanRequest> Many(int count) => _faker.Generate(count); }
Resultados — dotnet test
24/24 tests · 1.2s · 0 fallos · 0 skips
$ dotnet test --logger "console;verbosity=normal"
Determining projects to restore...
All projects are up-to-date for restore.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 24, Skipped: 0, Total: 24, Duration: 1,2s
Test run summary:
─────────────────────────────────────────────────────────
NutriTrack.UnitTests (14 tests) ✓ 0.3s
NutriTrack.IntegrationTests (10 tests) ✓ 0.9s
─────────────────────────────────────────────────────────
Build succeeded.
$ dotnet test --collect:"XPlat Code Coverage"
Attachments:
/coverage/coverage.cobertura.xml
Line coverage: 94.2%
Branch coverage: 88.7%
Test Explorer — 24 tests
✓ All passed
| Estado | Test | Clase | Tipo | ms |
|---|---|---|---|---|
| ✓ | CreatePlanAsync_ReturnsCreatedPlan_WhenRequestIsValid | PlanServiceTests | Unit | 12ms |
| ✓ | CreatePlanAsync_PersistsPlan_WhenRequestIsValid | PlanServiceTests | Unit | 8ms |
| ✓ | CreatePlanAsync_ThrowsDomainException_WhenCaloriesAreZero | PlanServiceTests | Unit | 6ms |
| ✓ | ArchivePlanAsync_SetsStatusArchived_WhenPlanExists | PlanServiceTests | Unit | 9ms |
| ✓ | ArchivePlanAsync_ThrowsNotFoundException_WhenPlanDoesNotExist | PlanServiceTests | Unit | 7ms |
| ✓ | IsValidMacroBalance_ReturnsExpected(180,60,150,True) | MacroValidatorTests | Theory | 3ms |
| ✓ | IsValidMacroBalance_ReturnsExpected(0,60,150,False) | MacroValidatorTests | Theory | 2ms |
| ✓ | IsValidMacroBalance_ReturnsExpected(180,-5,150,False) | MacroValidatorTests | Theory | 2ms |
| ✓ | IsValidMacroBalance_ReturnsExpected(500,500,500,False) | MacroValidatorTests | Theory | 2ms |
| ✓ | CreatePlan_Returns201_WithLocation_WhenRequestIsValid | NutritionApiTests | API | 180ms |
| ✓ | GetSummary_Returns404_WhenPatientDoesNotExist | NutritionApiTests | API | 45ms |
| ✓ | GetPlan_Returns200_WithCorrectData_WhenPlanExists | NutritionApiTests | API | 210ms |
| ✓ | LogFoodAsync_PersistsEntry_WhenDataIsValid | FoodLogRepoTests | Postgres | 320ms |
| ✓ | GetDailySummaryAsync_ReturnsAggregatedCalories | FoodLogRepoTests | Postgres | 285ms |
Anti-Patrones — Evita Esto
Comparativa mal vs bien · convenciones del equipo NutriTrack
✗ Anti-patrón
// Nombre que describe implementación
void Test_ServiceCreatesObject()
✓ Correcto
// Nombre = comportamiento + condición
void CreatePlanAsync_ReturnsPlan_WhenValid()
✗ Anti-patrón
// Thread.Sleep en tests async
await service.DoAsync();
Thread.Sleep(500); // esperar
✓ Correcto
// await real, sin sleeps artificiales
var result = await service.DoAsync();
result.Should().NotBeNull();
✗ Anti-patrón
// Estado compartido entre tests
static NutritionPlan _sharedPlan;
// rompe aislamiento → flaky tests
✓ Correcto
// Constructor xUnit por cada test
public PlanServiceTests()
{ _sut = new PlanService(_repo); }
✗ Anti-patrón
// Olvidar CancellationToken
await repo.AddAsync(plan); // sin CT
✓ Correcto
// Siempre pasar Y verificar CT
await repo.Received(1).AddAsync(
Arg.Any<Plan>(), Arg.Any<CT>());
Cobertura de Código
XPlat Code Coverage · reporte Cobertura XML
Estructura del Proyecto de Tests
Separación Unit / Integration / TestHelpers
tests/
NutriTrack.UnitTests/
Services/
PlanServiceTests.cs ← 8 tests
PaymentServiceTests.cs ← 6 tests
Validators/
MacroValidatorTests.cs ← 6 tests · Theory
NutriTrack.IntegrationTests/
Api/
NutritionApiTests.cs ← 5 tests · WebAppFactory
Repositories/
FoodLogRepositoryTests.cs ← 5 tests · Testcontainers
NutriTrack.TestHelpers/
Builders/
NutritionPlanBuilder.cs
PlanRequestFaker.cs
Fixtures/
DatabaseFixture.cs