Unit Testing Umami.Net - Testing UmamiBackgroundSender (Italiano (Italian))

Unit Testing Umami.Net - Testing UmamiBackgroundSender

Comments

NOTE: Apart from English (and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.

Tuesday, 03 September 2024

//

7 minute read

Introduzione

Nell'articolo precedente, abbiamo discusso come testare la UmamiClient usando xUnit e Moq. In questo articolo, discuteremo come testare il UmamiBackgroundSender classe. La UmamiBackgroundSender è un po 'diverso da UmamiClient come usa IHostedService per rimanere in esecuzione in background e inviare richieste attraverso UmamiClient completamente fuori dalla thread di esecuzione principale (in modo che non blocchi l'esecuzione).

Come al solito puoi vedere tutto il codice sorgente per questo sul mio GitHub qui.

UmamiBackgroundSender

La struttura effettiva di UmamiBackgroundSender E' molto semplice. Si tratta di un servizio ospitato che invia richieste al server Umami non appena rileva una nuova richiesta. La struttura di base UmamiBackgroundSender la classe è mostrata di seguito:

public class UmamiBackgroundSender(IServiceScopeFactory scopeFactory, ILogger<UmamiBackgroundSender> logger) : IHostedService
{

    private  Channel<SendBackgroundPayload> _channel = Channel.CreateUnbounded<SendBackgroundPayload>();

    private Task _sendTask = Task.CompletedTask;
    
        public Task StartAsync(CancellationToken cancellationToken)
    {

        _sendTask = SendRequest(_cancellationTokenSource.Token);
        return Task.CompletedTask;
    }
    
            public async Task StopAsync(CancellationToken cancellationToken)
        {
            logger.LogInformation("UmamiBackgroundSender is stopping.");

            // Signal cancellation and complete the channel
            await _cancellationTokenSource.CancelAsync();
            _channel.Writer.Complete();
            try
            {
                // Wait for the background task to complete processing any remaining items
                await Task.WhenAny(_sendTask, Task.Delay(Timeout.Infinite, cancellationToken));
            }
            catch (OperationCanceledException)
            {
                logger.LogWarning("StopAsync operation was canceled.");
            }
        }
        
                private async Task SendRequest(CancellationToken token)
    {
        logger.LogInformation("Umami background delivery started");

        while (await _channel.Reader.WaitToReadAsync(token))
        {
            while (_channel.Reader.TryRead(out var payload))
            {
                try
                {
                   using  var scope = scopeFactory.CreateScope();
                    var client = scope.ServiceProvider.GetRequiredService<UmamiClient>();
                    // Send the event via the client
                    await client.Send(payload.Payload, type:payload.EventType);

                    logger.LogInformation("Umami background event sent: {EventType}", payload.EventType);
                }
                catch (OperationCanceledException)
                {
                    logger.LogWarning("Umami background delivery canceled.");
                    return; // Exit the loop on cancellation
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "Error sending Umami background event.");
                }
            }
        }
    }

    private record SendBackgroundPayload(string EventType, UmamiPayload Payload);
    
    }

Come potete vedere questo è solo un classico IHostedService è aggiunto alla nostra collezione di servizi in ASP.NET utilizzando il services.AddHostedService<UmamiBackgroundSender>() metodo. Questo dà il via al StartAsync metodo all'inizio dell'applicazione. Lo sguardo all'interno del SendRequest metodo è dove la magia accade. Qui è dove leggiamo dal canale e inviamo la richiesta al server Umami.

Ciò esclude i metodi effettivi per inviare le richieste (indicate di seguito).

public async Task TrackPageView(string url, string title, UmamiPayload? payload =null, UmamiEventData? eventData = null)

public async Task Identify(string? email = null, string? username = null,
        string? sessionId = null, string? userId = null, UmamiEventData? eventData = null)   

        public async Task IdentifySession(string sessionId, UmamiEventData? eventData = null)

public async Task Track(string eventName, UmamiEventData? eventData = null)

public async Task Send(UmamiPayload? payload = null, UmamiEventData? eventData = null,
        string eventType = "event")

Tutto ciò che fa veramente è impacchettare la richiesta fino al SendBackgroundPayload registrare e inviarlo al canale.

Il nostro nidificato ricevere loop in SendRequest continuerà a leggere dal canale fino a quando non sarà chiuso. E' qui che concentreremo i nostri sforzi di sperimentazione.

  while (await _channel.Reader.WaitToReadAsync(token))
        {
            while (_channel.Reader.TryRead(out var payload))
            {
            }
        }    

Il servizio di background ha alcune semantiche che gli permettono di spegnere il messaggio appena arriva. Tuttavia questo solleva un problema; se non otteniamo un valore restituito dal Send Come facciamo a testare che questo stia davvero facendo qualcosa?

Prova UmamiBackgroundSender

Quindi la domanda è come facciamo a testare questo servizio cinquen non c'è risposta a test effettivamente contro?

La risposta è quella di iniettare HttpMessageHandler al client HttpClient che mandiamo nel nostro UmamiClient. Questo ci permetterà di intercettare la richiesta e controllare il suo contenuto.

EchoMockHttpMessageHandler

Ricorderete dall'articolo precedente che abbiamo creato un finto HttpMessageHandler. Questo vive all'interno del EchoMockHandler classe statica:

public static class EchoMockHandler
{
    public static HttpMessageHandler Create(
        Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responseFunc)
    {
        var mockHandler = new Mock<HttpMessageHandler>();
        mockHandler.Protected()
            .Setup<Task<HttpResponseMessage>>(
                "SendAsync",
                ItExpr.IsAny<HttpRequestMessage>(),
                ItExpr.IsAny<CancellationToken>())
            .ReturnsAsync((HttpRequestMessage request, CancellationToken cancellationToken) =>
                responseFunc(request, cancellationToken).Result);

        return mockHandler.Object;
    }

Potete vedere qui usiamo Mock per impostare un SendAsync metodo che restituirà una risposta basata sulla richiesta (in HttpClient tutte le richieste async vengono effettuate attraverso SendAsync).

Vedete, abbiamo preparato il Mock per la prima volta.

     var mockHandler = new Mock<HttpMessageHandler>();

Poi usiamo la magia di Protected per impostare il SendAsync metodo. Questo è perché SendAsync non è normalmente accessibile nelle API pubbliche di HttpMessageHandler.

public abstract class HttpMessageHandler : IDisposable
    {
        protected HttpMessageHandler()
        {
        }
        protected internal abstract Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);

Poi usiamo solo il catch-all ItExpr.IsAny per corrispondere a qualsiasi richiesta e restituire la risposta dal responseFunc Passiamo dentro.

Metodi di prova.

All'interno della UmamiBackgroundSender_Tests classe abbiamo un modo comune per definire tutti i metodi di prova.

Configurazione

[Fact]
    public async Task Track_Page_View()
    {
        var page = "https://background.com";
        var title = "Background Example Page";
        var tcs = new TaskCompletionSource<bool>();
        // Arrange
        var handler = EchoMockHandler.Create(async (message, token) =>
        {
            try
            {
                var responseContent = EchoMockHandler.ResponseHandler(message, token);
                var jsonContent = await responseContent.Result.Content.ReadFromJsonAsync<EchoedRequest>(token);
                var content = new StringContent("{}", Encoding.UTF8, "application/json");
                Assert.Contains("api/send", message.RequestUri.ToString());
                Assert.NotNull(jsonContent);
                Assert.Equal(page, jsonContent.Payload.Url);
                Assert.Equal(title, jsonContent.Payload.Title);
                // Signal completion
                tcs.SetResult(true);

                return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
            }
            catch (Exception e)
            {
                
                tcs.SetException(e);
                return new HttpResponseMessage(HttpStatusCode.InternalServerError);
            }
        });

        var (backgroundSender, hostedService) = GetServices(handler);
        var cancellationToken = new CancellationToken();
        await hostedService.StartAsync(cancellationToken);
        await backgroundSender.TrackPageView(page, title);
        var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(1000, cancellationToken));
        if (completedTask != tcs.Task)
        {
            throw new TimeoutException("The background task did not complete in time.");
        }
        
        await tcs.Task;
        await backgroundSender.StopAsync(CancellationToken.None);
    }

Una volta che abbiamo questo definito abbiamo bisogno di gestire il nostro IHostedService durata nel metodo di prova:

       var (backgroundSender, hostedService) = GetServices(handler);
        var cancellationToken = new CancellationToken();
        await hostedService.StartAsync(cancellationToken);
        await backgroundSender.TrackPageView(page, title);
        var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(1000, cancellationToken));
        if (completedTask != tcs.Task)
        {
            throw new TimeoutException("The background task did not complete in time.");
        }
        
        await tcs.Task;
        await backgroundSender.StopAsync(CancellationToken.None);
    }

