Hablemos sobre .Net y Azure

Mes: junio 2021

Evitar usuarios de redes sociales duplicados en Azure AD B2C (Graph)

En artículo anterior de la serie armamos nuestro Conector y configuramos Azure AD B2C para utilizarlo y así detectar y prevenir usuarios duplicados. Además creamos una Azure Function con código base para probar el conector.

En este post final completaremos la implementación escribiendo el código necesario para consultar Microsoft Graph API por usuarios con el mismo email ya sea como identidad o email adicional.

Preparación

Antes de comenzar con la implementación necesitamos recolectar las siguientes piezas de información y hacer algunas configuraciones:

  1. ID de Tenant o Directorio
  2. ID de Aplicación
  3. Generar un Secreto de Cliente
  4. Dar permisos para Microsoft Graph API

ID de Tenant y ID de Aplicación

Para obtener las primeras dos piezas de información que necesitamos debemos ir al registro de aplicaciones seleccionar la aplicación con la cuál vamos a trabajar. Cuándo realizamos la configuración inicial creamos un registro de aplicación llamado Sitio Web Increíble, ese es el que seleccionaremos en este caso.

Dela sección Información general del registro de aplicación podremos ver la información necesaria.

Registro de aplicación
Registro de aplicación

Generar secreto de cliente

Desde el mismo registro de aplicación podremos generar un secreto de cliente. En la sección Certificados y Secretos creamos un Nuevo secreto de cliente. Sólo necesitamos completar la descripción (la cuál es usada como referencia) y seleccionar una expiración.

Generación de secreto de cliente
Generación de secreto de cliente

Al finalizar veremos el secreto de cliente en la lista de secretos, necesitamos copiar el valor. Es importante tener en cuenta que una vez que abandonemos esta página ya no podremos a volver a ver el valor.

Valor del secreto de cliente
Valor del secreto de cliente

Permisos para Microsoft Graph API

Desde la sección Permisos de API en el registro de aplicación, agregamos un nuevo permiso de Microsoft Graph. Dentro de la categoría Permisos de Aplicación agregamos el permiso User.Read.All como se muestra a continuación:

Permisos de Microsoft Graph para leer usuarios

Es muy importante conceder consentimiento permisos de administrador para nuestro tenant, de lo contrario los permisos no serán efectivos.

Implementación

Si bien Microsoft Graph expone una API REST muy bien documentada, el equipo de Microsoft creó una librería para facilitarnos la tarea y que se encuentra disponible vía paquetes de NuGet. Comenzaremos agregando al proyecto de Azure Function los siguientes paquetes de NuGet:

Microsoft.Graph
Microsoft.Graph.Auth
Microsoft.Identity.Client

Luego, a partir del ID de Aplicación, el ID de Tenant y el Secreto de Cliente que obtuvimos anteriormente, vamos a generar una instancia de GraphServiceClient:

// Usings
using Microsoft.Graph;
using Microsoft.Graph.Auth;
using Microsoft.Identity.Client;

// Código
var confidentialClientApplication = ConfidentialClientApplicationBuilder
    .Create("<< ID de Aplicación >>")
    .WithTenantId("<< ID de Tenant >>")
    .WithClientSecret("<< Secreto de Cliente >>")
    .Build();

var authProvider = new ClientCredentialProvider(confidentialClientApplication);

var graphClient = new GraphServiceClient(authProvider);

Buscando usuarios por email

Estamos buscando encontrar usuarios con el mismo email que el usuario actual está intentando usar, con lo cuál buscamos usuario cuya identidad sea el mail o usuarios que tengan el email como parte de sus emails adicionales. Esto requiere dos peticiones diferentes.

  • Búsqueda por identidad
var usersByIdentity = (await _graphClient.Users
    .Request()
    .Filter($"identities/any(c:c/issuerAssignedId eq '{email}' and c/issuer eq '{"<< ID de Tenant >>"}')")
    .GetAsync())
        .ToArray();
  • Usuarios por otros emails:
var usersByOtherEmails = (await _graphClient.Users
    .Request()
    .Filter($"otherMails/any(c:c eq '{email}') and UserType eq 'Member'")
    .GetAsync())
        .ToArray();

