La construction d'objets avec des paramètres nuls dans les tests unitaires est-elle correcte?

37

J'ai commencé à écrire des tests unitaires pour mon projet actuel. Je n'ai pas vraiment d'expérience avec cela cependant. Je veux d’abord complètement "comprendre", donc je n’utilise actuellement ni mon framework IoC ni une bibliothèque moqueuse.

Je me demandais s'il y avait un problème avec la fourniture d'arguments nuls aux constructeurs des objets dans les tests unitaires. Laissez-moi vous donner un exemple de code:

public class CarRadio
{...}


public class Motor
{
    public void SetSpeed(float speed){...}
}


public class Car
{
    public Car(CarRadio carRadio, Motor motor){...}
}


public class SpeedLimit
{
    public bool IsViolatedBy(Car car){...}
}

Encore un autre exemple de code de voiture (MC), réduit aux seules parties importantes de la question. J'ai maintenant écrit un test qui ressemble à ceci:

public class SpeedLimitTest
{
    public void TestSpeedLimit()
    {
        Motor motor = new Motor();
        motor.SetSpeed(10f);
        Car car = new Car(null, motor);

        SpeedLimit speedLimit = new SpeedLimit();
        Assert.IsTrue(speedLimit.IsViolatedBy(car));
    }
}

Le test fonctionne bien. SpeedLimita besoin d'un Caravec un Motorafin de faire sa chose. Cela ne l’intéresse pas CarRadiodu tout, alors j’ai fourni null pour cela.

Je me demande si un objet fournissant une fonctionnalité correcte sans être entièrement construit est une violation de SRP ou une odeur de code. J'ai l'impression que c'est le cas, mais speedLimit.IsViolatedBy(motor)je ne me sens pas bien non plus: une limite de vitesse est violée par une voiture, pas par un moteur. Peut-être ai-je simplement besoin d'une perspective différente pour les tests unitaires par rapport au code fonctionnel, parce que l'objectif est de tester uniquement une partie du tout.

La construction d'objets avec null dans les tests unitaires a-t-elle une odeur de code?

R. Schmitz
la source
24
Un peu hors sujet, mais Motorne devrait probablement pas en avoir speeddu tout. Il devrait avoir un throttleet calculer un torquebasé sur le courant rpmet throttle. C’est le travail de la voiture Transmissiond’intégrer cela dans la vitesse actuelle et de transformer cela en un rpmsignal de retour vers le Motor... Mais je suppose que vous n’avez pas eu le souci de réaliser le réalisme de toute façon, n'est-ce pas?
cmaster
17
L'odeur de code est que votre constructeur prend null sans se plaindre en premier lieu. Si vous pensez que cela est acceptable (vous avez peut-être vos raisons): allez-y, testez-le!
Tommy
4
Considérons le modèle Null-Object . Cela simplifie souvent le code, tout en le rendant compatible avec NPE.
Bakuriu
17
Félicitations : vous avez testé cela avec une nullradio, la limite de vitesse est correctement calculée. Maintenant, vous pouvez créer un test pour valider la limite de vitesse avec une radio; juste au cas où le comportement serait différent ...
Matthieu M.
3
Vous n'êtes pas sûr de cet exemple, mais parfois, le fait que vous souhaitiez uniquement tester la moitié d'une classe est un indice qu'il faut diviser quelque chose
Owen

Réponses:

70

Dans le cas de l'exemple ci-dessus, il est raisonnable qu'un Carpeut exister sans un CarRadio. Dans ce cas, je dirais que non seulement il est acceptable de passer à zéro CarRadioparfois, je dirais que c'est obligatoire. Vos tests doivent s'assurer que l'implémentation de la Carclasse est robuste et ne lève pas d'exceptions de pointeur nul lorsque aucun CarRadion'est présent.

Cependant, prenons un exemple différent - considérons a SteeringWheel. Supposons qu'un Cara doit avoir un SteeringWheel, mais le test de vitesse ne s'en soucie pas vraiment. Dans ce cas, je ne passerais pas un zéro SteeringWheelcar cela pousserait la Carclasse dans des endroits où elle n’aurait pas été conçue. Dans ce cas, vous feriez mieux de créer une sorte de DefaultSteeringWheelqui (pour continuer la métaphore) est bloqué en ligne droite.

