Devez-vous coder vos données de manière rigide dans tous les tests unitaires?

33

La plupart des didacticiels et des exemples de tests unitaires qui y sont proposés impliquent généralement la définition des données à tester pour chaque test. Je suppose que cela fait partie de la théorie "tout devrait être testé isolément".

Cependant, j’ai constaté que lorsqu’il s’agissait d’applications à plusieurs niveaux avec beaucoup d’ ID , le code nécessaire à la configuration de chaque test devenait très long. Au lieu de cela, j'ai construit un certain nombre de classes de base de test dont je peux maintenant hériter et qui ont beaucoup d'échafaudages de test prédéfinis.

Dans ce cadre, je construis également de faux jeux de données qui représentent la base de données d’une application en cours d’exécution, même s’il n’ya généralement qu’une ou deux lignes dans chaque "table".

Est-ce une pratique acceptée de prédéfinir, si ce n’est tout, la majorité des données de test pour tous les tests unitaires?

Mise à jour

D'après les commentaires ci-dessous, j'ai l'impression que je fais plus d'intégration que de tests unitaires.

Mon projet actuel est ASP.NET MVC, qui utilise Unité de travail sur Entity Framework Code First et Moq à des fins de test. Je me suis moqué de l'UoW et des référentiels, mais j'utilise les vraies classes de logique métier et teste les actions du contrôleur. Les tests vérifieront souvent que le UoW a été commis, par exemple:

[TestClass]
public class SetupControllerTests : SetupControllerTestBase {
  [TestMethod]
  public void UserInvite_ExistingUser_DoesntInsertNewUser() {
    // Arrange
    var model = new Mandy.App.Models.Setup.UserInvite() {
      Email = userData.First().Email
    };

    // Act
    setupController.UserInvite(model);

    // Assert
    mockUserSet.Verify(m => m.Add(It.IsAny<UserProfile>()), Times.Never);
    mockUnitOfWork.Verify(m => m.Commit(), Times.Once);
  }
}

SetupControllerTestBaseconstruit la maquette UoW, et instancie la userLogic.

De nombreux tests nécessitent la présence d'un utilisateur ou d'un produit existant dans la base de données. C'est pourquoi j'ai pré-renseigné ce que la simulation UoW renvoie, dans cet exemple userData, qui consiste en IList<User>un enregistrement avec un seul utilisateur.

mattdwen
la source
4
Le problème avec les tutoriels / exemples est qu'ils doivent être simples, mais vous ne pouvez pas montrer de solution à un problème complexe avec un exemple simple. Ils devraient être accompagnés par des "études de cas" décrivant la façon dont l'outil est utilisé dans des projets réels de taille raisonnable, mais ils le sont rarement.
Jan Hudec
Vous pourriez peut-être ajouter quelques exemples de code dont vous n'êtes pas totalement satisfait.
Luc Franken
Si vous avez besoin de beaucoup de code d'installation pour exécuter un test, vous risquez de lancer un test fonctionnel. Si le test échoue lorsque vous modifiez le code mais que le code n’est pas faux. C'est définitivement un test fonctionnel.
Reactgular
Le livre "xUnit Test Patterns" constitue un solide exemple pour les fixtures et les aides réutilisables. Le code de test doit être aussi facile à gérer que tout autre code.
Chuck Krutsinger
Cet article peut être utile: yegor256.com/2015/05/25/unit-test-scaffolding.html
yegor256

Réponses:

25

En fin de compte, vous voulez écrire le moins de code possible pour obtenir le plus de résultats possible. Avoir le même code dans plusieurs tests a) a tendance à conduire à un codage copier-coller et b) signifie que si une signature de méthode change, vous risquez de devoir réparer de nombreux tests interrompus.

J'utilise l'approche consistant à avoir des classes TestHelper standard qui me fournissent un grand nombre de types de données que j'utilise régulièrement, afin de pouvoir créer des ensembles d'entités standard ou de classes DTO pour mes tests afin d'interroger et de savoir exactement ce que j'obtiendrai à chaque fois. Je peux donc appeler TestHelper.GetFooRange( 0, 100 )pour obtenir une plage de 100 objets Foo avec tous leurs classes / champs dépendants.