Si alguna de las dos peticiones anteriores contiene algún resultado, eso quiere decir entonces que hemos encontrado otro usuario con el mismo email que el usuario actual y demos detener el flujo de usuario. De lo contrario podemos continuar con la ejecución del flujo de usuario.

Quiero hacer mención al los usuarios de Stackoverflow boehlefeld y LuisEduardox de quién tomé las dos consultas necesarias para interactuar con Microsoft Graph a partir de las respuestas aquí y aquí mencionadas.

Como vimos en el post anterior, detenemos la ejecución del flujo de usuario retornando como respuesta "action" : "ShowBLockPage".

if (usersByIdentity.Any() || usersByOtherEmails.Any())
{
    return new OkObjectResult(
        new
        {
            version = "1.0.0",
            action = "ShowBlockPage",
            userMessage = $"Email {req.email} is duplicated."
        });
}

return new OkObjectResult(
    new
    {
        version = "1.0.0",
        action = "Continue"
    });

Solución completa

Juntando todas las piezas, nuestra Azure Function se ve de la siguiente forma:

var confidentialClientApplication = ConfidentialClientApplicationBuilder
    .Create("<< ID de Aplicación >>")
    .WithTenantId("<< ID de Tenant >>")
    .WithClientSecret("<< Secreto de Cliente >>")
    .Build();

var authProvider = new ClientCredentialProvider(confidentialClientApplication);

var graphClient = new GraphServiceClient(authProvider);

var usersByIdentity = (await _graphClient.Users
    .Request()
    .Filter($"identities/any(c:c/issuerAssignedId eq '{email}' and c/issuer eq '{"<< ID de Tenant >>"}')")
    .GetAsync())
        .ToArray();

var usersByOtherEmails = (await _graphClient.Users
    .Request()
    .Filter($"otherMails/any(c:c eq '{email}') and UserType eq 'Member'")
    .GetAsync())
        .ToArray();

if (usersByIdentity.Any() || usersByOtherEmails.Any())
{
    return new OkObjectResult(
        new
        {
            version = "1.0.0",
            action = "ShowBlockPage",
            userMessage = $"Email {req.email} is duplicated."
        });
}

return new OkObjectResult(
    new
    {
        version = "1.0.0",
        action = "Continue"
    });

Sólo nos resta desplegar la nueva versión de nuestra Azure Function y probar.

Conclusión

En este post nos enfocamos en la parte final de la solución la cual implicó la interacción con Microsoft Graph para detectar usuarios con el mismo email que el usuario que se está intentando crear.

Una solución un poco más elegante pero mucho más compleja es la unificación de identidades, para lo que necesitamos utilizar Políticas Personalizadas de Azure AD B2C. Dejaremos esa para más adelante.

Controllers y CancellationTokens en .Net 5

Hola! Quiero comenzar el post con la siguiente pregunta:
Si haces una solicitud a tu Web Api desde un cliente REST (Postman por ejemplo) y luego la cancelas antes de que termine, ¿Qué sucede?

En pocas palabras, podría no pasar nada o podríamos estar desperdiciando recursos sin necesidad. Todo depende de cómo estés escribiendo los controladores.

En los próximos párrafos intentaré responder esa pregunta, y mediante ejemplos, veremos como solucionarlo.

Caso de uso

Analicemos el siguiente controlador MVC de una aplicación Web API regular. Es un controlador muy simple que simula una operación costosa. En este caso es un simple Task.Delay() de 5 y 10 segundos respectivamente.

[HttpGet]
public async Task<IActionResult> Get()
{
    _logger.LogInformation("1- Ready to start");

    await Task.Delay(TimeSpan.FromSeconds(5));

    _logger.LogInformation("2- Half of the work done");

    await Task.Delay(TimeSpan.FromSeconds(10));

    _logger.LogInformation("3- We finished!!!");

    return Ok();
}

Y ahora lo probaremos desde Postman. Para esto ejecutamos la solución y luego hacemos una petición a nuestra API. La idea es esperar los primeros 5 segundos y luego cancelar la petición desde Postman. La petición completa toma algo más de 15 segundos.

