J'ai commencé à écrire des tests unitaires pour mon projet actuel. Mais je n'ai pas vraiment d'expérience avec ça. Je veux d'abord "l'obtenir" complètement, donc je n'utilise actuellement ni mon framework IoC ni une bibliothèque de simulation.
Je me demandais s'il n'y avait rien de mal à fournir des arguments nuls aux constructeurs d'objets dans les tests unitaires. Permettez-moi de fournir 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 (TM), réduit aux seules parties importantes de la question. J'ai maintenant écrit un test quelque chose comme 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. SpeedLimit
a besoin d'un Car
avec un Motor
pour faire son travail. Il n'est pas du tout intéressé par un CarRadio
, j'ai donc fourni null pour cela.
Je me demande si un objet fournissant des fonctionnalités correctes sans être entièrement construit est une violation de SRP ou une odeur de code. J'ai ce sentiment persistant que c'est le cas, mais speedLimit.IsViolatedBy(motor)
ne se sent pas bien non plus - une limite de vitesse est violée par une voiture, pas par un moteur. Peut-être que j'ai juste besoin d'une perspective différente pour les tests unitaires par rapport au code de travail, car l'intention est de ne tester qu'une partie de l'ensemble.
La construction d'objets avec null dans les tests unitaires est-elle une odeur de code?
Dans le cas de l'exemple ci-dessus, il est raisonnable qu'un Car
puisse exister sans CarRadio
. Dans ce cas, je dirais que non seulement il est acceptable de passer un null CarRadio
parfois, je dirais que c'est obligatoire. Vos tests doivent garantir que l'implémentation de la classe Car
est robuste et ne lève pas d'exceptions de pointeur nul quand aucun CarRadio
n'est présent.
Cependant, prenons un exemple différent - considérons un SteeringWheel
. Supposons qu'un Car
doit avoir un SteeringWheel
, mais le test de vitesse ne s'en soucie pas vraiment. Dans ce cas, je ne passerais pas un SteeringWheel
nul car cela pousse alors la classe Car
dans des endroits où elle n'est pas conçue pour aller. Dans ce cas, vous feriez mieux de créer une sorte de DefaultSteeringWheel
qui (pour continuer la métaphore) est verrouillé en ligne droite.
La construction d'objets avec null dans les tests unitaires est-elle une odeur de code?
Oui. Notez la définition C2 de 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. Il n'est pas du tout intéressé par un CarRadio, j'ai donc fourni null pour cela.
Si vous essayez de démontrer que speedLimit.IsViolatedBy(car)
ne dépend pas de l'argument CarRadio
passé au constructeur, alors il serait probablement plus clair pour le futur responsable de rendre cette contrainte explicite en introduisant un test double qui échoue au test s'il est invoqué.
Si, comme cela est plus probable, vous essayez simplement de vous épargner le travail de création d'un CarRadio
dont vous savez qu'il n'est pas utilisé dans ce cas, vous devriez remarquer que
CarRadio
n'est pas utilisé s'échapper dans le test)Car
sans spécifier un CarRadio
Je soupçonne fortement que le code essaie de vous dire que vous voulez un constructeur qui ressemble à
public Car(IMotor motor) {...}
puis ce constructeur peut décider quoi faire avec le fait qu'il n'y a pas de CarRadio
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 à autre chose lors du test de SpeedLimit. Vous pouvez voir que le test est appelé "SpeedLimitTest" et 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 que le contrôle SpeedLimit se soucie probablement est la vitesse .
Cela pourrait être un indice que SpeedLimit.isViolatedBy
devrait 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 {...}
IHaveSpeed
est un nom vraiment nul; nous espérons que votre langue de domaine aura une amélioration.
Avec cette interface en place, votre test pour SpeedLimit
pourrait être beaucoup plus simple
public void TestSpeedLimit()
{
IHaveSpeed somethingTravelingQuickly = new IHaveSpeed {
float speed() { return 10f; }
}
SpeedLimit speedLimit = new SpeedLimit();
Assert.IsTrue(speedLimit.IsViolatedBy(somethingTravelingQuickly));
}
La construction d'objets avec null dans les tests unitaires est-elle une odeur de code?
Pas du tout. Au contraire, si NULL
est une valeur disponible en argument (certaines langues peuvent avoir des constructions qui interdisent le passage d'un pointeur nul), vous devriez la tester.
Une autre question est de savoir ce qui se passe lorsque vous passez NULL
. Mais c'est exactement ce qu'est un test unitaire: s'assurer qu'un est défini se produit lorsque pour chaque entrée possible. Et NULL
est une entrée potentielle, donc vous devriez avoir un résultat défini et vous devriez avoir un test pour cela.
Donc, si votre voiture est supposée être constructible sans radio, vous devriez écrivez un test pour cela. Si ce n'est pas le cas et que est supposé lever une exception, vous devez écrire un test pour ça.
Dans tous les cas, oui vous devriez écrire un test pour NULL
. Ce serait une odeur de code si vous l'ignoriez. Cela signifierait que vous avez une branche de code non testée que vous venez de supposer comme par magie exempte de bogues.
Veuillez noter que je suis d'accord avec les autres réponses que votre code sous test pourrait être amélioré. Néanmoins, si le code amélioré a des paramètres qui sont des pointeurs, passer NULL
même pour le code amélioré est un bon test. Je voudrais qu'un test montre ce qui se passe quand je passe NULL
comme moteur. Ce n'est pas une odeur de code, c'est l'opposé d'une odeur de code. C'est des tests.
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 lever une ArgumentNullException si ces injections sont nulles. De cette façon, je peux les utiliser en toute sécurité dans ma classe, sans des dizaines de contrôles nuls répartis autour. Voici un schéma très courant. Notez l'utilisation de readonly pour garantir 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 test unitaire ou deux, où j'injecter des valeurs nulles, juste pour m'assurer que mes vérifications nulles fonctionnent comme prévu.
Cela dit, cela devient un non-problème, une fois que vous faites deux choses:
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 le comprendre sans vous moquer d'abord, vous pouvez toujours le faire, tant que vous utilisez des interfaces. Créez simplement un CarRadio et un moteur factices comme suit, et injectez-les à la place:
class DummyCarRadio : ICarRadio
{
...
}
class DummyMotor : IMotor
{
...
}
...
var car = new Car(new DummyCarRadio(), new DummyMotor())
Revenons en arrière aux premiers principes.
Un test unitaire teste certains aspects d'une seule classe.
Il y aura cependant des constructeurs ou des méthodes qui prendront d'autres classes comme paramètres. La façon dont vous les gérez variera d'un cas à l'autre, mais la configuration devrait être minimale, car elles sont tangentes à l'objectif. Il peut y avoir des scénarios où le passage d'un paramètre NULL est parfaitement valide - comme 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 mai voulez également passer NULL à d'autres paramètres pour vérifier que tout le code défensif et les mécanismes de gestion des exceptions fonctionnent selon les besoins.
Jusqu'ici tout va bien. Cependant, que se passe-t-il si NULL ne l'est pas une valeur valide. Eh bien, vous êtes ici dans le monde trouble de moqueries, talons, mannequins et contrefaçons .
Si tout cela semble un peu trop lourd à prendre, vous utilisez en fait déjà un mannequin en passant NULL dans le constructeur Car car c'est une valeur qui ne sera potentiellement pas utilisée.
Ce qui devrait devenir clair assez rapidement, c'est que vous pouvez potentiellement tirer votre code avec beaucoup de manipulation NULL. Si vous remplacez les paramètres de classe par des interfaces, vous ouvrez également la voie à la simulation et à la DI, ce qui les rend beaucoup plus simples à gérer.
Ce n'est pas seulement correct, c'est une technique bien connue pour briser les dépendances pour obtenir du code hérité dans un faisceau de test. (Voir "Travailler efficacement avec le 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é fonctionne toujours correctement".
L'odeur est dans la mise en œuvre. En déclarant un argument constructeur, vous déclarez "cette classe a besoin de cette chose pour fonctionner correctement". De toute évidence, c'est faux. Tout ce qui est faux est un peu malodorant.
En jetant un œil à votre conception, je suggérerais qu'un Car
est composé d'un ensemble de Features
. Une fonctionnalité peut être un CarRadio
, ou EntertainmentSystem
qui peut consister en des choses comme un CarRadio
, DvdPlayer
etc.
Donc, au minimum, vous devrez construire un Car
avec un ensemble de Features
. EntertainmentSystem
et CarRadio
seraient des implémenteurs de l'interface (Java, C # etc) du public [I|i]nterface Feature
et ce serait une instance du modèle de conception composite.
Par conséquent, vous construirez toujours un Car
avec Features
et un test unitaire n'instanciera jamais un Car
sans Features
. Bien que vous puissiez envisager de vous moquer d'un BasicTestCarFeatureSet
qui possède un ensemble minimal de fonctionnalités requises pour votre test le plus élémentaire.
Juste quelques réflexions.
John