14
Tests unitarios
5
Propiedades FsCheck
5
Tests integración
1
Tests Unitarios — Pipeline Domain
xUnit + FsUnit.xUnit + Unquote · tests/Unit/PipelineTests.fs
PipelineTests.fs
UNIT
1module AgentFlow.Tests.Unit.PipelineTests23open Xunit4open FsUnit.Xunit5open Swensen.Unquote6open AgentFlow.Domain.Pipeline78// ── Fixtures ──────────────────────────────────────────────────────────9let validStep id name =10 { StepId = id; Name = name; AgentKind = "summarise"; CreditCost = 2 }1112let twoSteps = [ validStep "s1" "Extraer keywords"; validStep "s2" "Generar copy" ]1314[<Fact>]15let ``create returns Ok when name and steps are valid`` () =16 let result = Pipeline.create "cultiva-user-1" "Social Media Pipeline" twoSteps17 result |> should be (ofCase <@ Ok @>)1819[<Fact>]20let ``created pipeline trims whitespace from name`` () =21 let result = Pipeline.create "cultiva-user-1" " SEO Pipeline " twoSteps22 match result with23 | Ok p -> test <@ p.Name = "SEO Pipeline" @>24 | Error _ -> failwith "Expected Ok"2526[<Fact>]27let ``create fails with EmptyName when name is blank`` () =28 let result = Pipeline.create "cultiva-user-1" " " twoSteps29 test <@ result = Error EmptyName @>3031[<Fact>]32let ``create fails with DuplicateStepId when two steps share an id`` () =33 let steps = [ validStep "s1" "Paso A"; validStep "s1" "Paso B duplicado" ]34 let result = Pipeline.create "cultiva-user-1" "Dup Pipeline" steps35 test <@ result = Error (DuplicateStepId "s1") @>3637// ── Parameterized ─────────────────────────────────────────────────────38[<Theory>]39[<InlineData("")>]40[<InlineData(" ")>]41[<InlineData("\t\n")>]42let ``create rejects blank or whitespace-only name`` (name: string) =43 let result = Pipeline.create "owner" name twoSteps44 result |> should be (ofCase <@ Error @>)
2
State Machine — PipelineRun
Unquote quotations para mensajes de error expresivos · tests/Unit/PipelineRunTests.fs
PipelineRunTests.fs
UNIT
1[<Fact>]2let ``startRun transitions from Pending to Running`` () =3 let run = newPendingRun ()4 let result = startRun run5 match result with6 | Ok r -> test <@ r.Status = Running && r.StartedAt.IsSome @>7 | Error e -> failwith e89[<Fact>]10let ``completeRun sets Completed status with duration`` () =11 let run = { newPendingRun () with Status = Running }12 let result = completeRun 4200 run13 match result with14 | Ok r ->15 test <@ r.Status = Completed 4200 @>16 test <@ r.FinishedAt.IsSome @>17 | Error e -> failwith e1819[<Fact>]20let ``startRun fails when run is already Running`` () =21 let run = { newPendingRun () with Status = Running }22 let result = startRun run23 test <@ Result.isError result @>
3
Property-Based Testing — CreditLedger
FsCheck.xUnit · invariantes del sistema de créditos · tests/Properties/CreditLedgerProperties.fs
CreditLedgerProperties.fs
PROPERTY
1open FsCheck2open FsCheck.Xunit34// Generador personalizado: ledger con saldo garantizado5type LedgerGen =6 static member FundedLedger () =7 gen {8 let! topUpAmount = Gen.choose (10, 1000)9 let! owner = Gen.elements [ "cultiva-user-1"; "cultiva-user-2" ]10 let entry = { Amount = topUpAmount; Reason = "Top-up"; ... }11 return { OwnerId = owner; Entries = [ entry ] }12 } |> Arb.fromGen1314[<Property>]15let ``balance of empty ledger is always zero`` (owner: NonEmptyString) =16 let ledger = emptyLedger owner.Get17 balance ledger = 01819[<Property>]20let ``crediting positive amount always increases balance`` (amount: PositiveInt) =21 let ledger = emptyLedger "test-owner"22 let before = balance ledger23 match credit amount.Get "bonus" ledger with24 | Ok l -> balance l > before25 | Error _ -> false2627[<Property(Arbitrary = [| typeof<LedgerGen> |])>]28let ``debit and then credit restores original balance`` (funded: CreditLedger) =29 let bal = balance funded30 let amount = max 1 (bal / 2)31 match debit amount "consumo" funded with32 | Ok debited ->33 match credit amount "reembolso" debited with34 | Ok restored -> balance restored = bal35 | _ -> false36 | _ -> true // propiedad trivialmente válida sin fondos3738[<Property>]39let ``balance never goes negative after valid operations`` (ops: PositiveInt list) =40 // 100 casos random generados por FsCheck → invariante de no negatividad41 let finalLedger = ops |> List.fold ...42 match finalLedger with43 | Ok l -> balance l >= 044 | Error _ -> false
4
Mocking Funcional — PipelineService
Stubs como record de funciones · sin frameworks de mocking · tests/Unit/PipelineServiceTests.fs
PipelineServiceTests.fs
MOCK
1// Dependencias como record de funciones (patrón F# idiomático)2type PipelineDeps = {3 FindPipeline : Guid -> Task<Pipeline option>4 SaveRun : PipelineRun -> Task<unit>5 DebitCredits : string -> int -> Task<Result<unit, string>>6 NotifyOwner : string -> string -> Task<unit>7}89[<Fact>]10let ``launchRun debits exact credit cost of pipeline`` () = task {11 let pipeline = samplePipeline () // cost = 3 + 5 = 812 let mutable debited = 013 let deps =14 { defaultDeps (Some pipeline) (ResizeArray()) (ResizeArray()) with15 DebitCredits = fun _ amount -> task {16 debited <- amount17 return Ok ()18 } }1920 let! _ = PipelineService.launchRun deps pipeline.Id "owner"2122 test <@ debited = 8 @>23}2425[<Fact>]26let ``launchRun does not save run when credits are insufficient`` () = task {27 let saved = ResizeArray<PipelineRun>()28 let deps =29 { defaultDeps (Some pipeline) saved (ResizeArray()) with30 DebitCredits = fun _ _ -> Task.FromResult (Error "Insufficient credits") }3132 let! result = PipelineService.launchRun deps pipeline.Id "broke-user"3334 test <@ Result.isError result @>35 test <@ saved.Count = 0 @> // no debe guardar run sin créditos36}
5
Tests de Integración — Pipelines API
WebApplicationFactory · InMemoryDatabase · HTTP real contra la app · tests/Integration/PipelinesApiTests.fs
PipelinesApiTests.fs
INTEGRATION
1type AgentFlowApiTests (factory: WebApplicationFactory<Program>) =2 interface IClassFixture<WebApplicationFactory<Program>>34 let client =5 factory.WithWebHostBuilder(fun builder ->6 builder.ConfigureServices(fun services ->7 services.RemoveAll<DbContextOptions<AppDbContext>>() |> ignore8 services.AddDbContext<AppDbContext>(fun options ->9 options.UseInMemoryDatabase("AgentFlowTestDb") |> ignore10 ) |> ignore11 )12 ).CreateClient()1314 [<Fact>]15 member _.``POST pipeline returns 201 Created with valid payload`` () = task {16 let! response = client.PostAsJsonAsync("/api/pipelines", validPayload ())17 test <@ response.StatusCode = HttpStatusCode.Created @>18 }1920 [<Fact>]21 member _.``Created pipeline is retrievable by id`` () = task {22 let! createResp = client.PostAsJsonAsync("/api/pipelines", validPayload ())23 let! created = createResp.Content.ReadFromJsonAsync<PipelineResponse>()2425 let! getResp = client.GetAsync($"/api/pipelines/{created.Id}")26 let! fetched = getResp.Content.ReadFromJsonAsync<PipelineResponse>()2728 test <@ fetched.Id = created.Id @>29 test <@ fetched.Name = "Newsletter Automation" @>30 test <@ fetched.Steps = 2 @>31 }3233 [<Theory>]34 [<InlineData("")>]35 [<InlineData(" ")>]36 member _.``POST pipeline returns 422 when name is blank`` (badName: string) = task {37 let payload = { validPayload () with Name = badName }38 let! response = client.PostAsJsonAsync("/api/pipelines", payload)39 test <@ response.StatusCode = HttpStatusCode.UnprocessableEntity @>40 }
6
Anti-Patrones Detectados y Soluciones
Errores comunes en suites F# y cómo evitarlos
Guía de buenas prácticas
| Anti-Patrón | Problema | Solución aplicada |
|---|---|---|
| Thread.Sleep en async | Tests lentos o flaky | Task.Delay + CancellationToken |
| Assert.Equal en DU | Mensajes de error opacos | test <@ r.Status = Completed 4200 @> |
| Estado mutable compartido | Tests dependientes entre sí | ResizeArray local por test, sin statics |
| Mockear implementación | Tests frágiles al refactor | Stubs funcionales tipados (PipelineDeps) |
| Ignorar CancellationToken | Tests que no terminan | CancellationToken en todos los async |
| Skip property-based tests | Edge cases sin cubrir | FsCheck para todo invariante numérico |
| BD real en tests integración | Tests lentos, estado sucio | UseInMemoryDatabase en WebApplicationFactory |
Comandos de ejecución
CLI
# Todos los tests
dotnet test tests/AgentFlow.Tests/
# Solo properties (FsCheck)
dotnet test --filter "FullyQualifiedName~Properties"
# Con cobertura (coverlet)
dotnet test --collect:"XPlat Code Coverage" --results-directory coverage/
# Modo watch durante desarrollo
dotnet watch test --project tests/AgentFlow.Tests/