Cancelación de la solicitud
Cancelación de la solicitud

Observación

En la imagen anterior vemos sobre la izquierda Postman y sobre la derecha la consola de Visual Studio mostrando los logs de la API.

Algo muy interesante es que a pesar de haber cancelado la petición luego de los 5 segundos los logs siguen apareciendo en la consola. Como podemos apreciar, Postman efectivamente cancela la solicitud, pero la API parece ignorar la cancelación.

Si efectivamente estuviéramos realizando una operación costosa, cómo puede ser acceso a base de datos o comunicación con otros servicios, habríamos desperdiciados recursos procesando parte de una solicitud que ya no tiene sentido debido a que el cliente desistió de la misma.

Aspectos técnicos

Esta situación donde una tarea es cancelada o abortada es bastante frecuente en distintos tipos de aplicaciones (ya sean web, desktop o nativas) y el framework nos provee mecanismos para hacer la tarea mucho mas simple.

A su vez, Asp.Net esta preparado para aprovechar esos mecanismos y detectar cuando el cliente canceló la solicitud o la conexión ha sido interrumpida, es nuestra responsabilidad hacer uso de ellos.

CancellationTokens

Cada vez que una acción es ejecutada el framework inyectará una instancia de un CancellationToken enlazada al ciclo de vida de la petición. De esta forma podremos detectar si la la petición ha sido cancelada.

Podemos lanzar un excepción del tipo TaskCancelledException como en el siguiente ejemplo:

[HttpGet]
public IActionResult Get(CancellationToken token)
{
    token.ThrowIfCancellationRequested();

    return Ok();
}

O bien podemos validar si se ha solicitado su cancelación:

[HttpGet]
public IActionResult Get(CancellationToken token)
{
    if(token.IsCancellationRequested)
    {
        return BadRequest("Solicitud cancelada.");
    }

    return Ok();
}

Pasando CancellationTokens en la cadena de ejecución

En general, es común que los métodos que impliquen algún tipo de tarea asincrónica (I/O en general como comunicación con base de datos, manejos de streams, network, etc.) acepten un CancellationToken como último parámetro.

[HttpGet]
public async Task<IActionResult> Get(CancellationToken token)
{
    await Task.Delay(TimeSpan.FromSeconds(5), token);

    return Ok();
}

Modificando el controlador

Cómo vimos en el ejemplo anterior, el cambio necesario para manejar correctamente la cancelación de la solicitud solo requiere agregar un parámetro adicional CancellationToken a la acción del controlador y pasar ese token al método Task.Delay().

[HttpGet]
public async Task<IActionResult> Get(CancellationToken token)
{
    _logger.LogInformation("1- Ready to start");

    await Task.Delay(TimeSpan.FromSeconds(5), token);

    _logger.LogInformation("2- Half of the work done");

    await Task.Delay(TimeSpan.FromSeconds(10), token);

    _logger.LogInformation("3- We finished!!!");

    return Ok();
}

Esta vez el comportamiento será diferente:

Cancelación de la solicitud - Error
Cancelación de la solicitud – Error

Dos cosas interesantes podemos destacar:

  1. Por un lado la tarea efectivamente fue cancelada y la ejecución de la petición no continuo.
  2. Se produjo una excepción

TaskCancelledException

Cuando un CancellationToken recibe la notificación de cancelación arroja una excepción del tipo TaskCancelledException. Es nuestra responsabilidad manejar en forma correcta esta situación.

Es importante mencionar que la forma en la que manejamos las cancelaciones es completamente arbitraria y depende del tipo de software y reglas de negocio con las que estemos trabajando.

A fines prácticos para nuestro ejemplo, voy a utilizar un simple try/catch.

[HttpGet]
public async Task<IActionResult> Get(CancellationToken token)
{
    try
    {
        _logger.LogInformation("1- Ready to start");

        await Task.Delay(TimeSpan.FromSeconds(5), token);

        _logger.LogInformation("2- Half of the work done");

        await Task.Delay(TimeSpan.FromSeconds(10), token);

        _logger.LogInformation("3- We finished!!!");

        return Ok();
    }
    catch (TaskCanceledException)
    {
        _logger.LogInformation("Task was cancelled");

        return BadRequest();
    }
}
Cancelación de la solicitud - Éxito
Cancelación de la solicitud – Éxito

