Patrones de Pruebas en F#
Patrones completos de pruebas para aplicaciones F# usando xUnit, FsUnit, Unquote, FsCheck y prácticas modernas de pruebas en .NET.
Cuándo activarlo
- Escribir nuevas pruebas para código F#
- Revisar la calidad y cobertura de las pruebas
- Configurar infraestructura de pruebas para proyectos F#
- Depurar pruebas lentas o intermitentes
Pila del framework de pruebas
| Herramienta | Propósito |
|---|---|
| xUnit | Framework de pruebas (elección estándar en el ecosistema .NET) |
| FsUnit.xUnit | Sintaxis de aserciones amigable con F# para xUnit |
| Unquote | Biblioteca de aserciones que usa quotationes de F# para mensajes de error claros |
| FsCheck.xUnit | Pruebas basadas en propiedades integradas con xUnit |
| NSubstitute | Simulación (mocking) de dependencias .NET |
| Testcontainers | Infraestructura real en pruebas de integración |
| WebApplicationFactory | Pruebas de integración en ASP.NET Core |
Pruebas Unitarias con xUnit + FsUnit
Estructura básica de prueba
module OrderServiceTests
open Xunit
open FsUnit.Xunit
[<Fact>]
let ``create establece el estado como Pending`` () =
let order = Order.create "cust-1" [ validItem ]
order.Status |> should equal Pending
[<Fact>]
let ``confirm cambia el estado a Confirmed`` () =
let order = Order.create "cust-1" [ validItem ]
let confirmed = Order.confirm order
confirmed.Status |> should be (ofCase <@ Confirmed @>)
Aserciones con Unquote
Unquote usa quotationes de F# para que los mensajes de error muestren la expresión completa que falló, no solo "se esperaba X, se obtuvo Y".
module OrderValidationTests
open Xunit
open Swensen.Unquote
[<Fact>]
let ``PlaceOrder devuelve éxito cuando la solicitud es válida`` () =
let request = { CustomerId = "cust-123"; Items = [ validItem ] }
let result = OrderService.placeOrder request
test <@ Result.isOk result @>
[<Fact>]
let ``el total del pedido suma los precios de los artículos`` () =
let items = [ { Sku = "A"; Quantity = 2; Price = 10m }
{ Sku = "B"; Quantity = 1; Price = 5m } ]
let total = Order.calculateTotal items
test <@ total = 25m @>
[<Fact>]
let ``el email validado rechaza entrada vacía`` () =
let result = ValidatedEmail.create ""
test <@ Result.isError result @>
Pruebas Asíncronas
[<Fact>]
let ``PlaceOrder devuelve éxito cuando la solicitud es válida`` () = task {
let deps = createTestDeps ()
let request = { CustomerId = "cust-123"; Items = [ validItem ] }
let! result = OrderService.placeOrder deps request
test <@ Result.isOk result @>
}
[<Fact>]
let ``PlaceOrder devuelve error cuando los artículos están vacíos`` () = task {
let deps = createTestDeps ()
let request = { CustomerId = "cust-123"; Items = [] }
let! result = OrderService.placeOrder deps request
test <@ Result.isError result @>
}
Pruebas Parametrizadas con Theory
[<Theory>]
[<InlineData("")>]
[<InlineData(" ")>]
let ``PlaceOrder rechaza ID de cliente vacío`` (customerId: string) =
let request = { CustomerId = customerId; Items = [ validItem ] }
let result = OrderService.placeOrder request
result |> should be (ofCase <@ Error @>)
[<Theory>]
[<InlineData("", false)>]
[<InlineData("a", false)>]
[<InlineData("[email protected]", true)>]
[<InlineData("[email protected]", true)>]
let ``IsValidEmail devuelve el resultado esperado`` (email: string, expected: bool) =
test <@ EmailValidator.isValid email = expected @>
Pruebas Basadas en Propiedades con FsCheck
Usando FsCheck.xUnit
open FsCheck
open FsCheck.Xunit
[<Property>]
let ``el total del pedido siempre es no negativo`` (items: NonEmptyList<PositiveInt * decimal>) =
let orderItems =
items.Get
|> List.map (fun (qty, price) ->
{ Sku = "SKU"; Quantity = qty.Get; Price = abs price })
let total = Order.calculateTotal orderItems
total >= 0m
[<Property>]
let ``la serialización es reversible`` (order: Order) =
let json = JsonSerializer.Serialize order
let deserialized = JsonSerializer.Deserialize<Order> json
deserialized = order
Generadores Personalizados
type OrderGenerators =
static member ValidEmail () =
gen {
let! user = Gen.elements [ "alice"; "bob"; "carol" ]
let! domain = Gen.elements [ "example.com"; "test.org" ]
return $"{user}@{domain}"
}
|> Arb.fromGen
[<Property(Arbitrary = [| typeof<OrderGenerators> |])>]
let ``los emails válidos pasan la validación`` (email: string) =
EmailValidator.isValid email
Simulación de Dependencias
Stubs de Funciones (Preferido)
let createTestDeps () =
let mutable savedOrders = []
{ FindOrder = fun id -> task { return Map.tryFind id testData }
SaveOrder = fun order -> task { savedOrders <- order :: savedOrders }
SendNotification = fun _ -> Task.CompletedTask }
[<Fact>]
let ``PlaceOrder guarda el pedido confirmado`` () = task {
let mutable saved = []
let deps =
{ createTestDeps () with
SaveOrder = fun order -> task { saved <- order :: saved } }
let! _ = OrderService.placeOrder deps validRequest
test <@ saved.Length = 1 @>
}
NSubstitute para Interfaces .NET
open NSubstitute
[<Fact>]
let ``llama al repositorio con el ID correcto`` () = task {
let repo = Substitute.For<IOrderRepository>()
repo.FindByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Some testOrder))
let service = OrderService(repo)
let! _ = service.GetOrder(testOrder.Id, CancellationToken.None)
do! repo.Received(1).FindByIdAsync(testOrder.Id, Arg.Any<CancellationToken>())
}
Pruebas de Integración en ASP.NET Core
type OrderApiTests (factory: WebApplicationFactory<Program>) =
interface IClassFixture<WebApplicationFactory<Program>>
let client =
factory.WithWebHostBuilder(fun builder ->
builder.ConfigureServices(fun services ->
services.RemoveAll<DbContextOptions<AppDbContext>>() |> ignore
services.AddDbContext<AppDbContext>(fun options ->
options.UseInMemoryDatabase("TestDb") |> ignore) |> ignore))
.CreateClient()
[<Fact>]
member _.``GET order devuelve 404 cuando no se encuentra`` () = task {
let! response = client.GetAsync($"/api/orders/{Guid.NewGuid()}")
test <@ response.StatusCode = HttpStatusCode.NotFound @>
}
Organización de Pruebas
tests/
MyApp.Tests/
Unit/
OrderServiceTests.fs
PaymentServiceTests.fs
Integration/
OrderApiTests.fs
OrderRepositoryTests.fs
Properties/
OrderPropertyTests.fs
Helpers/
TestData.fs
TestDeps.fs
Antipatrones Comunes
| Antipatrón | Solución |
|---|---|
| Probar detalles de implementación | Probar comportamientos y resultados |
| Estado compartido mutable entre pruebas | Estado fresco por prueba |
Thread.Sleep en pruebas asíncronas | Usar Task.Delay con timeout, o helpers de sondeo |
Afirmar sobre la salida de sprintf | Afirmar sobre valores tipados y coincidencia de patrones |
Ignorar CancellationToken | Siempre pasar y verificar cancelación |
| Omitir pruebas basadas en propiedades | Usar FsCheck para cualquier función con invariantes claros |
Habilidades Relacionadas
dotnet-patterns- Patrones idiomáticos .NET, inyección de dependencias y arquitecturacsharp-testing- Patrones de pruebas en C# (la infraestructura compartida como WebApplicationFactory y Testcontainers también aplica a F#)
Ejecutar Pruebas
# Ejecutar todas las pruebas
dotnet test
# Ejecutar con cobertura
dotnet test --collect:"XPlat Code Coverage"
# Ejecutar proyecto específico
dotnet test tests/MyApp.Tests/
# Filtrar por nombre de prueba
dotnet test --filter "FullyQualifiedName~OrderService"
# Modo observador durante desarrollo
dotnet watch test --project tests/MyApp.Tests/