Potete vedere che passiamo nel gestore al nostro GetServices metodo di setup:

    private (UmamiBackgroundSender, IHostedService) GetServices(HttpMessageHandler handler)
    {
        var services = SetupExtensions.SetupServiceCollection(handler: handler);
        services.AddScoped<UmamiBackgroundSender>();
       

        services.AddScoped<IHostedService, UmamiBackgroundSender>(provider =>
            provider.GetRequiredService<UmamiBackgroundSender>());
        SetupExtensions.SetupUmamiClient(services);
        var serviceProvider = services.BuildServiceProvider();
        var backgroundSender = serviceProvider.GetRequiredService<UmamiBackgroundSender>();
        var hostedService = serviceProvider.GetRequiredService<IHostedService>();
        return (backgroundSender, hostedService);
    }

Qui passiamo nel nostro gestore ai nostri servizi per agganciarlo nel UmamiClient Prepararsi.

A questo punto aggiungiamo l'emendamento n. UmamiBackgroundSender alla raccolta di servizi e ottenere il IHostedService dal fornitore del servizio. Poi restituisci questo alla classe test per consentirne l'uso.

Hosted Service Lifetime

Ora che abbiamo tutte queste cose, possiamo semplicemente... StartAsync il Servizio Hosted, utilizzarlo quindi attendere che si fermi:

        await hostedService.StartAsync(cancellationToken);
        await backgroundSender.TrackPageView(page, title);
        var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(1000, cancellationToken));
        if (completedTask != tcs.Task)
        {
            throw new TimeoutException("The background task did not complete in time.");
        }
        
        await tcs.Task;
        await backgroundSender.StopAsync(CancellationToken.None);

