Méthodes de tests unitaires à rendement indéterminé

37

J'ai une classe destinée à générer un mot de passe aléatoire d'une longueur également aléatoire, mais limité entre une longueur minimale et maximale définie.

Je construis des tests unitaires et j'ai rencontré un petit problème intéressant avec cette classe. L’idée d’un test unitaire est qu’il soit répétable. Si vous exécutez le test cent fois, il devrait donner les mêmes résultats cent fois. Si vous dépendez d'une ressource qui peut ou peut ne pas être là ou peut ne pas être dans l'état initial que vous attendez, alors vous êtes censé vous moquer de la ressource en question pour vous assurer que votre test est vraiment toujours répétable.

Mais qu'en est-il dans les cas où le SUT est censé générer une sortie indéterminée?

Si je fixe la longueur minimale et maximale à la même valeur, je peux facilement vérifier que le mot de passe généré est de la longueur attendue. Mais si je spécifie une plage de longueurs acceptables (par exemple, 15 à 20 caractères), vous avez maintenant le problème suivant: vous pouvez exécuter le test cent fois et obtenir 100 passages, mais à la 101ème exécution, vous pourriez obtenir une chaîne de 9 caractères.

Dans le cas de la classe de mot de passe, qui est assez simple en son cœur, cela ne devrait pas être un gros problème. Mais cela m'a fait penser au cas général. Quelle est la stratégie généralement acceptée comme la meilleure lorsqu’il s’agit de SEU générant une sortie indéterminée par conception?

GordonM
la source
9
Pourquoi les votes serrés? Je pense que c'est une question parfaitement valide.
Mark Baker
Hein, merci pour le commentaire. Je n'ai même pas remarqué cela, mais maintenant je me demande la même chose. La seule chose à laquelle je pouvais penser était qu'il s'agissait d'un cas général plutôt que d'un cas spécifique, mais je pouvais simplement poster le code source de la classe de mot de passe susmentionnée et demander "Comment puis-je tester cette classe?" au lieu de "Comment tester une classe indéterminée?"
GordonM
1
@MarkBaker Parce que la plupart des questions unittesting sont sur programmers.se. C'est un vote pour la migration, pas pour fermer la question.
Ikke

Réponses:

20

Les sorties "non déterministes" devraient pouvoir devenir déterministes aux fins des tests unitaires. Une façon de gérer le caractère aléatoire consiste à permettre le remplacement du moteur aléatoire. Voici un exemple (PHP 5.3+):

function DoSomethingRandom($getRandomIntLessThan)
{
    if ($getRandomIntLessThan(2) == 0)
    {
        // Do action 1
    }
    else
    {
        // Do action 2
    }
}

// For testing purposes, always return 1
$alwaysReturnsOne = function($n) { return 1; };
DoSomethingRandom($alwaysReturnsOne);

Vous pouvez créer une version de test spécialisée de la fonction qui renvoie toute séquence de chiffres que vous souhaitez vous assurer que le test est entièrement répétable. Dans le programme réel, vous pouvez avoir une implémentation par défaut qui pourrait être la solution de secours si elle n’est pas remplacée.

bobbymcr
la source
1
Toutes les réponses données comportaient de bonnes suggestions, mais c’est celle-ci qui, selon moi, résume le problème fondamental, de sorte qu’elle est acceptée.
GordonM
1
Presque tout cloue sur la tête. Bien que non déterministe, il existe encore des limites.
surfasb
21

Le mot de passe de sortie réel peut ne pas être déterminé à chaque fois que la méthode est exécutée, mais il conservera néanmoins des fonctionnalités déterminées pouvant être testées, telles que la longueur minimale, les caractères compris dans un jeu de caractères déterminé, etc.

Vous pouvez également vérifier que la routine renvoie un résultat déterminé à chaque fois en configurant votre générateur de mot de passe avec la même valeur à chaque fois.

Mark Baker
la source
La classe PW conserve une constante qui est essentiellement le pool de caractères à partir duquel le mot de passe doit être généré. En le sous-classant et en remplaçant la constante par un seul caractère, j'ai réussi à éliminer un domaine de non-détermination à des fins de test. Donc merci.
GordonM
14

Test contre "le contrat". Lorsque la méthode est définie comme "génère des mots de passe de 15 à 20 caractères avec un z", testez-le de cette façon

