Skip to content

Integration tests against Cosmos Emulator #13

@xperiandri

Description

@xperiandri

Taking the code below as an example:

  1. create test base, integration test base and database factory (non-abstract).
  2. plan which integration tests and data seeding scenarios needed to cover the library functionality (look at https://github.com/Azure/azure-cosmos-dotnet-v3)
  3. 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

Metadata

Metadata

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions