F# 테스트 패턴
xUnit, FsUnit, Unquote, FsCheck 및 최신 .NET 테스트 관행을 사용한 F# 애플리케이션의 포괄적인 테스트 패턴입니다.
활성화 시점
- F# 코드에 대한 새 테스트 작성 시
- 테스트 품질 및 커버리지 검토 시
- F# 프로젝트의 테스트 인프라 설정 시
- 불안정하거나 느린 테스트 디버깅 시
테스트 프레임워크 스택
| 도구 | 목적 |
|---|---|
| xUnit | 테스트 프레임워크 (표준 .NET 에코시스템 선택) |
| FsUnit.xUnit | xUnit을 위한 F# 친화적 단언 구문 |
| Unquote | 명확한 실패 메시지를 위한 F# 인용(quotation) 기반 단언 라이브러리 |
| FsCheck.xUnit | xUnit에 통합된 속성 기반 테스트 |
| NSubstitute | .NET 의존성 모킹 |
| Testcontainers | 통합 테스트에서의 실제 인프라 |
| WebApplicationFactory | ASP.NET Core 통합 테스트 |
xUnit + FsUnit을 사용한 단위 테스트
기본 테스트 구조
module OrderServiceTests
open Xunit
open FsUnit.Xunit
[<Fact>]
let ``create는 상태를 Pending으로 설정한다`` () =
let order = Order.create "cust-1" [ validItem ]
order.Status |> should equal Pending
[<Fact>]
let ``confirm은 상태를 Confirmed로 변경한다`` () =
let order = Order.create "cust-1" [ validItem ]
let confirmed = Order.confirm order
confirmed.Status |> should be (ofCase <@ Confirmed @>)
Unquote를 사용한 단언
Unquote는 F# 인용(quotation)을 사용하므로 실패 메시지에 "expected X got Y" 대신 실패한 전체 표현식이 표시됩니다.
module OrderValidationTests
open Xunit
open Swensen.Unquote
[<Fact>]
let ``PlaceOrder는 요청이 유효할 때 성공을 반환한다`` () =
let request = { CustomerId = "cust-123"; Items = [ validItem ] }
let result = OrderService.placeOrder request
test <@ Result.isOk result @>
[<Fact>]
let ``주문 총액은 상품 가격의 합이다`` () =
let items = [ { Sku = "A"; Quantity = 2; Price = 10m }
{ Sku = "B"; Quantity = 1; Price = 5m } ]
let total = Order.calculateTotal items
test <@ total = 25m @>
[<Fact>]
let ``검증된 이메일은 빈 입력을 거부한다`` () =
let result = ValidatedEmail.create ""
test <@ Result.isError result @>
비동기 테스트
[<Fact>]
let ``PlaceOrder는 요청이 유효할 때 성공을 반환한다`` () = task {
let deps = createTestDeps ()
let request = { CustomerId = "cust-123"; Items = [ validItem ] }
let! result = OrderService.placeOrder deps request
test <@ Result.isOk result @>
}
[<Fact>]
let ``PlaceOrder는 항목이 비어 있을 때 오류를 반환한다`` () = task {
let deps = createTestDeps ()
let request = { CustomerId = "cust-123"; Items = [] }
let! result = OrderService.placeOrder deps request
test <@ Result.isError result @>
}
Theory를 사용한 매개변수화된 테스트
[<Theory>]
[<InlineData("")>]
[<InlineData(" ")>]
let ``PlaceOrder는 빈 고객 ID를 거부한다`` (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은 예상된 결과를 반환한다`` (email: string, expected: bool) =
test <@ EmailValidator.isValid email = expected @>
FsCheck를 사용한 속성 기반 테스트
FsCheck.xUnit 사용
open FsCheck
open FsCheck.Xunit
[<Property>]
let ``주문 총액은 항상 음수가 아니다`` (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 ``직렬화가 라운드트립된다`` (order: Order) =
let json = JsonSerializer.Serialize order
let deserialized = JsonSerializer.Deserialize<Order> json
deserialized = order
사용자 정의 생성기
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 ``유효한 이메일은 검증을 통과한다`` (email: string) =
EmailValidator.isValid email
의존성 모킹
함수 스텁 (권장)
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는 확인된 주문을 저장한다`` () = task {
let mutable saved = []
let deps =
{ createTestDeps () with
SaveOrder = fun order -> task { saved <- order :: saved } }
let! _ = OrderService.placeOrder deps validRequest
test <@ saved.Length = 1 @>
}
.NET 인터페이스를 위한 NSubstitute
open NSubstitute
[<Fact>]
let ``올바른 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>())
}
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는 찾을 수 없을 때 404를 반환한다`` () = task {
let! response = client.GetAsync($"/api/orders/{Guid.NewGuid()}")
test <@ response.StatusCode = HttpStatusCode.NotFound @>
}
테스트 조직
tests/
MyApp.Tests/
Unit/
OrderServiceTests.fs
PaymentServiceTests.fs
Integration/
OrderApiTests.fs
OrderRepositoryTests.fs
Properties/
OrderPropertyTests.fs
Helpers/
TestData.fs
TestDeps.fs
일반적인 안티 패턴
| 안티 패턴 | 수정 방법 |
|---|---|
| 구현 세부 사항 테스트 | 동작과 결과를 테스트 |
| 변경 가능한 공유 테스트 상태 | 테스트마다 새로운 상태 사용 |
비동기 테스트에서 Thread.Sleep | Task.Delay와 타임아웃 또는 폴링 헬퍼 사용 |
sprintf 출력에 대한 단언 | 타입이 있는 값과 패턴 매칭에 대해 단언 |
CancellationToken 무시 | 항상 취소를 전달하고 확인 |
| 속성 기반 테스트 건너뛰기 | 명확한 불변량이 있는 모든 함수에 FsCheck 사용 |
관련 스킬
dotnet-patterns- 관용적인 .NET 패턴, 의존성 주입 및 아키텍처csharp-testing- C# 테스트 패턴 (WebApplicationFactory 및 Testcontainers와 같은 공유 인프라는 F#에도 적용됨)
테스트 실행
# 모든 테스트 실행
dotnet test
# 커버리지 포함 실행
dotnet test --collect:"XPlat Code Coverage"
# 특정 프로젝트 실행
dotnet test tests/MyApp.Tests/
# 테스트 이름으로 필터링
dotnet test --filter "FullyQualifiedName~OrderService"
# 개발 중 감시 모드
dotnet watch test --project tests/MyApp.Tests/