Modèles de test F#
Modèles de test complets pour les applications F# utilisant xUnit, FsUnit, Unquote, FsCheck et les pratiques modernes de test .NET.
Quand activer
- Écrire de nouveaux tests pour du code F#
- Réviser la qualité et la couverture des tests
- Mettre en place l'infrastructure de test pour des projets F#
- Déboguer des tests instables ou lents
Pile des frameworks de test
| Outil | Objectif |
|---|---|
| xUnit | Framework de test (choix standard de l'écosystème .NET) |
| FsUnit.xUnit | Syntaxe d'assertion adaptée à F# pour xUnit |
| Unquote | Bibliothèque d'assertions utilisant les quotations F# pour des messages d'échec clairs |
| FsCheck.xUnit | Tests basés sur les propriétés intégrés à xUnit |
| NSubstitute | Simulation de dépendances .NET |
| Testcontainers | Infrastructure réelle dans les tests d'intégration |
| WebApplicationFactory | Tests d'intégration ASP.NET Core |
Tests unitaires avec xUnit + FsUnit
Structure de test de base
module OrderServiceTests
open Xunit
open FsUnit.Xunit
[<Fact>]
let ``create définit le statut sur EnAttente`` () =
let order = Order.create "cust-1" [ validItem ]
order.Status |> should equal Pending
[<Fact>]
let ``confirm change le statut sur Confirmé`` () =
let order = Order.create "cust-1" [ validItem ]
let confirmed = Order.confirm order
confirmed.Status |> should be (ofCase <@ Confirmed @>)
Assertions avec Unquote
Unquote utilise les quotations F# afin que les messages d'échec affichent l'expression complète qui a échoué, pas seulement "attendu X obtenu Y".
module OrderValidationTests
open Xunit
open Swensen.Unquote
[<Fact>]
let ``PlaceOrder renvoie un succès quand la requête est valide`` () =
let request = { CustomerId = "cust-123"; Items = [ validItem ] }
let result = OrderService.placeOrder request
test <@ Result.isOk result @>
[<Fact>]
let ``le total de la commande additionne les prix des articles`` () =
let items = [ { Sku = "A"; Quantity = 2; Price = 10m }
{ Sku = "B"; Quantity = 1; Price = 5m } ]
let total = Order.calculateTotal items
test <@ total = 25m @>
[<Fact>]
let ``l'email validé rejette une entrée vide`` () =
let result = ValidatedEmail.create ""
test <@ Result.isError result @>
Tests asynchrones
[<Fact>]
let ``PlaceOrder renvoie un succès quand la requête est valide`` () = task {
let deps = createTestDeps ()
let request = { CustomerId = "cust-123"; Items = [ validItem ] }
let! result = OrderService.placeOrder deps request
test <@ Result.isOk result @>
}
[<Fact>]
let ``PlaceOrder renvoie une erreur quand les articles sont vides`` () = task {
let deps = createTestDeps ()
let request = { CustomerId = "cust-123"; Items = [] }
let! result = OrderService.placeOrder deps request
test <@ Result.isError result @>
}
Tests paramétrés avec Theory
[<Theory>]
[<InlineData("")>]
[<InlineData(" ")>]
let ``PlaceOrder rejette un ID client vide`` (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 renvoie le résultat attendu`` (email: string, expected: bool) =
test <@ EmailValidator.isValid email = expected @>
Tests basés sur les propriétés avec FsCheck
Utilisation de FsCheck.xUnit
open FsCheck
open FsCheck.Xunit
[<Property>]
let ``le total de la commande est toujours non négatif`` (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 sérialisation fait un aller-retour`` (order: Order) =
let json = JsonSerializer.Serialize order
let deserialized = JsonSerializer.Deserialize<Order> json
deserialized = order
Générateurs personnalisés
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 ``les emails valides passent la validation`` (email: string) =
EmailValidator.isValid email
Simulation de dépendances
Stubs de fonctions (recommandé)
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 sauvegarde la commande confirmée`` () = 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 pour les interfaces .NET
open NSubstitute
[<Fact>]
let ``appelle le dépôt avec le bon ID`` () = 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>())
}
Tests d'intégration 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 renvoie 404 quand non trouvé`` () = task {
let! response = client.GetAsync($"/api/orders/{Guid.NewGuid()}")
test <@ response.StatusCode = HttpStatusCode.NotFound @>
}
Organisation des tests
tests/
MyApp.Tests/
Unit/
OrderServiceTests.fs
PaymentServiceTests.fs
Integration/
OrderApiTests.fs
OrderRepositoryTests.fs
Properties/
OrderPropertyTests.fs
Helpers/
TestData.fs
TestDeps.fs
Anti-patrons courants
| Anti-patron | Correction |
|---|---|
| Tester les détails d'implémentation | Tester le comportement et les résultats |
| État de test partagé mutable | État frais par test |
Thread.Sleep dans les tests asynchrones | Utiliser Task.Delay avec timeout, ou des aides de sondage |
Assertion sur la sortie de sprintf | Assertion sur des valeurs typées et des filtres par motif |
Ignorer CancellationToken | Toujours passer et vérifier l'annulation |
| Ignorer les tests basés sur les propriétés | Utiliser FsCheck pour toute fonction avec des invariants clairs |
Compétences connexes
dotnet-patterns- Modèles .NET idiomatiques, injection de dépendances et architecturecsharp-testing- Modèles de test C# (l'infrastructure partagée comme WebApplicationFactory et Testcontainers s'applique aussi à F#)
Exécution des tests
# Exécuter tous les tests
dotnet test
# Exécuter avec couverture
dotnet test --collect:"XPlat Code Coverage"
# Exécuter un projet spécifique
dotnet test tests/MyApp.Tests/
# Filtrer par nom de test
dotnet test --filter "FullyQualifiedName~OrderService"
# Mode surveillance pendant le développement
dotnet watch test --project tests/MyApp.Tests/