Consideraciones

Un aspecto muy importante a tener en cuenta es qué tipo de consecuencias puede generar la cancelación de la solicitud en términos de consistencia, ya que podríamos potencialmente truncar una operación en la mitad.

Por ejemplo, si estamos trabajando con Entity Framework Core es conveniente agrupar las operaciones dentro de una transacción y pasar el CancellationToken. CommitAsync() y RollbackAsync() también aceptan un CancellationToken.

using (var transaction = await _dbContext.Database.BeginTransactionAsync(token))
{
    // Modifying operations here

    // Commit if successful
    await transaction.CommitAsync(token);
    // Rollback otherwise
    await transaction.RollbackAsync(token);
}

Conclusión

Por un lado podemos concluir que es importante contemplar la idea de que la petición puede ser abortada o cancelada, y podríamos estar malgastando recursos innecesarios.

Por el otro lado y para suerte nuestra, el framework nos provee todos los mecanismos necesarios para que la tarea se fácil y sencilla mediante la utilización de CancellationTokens.

Versionando APIs con .NET 5 (Net-Baires)

Uno de los grandes desafíos a la hora de diseñar y desarrollar REST APIs es cómo encarar aquellos cambios que afectan a los clientes, conocidos como breaking changes.

Para evitar los breaking changes es indispensable aplicar una estrategia de versionado clara y efectiva.

En esta sesión cuento todo lo que necesitamos saber para versionar nuestras APIs de forma eficiente y efectiva utilizando todo el poder que .NET 5 nos otorga.

El código completo de los ejemplos, como siempre disponible en mi GitHub.

Evitar usuarios de redes sociales duplicados en Azure AD B2C (Conectores)

En artículo anterior discutimos sobre este posible inconveniente cuando trabajamos con distintos proveedores de identidad y planteamos una propuesta de arquitectura combinando Conectores, Azure Functions y Microsoft Graph API..

En este post vamos a analizar qué son los Conectores y cómo nos pueden ser de gran ayuda para requerimientos de este tipo. Además crearemos un conector junto con una API muy simple utilizando Azure Functions.

Conectores

Ya vimos que los conectores nos proporcionan un mecanismo para ejecutar REST APIs antes de que Azure AD B2C haga la creación del usuario en la base de datos. Y vimos que se pueden ejecutar después de haber iniciado sesión con un proveedor de identidad externo y antes de crear el usuario.

Descubrimos además que sólo se ejecutan durante el registro de nuevos usuarios. Veamos entonces los aspectos técnicos más relevantes para poder completar nuestra implementación.

Casos de uso

Los conectores nos permiten extender la experiencia de registro e integrarla con sistemas externos:

  • Validación de datos: podemos validar determinados datos de entrada como puede ser el formato correcto de de identificación personal o cuenta bancaria.
  • Aprobación: en algunos casos prevenir la creación del usuario si no fue autorizado aún en una plataforma interna.
  • Agregar o sobrescribir atributos: podríamos sobrescribir algunos atributos, ya sea para darle un formato específico (Camel case). También podemos agregar o auto completar atributos.
  • Lógica de negocio: por ejemplo, notificar a otros sistemas externos.

En nuestro caso, validaremos contra Microsoft Graph si existe un usuario con ese email.

Solicitud (Request)

Cuando un conector es ejecutado envía una solicitud HTTP POST a la REST API que hemos configurado oportunamente. El cuerpo de la solicitud será enviado en formato JSON (Content-type: application/json), por ejemplo:

{
 "email": "johnsmith@facutherock.net",
 "identities": [
     {
     "signInType":"federated",
     "issuer":"github.com",
     "issuerAssignedId":"0123456789"
     }
 ],
 "displayName": "John Smith",
 "givenName":"John",
 "lastName":"Smith",
 "extension_<extensions-app-id>_Atributo1": "Atributo personalizo 2",
 "extension_<extensions-app-id>_Atributo2": "Atributo personalizo 2",
 "ui_locales":"es-ES"
}