Pete
la source
44
Et n'oubliez pas d'écrire un test unitaire pour vérifier qu'il Carest impossible de construire un SteeringWheel...
cmaster
13
Il se peut que je manque quelque chose, mais s'il est possible et même courant de créer un Carsans CarRadio, ne serait-il pas logique de créer un constructeur qui n'en nécessite pas un et ne pas avoir à se soucier d'un null explicite ?
Un earwig
16
Les voitures autonomes n'ont peut-être pas de volant. Je dis juste. ☺
BlackJack
1
@James_Parsons J'ai oublié d'ajouter qu'il s'agissait d'une classe qui n'avait pas de contrôle null car elle était uniquement utilisée en interne et recevait toujours des paramètres non nuls. D'autres méthodes Carauraient jeté un NRE avec un null CarRadio, mais le code SpeedLimitcorrespondant ne le ferait pas. Dans le cas de la question telle que publiée, vous auriez raison et je le ferais effectivement.
R. Schmitz
2
@mtraceur La façon dont tout le monde a supposé que la classe permettant la construction avec un argument nul signifie que null est une option valide m'a permis de réaliser que je devrais probablement avoir des contrôles nuls ici. La question que j'aurais alors serait couverte de nombreuses bonnes réponses, intéressantes et bien écrites, que je ne voudrais pas gâcher et qui m'ont aidé. Accepter également cette réponse maintenant parce que je n'aime pas laisser les choses inachevées et que c'est le vote le plus élevé (même s'ils ont tous été utiles).
R. Schmitz
23

La construction d'objets avec null dans les tests unitaires a-t-elle une odeur de code?

Oui. Notez la définition C2 de l' odeur de code : "un CodeSmell est un indice que quelque chose pourrait être faux, pas une certitude."

Le test fonctionne bien. SpeedLimit a besoin d'une voiture avec un moteur pour faire son travail. CarRadio ne l’intéresse pas du tout, j’ai donc fourni null pour cela.

Si vous essayez de démontrer que speedLimit.IsViolatedBy(car)cela ne dépend pas de l' CarRadioargument transmis au constructeur, il serait probablement plus clair pour le futur responsable de la maintenance de rendre cette contrainte explicite en introduisant un double test qui échoue au test s'il est appelé.

Si, comme il est plus probable, vous essayez simplement de vous épargner le travail de création d’une application CarRadioque vous savez inutilisée dans ce cas, vous devriez remarquer que

  • il s'agit d'une violation de l'encapsulation (vous laissez CarRadiole test non utilisé s'infiltrer dans le test)
  • vous avez découvert au moins un cas où vous souhaitez créer un Carsans spécifierCarRadio

Je soupçonne fortement que le code essaie de vous dire que vous voulez un constructeur qui ressemble à

public Car(IMotor motor) {...}

et puis ce constructeur peut décider quoi faire avec le fait qu'il n'y a pasCarRadio

public Car(IMotor motor) {
    this(null, motor);
}

ou

public Car(IMotor motor) {
    this( new ICarRadio() {...} , motor);
}

ou

public Car(IMotor motor) {
    this( new DefaultRadio(), motor);
}

etc.

Il s’agit de passer null à quelque chose d’autre en testant SpeedLimit. Vous pouvez voir que le test s'appelle "SpeedLimitTest" et que le seul Assert vérifie une méthode de SpeedLimit.

C'est une deuxième odeur intéressante: pour tester SpeedLimit, vous devez construire une voiture entière autour d'une radio, même si la seule chose qui intéresse le contrôle SpeedLimit est probablement la vitesse .

Cela pourrait être un indice qui SpeedLimit.isViolatedBydevrait accepter une interface de rôle que la voiture implémente , plutôt que d'exiger une voiture entière.

interface IHaveSpeed {
    float speed();
}

class Car implements IHaveSpeed {...}

IHaveSpeedest un nom vraiment moche; Espérons que votre langue de domaine aura une amélioration.

Avec cette interface en place, votre test SpeedLimitpourrait être beaucoup plus simple.

public void TestSpeedLimit()
{
    IHaveSpeed somethingTravelingQuickly = new IHaveSpeed {
        float speed() { return 10f; }
    }

    SpeedLimit speedLimit = new SpeedLimit();
    Assert.IsTrue(speedLimit.IsViolatedBy(somethingTravelingQuickly));
}
VoiceOfUnreason
la source
Dans votre dernier exemple, je suppose que vous devez créer une classe fictive qui implémente l' IHaveSpeedinterface de la manière (au moins en c #) de ne pas pouvoir instancier une interface elle-même.
Ryan Searle
1
En effet, l'efficacité de l'orthographe dépend de la saveur de Blub que vous utilisez pour vos tests.
VoiceOfUnreason
18

La construction d'objets avec null dans les tests unitaires a-t-elle une odeur de code?

Non