$this->assertTrue ((bool) preg_match('^[a-z]{15,20}$', $password));

De plus, vous pouvez extraire la génération, de sorte que tout ce qui en dépend puisse être testé en utilisant une autre classe de générateur "statique"

class RandomGenerator implements PasswordGenerator {
  public function create() {
    // Create $rndPwd
    return $rndPwd;
  }
}

class StaticGenerator implements PasswordGenerator {
  private $pwd;
  public function __construct ($pwd) { $this->pwd = $pwd; }
  public function create      ()     { return $this->pwd; }
}
KingCrunch
la source
La regex que vous avez donnée s'est avérée utile, alors j'ai inclus une version modifiée dans mon test. Merci.
GordonM
6

Vous avez une Password generatoret vous avez besoin d'une source aléatoire.

Comme vous l'avez dit dans la question, a randomcrée une sortie non déterministe car il s'agit d' un état global . Cela signifie qu’il accède à quelque chose en dehors du système pour générer des valeurs.

Vous ne pouvez jamais vous débarrasser de quelque chose comme ça pour toutes vos classes, mais vous pouvez séparer la génération de mot de passe pour la création de valeurs aléatoires.

<?php
class PasswordGenerator {

    public function __construct(RandomSource $randomSource) {
        $this->randomSource = $randomSource
    }

    public function generatePassword() {
        $password = '';
        for($length = rand(10, 16); $length; $length--) {
            $password .= $this-toChar($this->randomSource->rand(1,26));
        }
    }

}

Si vous structurez le code de cette manière, vous pouvez vous en moquer RandomSourcepour vos tests.

Vous ne pourrez pas le tester à 100%, RandomSourcemais les suggestions que vous avez reçues pour tester les valeurs de cette question pourront lui être appliquées (comme les tests qui rand->(1,26);renvoient toujours un nombre compris entre 1 et 26.

édorien
la source
C'est une excellente réponse.
Nick Hodges
3

Dans le cas d’un Monte Carlo de physique des particules, j’ai écrit des "tests unitaires" {*} qui invoquent la routine non déterministe avec un germe aléatoire prédéfini , puis j’exécute un nombre statistique de fois et vérifie les violations de contraintes (niveaux d’énergie). au-dessus de l'énergie d'entrée doit être inaccessible, tous les passages doivent sélectionner un niveau, etc.) et des régressions par rapport aux résultats précédemment enregistrés.


{*} Un tel test enfreint le principe du "test rapide" pour les tests unitaires, vous pouvez donc vous sentir mieux de les caractériser d'une autre manière: tests d'acceptation ou de régression, par exemple. Pourtant, j'ai utilisé mon framework de tests unitaires.

dmckee
la source
3

Je ne suis pas d'accord avec la réponse acceptée , pour deux raisons:

  1. Surapprentissage
  2. Impraticabilité

(Notez que cela peut être une bonne réponse dans de nombreuses circonstances, mais pas du tout, et peut-être pas du tout.)

Alors qu'est-ce que je veux dire par là? Par surapprentissage, nous entendons un problème typique des tests statistiques: il survient lorsque vous testez un algorithme stochastique par rapport à un ensemble de données trop contraint. Si vous revenez ensuite et affinez votre algorithme, vous l'aurez implicitement bien ajusté aux données d'entraînement (vous aurez accidentellement ajusté votre algorithme aux données de test), mais toutes les autres données ne seront peut-être pas du tout (car vous ne les testez jamais). .

(Incidemment, il s'agit toujours d'un problème persistant dans les tests unitaires. C'est pourquoi de bons tests sont complets , ou du moins représentatifs pour une unité donnée, et c'est difficile en général.)

Si vous faites vos tests déterministes en rendant le générateur de nombres aléatoires enfichable, vous effectuez toujours des tests sur le même ensemble de données très petit et (généralement) non représentatif . Cela déforme vos données et peut entraîner des biais dans votre fonction.