Los atributos o claims que no tengan valor (null or undefined) no serán informados en el JSON enviado como solicitud.

Tipo de respuesta (Response)

Nuestra REST API debe devolver una respuesta al conector de Azure AD B2C que luego será utilizada para continuar con el flujo de usuario. La respuesta debe estar en formato JSON (Content-type: application/json).

Hay tres tipos de respuesta:

  • Respuesta de continuación:
    Indica que el flujo debe continuar a la siguiente etapa. Si estamos ejecutando un conector luego del inicio de sesión con un proveedor externo, la siguiente etapa será la recolección de atributos adicionales. Si el conector ejecutado es antes de crear el usuario, la etapa siguiente es la creación del usuario.
    El código de estado de la respuesta debe ser 200 - OK y debe contener un JSON con un elemento "action" cuyo valor debe ser "Continue", como el siguiente:
{
    "version": "1.0.0",
    "action": "Continue",
    "postalCode": "12349", // atributo adicional o auto-completado
    "extension_<extensions-app-id>_CustomAttribute": "value" // Atributo personalizado adicional o auto-completado
}
  • Respuesta de bloqueo:
    Detiene el flujo de usuario y muestra un mensaje al usuario. El código de estado de la respuesta debe ser 200 - OK y debe contener un JSON con un elemento "action" cuyo valor debe ser "ShowBlockPage", como el siguiente:
{
    "version": "1.0.0",
    "action": "ShowBlockPage",
    "userMessage": "Hubo un error.",
}
  • Respuesta de validación:
    Esta respuesta sólo es válida en la recolección de atributos adicionales y muestra un error al usuario en relación a la información ingresada. Por ejemplo, "Apellido es obligatorio".
    El código de estado de la respuesta debe ser 400 - Bad Request, debe contener un JSON con un elemento "action" cuyo valor debe ser "ValidationError" y otro elemento "status" cuyo valor debe ser 400 o "400", como el siguiente:
{
    "version": "1.0.0",
    "status": 400,
    "action": "ValidationError",
    "userMessage": "Apellido es obligatorio."
}

Definiciones

Para la implementación que necesitamos, donde sólo queremos evitar usuarios repetidos con el mismo email pero usando diferentes proveedores de identidad, usaremos respuestas de continuación y respuestas de bloqueo.

La lógica responderá de la soguiente forma:

  • Si existe un usuario con el email indicado, devolveremos "ShowBlockPage".
  • Si no existe un usuario con el email indicado, devolveremos "Continue".

Manos a la obra

En este post nos concentraremos sólo en los conectores y dejaremos para el próximo la lógica necesaria para interactuar con Microsoft Graph API.

  1. Crearemos una Azure Function para atender la petición del conector.
  2. Crearemos un conector y lo asociaremos a la Azure Function.
  3. Configuraremos el flujo de usuario para utilizar el conector.

Azure Function

Para no extender demasiado el texto ni desviar el foco, obviaré la creación y publicación de un proyecto de Azure Functions. Si necesitas ayuda con eso, podrás encontrarlo aquí.

Utilizaremos una función del tipo HttpTrigger. Por el momento la lógica de la función será muy simple:

  • Si el dominio del email es "gmail.com" entonces devolvemos "ShowBlockPage" con el mensaje adicional “Usuario duplicado".
  • En cualquier otro caso devolvemos "Continue".
