Existe-t-il une réelle valeur dans le test unitaire d’un contrôleur dans ASP.NET MVC?

33

J'espère que cette question donne des réponses intéressantes, car c'est une question qui me dérange depuis un moment.

Existe-t-il une réelle valeur dans le test unitaire d’un contrôleur dans ASP.NET MVC?

Ce que je veux dire par là, c'est la plupart du temps (et je ne suis pas un génie), mes méthodes de contrôleur sont, même les plus complexes, quelque chose comme ceci:

public ActionResult Create(MyModel model)
{
    // start error list
    var errors = new List<string>();

    // check model state based on data annotations
    if(ModelState.IsValid)
    {
        // call a service method
        if(this._myService.CreateNew(model, Request.UserHostAddress, ref errors))
        {
            // all is well, data is saved, 
            // so tell the user they are brilliant
            return View("_Success");
        }
    }

    // add errors to model state
    errors.ForEach(e => ModelState.AddModelError("", e));

    // return view
    return View(model);
}

Le gros du travail est effectué par le pipeline MVC ou par ma bibliothèque de services.

Alors peut-être que les questions à poser pourraient être:

  • Quelle serait la valeur du test unitaire avec cette méthode?
  • ne serait-il pas cassé sur Request.UserHostAddresset ModelStateavec une exception NullReferenceException? Devrais-je essayer de me moquer de ceux-ci?
  • si je réfracte cette méthode dans une "aide" réutilisable (ce que je devrais probablement, compte tenu du nombre de fois que je le fais!), testerait que cela en valait la peine alors que tout ce que je teste réellement est principalement le "pipeline" qui, vraisemblablement, a été testé à un pouce de sa vie par Microsoft?

Je pense que ce que je veux vraiment dire, c'est que faire ce qui suit semble totalement inutile et faux

[TestMethod]
public void Test_Home_Index()
{
    var controller = new HomeController();
    var expected = "Index";
    var actual = ((ViewResult)controller.Index()).ViewName;
    Assert.AreEqual(expected, actual);
}

Évidemment, je suis obtus avec cet exemple exagérément inutile, mais est-ce que quelqu'un a quelque sagesse à ajouter ici?

Dans l'attente ... Merci.

LiverpoolsNumber9
la source
Je pense que le retour sur investissement de ce test ne vaut pas la peine, sauf si vous avez un temps et de l'argent infinis. J'écrirais des tests que Kevin fait remarquer pour vérifier les choses qui sont plus susceptibles de se briser ou qui vous aideront à modifier quelque chose avec confiance ou à vous assurer que la propagation des erreurs se produit comme prévu. Les tests de pipeline, si nécessaire, peuvent être réalisés à un niveau plus global / d'infrastructure et au niveau des méthodes individuelles, auront peu de valeur. Ne dis pas qu'ils ne valent rien, mais "peu". Donc, si cela donne un bon retour sur investissement dans votre cas, allez-y, sinon, attrapez le plus gros poisson en premier!
Mrchief

Réponses:

18

Même pour quelque chose d'aussi simple, un test unitaire servira à plusieurs fins

  1. La confiance, ce qui a été écrit est conforme à la sortie attendue. Il peut sembler banal de vérifier qu’il renvoie la vue correcte, mais le résultat obtenu est une preuve objective que la condition a été remplie.
  2. Les tests de régression. Si la méthode Create doit être modifiée, vous disposez toujours d'un test unitaire pour la sortie attendue. Oui, la sortie peut changer et donner lieu à un test fragile, mais il s'agit toujours d'une vérification du contrôle des modifications non géré.

Pour cette action particulière, je testerais pour le suivant

  1. Que se passe-t-il si _myService est null?
  2. Que se passe-t-il si _myService.Create lève une exception, en jette-t-il des spécifiques à gérer?
  3. Un _myService.Create réussi renvoie-t-il la vue _Success?
  4. Les erreurs sont-elles propagées jusqu'à ModelState?

Vous avez indiqué cocher Request et Model pour NullReferenceException et je pense que ModelState.IsValid se chargera de la gestion de NullReference for Model.

L'exécution de la requête vous permet de vous protéger contre une requête nulle qui est généralement impossible en production, mais peut se produire lors d'un test unitaire. Dans un test d'intégration, cela vous permettrait de fournir différentes valeurs UserHostAddress (une demande est toujours entrée par l'utilisateur en ce qui concerne le contrôle et doit être testée en conséquence).

Kevin
la source
Bonjour Kevin, merci d'avoir pris le temps de répondre. Je vais laisser un peu de temps pour voir si quelqu'un d'autre arrive avec quelque chose mais jusqu'ici, le vôtre est le plus logique / clair.
LiverpoolsNumber9
Spifty. Content que cela vous ait aidé.
Kevin
3