Le deuxième point, impraticabilité, se pose lorsque vous n’avez aucun contrôle sur la variable stochastique. Cela ne se produit généralement pas avec les générateurs de nombres aléatoires (sauf si vous avez besoin d'une "vraie" source de données aléatoires), mais cela peut arriver lorsque les stochastiques se faufilent dans votre problème par d'autres moyens. Par exemple, lorsque vous testez du code concurrent: les conditions de concurrence sont toujours stochastiques, vous ne pouvez pas (facilement) les rendre déterministes.

Le seul moyen de renforcer la confiance dans ces cas est de tester beaucoup . Faire mousser, rincer, répéter. Cela augmente la confiance, jusqu'à un certain niveau (à quel point le compromis pour des tests supplémentaires devient négligeable).

Konrad Rudolph
la source
2

Vous avez en fait de multiples responsabilités ici. Les tests unitaires et en particulier le TDD sont parfaits pour mettre en évidence ce genre de chose.

Les responsabilités sont:

1) Générateur de nombres aléatoires. 2) Formateur de mot de passe.

Le formateur de mot de passe utilise le générateur de nombres aléatoires. Injectez le générateur dans votre formateur via son constructeur en tant qu'interface. Vous pouvez maintenant tester complètement votre générateur de nombre aléatoire (test statistique) et tester le formateur en injectant un générateur de nombre aléatoire simulé.

Non seulement vous obtenez un meilleur code, vous obtenez de meilleurs tests.

Rob Smyth
la source
2

Comme les autres l'ont déjà mentionné, vous devez tester ce code en supprimant le caractère aléatoire.

Vous pouvez également avoir un test de niveau supérieur qui laisse le générateur de nombres aléatoires en place, teste uniquement le contrat (longueur du mot de passe, caractères autorisés, ...) et, en cas d'échec, sauvegarde suffisamment d'informations pour vous permettre de reproduire le système. Etat dans l'un des cas où le test aléatoire a échoué.

Peu importe que le test lui-même ne soit pas reproductible - tant que vous pouvez trouver la raison pour laquelle il a échoué cette fois.

Simon Richter
la source
2

De nombreuses difficultés de tests unitaires deviennent triviales lorsque vous refactorisez votre code pour rompre les dépendances. Une base de données, un système de fichiers, l’utilisateur ou, dans votre cas, une source d’aléatoire.

Une autre façon de voir les choses est que les tests unitaires sont supposés répondre à la question "ce code fait-il ce que je compte faire?". Dans votre cas, vous ne savez pas ce que vous voulez que le code fasse, car il n’est pas déterministe.

Dans cet esprit, séparez votre logique en petites pièces faciles à comprendre, à comprendre et à tester. Plus précisément, vous créez une méthode distincte (ou classe!) Qui prend une source aléatoire en entrée et génère le mot de passe en sortie. Ce code est clairement déterministe.

Dans votre test unitaire, vous lui communiquez la même entrée pas très aléatoire à chaque fois. Pour les très petits flux aléatoires, il suffit de coder en dur les valeurs de votre test. Sinon, fournissez une valeur constante au RNG dans votre test.

À un niveau de test plus élevé (appelez-le "acceptation" ou "intégration" ou autre), vous laisserez le code s'exécuter avec une véritable source aléatoire.