Pas du tout. Au contraire, si NULLest une valeur qui est disponible comme argument (certaines langues ont des constructions qui DISALLOW passant d'un pointeur NULL), vous devez le tester.

Une autre question est ce qui se passe lorsque vous passez NULL. Mais c’est exactement ce qu’est un test unitaire: s’assurer qu’un résultat défini est obtenu pour chaque entrée possible. Et NULL est une entrée potentielle, vous devriez donc avoir un résultat défini et vous devriez avoir un test pour cela.

Donc, si votre voiture est censée être constructible sans radio, vous devriez écrire un test pour cela. Si ce n'est pas le cas et est supposé lancer une exception, vous devriez écrire un test pour cela.

De toute façon, oui, vous devriez écrire un test NULL. Ce serait une odeur de code si vous l'omettiez. Cela signifierait que vous avez une branche de code non testée qui, comme vous venez de l’imaginer, serait exempte de bugs.


Veuillez noter que je suis d’accord avec les autres réponses pour dire que votre code testé pourrait être amélioré. Néanmoins, si le code amélioré a des paramètres qui sont des pointeurs, le passage NULLmême pour le code amélioré est un bon test. Je voudrais un test pour montrer ce qui se passe lorsque je passe en NULLtant que moteur. Ce n'est pas une odeur de code, c'est le contraire d'une odeur de code. C'est tester.

nvoigt
la source
On dirait que vous écrivez sur le test Carici. Bien que je ne voie rien, je considérerais une déclaration erronée, mais la question concerne les tests SpeedLimit.
R. Schmitz
@ R.Schmitz Vous ne savez pas ce que vous voulez dire? J'ai cité la question, il s'agit de passer NULLau constructeur de Car.
nvoigt
1
Il s’agit de passer null à quelque chose d’autre en testantSpeedLimit . Vous pouvez voir que le test s'appelle "SpeedLimitTest" et que la seule Assertvérification d'une méthode de SpeedLimit. Les tests Car- par exemple, "si votre voiture est supposée être constructible sans radio, vous devez écrire un test pour cela" - se produiraient dans un test différent.
R. Schmitz
7

Personnellement, j'hésiterais à injecter des valeurs nulles. En fait, chaque fois que j'injecte des dépendances dans un constructeur, l'une des premières choses que je fais est de générer une exception ArgumentNullException si ces injections sont nulles. De cette façon, je peux les utiliser en toute sécurité dans ma classe, sans avoir recours à des dizaines de contrôles nuls répartis. Voici un modèle très commun. Notez l'utilisation de readonly pour vous assurer que les dépendances définies dans le constructeur ne peuvent pas être définies sur null (ou toute autre chose) par la suite:

class Car
{
   private readonly ICarRadio _carRadio;
   private readonly IMotor _motor;

   public Car(ICarRadio carRadio, IMotor motor)
   {
      if (carRadio == null)
         throw new ArgumentNullException(nameof(carRadio));

      if (motor == null)
         throw new ArgumentNullException(nameof(motor));

      _carRadio = carRadio;
      _motor = motor;
   }
}

Avec ce qui précède, je pourrais peut-être avoir un ou deux tests unitaires, dans lesquels j'injecte des valeurs null, juste pour m'assurer que mes contrôles des valeurs Null fonctionnent correctement.

Cela dit, cela ne pose plus aucun problème, une fois que vous faites deux choses:

  1. Programmez aux interfaces au lieu des classes.
  2. Utilisez un framework moqueur (comme Moq pour C #) pour injecter des dépendances simulées au lieu de NULL.

Donc, pour votre exemple, vous auriez:

interface ICarRadio
{
   bool Tune(float frequency);
}

interface Motor
{
   bool SetSpeed(float speed);
}

...
var car = new Car(new Moq<ICarRadio>().Object, new Moq<IMotor>().Object);

Plus de détails sur l'utilisation de Moq peuvent être trouvés ici .

Bien sûr, si vous voulez comprendre sans vous moquer, vous pouvez toujours le faire, du moment que vous utilisez des interfaces. Créez simplement une CarRadio et un moteur factices comme suit, puis injectez-les:

class DummyCarRadio : ICarRadio
{
   ...
}
class DummyMotor : IMotor
{
   ...
}

...
var car = new Car(new DummyCarRadio(), new DummyMotor())
Éternel21
la source
3
C'est la réponse que j'aurais écrite
Liath
1
J'irais même jusqu'à dire que les objets factices doivent également remplir leur contrat. Si IMotoreu une getSpeed()méthode, le DummyMotoraurait besoin de retourner une vitesse modifiée après un succès setSpeedaussi.
ComFreek
1

Ce n'est pas juste d'accord, c'est une technique bien connue de rupture de dépendance pour obtenir du code hérité dans un faisceau de test. (Voir «Utilisation efficace du code hérité» par Michael Feathers.)

Ça sent? Bien...

Le test ne sent pas. Le test fait son travail. Il déclare «cet objet peut être nul et le code testé continue à fonctionner correctement».

L'odeur est dans la mise en œuvre. En déclarant un argument constructeur, vous déclarez que «cette classe a besoin de cette chose pour fonctionner correctement». Évidemment, c'est faux. Tout ce qui est faux est un peu malodorant.

Canard en caoutchouc
la source
1

Revenons aux premiers principes.

Un test unitaire teste certains aspects d'une classe.

Il y aura cependant des constructeurs ou des méthodes qui prendront d'autres classes en tant que paramètres. Votre façon de les gérer varie d’un cas à l’autre, mais la configuration doit être minimale, car elles sont tangentes à l’objectif. Il peut arriver que le passage d'un paramètre NULL soit parfaitement valide, par exemple une voiture sans radio.

Je suppose ici que nous testons simplement la ligne de course pour commencer (pour continuer le thème de la voiture), mais vous pouvez également passer à NULL à d’autres paramètres pour vérifier que tout code défensif et mécanisme de remise d’exception fonctionnent comme prévu. Champs obligatoires.

Jusqu'ici tout va bien. Cependant, que se passe-t-il si NULL n'est pas une valeur valide? Eh bien, vous êtes ici dans le monde trouble des moqueurs, des talons, des mannequins et des faux .

Si tout cela vous semble trop compliqué, vous utilisez déjà un mannequin en passant NULL dans le constructeur Car, car il s'agit d'une valeur qui ne sera pas potentiellement utilisée.

Ce qui devrait devenir clair assez rapidement, c’est que vous êtes potentiellement en train de manipuler votre code avec beaucoup de manipulations NULL. Si vous remplacez les paramètres de classe par des interfaces, vous ouvrez également la voie au mocking, aux DI, etc., ce qui les rend beaucoup plus simples à gérer.

Robbie Dee
la source
1
"Les tests unitaires sont destinés à tester le code d'une seule classe." Soupir . Ce n'est pas ce que sont les tests unitaires et suivre cette directive vous mènera soit dans des classes hypertrophiées, soit dans des tests contre-productifs, gênants.
Ant P
3
Traiter "l'unité" comme un euphémisme pour "la classe" est malheureusement une façon de penser très courante, mais en réalité, il ne fait que (a) encourager les tests de "vidage, vidage", ce qui peut totalement miner la validité du test entier suites et (b) imposer des limites qui devraient être libres de changer, ce qui peut paralyser la productivité. Je souhaite que plus de gens écoutent leur douleur et défient cette idée préconçue.
Ant P
3
Aucune telle confusion. Testez les unités de comportement, pas les unités de code. Il n’existe rien dans le concept de test unitaire - à part dans l’esprit largement répandu du test logiciel - qui implique qu’une unité de test est couplée au concept distinct de POO d’une classe. Et une idée fausse très préjudiciable qui l’est aussi.
Ant P
1
Je ne suis pas sûr de savoir exactement ce que vous voulez dire - je soutiens exactement le contraire de ce que vous insinuez. Détruisez les limites dures (si vous devez absolument le faire) et testez une unité comportementale . Via l'API publique. La nature de cette API dépend de ce que vous construisez, mais son encombrement doit être minimal. L'ajout d'abstraction, de polymorphisme ou de code de restructuration ne devrait pas entraîner l'échec des tests - "unité par classe" contredit cette affirmation et compromet totalement la valeur des tests.
Ant P
1
RobbieDee - Je parle de l'inverse. Considérez que vous êtes peut-être celui qui entretient les idées fausses courantes, revisitez ce que vous considérez comme une "unité" et approfondissez peut-être un peu plus ce que Kent Beck parlait réellement lorsqu'il parlait du TDD. Ce que la plupart des gens appellent maintenant TDD est une monstruosité culte du fret. youtube.com/watch?v=EZ05e7EMOLM
Ant P
1

Jetant un coup d'oeil à votre conception, je suggérerais qu'un Carest composé d'un ensemble de Features. Une fonctionnalité peut-être une CarRadio, ou EntertainmentSystemqui peut être composée d'éléments tels qu'un CarRadio, DvdPlayeretc.

Donc, au minimum, vous devriez construire un Caravec un ensemble de Features. EntertainmentSystemet CarRadioseraient des implémenteurs de l'interface (Java, C #, etc.) de public [I|i]nterface Featureet ceci serait une instance du modèle de conception Composite.

Par conséquent, vous construirez toujours un Caravec Featureset un test unitaire n’instanciera jamais un Carsans Features. Bien que vous puissiez envisager de vous moquer d'un ensemble BasicTestCarFeatureSetcomportant un ensemble minimal de fonctionnalités requises pour votre test le plus élémentaire.

Juste quelques pensées.

John

johnd27
la source