🧪 Testing C#/.NET · NutriTrack SaaS

Suite de Tests NutriTrack API

Cobertura completa con xUnit + FluentAssertions + NSubstitute + Testcontainers para una API ASP.NET Core 8 de nutrición clínica.

xUnit 2.9 FluentAssertions 6.12 NSubstitute 5.3 Testcontainers 3.9 ASP.NET Core 8 Bogus 35.5
24 Tests totales
24 Pasados
14 Unitarios
10 Integración
94% Line Coverage
1.2s Tiempo total
🔬
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
zsh — NutriTrack.sln
$ 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
PlanService 97%
MacroValidator 100%
NutritionSummaryController 91%
SqlFoodLogRepository 88%
📁
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