Taking the code below as an example:
- create test base, integration test base and database factory (non-abstract).
- plan which integration tests and data seeding scenarios needed to cover the library functionality (look at https://github.com/Azure/azure-cosmos-dotnet-v3)
- create planned integration tests
namespace Tests.Integration
open System
open System.Threading.Tasks
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Options
open Microsoft.VisualStudio.TestTools.UnitTesting
open Ecierge.Models
open Ecierge.Server.Persistence.Models
[<AbstractClass; TestClass>]
type TestBase () =
member val TestContext = Unchecked.defaultof<TestContext> with get, set
member test.CancellationToken = test.TestContext.CancellationTokenSource.Token
[<AbstractClass; TestClass>]
type SingleHostIntegrationTestBase () =
inherit TestBase ()
[<DefaultValue>]
static val mutable private application : TestApplicationFactoryBase
static member Application = SingleHostIntegrationTestBase.application
[<ClassInitialize(InheritanceBehavior.BeforeEachDerivedClass)>]
static member Initialize (ctx : TestContext) =
SingleHostIntegrationTestBase.application <- new TestApplicationFactory (ctx.FullyQualifiedTestClassName)
[<ClassCleanup(InheritanceBehavior.BeforeEachDerivedClass)>]
static member Cleanup () : Task = task { do! SingleHostIntegrationTestBase.application.DisposeAsync () }
[<AbstractClass; TestClass; TestCategory "Cosmos DB Emulator">]
type IntegrationTestBase<'Factory when 'Factory :> DatabaseTestApplicationFactory and 'Factory :> IDatabaseTestApplicationFactory and 'Factory : not struct and 'Factory : not null>
()
=
inherit TestBase ()
[<DefaultValue false>]
val mutable application : 'Factory
member test.Application = test.application
abstract ConfigureServices : IServiceCollection -> unit
default _.ConfigureServices _ = ()
[<TestInitialize>]
member test.Initialize () : Task = task {
test.application <- 'Factory.Create (test.TestContext, test.ConfigureServices) :?> 'Factory
do! test.application.InitializeAsync ()
do! test.application.SeedDataAsync ()
}
[<TestCleanup>]
member test.Cleanup () : Task = task {
match withNull test.application with
| null -> ()
| application ->
do! application.CleanupAsync ()
// The second test fails for some reason
//do! application.DisposeAsync ()
Task.Run (
Func<Task> (fun () -> task {
do! Task.Delay 1000
do! application.DisposeAsync ()
test.application <- Unchecked.defaultof<_>
})
)
|> ignore
}
/// <summary> Restores entity from the server by its events and compares it to the provided entity.</summary>
/// <param name="host">Functions host used in the test. Must be resolved at the very beginning of the test</param>
/// <remarks>Reslove functions host at the very beginning of the test by awaiting it</remarks>
member test.RestoreEntityAndComapreAsync<'Entity, 'Id, 'PartitionKey when 'Entity :> ICosmosEntity<'Id, 'PartitionKey>>
(host : FunctionsHost, tenantId : ApplicationTenantId, entityId : string, entity : 'Entity)
=
host.RestoreAndComapreAsync (
tenantId,
entityId,
entity,
test.Application.Services
.GetRequiredService<IOptions<Ecierge.Server.AzureCosmos.Json.JsonOptions>>()
.Value.SerializerOptions,
test.CancellationToken
)
namespace Tests.Integration
open System
open System.Diagnostics
open System.Net.Http
open System.Net.Http.Headers
open System.Runtime.InteropServices
open System.Text
open System.Threading.Tasks
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.Mvc.Testing
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open Microsoft.VisualStudio.TestTools.UnitTesting
open IcedTasks
open OpenIddict.Validation.AspNetCore
open Ecierge
open Ecierge.Models
open Ecierge.Primitives
open Ecierge.Server.AzureCosmos.Setup
open Ecierge.Server.AzureCosmos.Containers
open Ecierge.Server.AzureCosmos.Tests
open Ecierge.Server.Configuration
open Ecierge.Server.Identity
open Ecierge.Testing
open Ecierge.Testing.CosmosDbOptions
open Ecierge.Server.Tests.Authentication
type TestApplicationFactoryBase = WebApplicationFactory<Server.Program.App>
type TestApplicationFactory (uniqueDatabaseIdentifier : string) =
inherit TestApplicationFactoryBase ()
[<Literal>]
static let graphQLEndpoint = "/GraphQL"
static member GraphQLEndpoint = graphQLEndpoint
member factory.ConfigureConsoleHttpClientForTenant (tenantId : ApplicationTenantId) (httpClient : HttpClient) =
httpClient.DefaultRequestHeaders.Authorization <- AuthenticationHeaderValue ("Bearer", $"{tenantId:GUID}|test")
httpClient.BaseAddress <- Uri (factory.Server.BaseAddress, TestApplicationFactory.GraphQLEndpoint)
member private factory.ConfigureConsoleHttpClientForUser
(tenantId : ApplicationTenantId)
(userId : ConsoleUserId)
(httpClient : HttpClient)
=
let userIdString =
if userId = Unchecked.defaultof<_> then
"test"
else
userId |> ValidString.value
httpClient.DefaultRequestHeaders.Authorization <- AuthenticationHeaderValue ("Bearer", $"{tenantId:GUID}|{userIdString}")
httpClient.BaseAddress <- Uri (factory.Server.BaseAddress, TestApplicationFactory.GraphQLEndpoint)
member factory.CreateConsoleHttpClient (tenantId : ApplicationTenantId, [<Optional>] userId) =
let httpClient = factory.CreateClient ()
factory.ConfigureConsoleHttpClientForUser tenantId userId httpClient
httpClient
override _.ConfigureWebHost builder =
builder.UseSetting ("Health:Disable", "True") |> ignore
builder.ConfigureLogging (fun logging ->
logging.AddFilter("Quartz", LogLevel.Error).AddFilter ("OpenIddict", LogLevel.Trace)
|> ignore
)
|> ignore
builder.ConfigureServices (fun services ->
services
.Configure<ConsoleIdentityCosmosDbOptions>(setTestDatabaseName uniqueDatabaseIdentifier)
.Configure<CosmosDbOptions> (fun options ->
options.RunMigrations <- false
setTestDatabaseName uniqueDatabaseIdentifier options
)
|> ignore
// TODO: Replace services with mocks here
services
.AddDatabaseCreator()
.AddAuthentication(
AuthenticationOptions.removeScheme OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme
)
.AddFakeAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)
.Services.AddOpenIddict()
.AddFakeValidation()
.Services.RemoveQuartz ()
|> ignore
)
|> ignore
base.ConfigureWebHost builder
#nowarn "3535"
type IDatabaseTestApplicationFactory =
static abstract member Create :
context : TestContext * configureServices : (IServiceCollection -> unit) | null -> DatabaseTestApplicationFactory
and [<AbstractClass>] DatabaseTestApplicationFactory
/// <param name="containerCreationParameters">Specifies which containers to create</param>
(
ctx : TestContext,
containerCreationParameters : ContainerCreationParameters,
functionEntityType : FunctionEntityType voption,
[<Optional>] configureServices : (IServiceCollection -> unit) | null
)
=
inherit TestApplicationFactory (ctx.GetTestDatabaseIdentifier ())
static let buildFunctionsProjectTcs = new TaskCompletionSource<TestContext> ()
static let buildFunctionsProjectTask = task {
let! ctx = buildFunctionsProjectTcs.Task
let (functionsDirectory, workingDirectory) = getAzureFunctionsPaths ctx
let buildPsi =
new ProcessStartInfo (
"dotnet",
$"publish AzureFunctions.csproj -c Debug -o \"{functionsDirectory}\" -m:1 -v:q",
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
)
buildPsi.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] <- "1"
buildPsi.Environment["DOTNET_NOLOGO"] <- "1"
buildPsi.Environment["MSBUILDNODECOUNT"] <- "1"
use buildProc = new Process (StartInfo = buildPsi)
let buildSb = StringBuilder ()
let onBuildData (e : DataReceivedEventArgs) =
match e.Data with
| null -> ()
| data -> lock buildSb (fun () -> buildSb.AppendLine (data) |> ignore)
buildProc.OutputDataReceived.Add onBuildData
buildProc.ErrorDataReceived.Add onBuildData
if not (buildProc.Start ()) then
failwith "Failed to start dotnet build for Azure Functions"
buildProc.BeginOutputReadLine ()
buildProc.BeginErrorReadLine ()
do! buildProc.WaitForExitAsync (ctx.CancellationToken)
if buildProc.ExitCode <> 0 then
let output = buildSb.ToString ()
failwith $"Azure Functions build failed. Output:{Environment.NewLine}{output}"
return functionsDirectory
}
let functionsHost =
Lazy<_> (
valueFactory = coldTask {
let! functionsDirectory = buildFunctionsProjectTask
return!
FunctionsHost.StartAsync (
uniqueDatabaseIdentifier = ctx.GetTestDatabaseIdentifier (),
functionsDirectory = functionsDirectory,
functionEntityType = functionEntityType,
cancellationToken = ctx.CancellationToken
)
},
isThreadSafe = true
)
member _.FunctionsHost =
buildFunctionsProjectTcs.TrySetResult ctx |> ignore
functionsHost.Value
override _.ConfigureWebHost builder =
base.ConfigureWebHost builder
builder.ConfigureServices (fun services ->
match withNull configureServices with
| null -> ()
| configure -> configure services
services.AddSingleton<DatabaseTestInitializer> (fun sp ->
let parametersWithEvents = containerCreationParameters.AddContainer DatabaseContainer.Events
DatabaseTestInitializer (sp, ctx, parametersWithEvents)
)
|> ignore
)
|> ignore
member private factory.Database = factory.Services.GetRequiredService<DatabaseTestInitializer> ()
member factory.InitializeAsync () = factory.Database.InitializeAsync ()
member factory.CleanupAsync () = factory.Database.CleanupAsync ()
override _.Dispose disposing =
if disposing && functionsHost.IsValueCreated then
let hostTask = functionsHost.Value
if hostTask.IsCompletedSuccessfully then
(hostTask.Result :> IDisposable).Dispose ()
base.Dispose disposing
abstract member SeedDataAsync : unit -> Task
default _.SeedDataAsync () = Task.CompletedTask
Taking the code below as an example: