web-dev-qa-db-fra.com

ASP.Net Core 2.0 SignInAsync renvoie une exception. La valeur ne peut pas être null, fournisseur.

J'ai une application Web ASP.Net Core 2.0 que je rééquipe avec des tests unitaires (avec NUnit). L'application fonctionne correctement et la plupart des tests effectués jusqu'à présent fonctionnent correctement.

Cependant, le test de l'authentification/autorisation (un utilisateur est-il connecté et peut-il accéder aux actions filtrées par [Authorize]) échoue-t-il avec ...

System.ArgumentNullException: Value cannot be null.
Parameter name: provider

...après...

await HttpContext.SignInAsync(principal);

... mais la cause sous-jacente n’est pas claire. L'exécution du code s'arrête ici dans la méthode appelée et aucune exception n'est indiquée dans le IDE, mais l'exécution du code revient à l'appelant, puis se termine (mais je vois toujours The program '[13704] dotnet.exe' has exited with code 0 (0x0). dans la fenêtre de sortie de VS.)

L'explorateur de tests apparaît en rouge et donne l'exception mentionnée (sinon, je n'aurais aucune idée du problème.)

Je travaille sur la création d'un repro à diriger vers les personnes à (se révèle être un peu impliqué jusqu'à présent.)

Est-ce que quelqu'un sait comment identifier la cause sous-jacente? S'agit-il d'un problème lié à l'ID (quelque chose de nécessaire qui n'est pas fourni dans le test mais est en exécution normale)?

UPDATE1: Fourniture du code d'authentification demandé ...

public async Task<IActionResult> Registration(RegistrationViewModel vm) {
    if (ModelState.IsValid) {
        // Create registration for user
        var regData = createRegistrationData(vm);
        _repository.AddUserRegistrationWithGroup(regData);

        var claims = new List<Claim> {
            new Claim(ClaimTypes.NameIdentifier, regData.UserId.ToString())
        };
        var ident = new ClaimsIdentity(claims);
        var principal = new ClaimsPrincipal(ident);

        await HttpContext.SignInAsync(principal); // FAILS HERE

        return RedirectToAction("Welcome", "App");
    } else {
        ModelState.AddModelError("", "Invalid registration information.");
    }

    return View();
}

Le code de test qui échoue ...

public async Task TestRegistration()
{
    var ctx = Utils.GetInMemContext();
    Utils.LoadJsonData(ctx);
    var repo = new Repository(ctx);
    var auth = new AuthController(repo);
    auth.ControllerContext = new ControllerContext();
    auth.ControllerContext.HttpContext = new DefaultHttpContext();

    var vm = new RegistrationViewModel()
    {
        OrgName = "Dev Org",
        BirthdayDay = 1,
        BirthdayMonth = "January",
        BirthdayYear = 1979 
    };

    var orig = ctx.Registrations.Count();
    var result = await auth.Registration(vm); // STEPS IN, THEN FAILS
    var cnt = ctx.Registrations.Count();
    var view = result as ViewResult;

    Assert.AreEqual(0, orig);
    Assert.AreEqual(1, cnt);
    Assert.IsNotNull(result);
    Assert.IsNotNull(view);
    Assert.IsNotNull(view.Model);
    Assert.IsTrue(string.IsNullOrEmpty(view.ViewName) || view.ViewName == "Welcome");
}

UPDATE3: Sur la base de chat @nkosi a suggéré / qu’il s’agit d’un problème découlant du fait que je ne remplis pas les conditions requises pour l’injection de dépendance de HttpContext

Cependant , ce qui n’est pas encore clair, c’est: s’il s’agit en fait de ne pas fournir la dépendance de service appropriée, pourquoi le code fonctionne-t-il normalement (s’il n’est pas testé)? Le SUT (contrôleur) accepte uniquement un paramètre IRepository (c'est donc tout ce qui est fourni dans tous les cas.) Pourquoi créer un ctor surchargé (ou un simulacre) juste pour le test, alors que le ctor existant est tout ce qui est appelé lors de l'exécution du programme et ça fonctionne sans problème?

UPDATE4 : Alors que @Nkosi a répondu au bogue/à une solution, je me demande toujours pourquoi le IDE ne présente pas de manière précise et cohérente l'exception sous-jacente. S'agit-il d'un bogue ou est-ce dû aux opérateurs asynchrone/wait et à l'adaptateur/exécuteur de test NUnit? Pourquoi les exceptions ne "sautent-elles pas" comme je l'aurais prévu lors du débogage du test et si le code de sortie est toujours égal à zéro (indiquant généralement un état de retour réussi)?

8
t.j.

Ce qui n’est pas encore clair, c’est: s’il s’agit, en fait, de ne pas fournir la dépendance de service appropriée, pourquoi le code fonctionne-t-il normalement (s’il n’est pas testé)? Le SUT (contrôleur) accepte uniquement un paramètre IRepository (c'est donc tout ce qui est fourni dans tous les cas.) Pourquoi créer un ctor surchargé (ou un simulacre) juste pour le test, alors que le ctor existant est tout ce qui est appelé lors de l'exécution du programme ça fonctionne sans problème?

Vous mélangez quelques éléments ici: tout d’abord, il n’est pas nécessaire de créer des constructeurs séparés. Pas pour les tests, ni pour exécuter cela dans le cadre de votre application.

Vous devez définir toutes les dépendances directes de votre contrôleur en tant que paramètres du constructeur. Ainsi, lorsque cela s'exécutera dans le cadre de l'application, le conteneur d'injection de dépendance fournira ces dépendances au contrôleur.

Mais c’est aussi l’essentiel ici: lors de l’exécution de votre application, il existe un conteneur d’injection de dépendance chargé de créer des objets et de fournir les dépendances requises. Vous n’avez donc pas vraiment besoin de vous inquiéter d’où ils viennent. Ceci est différent lors des tests unitaires. Dans les tests unitaires, nous ne souhaitons pas utiliser l’injection de dépendance, car elle ne ferait que masquer les dépendances et, partant, les éventuels effets secondaires pouvant entrer en conflit avec notre test. Compter sur l'injection de dépendance dans un test unitaire est un très bon signe que vous ne testez pas unité mais faites plutôt un test {intégration} _ (au moins si vous testez réellement un conteneur DI).

Au lieu de cela, dans les tests unitaires, nous voulons créer tous les objets explicitement fournissant explicitement toutes les dépendances. Cela signifie que nous rouvrons le contrôleur et que nous transmettons toutes les dépendances du contrôleur. Idéalement, nous utilisons des simulacres afin de ne pas dépendre d’un comportement externe dans notre test unitaire.

Tout cela est plutôt simple la plupart du temps. Malheureusement, les contrôleurs ont quelque chose de spécial: les contrôleurs ont une propriété ControllerContext qui est fournie automatiquement pendant le cycle de vie de MVC. Certains composants de MVC ont des éléments similaires (par exemple, la variable ViewContext est également fournie automatiquement). Ces propriétés ne sont pas injectées par le constructeur. La dépendance n'est donc pas explicitement visible. En fonction de ce que fait le contrôleur, vous devrez peut-être également définir ces propriétés lorsque l'unité testera le contrôleur.


En venant à votre test unitaire, vous utilisez HttpContext.SignInAsync(principal) dans l’action de votre contrôleur, alors malheureusement, vous utilisez directement la HttpContext.

SignInAsync est une méthode d'extension qui effectuera essentiellement les opérations suivantes :

context.RequestServices.GetRequiredService<IAuthenticationService>().SignInAsync(context, scheme, principal, properties);

Ainsi, cette méthode, pour des raisons pratiques, utilisera le modèle service locator pattern pour extraire un service du conteneur d'injection de dépendance afin de procéder à la connexion. Ainsi, cet appel de méthode sur la HttpContext extraira d'autres dépendances implicites que vous ne découvrirez que lorsque votre test échouera. Cela devrait servir d'exemple à propos de pourquoi vous devriez éviter le modèle de localisateur de service : Les dépendances explicites dans le constructeur sont beaucoup plus faciles à gérer. - Mais ici, c'est une méthode pratique, donc nous devrons vivre avec cela et ajuster le test pour fonctionner avec cela.

En fait, avant de passer à autre chose, je voudrais mentionner ici une solution de rechange intéressante: le contrôleur étant une variable AuthController, je ne peux qu’imaginer que l’un de ses objectifs principaux est d’effectuer des tâches d’authentification, de connecter des utilisateurs, etc. Par conséquent, il peut être judicieux de ne pas utiliser HttpContext.SignInAsync, mais plutôt d'avoir la IAuthenticationService en tant que dépendance explicite sur le contrôleur et d'appeler directement les méthodes dessus. De cette façon, vous avez une dépendance claire que vous pouvez remplir lors de vos tests et vous n'avez pas besoin de vous impliquer avec le localisateur de service.

Bien sûr, ce serait un cas spécial pour ce contrôleur et ne fonctionnerait pas pour tous appel possible des méthodes d’extension sur la HttpContext. Voyons donc comment nous pouvons tester cela correctement:

Comme on peut voir dans le code ce que SignInAsync fait réellement, nous devons fournir un IServiceProvider pour HttpContext.RequestServices et le rendre capable de renvoyer un IAuthenticationService. Nous allons donc nous moquer de ceux-ci:

var authenticationServiceMock = new Mock<IAuthenticationService>();
authenticationServiceMock
    .Setup(a => a.SignInAsync(It.IsAny<HttpContext>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<AuthenticationProperties>()))
    .Returns(Task.CompletedTask);

var serviceProviderMock = new Mock<IServiceProvider>();
serviceProviderMock
    .Setup(s => s.GetService(typeof(IAuthenticationService)))
    .Returns(authenticationServiceMock.Object);

Ensuite, nous pouvons transmettre ce fournisseur de services dans la ControllerContext après la création du contrôleur:

var controller = new AuthController();
controller.ControllerContext = new ControllerContext
{
    HttpContext = new DefaultHttpContext()
    {
        RequestServices = serviceProviderMock.Object
    }
};

C’est tout ce dont nous avons besoin pour faire fonctionner HttpContext.SignInAsync.

Malheureusement, il y a un peu plus que cela. Comme je l'ai expliqué dans cette autre réponse (que vous avez déjà trouvée), le renvoi d'une RedirectToActionResult à partir d'un contrôleur causera des problèmes lorsque vous aurez configuré la RequestServices dans un test unitaire. Puisque RequestServices n'est pas null, l'implémentation de RedirectToAction tentera de résoudre un IUrlHelperFactory et ce résultat doit être non-nul. En tant que tel, nous devons élargir un peu nos simulacres pour fournir également celui-ci:

var urlHelperFactory = new Mock<IUrlHelperFactory>();
serviceProviderMock
    .Setup(s => s.GetService(typeof(IUrlHelperFactory)))
    .Returns(urlHelperFactory.Object);

Heureusement, nous n’avons pas besoin de faire autre chose, ni d’ajouter de logique à la maquette de l’usine. C’est suffisant si c’est juste là.

Donc, avec cela, nous pouvons tester correctement l’action du contrôleur:

// mock setup, as above
// …

// arrange
var controller = new AuthController(repositoryMock.Object);
controller.ControllerContext = new ControllerContext
{
    HttpContext = new DefaultHttpContext()
    {
        RequestServices = serviceProviderMock.Object
    }
};

var registrationVm = new RegistrationViewModel();

// act
var result = await controller.Registration(registrationVm);

// assert
var redirectResult = result as RedirectToActionResult;
Assert.NotNull(redirectResult);
Assert.Equal("Welcome", redirectResult.ActionName);

Je me demande encore pourquoi le IDE ne présente pas avec précision/cohérence l'exception sous-jacente. S'agit-il d'un bogue ou est-ce dû aux opérateurs asynchrone/wait et à l'adaptateur/exécuteur de test NUnit?

J’ai vu quelque chose de similaire dans le passé avec mes tests asynchrones, que je ne pouvais pas les déboguer correctement ou que les exceptions ne seraient pas affichées correctement. Je ne me souviens pas de l’avoir vu dans les versions récentes de Visual Studio et xUnit (j’utilise personnellement xUnit, pas NUnit). Si cela vous aide, exécuter les tests à partir de la ligne de commande avec dotnet test fonctionnera normalement correctement et vous obtiendrez des traces de pile appropriées (async) pour les échecs.

9
poke

S'agit-il d'un problème lié à l'ID (quelque chose de nécessaire qui n'est pas fourni dans le test mais est en exécution normale)?

OUI

Vous appelez des fonctionnalités que le framework aurait configurées pour vous au moment de l'exécution. Pendant les tests unitaires isolés, vous devrez les configurer vous-même. 

Il manque dans le HttpContext du contrôleur une IServiceProvider qu'il utilise pour résoudre IAuthenticationService. Ce service est ce qui s'appelle réellement SignInAsync

Pour laisser ....

await HttpContext.SignInAsync(principal);  // FAILS HERE

... dans l'action Registration à exécuter complètement lors du test d'unité, vous devrez vous moquer d'un fournisseur de services pour que la méthode d'extension SignInAsync n'échoue pas.

Mettre à jour le dispositif de test unitaire

//...code removed for brevity

auth.ControllerContext.HttpContext = new DefaultHttpContext() {
    RequestServices = createServiceProviderMock()
};

//...code removed for brevity

createServiceProviderMock() est une petite méthode utilisée pour simuler un fournisseur de services qui sera utilisé pour renseigner le HttpContext.RequestServices

public IServiceProvider createServiceProviderMock() {
    var authServiceMock = new Mock<IAuthenticationService>();
    authServiceMock
        .Setup(_ => _.SignInAsync(It.IsAny<HttpContext>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<AuthenticationProperties>()))
        .Returns(Task.FromResult((object)null)); //<-- to allow async call to continue

    var serviceProviderMock = new Mock<IServiceProvider>();
    serviceProviderMock
        .Setup(_ => _.GetService(typeof(IAuthenticationService)))
        .Returns(authServiceMock.Object);

    return serviceProviderMock.Object;
}

Je suggérerais également de se moquer de la Repository aux fins d’un test unitaire isolé de l’action de ce contrôleur pour s’assurer de son achèvement sans aucun effet négatif.

2
Nkosi

comme @poke a mentionné que vous feriez mieux de ne pas utiliser Dependency Injection dans les tests unitaires et de fournir des dépendances explicitement (en vous moquant), mais j'ai eu ce problème dans mes tests d'intégration et j'ai pensé que le problème venait de la propriété RequestServices de HttpContext qui n'est pas correctement initialisée tests (étant donné que nous n'utilisons pas HttpContext dans les tests), j'ai donc enregistré ma HttpContextAccessor comme ci-dessous et passé tous les services requis moi-même (manuellement) et résolu le problème. voir le code ci-dessous

Services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor() { HttpContext = new DefaultHttpContext() { RequestServices = Services.BuildServiceProvider() } });

Je conviens que ce n'est pas une solution très propre, mais notez que j'ai écrit et utilisé ce code uniquement dans mes tests afin de fournir les dépendances HttContext requises (qui n'étaient pas fournies automatiquement dans la méthode de test), dans votre application IHttpContextAccessor, HttpContext et leurs services requis fourni automatiquement par framework.

voici toute ma méthode d'enregistrement de dépendance dans mon constructeur de classe de base de tests

 public class MyTestBaseClass
 {
  protected ServiceCollection Services { get; set; } = new ServiceCollection();
  MyTestBaseClass
 {

   Services.AddDigiTebFrameworkServices();
        Services.AddDigiTebDBContextService<DigiTebDBContext> 
        (Consts.MainDBConnectionName);
        Services.AddDigiTebIdentityService<User, Role, DigiTebDBContext>();
        Services.AddDigiTebAuthServices();
        Services.AddDigiTebCoreServices();
        Services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor() { HttpContext = new DefaultHttpContext() { RequestServices = Services.BuildServiceProvider() } });
}
}
1
Code_Worm