Jay Bazuzi
la source
Cette réponse me le permettait: j'avais vraiment deux fonctions en une: le générateur de nombres aléatoires et la fonction qui faisait quelque chose avec ce nombre aléatoire. J'ai simplement refactoré, et maintenant je peux facilement tester la partie non déterministe du code et lui donner les paramètres générés par la partie aléatoire. La bonne chose est que je peux alors lui donner (différents ensembles de) paramètres fixes dans mon test unitaire (j'utilise un générateur de nombres aléatoires de la bibliothèque standard, donc pas de tests unitaires de toute façon).
Neuronet
1

La plupart des réponses ci-dessus indiquent que la voie à suivre est de se moquer du générateur de nombres aléatoires, mais j’utilisais simplement la fonction intégrée mt_rand. Permettre de se moquer aurait signifié réécrire la classe pour exiger l'injection d'un générateur de nombres aléatoires au moment de la construction.

Ou alors j'ai pensé!

L'une des conséquences de l'ajout d'espaces de noms est que le moquage construit dans les fonctions PHP est devenu incroyablement difficile à trivialement simple. Si le SUT se trouve dans un espace de noms donné, il vous suffit de définir votre propre fonction mt_rand dans le test unitaire sous cet espace de noms. Ce dernier sera utilisé à la place de la fonction PHP intégrée pour la durée du test.

Voici la suite de tests finalisée:

namespace gordian\reefknot\util;

/**
 * The following function will take the place of mt_rand for the duration of 
 * the test.  It always returns the number exactly half way between the min 
 * and the max.
 */
function mt_rand ($min = 42, $max = NULL)
{
    $min    = intval ($min);
    $max    = intval ($max);

    $max    = $max < $min? $min: $max;
    $ret    = round (($max - $min) / 2) + $min;

    //fwrite (STDOUT, PHP_EOL . PHP_EOL . $ret . PHP_EOL . PHP_EOL);
    return ($ret);
}

/**
 * Override the password character pool for the test 
 */
class PasswordSubclass extends Password
{
    const CHARLIST  = 'AAAAAAAAAA';
}

/**
 * Test class for Password.
 * Generated by PHPUnit on 2011-12-17 at 18:10:33.
 */
class PasswordTest extends \PHPUnit_Framework_TestCase
{

    /**
     * @var gordian\reefknot\util\Password
     */
    protected $object;

    const PWMIN = 15;
    const PWMAX = 20;

    /**
     * Sets up the fixture, for example, opens a network connection.
     * This method is called before a test is executed.
     */
    protected function setUp ()
    {
    }

    /**
     * Tears down the fixture, for example, closes a network connection.
     * This method is called after a test is executed.
     */
    protected function tearDown ()
    {

    }

    public function testGetPassword ()
    {
        $this -> object = new PasswordSubclass (self::PWMIN, self::PWMAX);
        $pw = $this -> object -> getPassword ();
        $this -> assertTrue ((bool) preg_match ('/^A{' . self::PWMIN . ',' . self::PWMAX . '}$/', $pw));
        $this -> assertTrue (strlen ($pw) >= self::PWMIN);
        $this -> assertTrue (strlen ($pw) <= self::PWMAX);
        $this -> assertTrue ($pw === $this -> object -> getPassword ());
    }

    public function testGetPasswordFixedLen ()
    {
        $this -> object = new PasswordSubclass (self::PWMIN, self::PWMIN);
        $pw = $this -> object -> getPassword ();
        $this -> assertTrue ($pw === 'AAAAAAAAAAAAAAA');
        $this -> assertTrue ($pw === $this -> object -> getPassword ());
    }

    public function testGetPasswordFixedLen2 ()
    {
        $this -> object = new PasswordSubclass (self::PWMAX, self::PWMAX);
        $pw = $this -> object -> getPassword ();
        $this -> assertTrue ($pw === 'AAAAAAAAAAAAAAAAAAAA');
        $this -> assertTrue ($pw === $this -> object -> getPassword ());
    }

    public function testInvalidLenThrowsException ()
    {
        $exception  = NULL;
        try
        {
            $this -> object = new PasswordSubclass (self::PWMAX, self::PWMIN);
        }
        catch (\Exception $e)
        {
            $exception  = $e;
        }
        $this -> assertTrue ($exception instanceof \InvalidArgumentException);
    }
}

Je pensais en parler, car redéfinir les fonctions internes de PHP est une autre utilisation d’espaces de noms qui ne m’était tout simplement pas venu à l’esprit. Merci à tous pour l'aide avec cela.

GordonM
la source
0

Il convient d'inclure un test supplémentaire dans cette situation, qui permet de s'assurer que les appels répétés au générateur de mot de passe génèrent des mots de passe différents. Si vous avez besoin d'un générateur de mot de passe thread-safe, vous devez également tester les appels simultanés à l'aide de plusieurs threads.

Cela garantit essentiellement que vous utilisez correctement votre fonction aléatoire et que vous ne réintroduisez pas à chaque appel.

Torbjørn
la source
En fait, la classe est conçue pour que le mot de passe soit généré lors du premier appel à getPassword (), puis se verrouille, de sorte qu'il renvoie toujours le même mot de passe pour la durée de vie de l'objet. Ma suite de tests vérifie déjà que plusieurs appels à getPassword () sur la même instance de mot de passe retournent toujours la même chaîne de mot de passe. En ce qui concerne la sécurité des threads, ce n'est pas vraiment un problème en PHP :)
GordonM