Test de boîte blanche

Le développement de programmes de haute qualité implique que le programme et ses composants soient testés. Les tests unitaires classiques consistent à diviser un grand programme en petits blocs qui sont pratiques pour les tests. Ou, si le développement de tests a lieu en parallèle avec le développement de code ou si des tests sont développés avant le programme (TDD - développement piloté par les tests), alors le programme est initialement développé en petits blocs adaptés aux exigences des tests.


L'une des variétés de tests unitaires peut être considérée comme un test basé sur les propriétés (cette approche est implémentée, par exemple, dans les bibliothèques QuickCheck , ScalaCheck ). Cette approche est basée sur la recherche de propriétés universelles qui devraient être valides pour toutes les données d'entrée. Par exemple, la sérialisation suivie de la désérialisation devrait produire le même objet . Ou, le nouveau tri ne doit pas modifier l'ordre des éléments de la liste . Pour vérifier ces propriétés universelles, les bibliothèques ci-dessus prennent en charge un mécanisme pour générer des données d'entrée aléatoires. Cette approche fonctionne particulièrement bien pour les programmes basés sur des lois mathématiques qui servent de propriétés universelles valables pour une large classe de programmes. Il existe même une bibliothèque de propriétés mathématiques prêtes à l'emploi - discipline - qui vous permet de vérifier les performances de ces propriétés dans de nouveaux programmes (un bon exemple de réutilisation de tests).


Parfois, il s'avère qu'il est nécessaire de tester un programme complexe sans pouvoir l'analyser en parties vérifiables indépendamment. Dans ce cas, le programme de test est noir boîte blanche (blanche - car nous avons la possibilité d'étudier la structure interne du programme).


Sous la coupe, plusieurs approches pour tester des programmes complexes avec une entrée avec différents degrés de complexité (implication) et différents degrés de couverture sont décrites.


* Dans cet article, nous supposons que le programme testé peut être représenté comme une fonction pure sans état interne. (Certaines des considérations ci-dessous peuvent être appliquées si l'état interne est présent, mais il est possible de réinitialiser cet état à une valeur fixe.)


Banc d'essai


Tout d'abord, puisqu'une seule fonction est testée, dont le code appelant est toujours le même, nous n'avons pas besoin de créer des tests unitaires séparés. Tous ces tests seraient les mêmes, précis à l'entrée et aux contrôles. Il suffit de transmettre les données source ( input ) en boucle et de vérifier les résultats ( input expectedOutput ). Afin d'identifier un ensemble problématique de données de test en cas de détection d'erreur, toutes les données de test doivent être étiquetées. Ainsi, un ensemble de données de test peut être représenté comme un triple:


 case class TestCase[A, B](label: String, input: A, expectedOutput: B) 

Le résultat d'une exécution peut être représenté par TestCaseResult :


 case class TestCaseResult[A, B](testCase: TestCase[A, B], actualOutput: Try[B]) 

(Nous présentons le résultat du lancement en utilisant Try to catch possible exceptions.)


Pour simplifier l'exécution de toutes les données de test via le programme testé, vous pouvez utiliser une fonction d'assistance qui appellera le programme pour chaque valeur d'entrée:


 def runTestCases[A, B](cases: Seq[TestCase[A, B])(f: A => B): Seq[TestCaseResult[A, B]] = cases .map{ testCase => TestCaseResult(testCase, Try{ f(testCase.input) } ) } .filter(r => r.actualOutput != Success(r.testCase.expectedOutput)) 

Cette fonction d'assistance renvoie les données et les résultats problématiques qui sont différents de ceux attendus.


Pour plus de commodité, vous pouvez formater les résultats du test.


 def report(results: Seq[TestCaseResult[_, _]]): String = s"Failed ${results.length}:\n" + results .map(r => r.testCase.label + ": expected " + r.testCase.expectedOutput + ", but got " + r.actualOutput) .mkString("\n") 

et n'afficher un rapport qu'en cas d'erreur:


 val testCases = Seq( TestCase("1", 0, 0) ) test("all test cases"){ val testBench = runTestCases(testCases) _ val results = testBench(f) assert(results.isEmpty, report(results)) } 

Préparation des entrées


Dans le cas le plus simple, vous pouvez créer manuellement des données de test pour tester le programme, les écrire directement dans le code de test et les utiliser, comme indiqué ci-dessus. Il s'avère souvent que des cas intéressants de données de test ont beaucoup en commun et peuvent être présentés comme une instance de base, avec des modifications mineures.


 val baseline = MyObject(...) //        val testCases = Seq( TestCase("baseline", baseline, ???), TestCase("baseline + (field1 = 123)", baseline.copy(field1 = "123"), ???) ) 

Lorsque vous travaillez avec des structures de données immuables imbriquées, les lentilles sont très utiles, par exemple, à partir de la bibliothèque Monocle :


 val baseline = ??? val testObject1 = (field1 composeLens field2).set("123")(baseline) //    : val testObject1 = baseline.copy(field1 = baseline.field1.copy(field2 = "123")) 

Les objectifs vous permettent de «modifier» avec élégance les parties profondément imbriquées des structures de données: chaque objectif est un getter et un setter pour une propriété. Les lentilles peuvent être combinées pour produire des lentilles qui «se concentrent» sur le niveau suivant.


Utilisation de DSL pour présenter les modifications


Ensuite, nous considérerons la formation de données de test en apportant des modifications à un objet d'entrée initial. Habituellement, pour obtenir l'objet de test dont nous avons besoin, nous devons apporter quelques modifications. Dans le même temps, il est très utile d'inclure une liste de modifications dans la description textuelle de TestCase:


 val testCases = Seq( TestCase("baseline", baseline, ???), TestCase("baseline + " + "(field1 = 123) + " + //  1-  "(field2 = 456) + " + // 2- "(field3 = 789)", // 3- baseline .copy(field1 = "123") // 1-  .copy(field2 = "456") // 2-  .copy(field3 = "789"), // 3-  ???) ) 

Ensuite, nous saurons toujours pour quelles données de test le test est effectué.


Pour que la liste textuelle des changements ne s'écarte pas des changements réels, vous devez suivre le principe d'une "version unique de la vérité". (Si la même information est requise / utilisée à plusieurs points, il devrait y avoir une seule source principale d'informations uniques et les informations devraient être distribuées automatiquement à tous les autres points d'utilisation, avec les transformations nécessaires. Si ce principe est violé et que la copie manuelle des informations est inévitable . informations sur la version de divergence à différents points dans d' autres termes dans la description des données de test, nous voyons une, et des données de test -. autre exemple, la copie d' un changement field2 = "456" et le réglage dans le field3 = "789" nous Mauger accidentellement oublier de corriger la description. Par conséquent, la description ne reflète deux changements de trois.)


Dans notre cas, la principale source d'information est les modifications elles-mêmes, ou plutôt le code source du programme qui effectue les modifications. Nous aimerions en déduire un texte décrivant les changements. À l'inverse, comme première option, vous pouvez suggérer d'utiliser une macro qui capturera le code source des modifications et utilisera le code source comme documentation. Il s'agit, apparemment, d'un moyen efficace et relativement simple de documenter les changements réels et il pourrait très bien être appliqué dans certains cas. Malheureusement, si nous présentons les modifications en texte brut, nous perdons la possibilité de faire des transformations significatives de la liste des modifications. Par exemple, détectez et éliminez les modifications en double ou qui se chevauchent, établissez une liste de modifications d'une manière pratique pour l'utilisateur final.


Pour pouvoir gérer les modifications, vous devez en avoir un modèle structuré. Le modèle doit être suffisamment expressif pour décrire tous les changements qui nous intéressent. Une partie de ce modèle, par exemple, sera l'adressage des champs d'objet, des constantes, des opérations d'affectation.


Le modèle de changement doit permettre de résoudre les tâches suivantes:


  1. Générez des instances de modèle de changement. (Autrement dit, créer une liste spécifique de modifications.)
  2. Formation d'une description textuelle des changements.
  3. Application des modifications aux objets de domaine.
  4. Effectuer des transformations d'optimisation sur le modèle.

Si un langage de programmation universel est utilisé pour apporter des modifications, il peut être difficile de représenter ces modifications dans le modèle. Le code source du programme peut utiliser des constructions complexes qui ne sont pas prises en charge par le modèle. Un tel programme peut utiliser des motifs secondaires, tels que des lentilles ou la méthode de copy , pour modifier les champs d'un objet, qui sont des abstractions de niveau inférieur par rapport au niveau du modèle de changement. Par conséquent, une analyse supplémentaire de ces modèles peut être nécessaire pour générer des instances de modifications. Ainsi, au départ, une bonne option en utilisant une macro n'est pas très pratique.


Une autre façon de créer des instances du modèle de changement peut être un langage spécialisé (DSL), qui crée des objets de modèle de changement à l'aide d'un ensemble de méthodes d'extension et d'opérateurs auxiliaires. Eh bien, dans les cas les plus simples, les instances du modèle de changement peuvent être créées directement via les constructeurs.


Modifier les détails de la langue

Le langage de changement est une construction assez complexe qui comprend plusieurs composants, qui sont à leur tour non triviaux.


  1. Modèle de structure de données.
  2. Changer de modèle.
  3. En fait Embedded (?) DSL - constructions auxiliaires, méthodes d'extension, pour une construction pratique des changements.
  4. Un interprète des changements qui vous permet de réellement "modifier" un objet (en fait, bien sûr, créer une copie modifiée).

Voici un exemple de programme écrit en DSL:


 val target: Entity[Target] // ,     val updateField1 = target \ field1 := "123" val updateField2 = target \ subobject \ field2 := "456" // ,   DSL: val updateField1 = SetProperty(PropertyAccess(target, Property(field1, typeTag[String])), LiftedString("123")) val updateField2 = SetProperty(PropertyAccess(PropertyAccess(target, Property(subobject, typeTag[SubObject])), Property(field2, typeTag[String])), LiftedString("456")) 

Autrement dit, à l'aide des méthodes d'extension \ et := , PropertyAccess , les objets SetProperty sont formés à partir des objets target , field1 , subobject , field2 précédemment créés. De plus, en raison de conversions implicites (dangereuses), la chaîne "123" est compressée dans une LiftedString (vous pouvez vous passer de conversions implicites et appeler explicitement la méthode correspondante: lift("123") ).


Une ontologie typée peut être utilisée comme modèle de données (voir https://habr.com/post/229035/ et https://habr.com/post/222553/ ). (En bref: les objets de nom sont déclarés qui représentent les propriétés de tout type de domaine: val field1: Property[Target, String] .) Dans ce cas, les données réelles peuvent être stockées, par exemple, sous la forme de JSON. La commodité d'une ontologie typée dans notre cas réside dans le fait que le modèle de changement fonctionne généralement avec les propriétés individuelles des objets, et l'ontologie fournit simplement un outil approprié pour adresser les propriétés.


Pour représenter les modifications, vous avez besoin d'un ensemble de classes du même plan que la classe SetProperty ci-dessus:


  • Modify - application de la fonction,
  • Changes - appliquer plusieurs modifications séquentiellement
  • ForEach - appliquez des modifications à chaque élément de la collection,
  • etc.

L'interpréteur de changement de langue est un évaluateur d'expressions récursives régulières basé sur PatternMatching. Quelque chose comme:


 def eval(expression: DslExpression, gamma: Map[String, Any]): Any = expression match { case LiftedString(str) => str case PropertyAccess(obj, prop) => Getter(prop)(gamma).get(obj) } def change[T] (expression: DslChangeExpression, gamma: Map[String, Any], target: T): T = expression match { case SetProperty(path, valueExpr) => val value = eval(valueExpr, gamma) Setter(path)(gamma).set(value)(target) } 

Pour opérer directement sur les propriétés des objets, vous devez spécifier getter et setter pour chaque propriété utilisée dans le modèle de modification. Ceci peut être réalisé en remplissant la carte entre les propriétés ontologiques et leurs lentilles correspondantes.


Cette approche dans son ensemble fonctionne et vous permet en effet de décrire les changements une fois, mais progressivement, il est nécessaire de représenter des changements de plus en plus complexes et le modèle des changements se développe quelque peu. Par exemple, si vous devez modifier une propriété en utilisant la valeur d'une autre propriété du même objet (par exemple, field1 = field2 + 1 ), vous devez prendre en charge les variables au niveau DSL. Et si la modification d'une propriété n'est pas triviale, au niveau DSL, la prise en charge des expressions et des fonctions arithmétiques est requise.


Test de branche


Le code de test peut être linéaire, puis, dans l'ensemble, un seul ensemble de données de test suffit pour comprendre s'il fonctionne. S'il existe une branche ( if-then-else ), vous devez exécuter la boîte blanche au moins deux fois avec des données d'entrée différentes pour que les deux branches soient exécutées. Le nombre d'ensembles de données d'entrée suffisant pour couvrir toutes les branches est apparemment numériquement égal à la complexité cyclomatique du code à branches.


Comment former tous les ensembles de données d'entrée? Comme nous avons affaire à une boîte blanche, nous pouvons isoler les conditions de branchement et modifier deux fois l'objet d'entrée afin que dans un cas une branche soit exécutée, dans l'autre cas une autre. Prenons un exemple:


 if (object.field1 == "123") A else B 

Ayant une telle condition, nous pouvons former deux cas de test:


 val testCase1 = TestCase("A", field1.set("123")(baseline), /* result of A */) val testCase2 = TestCase("B", field1.set(/*  "123", , , */"123" + "1">)(baseline), /*result of B*/) 

(Si l'un des scénarios de test ne peut pas être créé, nous pouvons supposer que le code mort a été détecté et que la condition, ainsi que la branche correspondante, peuvent être supprimées en toute sécurité.)


Si les propriétés indépendantes d'un objet sont vérifiées dans plusieurs branches, il est assez simple de former un ensemble exhaustif d'objets de test modifiés qui couvre complètement toutes les combinaisons possibles.


DSL pour former toutes les combinaisons de changements

Examinons plus en détail le mécanisme qui permet de former toutes les listes de changements possibles qui couvrent entièrement toutes les branches. Afin d'utiliser la liste des modifications pendant les tests, nous devons combiner toutes les modifications en un seul objet, que nous soumettrons à l'entrée du code testé, c'est-à-dire que la prise en charge de la composition est requise. Pour ce faire, vous pouvez soit utiliser la DSL ci-dessus pour modéliser les modifications, puis une simple liste de modifications suffit, ou vous pouvez présenter une modification comme une fonction de modification T => T :


 val change1: T => T = field1.set("123")(_) // val change1: T => T = _.copy(field1 = "123") val change2: T => T = field2.set("456") 

alors la chaîne des changements sera simplement une composition de fonctions:


 val changes = change1 compose change2 

ou, pour une liste de modifications:


 val rawChangesList: Seq[T => T] = Seq(change1, change2) val allChanges: T => T = rawChangesList.foldLeft(identity)(_ compose _) 

Pour enregistrer de manière compacte toutes les modifications correspondant à toutes les branches possibles, vous pouvez utiliser la DSL du niveau d'abstraction suivant, qui simule la structure de la boîte blanche testée:


 val tests: Seq[(String, T => T)] = IF("field1 == '123'") //  ,    THEN( field1.set("123"))( //  target \ field1 := "123" IF("field2 == '456') THEN(field2.set("456"))(TERMINATE) ELSE(field2.set("456" + "1"))(TERMINATE) ) ELSE( field1.set("123" + "1") )(TERMINATE) 

Ici, la collection de tests contient des modifications agrégées correspondant à toutes les combinaisons possibles de branches. Un paramètre de type String contiendra tous les noms des conditions et toutes les descriptions des modifications à partir desquelles la fonction de modification globale est formée. Et le deuxième élément d'une paire de type T => T n'est que la fonction agrégée des changements obtenus à la suite de la composition des changements individuels.


Pour obtenir les objets modifiés, vous devez appliquer toutes les fonctions de modification agrégées à l'objet de base:


 val tests2: Seq[(String, T)] = tests.map(_.map_2(_(baseline))) 

En conséquence, nous obtenons une collection de paires, et la ligne décrira les changements appliqués, et le deuxième élément de la paire sera l'objet dans lequel tous ces changements seront combinés.


Sur la base de la structure du modèle du code testé sous forme d'arbre, les listes de modifications représenteront le chemin de la racine aux feuilles de cet arbre. Ainsi, une partie importante des modifications sera dupliquée. Vous pouvez vous débarrasser de cette duplication à l'aide de l'option DSL, dans laquelle les modifications sont directement appliquées à l'objet de ligne de base lorsque vous vous déplacez le long des branches. Dans ce cas, moins de calculs inutiles seront effectués.


Génération automatique des données de test


Puisque nous avons affaire à une boîte blanche, nous pouvons voir toutes les branches. Cela permet de construire un modèle de logique contenu dans une boîte blanche et d'utiliser le modèle pour générer des données de test. Si le code de test est écrit en Scala, vous pouvez, par exemple, utiliser scalameta pour lire le code, avec conversion ultérieure en modèle logique. Encore une fois, comme dans la question précédemment discutée de la modélisation de la logique des changements, il nous est difficile de modéliser toutes les possibilités d'un langage universel. De plus, nous supposerons que le code testé est implémenté en utilisant un sous-ensemble limité de la langue, ou dans une autre langue ou DSL, qui est initialement limitée. Cela nous permet de nous concentrer sur les aspects de la langue qui nous intéressent.


Prenons un exemple de code contenant une seule branche:


 if(object.field1 == "123") A else B 

La condition divise l'ensemble des valeurs de field1 en deux classes d'équivalence: == "123" et != "123" . Ainsi, l'ensemble complet des données d'entrée est également divisé en deux classes d'équivalence par rapport à cette condition - ClassCondition1IsTrue et ClassCondition1IsFalse . Du point de vue de l'exhaustivité de la couverture, il nous suffit de prendre au moins un exemple de ces deux classes pour couvrir à la fois les branches A et B Pour la première classe, nous pouvons construire un exemple, dans un sens, d'une manière unique: prendre un objet aléatoire, mais changer le field1 en "123" . De plus, l'objet apparaîtra certainement dans la classe d'équivalence ClassCondition1IsTrue et les calculs suivront la branche A Il y a plus d'exemples pour la deuxième classe. Une façon de générer un exemple de la deuxième classe consiste à générer des objets d'entrée arbitraires et à éliminer ceux avec field1 == "123" . Une autre façon: pour prendre un objet aléatoire, mais changez le field1 en "123" + "*" (pour la modification, vous pouvez utiliser n'importe quel changement dans la ligne de contrôle pour vous assurer que la nouvelle ligne n'est pas égale à la ligne de contrôle).


Arbitrary et Gen de la bibliothèque ScalaCheck conviennent parfaitement comme Arbitrary données Arbitrary .


Essentiellement, nous appelons la fonction booléenne utilisée dans l' if . Autrement dit, nous trouvons toutes les valeurs de l'objet d'entrée pour lesquelles cette fonction booléenne prend la valeur true - ClassCondition1IsTrue , et toutes les valeurs de l'objet d'entrée pour lesquelles elle prend la valeur false - ClassCondition1IsFalse .


De manière similaire, il est possible de générer des données adaptées aux contraintes générées par de simples opérateurs conditionnels à constantes (plus / moins qu'une constante, incluse dans un ensemble, commence par une constante). Ces conditions sont faciles à inverser. Même si des fonctions simples sont appelées dans le code de test, nous pouvons remplacer leur appel par leur définition (en ligne) et toujours inverser les expressions conditionnelles.


Fonctions réversibles dures


La situation est différente lorsque la condition utilise une fonction difficile à inverser. Par exemple, si une fonction de hachage est utilisée, il ne semble pas possible de générer automatiquement un exemple donnant la valeur souhaitée du code de hachage.


Dans ce cas, vous pouvez ajouter un paramètre supplémentaire à l'objet d'entrée qui représente le résultat du calcul de fonction, remplacer l'appel de fonction par un appel à ce paramètre et mettre à jour ce paramètre, malgré la violation de la connexion fonctionnelle:


 if(sha(object.field1)=="a9403...") ... //     ==> if(object.sha_field1 == "a9403...") ... 

Un paramètre supplémentaire permet l'exécution de code à l'intérieur de la branche, mais, évidemment, cela peut conduire à des résultats réellement incorrects. Autrement dit, le programme de test produira des résultats qui ne peuvent jamais être observés dans la réalité. Néanmoins, la vérification d'une partie du code qui nous est autrement inaccessible est toujours utile et peut être considérée comme une forme de test unitaire. Après tout, même pendant les tests unitaires, une sous-fonction est appelée avec des arguments qui ne peuvent jamais être utilisés dans le programme.


Avec de telles manipulations, nous remplaçons (remplaçons) l'objet de test. Cependant, dans un sens, le programme nouvellement construit inclut la logique de l'ancien programme. En effet, si comme valeurs des nouveaux paramètres artificiels nous prenons les résultats du calcul des fonctions que nous avons remplacées par les paramètres, le programme produira les mêmes résultats. Apparemment, tester le programme modifié peut encore être intéressant. Vous devez juste vous rappeler dans quelles conditions le programme modifié se comportera de la même manière que le programme d'origine.


Conditions dépendantes


, . , , , . , . (, , x > 0 , — x <= 1 . , — (-∞, 0] , (0, 1] , (1, +∞) , — .)


, , , true false . , , " " .



, , :


 if(x > 0) if(y > 0) if (y > x) 

( > 0 , — y > x .)
"", , , , , . , " " .
, "", ( y == x + 1 ), , .
"" ( y > x + 1 && y < x + 2 ), , .



, , - , "c " ( Symbolic Execution , ), . ( field1 = field1_initial_value ). , . :


 val a = field1 + 10 //    a = field_initial_value + 10 val b = a * 3 //    b = 3 * field_initial_value + 30 

true false . . . Par exemple


 if(a > 0) A else B //   A ,  field_initial_value + 10 > 0 //   B ,  field_initial_value + 10 <= 0 

, , , , . , (, , ).



. , , . , . , .


, . . , , . , , , . , , , , , ?


Y- ( " " , stackoverflow:What is a Y-combinator? (2- ) , habr: Y- 7 ). , . ( , , .) . , . , "" . Y- " " ( ).


( ). , . , . , . , , , TestCase '. , , ( throw Nothing bottom , ). .


, . . , , . , . , , . , , , , . , . , .



, , , , 100% . , , . Hm. . , , , ? , , - .


:


  1. .
  2. ( ).
  3. , .
  4. , .

, , . -, , , , . -, , , ( , ), , , "" . / , .


Conclusion


" " " ". , , , , . , .


, , , , . -, , ( ), . -, -, . DSL, , . -, , . -, , ( , , ). .


, , . , , - .


Remerciements


@mneychev .

Source: https://habr.com/ru/post/fr422283/


All Articles