CULTIVA IA patrones-testing-fsharp

AgentFlow — Suite de Tests F#

Tests unitarios · Property-based · Integración · Mocking funcional · WebApplicationFactory
xUnit FsUnit.xUnit Unquote FsCheck.xUnit NSubstitute WebApplicationFactory .NET 8 F# 8
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.PipelineTests
2
3open Xunit
4open FsUnit.Xunit
5open Swensen.Unquote
6open AgentFlow.Domain.Pipeline
7
8// ── Fixtures ──────────────────────────────────────────────────────────
9let validStep id name =
10 { StepId = id; Name = name; AgentKind = "summarise"; CreditCost = 2 }
11
12let twoSteps = [ validStep "s1" "Extraer keywords"; validStep "s2" "Generar copy" ]
13
14[<Fact>]
15let ``create returns Ok when name and steps are valid`` () =
16 let result = Pipeline.create "cultiva-user-1" "Social Media Pipeline" twoSteps
17 result |> should be (ofCase <@ Ok @>)
18
19[<Fact>]
20let ``created pipeline trims whitespace from name`` () =
21 let result = Pipeline.create "cultiva-user-1" " SEO Pipeline " twoSteps
22 match result with
23 | Ok p -> test <@ p.Name = "SEO Pipeline" @>
24 | Error _ -> failwith "Expected Ok"
25
26[<Fact>]
27let ``create fails with EmptyName when name is blank`` () =
28 let result = Pipeline.create "cultiva-user-1" " " twoSteps
29 test <@ result = Error EmptyName @>
30
31[<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" steps
35 test <@ result = Error (DuplicateStepId "s1") @>
36
37// ── 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 twoSteps
44 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 run
5 match result with
6 | Ok r -> test <@ r.Status = Running && r.StartedAt.IsSome @>
7 | Error e -> failwith e
8
9[<Fact>]
10let ``completeRun sets Completed status with duration`` () =
11 let run = { newPendingRun () with Status = Running }
12 let result = completeRun 4200 run
13 match result with
14 | Ok r ->
15 test <@ r.Status = Completed 4200 @>
16 test <@ r.FinishedAt.IsSome @>
17 | Error e -> failwith e
18
19[<Fact>]
20let ``startRun fails when run is already Running`` () =
21 let run = { newPendingRun () with Status = Running }
22 let result = startRun run
23 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 FsCheck
2open FsCheck.Xunit
3
4// Generador personalizado: ledger con saldo garantizado
5type 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.fromGen
13
14[<Property>]
15let ``balance of empty ledger is always zero`` (owner: NonEmptyString) =
16 let ledger = emptyLedger owner.Get
17 balance ledger = 0
18
19[<Property>]
20let ``crediting positive amount always increases balance`` (amount: PositiveInt) =
21 let ledger = emptyLedger "test-owner"
22 let before = balance ledger
23 match credit amount.Get "bonus" ledger with
24 | Ok l -> balance l > before
25 | Error _ -> false
26
27[<Property(Arbitrary = [| typeof<LedgerGen> |])>]
28let ``debit and then credit restores original balance`` (funded: CreditLedger) =
29 let bal = balance funded
30 let amount = max 1 (bal / 2)
31 match debit amount "consumo" funded with
32 | Ok debited ->
33 match credit amount "reembolso" debited with
34 | Ok restored -> balance restored = bal
35 | _ -> false
36 | _ -> true // propiedad trivialmente válida sin fondos
37
38[<Property>]
39let ``balance never goes negative after valid operations`` (ops: PositiveInt list) =
40 // 100 casos random generados por FsCheck → invariante de no negatividad
41 let finalLedger = ops |> List.fold ...
42 match finalLedger with
43 | Ok l -> balance l >= 0
44 | 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}
8
9[<Fact>]
10let ``launchRun debits exact credit cost of pipeline`` () = task {
11 let pipeline = samplePipeline () // cost = 3 + 5 = 8
12 let mutable debited = 0
13 let deps =
14 { defaultDeps (Some pipeline) (ResizeArray()) (ResizeArray()) with
15 DebitCredits = fun _ amount -> task {
16 debited <- amount
17 return Ok ()
18 } }
19
20 let! _ = PipelineService.launchRun deps pipeline.Id "owner"
21
22 test <@ debited = 8 @>
23}
24
25[<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()) with
30 DebitCredits = fun _ _ -> Task.FromResult (Error "Insufficient credits") }
31
32 let! result = PipelineService.launchRun deps pipeline.Id "broke-user"
33
34 test <@ Result.isError result @>
35 test <@ saved.Count = 0 @> // no debe guardar run sin créditos
36}
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>>
3
4 let client =
5 factory.WithWebHostBuilder(fun builder ->
6 builder.ConfigureServices(fun services ->
7 services.RemoveAll<DbContextOptions<AppDbContext>>() |> ignore
8 services.AddDbContext<AppDbContext>(fun options ->
9 options.UseInMemoryDatabase("AgentFlowTestDb") |> ignore
10 ) |> ignore
11 )
12 ).CreateClient()
13
14 [<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 }
19
20 [<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>()
24
25 let! getResp = client.GetAsync($"/api/pipelines/{created.Id}")
26 let! fetched = getResp.Content.ReadFromJsonAsync<PipelineResponse>()
27
28 test <@ fetched.Id = created.Id @>
29 test <@ fetched.Name = "Newsletter Automation" @>
30 test <@ fetched.Steps = 2 @>
31 }
32
33 [<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/