[FunctionName("PreventUserDuplication")]
public static IActionResult Run(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post")] dynamic req)
{
    if (req.email.EndsWith("gmail.com"))
    {
        return new OkObjectResult(
            new
            {
                version = "1.0.0",
                action = "ShowBlockPage",
                userMessage = $"Email {req.email} is duplicated."
            });
    }
    return new OkObjectResult(
        new
        {
            version = "1.0.0",
            action = "Continue"
        });
}

Finalmente debemos publicar el código y obtener la URL correspondiente ya que la necesitaremos en el siguiente paso.

La implementación que estamos realizando es solo “de muestra” y no es la implementación final. La idea es entender como funcionan los conectores.

Crear conector

Desde la sección Conectores de API agregaremos un nuevo conector.

Conectores de API
Conectores de API
Configuración de conector
Configuración de conector

En la pantalla de configuración del nuevo conector elegiremos un nombre para mostrar y luego completaremos el campo Dirección URL del punto de conexión con la URL de la Azure Function del punto anterior.

Tipo de autenticación usaremos Básico y para Nombre de Usuario y Contraseña pondremos cualquier valor , más adelante veremos por qué.

Al finalizar guardamos los cambios y terminamos.

Flujo de usuario

Flujo de usuario - Conectores
Flujo de usuario – Conectores

La configuración del flujo de usuario es bastante simple, solo debemos seleccionar que conector usar y en qué parte del flujo.

Siguiendo con nuestro, vamos a utilizar el mismo conector en ambas instancias, luego del inicio de sesión con un proveedor externo y antes de la ceación del usuario.

Pruebas

Para las pruebas intentaremos registrar un nuevo usuario con una cuenta de email terminada en “gmail.com” y otra terminada con cualquier dominio.

El flujo de usuario debería bloquear al usuario si la cuenta de email es de “gmail.com” y debería continuar con otras cuentas.

Usuario duplicado
Usuario duplicado

Para proveedores de identidad externos el comportamiento será el mismo.

Seguridad

Al momento de escribir este post, los conectores aceptan como mecanismo de seguridad autenticación básica o certificados (preview).

El mecanismo que elegimos anteriormente es Básico, el cuál requiere Usuario y Contraseña.

Es responsabilidad de la API validar correctamente el usuario y contraseña al recibir el request.

La ventaja de utilizar Azure Functions es que podemos utilizar la seguridad integrada que el servicio ofrece mediante llaves de función (Function Keys)

Conclusión

Hemos implementado en forma satisfactoria nuestra primera integración a través de Conectores de API, y si bien la implementación es solo “de muestra”, nos permitió entender como funcionan.

Para poder completar la implementación del requerimiento sólo nos falta completar la implementación de la Azure Function para integrarnos con Microsoft Graph API, te lo cuento en el próximo post!

Unit-Testeando HttpClient con .NET 5

Ya desde las primeras versiones, .NET Core utiliza inyección de dependencias desde sus cimientos, y provee un completo mecanismo de Inversión de Control (IoC) para hacernos la vida más simple.

Podemos registrar cualquier clase y delegar en el framework la tarea de generar instancias e inyectar los objetos, y la clase HttpClient no es la excepción. Ahora, cómo inyectamos HttpClient fakes?

Ahora veremos todo lo necesario para poder escribir buenos test unitarios con excelentes HttpClientfakeados“!

Un fake es un objeto usamos para simular un comportamiento específico. Es uno de los conceptos de las pruebas unitarias.

Contexto

Hemos estado trabajando en un nuevo servicio que realiza una petición HTTP como parte de las reglas de negocio.

public class StockService
{
    private readonly HttpClient _httpClient;

    public StockService(HttpClient httpClient) =>
        _httpClient = httpClient;

    public async Task<string> IsAvailableAsync(bool check)
    {
        if (check == false)
            return "N";

        var result = await _httpClient.GetAsync("stock");

        // To keep it simple, we ignore other results
        return result.StatusCode switch
        {
            HttpStatusCode.OK => "S",
            HttpStatusCode.BadRequest => "E"
        };
    }
}

A su vez, registramos el HttpClient en Startup.ConfigureServices() donde además configuramos la dirección base del servidor.

public void ConfigureServices(IServiceCollection services)
{
    // Other registrations...
    services.AddHttpClient<StockService>(client =>
    {
        // For demo only. Use settings instead
        client.BaseAddress = new Uri("https://api.facutherock.net");
    });
}

Necesitamos asegurarnos de que este servicio se comporte acorde a la especificación.

Requerimientos

Queremos escribir buenos test unitarios para asegurarnos que el servicio StockService.IsAvailableAsync() cumpla con los siguientes requerimientos:

  1. Retornar "N" si el parámetro check es igual a false.
  2. Si el parámetro check es igual a true y el resultado de la petición es exitosa (200 – Ok) devolver "S".
  3. Si el parámetro check es igual a true pero el resultado de la petición es de error (400 – BadRequest) devolver "E".

Si bien los requerimientos son muy simples, nos van a servir para demostrar los aspectos que necesitamos.

Desafío

A primera vista podríamos intentar utilizar un HttpClient directamente, cómo en el siguiente ejemplo:

[Fact]
public async Task BadTest()
{
    // Arrange
    var httpClient = new HttpClient();
    var sut = new StockService(httpClient);

    // Act
    var result = await sut.IsAvailableAsync(true);

    // Assert
    Assert.Equal("S", result);
}

Sin embargo, ese test fallaría ya que intentaría hacer una petición a un servidor inválido. Incluso si no fallase, implicaría una petición real, lo cuál no es recomendable para un test unitario.

El desafío consiste en crear una versión del HttpClient que simule hacer la petición, pero que realmente no lo haga.

Aspectos técnicos

La clase HttpClient realiza las peticiones utilizando un HttpMessageHandler de base, el cuál expone un método llamado SendAsync.

La manera en la que podemos simular determinados comportamientos es sobrescribiendo el método anterior. Para esto necesitamos generar nuestra propia subclase.

Podremos inyectar nuestra subclase utilizando una de las sobrecargas que la clase HttpClient tiene aceptando una instancia de HttpMessageHandler.

Tiempo de codear

Vamos a empezar a escribir los test necesarios para asegurar el buen funcionamiento de nuestro servicio.

La estrategia consiste en ir generando subclases de HttpMessageHandler que respondan siempre de la misma forma y cuya respuesta sea controlada por nosotros.

Requerimiento 1

El test para este requerimiento es el más simple de implementar, ya que en este caso no necesitamos utilizar el HttpClient para la petición.

Podemos utilizar directamente una nueva instancia de HttpClient:

[Fact]
public async Task FirstRequirementTest()
{
    // Arrange
    var httpClient = new HttpClient();
    var sut = new StockService(httpClient);

    // Act
    var result = await sut.IsAvailableAsync(false);

    // Assert
    Assert.Equal("N", result);
}

Requerimiento 2

Este test es un poco más complejo, ya que necesitamos que la instancia de HttpClient devuelva un mensaje de retorno exitoso.

Para esto, crearemos una subclase de HttpMessageHandler y sobrescribiremos el método SendAsync para devolver siempre un resultado exitoso.

public class OkHttpMessageHandler : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var okResult = new HttpResponseMessage
        {
            StatusCode = System.Net.HttpStatusCode.OK
        };

        return Task.FromResult(okResult);
    }
}