En particulier lorsqu'il existe des relations complexes configurées dans un système de type ORM qui doivent être présentes pour que les éléments fonctionnent correctement, mais ne sont pas nécessairement significatives pour ce test qui peut vous faire gagner beaucoup de temps.

Dans des situations où je teste près du niveau de données, je crée parfois une version de test de ma classe de référentiel qui peut être interrogée de la même manière (là encore, il s'agit d'un environnement de type ORM, ce qui ne serait pas pertinent par rapport à base de données réelle), car se moquer des réponses exactes aux requêtes demande beaucoup de travail et ne procure souvent que des avantages mineurs.

Il y a certaines choses à faire attention, bien que dans les tests unitaires:

  • Assurez-vous que vos simulacres sont simulacres . Les classes qui effectuent des opérations autour de la classe en cours de test doivent être des objets fictifs si vous effectuez des tests unitaires. Votre classe de type DTO / entité peut être la vraie chose, mais si les classes effectuent des opérations, vous devez vous moquer de ces dernières. Sinon, lorsque le code de support change et que vos tests commencent à échouer, vous devez chercher beaucoup plus longtemps pour déterminer le changement. effectivement causé le problème.
  • Assurez-vous de tester vos cours . Parfois, si l’on examine une série de tests unitaires, il devient évident que la moitié des tests teste davantage le framework moqueur que le code qu’ils sont censés tester.
  • Ne réutilisez pas les objets fictifs / supports C'est un gros problème. Lorsque vous commencez à essayer d'être intelligent avec des tests unitaires supportant du code, il est très facile de créer par inadvertance des objets qui persistent entre les tests, ce qui peut avoir des effets imprévisibles. Par exemple, hier, j’avais un test qui avait réussi lorsqu’il était exécuté seul, qui avait été exécuté tous les tests de la classe, mais avait échoué lors de l’exécution de la suite de tests complète. Il s'est avéré qu'il y avait un objet statique sournois dans un assistant de test qui, quand je l'ai créé, n'aurait certainement jamais causé de problème. Rappelez-vous: au début du test, tout est créé, à la fin du test, tout est détruit.
glénatron
la source
10

Tout ce qui rend l'intention de votre test plus lisible.

En règle générale:

Si les données font partie du test (par exemple, si les lignes dont l'état est 7 ne doivent pas être imprimées), codez-les dans le test, de manière à indiquer clairement ce que l'auteur voulait qu'il se produise.

Si les données sont simplement suffisantes pour s’assurer qu’il a quelque chose à travailler (par exemple, si l’enregistrement est complet si le service de traitement lève une exception), alors il faut absolument une méthode BuildDummyData ou une classe de test qui conserve les données non pertinentes en dehors du test .

Mais notez que je peine à penser à un bon exemple de ce dernier. Si vous en avez beaucoup dans un appareil de test unitaire, vous avez probablement un problème différent à résoudre ... peut-être que la méthode à tester est trop complexe.

pdr
la source
+1 je suis d'accord. Cela sent ce qu'il teste est trop étroitement couplé pour les tests unitaires.
Reactgular
5

Différentes méthodes de test

Définissez d’abord ce que vous faites: Test unitaire ou test d’intégration . Le nombre de couches n'est pas pertinent pour les tests unitaires car vous ne testez qu'une seule classe. Vous vous moquez du reste. Pour les tests d'intégration, il est inévitable de tester plusieurs couches. Si vous avez de bons tests unitaires en place, l’astuce consiste à rendre les tests d’intégration peu complexes.

Si vos tests unitaires sont bons, vous n'avez pas à répéter tous les détails lors des tests d'intégration.

Les termes que nous utilisons sont un peu dépendants de la plate-forme, mais vous pouvez les trouver dans presque toutes les plates-formes de test / développement:

Exemple d'application

En fonction de la technologie utilisée, les noms peuvent différer, mais je vais utiliser ceci à titre d'exemple:

Si vous avez une application CRUD simple avec le modèle Product, ProductsController et une vue d'index qui génère un tableau HTML avec les produits:

Le résultat final de l'application montre un tableau HTML avec une liste de tous les produits actifs.

Tests unitaires

Modèle

Le modèle que vous pouvez tester assez facilement. Il existe différentes méthodes pour cela; nous utilisons des fixtures. Je pense que c'est ce que vous appelez des "faux jeux de données". Ainsi, avant que chaque test soit exécuté, nous créons la table et intégrons les données d'origine. La plupart des plates-formes ont des méthodes pour cela. Par exemple, dans votre classe de test, une méthode setUp () qui est exécutée avant chaque test.

Ensuite, nous exécutons notre test, par exemple: les produits testGetAllActive .

Nous testons donc directement sur une base de données de test. Nous ne simulons pas la source de données; nous faisons toujours la même chose. Cela nous permet par exemple de tester avec une nouvelle version de la base de données, et tout problème de requête se posera.

Dans le monde réel, vous ne pouvez pas toujours suivre la responsabilité à 100% . Si vous voulez faire encore mieux, vous pouvez utiliser une source de données à laquelle vous vous moquez. Pour nous (nous utilisons un ORM) cela ressemble à tester une technologie déjà existante. De plus, les tests deviennent beaucoup plus complexes et ne testent pas vraiment les requêtes. Donc, on continue comme ça.

Les données codées en dur sont stockées séparément dans les appareils. La fixture ressemble donc à un fichier SQL avec une instruction create table et des insertions pour les enregistrements que nous utilisons. Nous les gardons petits, sauf s’il est vraiment nécessaire de tester avec de nombreux enregistrements.

class ProductModel {
  public function getAllActive() {
    return $this->find('all', array('conditions' => array('active' => 1)));
  }
}

Manette

Le contrôleur a besoin de plus de travail, car nous ne voulons pas tester le modèle avec. Donc, ce que nous faisons est de nous moquer du modèle. Cela signifie: Nous testons la méthode: index () qui devrait renvoyer une liste d'enregistrements.

Nous simulons donc la méthode de modèle getAllActive () et y ajoutons des données fixes (deux enregistrements, par exemple). Nous testons maintenant les données envoyées par le contrôleur à la vue et nous comparons si nous récupérons réellement ces deux enregistrements.

function testProductIndexLoggedIn() {
  $this->setLoggedIn();
  $this->ProductsController->mock('ProductModel', 'index', function(return array(your records) ));
  $result=$this->ProductsController->index();
  $this->assertEquals(2, count($result['products']));
}

C'est assez. Nous essayons d’ajouter le moins de fonctionnalités possibles au contrôleur car cela complique les tests. Mais bien sûr, il y a toujours du code dedans. Par exemple, nous testons des exigences telles que: Afficher ces deux enregistrements uniquement si vous êtes connecté.

Ainsi, le contrôleur a besoin d’une maquette normale et d’un petit fichier de données codées en dur. Pour un système de connexion, peut-être un autre. Dans notre test, nous avons une méthode d'assistance pour cela: setLoggedIn (). Cela facilite les tests avec ou sans connexion.

class ProductsController {
  public function index() {
    if($this->loggedIn()) {
      $this->set('products', $this->ProductModel->getAllActive());
    }
  }
}

Les vues

Le test des vues est difficile. Nous séparons d’abord la logique qui se répète. Nous le mettons dans Helpers et testons ces classes de manière stricte. Nous attendons toujours le même résultat. Par exemple, generateHtmlTableFromArray ().

Nous avons ensuite des vues spécifiques au projet. Nous ne testons pas ceux-ci. Il n'est pas vraiment souhaitable de tester ceux-ci. Nous les gardons pour les tests d'intégration. Parce que nous avons extrait une grande partie du code, nous avons ici un risque moins élevé.

Si vous commencez à les tester, vous devrez probablement modifier vos tests chaque fois que vous modifiez un élément HTML, ce qui n’est pas utile pour la plupart des projets.

echo $this->tableHelper->generateHtmlTableFromArray($products);

Test d'intégration

Selon votre plate-forme, vous pouvez travailler avec des histoires d'utilisateurs, etc. Cela peut être basé sur le Web, comme Selenium ou d'autres solutions comparables.

Généralement, nous chargeons simplement la base de données avec les fixtures et affirmons quelles données devraient être disponibles. Pour les tests d'intégration complets, nous utilisons généralement des exigences très globales. Donc: définissez le produit sur actif, puis vérifiez si le produit devient disponible.

Nous ne testons pas tout à nouveau, par exemple si les champs appropriés sont disponibles. Nous testons les plus grandes exigences ici. Puisque nous ne voulons pas dupliquer nos tests à partir du contrôleur ou de la vue. Si quelque chose est vraiment essentiel / clé dans votre application ou pour des raisons de sécurité (le mot de passe de vérification n'est PAS disponible), nous les ajoutons pour nous assurer qu'il est correct.

Les données codées en dur sont stockées dans les appareils.

function testIntegrationProductIndexLoggedIn() {
  $this->setLoggedIn();
  $result=$this->request('products/index');

  $expected='<table';
  $this->assertContains($expected, $result);

  // Some content from the fixture record
  $expected='<td>Product 1 name</td>';
  $this->assertContains($expected, $result);
}
Luc Franken
la source
C'est une excellente réponse à une question totalement différente.
pdr
Merci pour les commentaires. Vous avez peut-être raison de ne pas en avoir parlé trop précisément. La raison de la réponse verbeuse est parce que je vois l'une des choses les plus difficiles lorsque je teste la question posée. L'aperçu de la manière dont les tests isolés s'harmonisent avec les différents types de tests. C'est pourquoi j'ai ajouté dans chaque partie la manière dont les données sont traitées (ou séparées). Jetons un coup d'oeil pour voir si je peux l'obtenir plus clair.
Luc Franken
La réponse a été mise à jour avec des exemples de code pour expliquer comment tester sans appeler toutes sortes d'autres classes.
Luc Franken
4

Si vous écrivez des tests qui impliquent beaucoup de DI et de câblage, jusqu'à utiliser de "vraies" sources de données, vous avez probablement quitté le domaine des tests unitaires standard pour entrer dans le domaine des tests d'intégration.

Pour les tests d'intégration, je pense que ce n'est pas une mauvaise idée d'avoir une logique de configuration de données commune. Le but principal de ces tests est de prouver que tout est correctement configuré. Ceci est plutôt indépendant des données concrètes envoyées par votre système.

Pour les tests unitaires, par contre, je recommanderais de garder la cible d'une classe de tests une seule "vraie" classe et de se moquer de tout le reste. Ensuite, vous devriez vraiment coder en dur les données de test pour vous assurer de couvrir autant de chemins spéciaux que de bogues précédents.

Pour ajouter un élément semi-dur / aléatoire aux tests, j'aime bien introduire des usines de modèles aléatoires. Lors d'un test utilisant une instance de mon modèle, j'utilise ensuite ces fabriques pour créer un objet modèle valide, mais totalement aléatoire, puis pour coder en dur uniquement les propriétés qui présentent un intérêt pour le test en question. De cette façon, vous spécifiez toutes les données pertinentes directement dans votre test, tout en vous évitant de spécifier également toutes les données non pertinentes et (dans une certaine mesure) de tester l'absence de dépendances involontaires sur d'autres champs de modèle.

Sven Amann
la source
-1

Je pense qu'il est assez courant de coder en dur la plupart des données pour vos tests.

Prenons une situation simple dans laquelle un ensemble de données particulier provoque un bogue. Vous pouvez créer spécifiquement un test unitaire pour ces données afin d’exercer le correctif et d’assurer que le bogue ne se reproduit plus. Au fil du temps, vos tests disposeront d'un ensemble de données couvrant un certain nombre de cas de test.

Les données de test prédéfinies vous permettent également de créer un ensemble de données couvrant un large éventail de situations connues.

Cela dit, je pense qu’il est également utile d’avoir des données aléatoires dans vos tests.

Sasbury
la source
Avez-vous vraiment lu la question et pas seulement le titre?
Jakob
Il est important d’avoir des données aléatoires dans vos tests - Oui, car rien n’a autant à essayer de comprendre ce qui s’est passé dans un test une fois par semaine.
pdr
Il est utile de disposer de données aléatoires dans vos tests pour les tests de bizutage / fuzzing / saisie. Mais pas dans vos tests unitaires, ce serait un cauchemar.
Glenatron