Mes contrôleurs sont également très petits. La plupart de la "logique" dans les contrôleurs est gérée à l'aide d'attributs de filtre (intégrés et écrits à la main). Donc, mon contrôleur n'a généralement qu'une poignée d'emplois:

  • Créez des modèles à partir de chaînes de requête HTTP, de valeurs de formulaire, etc.
  • Effectuer une validation de base
  • Appeler dans mes données ou ma couche métier
  • Générer un ActionResult

La plupart des liaisons de modèles sont effectuées automatiquement par ASP.NET MVC. DataAnnotations gère également la majeure partie de la validation.

Même avec si peu de choses à tester, je les écris toujours en général. En gros, je teste que mes référentiels sont appelés et que le ActionResulttype correct est renvoyé. J'ai une méthode pratique pour ViewResultm'assurer que le bon chemin de vue est renvoyé et que le modèle de vue ressemble à ce que j'attendais. J'ai un autre pour vérifier le bon contrôleur / action est défini pour RedirectToActionResult. J'ai d'autres tests pour JsonResult, etc. etc.

Le résultat malheureux de la sous-classification de la Controllerclasse est qu’elle fournit de nombreuses méthodes pratiques utilisant l’ HttpContextinterne. Cela rend difficile le test unitaire du contrôleur. Pour cette raison, je place généralement des HttpContextappels dépendants derrière une interface et la transmet au constructeur du contrôleur (j'utilise l'extension Web Ninject pour créer mes contrôleurs pour moi). Cette interface est généralement l'endroit où je colle les propriétés d'aide pour accéder à la session, aux paramètres de configuration, aux aides IPrinciple et URL.

Cela nécessite beaucoup de diligence raisonnable, mais je pense que cela en vaut la peine.

Parcs Travis
la source
Merci d'avoir pris le temps de répondre, mais 2 questions tout de suite. Premièrement, les "méthodes d'assistance" dans les tests unitaires sont très dangereuses. Deuxièmement, "testez que mes référentiels sont appelés" - voulez-vous dire par injection de dépendance?
LiverpoolsNumber9
Pourquoi les méthodes pratiques seraient-elles dangereuses? J'ai une BaseControllerTestsclasse où ils vivent tous. Je me moque de mes dépôts. Je les branche en utilisant Ninject.
Travis Parcs
Que se passe-t-il si vous avez fait une erreur ou une hypothèse incorrecte dans vos assistants? Mon autre point était que seul un test d'intégration (c'est-à-dire de bout en bout) pouvait "tester" si vos référentiels sont appelés. De toute façon, dans un test unitaire, vous feriez une nouvelle installation ou vous moquiez vos dépôts manuellement.
LiverpoolsNumber9
Vous passez le référentiel au constructeur. Vous vous moquez pendant le test. Vous vous assurez que la maquette est traitée comme prévu. Les assistants déconstruisent simplement ActionResults pour inspecter les URL, modèles, etc. transmis.
Travis Parks
Très bien, j'ai un peu mal compris ce que vous vouliez dire par "vérifier que mes référentiels sont appelés".
LiverpoolsNumber9
2

Évidemment, certains contrôleurs sont beaucoup plus complexes que cela, mais reposent uniquement sur votre exemple:

Que se passe-t-il si myService lève une exception?

En note de côté.

De plus, je m'interroge sur l'opportunité de passer une liste par référence (c'est inutile car c # passe par référence de toute façon mais même si elle ne l'était pas) - passer une action error (Action) que le service peut ensuite utiliser pour afficher des messages d'erreur qui peut ensuite être traité comme vous le souhaitez (peut-être souhaitez-vous l'ajouter à la liste, peut-être souhaitez-vous ajouter une erreur de modèle, peut-être souhaitez-vous le consigner)?

Dans votre exemple:

au lieu d'erreurs de référence, faites (chaîne s) => ModelState.AddModelError ("", s) par exemple.

Michael
la source
A noter, cela suppose que votre service réside dans la même application, sinon les problèmes de sérialisation entreront en jeu.
Michael
Le service serait dans une DLL séparée. Mais de toute façon, vous avez probablement raison sur le "ref". Sur votre autre point, peu importe que myService lève une exception. Je ne teste pas myService - je testerais les méthodes séparément. Je parle de tester purement "l'unité" ActionResult avec (probablement) un myService simulé.
LiverpoolsNumber9
Avez-vous un mappage 1: 1 entre votre service et votre contrôleur? Sinon, certains contrôleurs utilisent-ils plusieurs appels de service? Si oui, vous pouvez tester ces interactions?
Michael
Non. À la fin de la journée, les méthodes de service prennent des entrées (généralement un modèle de vue ou même juste des chaînes / ints), elles "font des choses", puis renvoient un bool / errors si false. Il n'y a pas de lien "direct" entre les contrôleurs et la couche service. Les sont complètement séparés.
LiverpoolsNumber9
Oui, je comprends cela. J'essaie de comprendre le modèle relationnel entre les contrôleurs et la couche service. En supposant que chaque contrôleur ne possède pas de méthode de service correspondante, il serait logique que certains contrôleurs aient besoin de plus d'une méthode de service?
Michael