Finalmente crearemos una instancia de un HttpClient y completaremos el test.

[Fact]
public async Task SecondRequirementTest()
{
    // Arrange
    var okHttpMessageHandler = new OkHttpMessageHandler();
    using var httpClient = new HttpClient(okHttpMessageHandler)
    {
        BaseAddress = new Uri("http://fake.server")
    };
    var sut = new StockService(httpClient);

    // Act
    var result = await sut.IsAvailableAsync(true);

    // Assert
    Assert.Equal("S", result);
}

Requerimiento 3

Este test es exactamente al anterior, solo que la respuesta del HttpClient de ser de error.

public class BadRequestHttpMessageHandler : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var badRequestResult = new HttpResponseMessage
        {
            StatusCode = System.Net.HttpStatusCode.BadRequest
        };

        return Task.FromResult(badRequestResult);
    }
}

Y el test queda de la siguiente forma:

[Fact]
public async Task ThirdRequirementTest()
{
    // Arrange
    var badRequestHttpMessageHandler = new BadRequestHttpMessageHandler();
    using var httpClient = new HttpClient(badRequestHttpMessageHandler)
    {
        BaseAddress = new Uri("http://fake.server")
    };
    var sut = new StockService(httpClient);

    // Act
    var result = await sut.IsAvailableAsync(true);

    // Assert
    Assert.Equal("E", result);
}

Conclusión

Para escribir buenos test unitarios en casos donde se utiliza la clase HttpClient, es necesario tener control total de las respuesta que el mismo retorna.

En este post vimos como utilizar HttpMessageHandler para simular el comportamiento deseado y asegurar un correcto funcionamiento.

© 2021 Facu The Rock

Tema por Anders NorenArriba ↑