Questo avvierà il servizio ospitato, inviare la richiesta, attendere la risposta poi interrompere il servizio.

Gestore messaggi

Iniziamo con l'istituzione del EchoMockHandler e della TaskCompletionSource che segnalerà che il test è completo. Questo è importante per riportare il contesto al thread di test principale in modo da poter catturare correttamente i guasti e i timeout.

La async (message, token) => {} è la funzione che passiamo nel nostro simulatore che abbiamo menzionato sopra. Qui possiamo verificare la richiesta e restituire una risposta (che in questo caso in realtà non facciamo nulla con).

La nostra EchoMockHandler.ResponseHandler è un metodo helper che restituirà il corpo della richiesta al nostro metodo, questo ci permette di verificare che il messaggio sta passando attraverso il UmamiClient alla HttpClient Giusto.

    public static async Task<HttpResponseMessage> ResponseHandler(HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        // Read the request content
        var requestBody = request.Content?.ReadAsStringAsync(cancellationToken).Result;
        // Create a response that echoes the request body
        var responseContent = requestBody ?? "No request body";
        // Return the response
        return await Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(responseContent, Encoding.UTF8, "application/json")
        });
    }

Poi afferrare questa risposta e deserializzarlo in un EchoedRequest Oggetto. Questo è un semplice oggetto che rappresenta la richiesta che abbiamo inviato al server.

public class EchoedRequest
{
    public string Type { get; set; }
    public UmamiPayload Payload { get; set; }
}

Vedete questo incapsula il Type e Payload della richiesta. Questo è ciò contro cui controlleremo nel nostro test.

      Assert.Contains("api/send", message.RequestUri.ToString());
      Assert.NotNull(jsonContent);
      Assert.Equal(page, jsonContent.Payload.Url);
      Assert.Equal(title, jsonContent.Payload.Title);

Ciò che è critico qui è come gestiamo i test falliti, come non siamo nel contesto principale thread qui abbiamo bisogno di utilizzare TaskCompletionSource per segnalare al thread principale che il test non è riuscito.

     catch (Exception e)
            {
                
                tcs.SetException(e);
                return new HttpResponseMessage(HttpStatusCode.InternalServerError);
            }

Questo metterà l'eccezione sulla TaskCompletionSource e restituire un errore di 500 alla prova.

In conclusione

Questo è il primo dei miei post più dettagliati, IHostedService garantisce questo perché è piuttosto complesso da testare quando come qui non restituisce un valore al chiamante.

logo

©2024 Scott Galloway