Métodos de prueba unitaria con salida indeterminada

37

Tengo una clase que está destinada a generar una contraseña aleatoria de una longitud que también es aleatoria, pero limitada a estar entre una longitud mínima y máxima definida.

Estoy construyendo pruebas unitarias, y me encontré con un pequeño inconveniente interesante con esta clase. Toda la idea detrás de una prueba unitaria es que debe ser repetible. Si ejecuta la prueba cien veces, debería dar los mismos resultados cien veces. Si depende de algún recurso que puede o no estar allí o puede estar o no en el estado inicial que espera, entonces debe burlarse del recurso en cuestión para asegurarse de que su prueba siempre sea repetible.

Pero ¿qué pasa en los casos en que se supone que el SUT genera una salida indeterminada?

Si arreglo la longitud mínima y máxima al mismo valor, entonces puedo verificar fácilmente que la contraseña generada tenga la longitud esperada. Pero si especifico un rango de longitudes aceptables (digamos 15-20 caracteres), ahora tiene el problema de que podría ejecutar la prueba cientos de veces y obtener 100 pases, pero en la ejecución 101 podría recuperar una cadena de 9 caracteres.

En el caso de la clase de contraseña, que es bastante simple en su núcleo, no debería ser un gran problema. Pero me hizo pensar en el caso general. ¿Cuál es la estrategia que generalmente se acepta como la mejor opción cuando se trata de SUT que generan resultados indeterminados por diseño?

GordonM
fuente
99
¿Por qué los votos cerrados? Creo que es una pregunta perfectamente válida.
Mark Baker, el
Eh, gracias por el comentario. Ni siquiera me di cuenta de eso, pero ahora me pregunto lo mismo. Lo único que se me ocurre es que se trata de un caso general en lugar de uno específico, pero podría publicar la fuente de la clase de contraseña mencionada anteriormente y preguntar "¿Cómo pruebo esa clase?" en lugar de "¿Cómo pruebo cualquier clase indeterminada?"
GordonM el
1
@ MarkBaker Porque la mayoría de las preguntas de prueba de unidad están en programmers.se. Es un voto a favor de la migración, no para cerrar la pregunta.
Ikke

Respuestas:

20

La salida "no determinista" debe tener una forma de volverse determinista a los efectos de las pruebas unitarias. Una forma de manejar la aleatoriedad es permitir el reemplazo del motor aleatorio. Aquí hay un ejemplo (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);

Puede hacer una versión de prueba especializada de la función que devuelva cualquier secuencia de números que desee para asegurarse de que la prueba sea completamente repetible. En el programa real, puede tener una implementación predeterminada que podría ser la alternativa si no se anula.

bobbymcr
fuente
1
Todas las respuestas dadas tenían buenas sugerencias que utilicé, pero creo que esta es la cuestión central para que se acepte.
GordonM
1
Casi lo clava en la cabeza. Si bien no determinista, todavía hay límites.
surfasb
21

Es posible que la contraseña de salida real no se determine cada vez que se ejecuta el método, pero aún tendrá características determinadas que se pueden probar, como la longitud mínima, los caracteres que se encuentran dentro de un conjunto de caracteres determinado, etc.

También puede probar que la rutina devuelve un resultado determinado cada vez al sembrar su generador de contraseñas con el mismo valor cada vez.

Mark Baker
fuente
La clase PW mantiene una constante que es esencialmente el conjunto de caracteres a partir del cual se debe generar la contraseña. Al subclasificarlo y anular la constante con un solo carácter, logré eliminar un área de no determinación para fines de prueba. Así que gracias.
GordonM
14

Prueba contra "el contrato". Cuando los métodos se definen como "genera contraseñas de 15 a 20 caracteres de longitud con az", pruébelo de esta manera

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

Además, puede extraer la generación, por lo que todo lo que se base en él puede probarse utilizando otra clase de generador "estático"

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
fuente
La expresión regular que diste resultó útil, así que incluí una versión ajustada en mi prueba. Gracias.
GordonM
6

Tienes un Password generatory necesitas una fuente aleatoria.

Como usted indicó en la pregunta, a randomhace una salida no determinista, ya que es un estado global . Lo que significa que accede a algo fuera del sistema para generar valores.

Nunca puedes deshacerte de algo así para todos sus clases, pero puede separar la generación de contraseña para la creación de valores aleatorios.

<?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 estructura el código de esta manera, puede burlarse del RandomSource para sus pruebas.

No podrá probar al 100%, RandomSourcepero las sugerencias que recibió para probar los valores en esta pregunta se pueden aplicar a ella (como la prueba que rand->(1,26);siempre devuelve un número del 1 al 26.

edoriano
fuente
Esa es una gran respuesta.
Nick Hodges
3

En el caso de una física de partículas Monte Carlo, he escrito "pruebas unitarias" {*} que invocan la rutina no determinista con una semilla aleatoria preestablecida , y luego ejecutan un número estadístico de veces y comprueban las violaciones de las restricciones (niveles de energía por encima de la energía de entrada debe ser inaccesible, todos los pases deben seleccionar algún nivel, etc.) y las regresiones contra los resultados previamente registrados.


{*} Dicha prueba viola el principio de "hacer que la prueba sea rápida" para las pruebas unitarias, por lo que podría sentirse mejor caracterizándolas de alguna otra manera: pruebas de aceptación o pruebas de regresión, por ejemplo. Aún así, utilicé mi marco de prueba de unidad.

dmckee
fuente
3

Tengo que estar en desacuerdo con la respuesta aceptada , por dos razones:

  1. Sobreajuste
  2. Impracticabilidad

(Tenga en cuenta que puede ser una buena respuesta en muchas circunstancias, pero no en todas, y tal vez no en la mayoría).

Entonces, ¿qué quiero decir con eso? Bueno, por sobreajuste me refiero a un problema típico de las pruebas estadísticas: el sobreajuste ocurre cuando se prueba un algoritmo estocástico contra un conjunto de datos demasiado restringido. Si luego regresa y refina su algoritmo, implícitamente hará que se ajuste muy bien a los datos de entrenamiento (accidentalmente se ajusta su algoritmo a los datos de la prueba), pero todos los demás datos tal vez no en absoluto (porque nunca prueba contra ellos) .

(Incidentalmente, este siempre es un problema que acecha con las pruebas unitarias. Es por eso que las buenas pruebas están completas , o al menos son representativas para una unidad dada, y esto es difícil en general.)

Si hace que sus pruebas sean deterministas haciendo que el generador de números aleatorios sea conectable, siempre realiza la prueba con el mismo conjunto de datos muy pequeño y (generalmente) no representativo . Esto sesga sus datos y puede generar sesgos en su función.

El segundo punto, la impracticabilidad, surge cuando no tienes ningún control sobre la variable estocástica. Esto no suele suceder con los generadores de números aleatorios (a menos que necesite una fuente "real" de aleatorias), pero puede ocurrir cuando los estocásticos se escabullen de su problema de otras maneras. Por ejemplo, cuando se prueba el código concurrente: las condiciones de carrera siempre son estocásticas, no puede (fácilmente) hacerlas deterministas.

La única forma de aumentar la confianza en esos casos es probar mucho . Enjabonar, enjuagar, repetir. Esto aumenta la confianza, hasta cierto nivel (en ese momento, la compensación por pruebas adicionales se vuelve insignificante).

Konrad Rudolph
fuente
2

Realmente tienes múltiples responsabilidades aquí. Las pruebas unitarias y, en particular, el TDD son excelentes para resaltar este tipo de cosas.

Las responsabilidades son:

1) Generador de números aleatorios. 2) Formateador de contraseña.

El formateador de contraseña utiliza el generador de números aleatorios. Inyecte el generador en su formateador a través de su constructor como una interfaz. Ahora puede probar completamente su generador de números aleatorios (prueba estadística) y puede probar el formateador inyectando un generador de números aleatorios simulados.

No solo obtienes un mejor código, obtienes mejores pruebas.

Rob Smyth
fuente
2

Como los otros ya han mencionado, usted prueba este código eliminando la aleatoriedad.

También es posible que desee tener una prueba de nivel superior que deje el generador de números aleatorios en su lugar, pruebe solo el contrato (longitud de la contraseña, caracteres permitidos, ...) y, en caso de falla, arroje suficiente información para permitirle reproducir el sistema estado en la única instancia donde la prueba aleatoria falló.

No importa que la prueba en sí no sea repetible, siempre que pueda encontrar la razón por la que falló esta vez.

Simon Richter
fuente
2

Muchas dificultades de prueba de unidad se vuelven triviales cuando refactoriza su código para cortar dependencias. Una base de datos, un sistema de archivos, el usuario o, en su caso, una fuente de aleatoriedad.

Otra forma de verlo es que se supone que las pruebas unitarias responden a la pregunta "¿este código hace lo que pretendo que haga?". En su caso, no sabe qué piensa hacer el código porque no es determinista.

Con esta mente, separe su lógica en partes pequeñas, fáciles de entender y fácilmente probadas de forma aislada. Específicamente, crea un método distinto (¡o clase!) Que toma una fuente de aleatoriedad como su entrada y produce la contraseña como una salida. Ese código es claramente determinista.

En su prueba unitaria, la alimenta con la misma entrada no aleatoria cada vez. Para transmisiones aleatorias muy pequeñas, simplemente codifique los valores en su prueba. De lo contrario, proporcione una semilla constante al RNG en su prueba.

En un nivel superior de prueba (llámelo "aceptación" o "integración" o lo que sea), dejará que el código se ejecute con una fuente aleatoria verdadera.

Jay Bazuzi
fuente
Esta respuesta lo acertó para mí: realmente tenía dos funciones en una: el generador de números aleatorios y la función que hizo algo con ese número aleatorio. Simplemente refactoré, y ahora puedo probar fácilmente la parte no determinista del código y alimentar los parámetros generados por la parte aleatoria. Lo bueno es que luego puedo alimentarlo (diferentes conjuntos de) parámetros fijos en mi prueba unitaria (estoy usando un generador de números aleatorios de la biblioteca estándar, así que no pruebas unitarias de todos modos).
neuronet
1

La mayoría de las respuestas anteriores indican que burlarse del generador de números aleatorios es el camino a seguir, sin embargo, simplemente estaba usando la función incorporada mt_rand. Permitir la burla habría significado reescribir la clase para requerir que se inyecte un generador de números aleatorios en el momento de la construcción.

¡O eso pensé!

Una de las consecuencias de la adición de espacios de nombres es que la burla integrada en las funciones de PHP ha pasado de ser increíblemente difícil a trivialmente simple. Si el SUT está en un espacio de nombres dado, entonces todo lo que necesita hacer es definir su propia función mt_rand en la prueba de la unidad bajo ese espacio de nombres, y se usará en lugar de la función PHP incorporada durante la duración de la prueba.

Aquí está el conjunto de pruebas finalizadas:

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);
    }
}

Pensé en mencionar esto, porque anular las funciones internas de PHP es otro uso para espacios de nombres que simplemente no se me habían ocurrido. Gracias a todos por la ayuda con esto.

GordonM
fuente
0

Hay una prueba adicional que debe incluir en esta situación, y esa es una para asegurarse de que las llamadas repetidas al generador de contraseñas realmente produzcan contraseñas diferentes. Si necesita un generador de contraseñas seguro para subprocesos, también debe probar las llamadas simultáneas utilizando múltiples subprocesos.

Básicamente, esto garantiza que esté utilizando su función aleatoria correctamente y que no vuelva a sembrar en cada llamada.

Torbjørn
fuente
En realidad, la clase está diseñada de tal manera que la contraseña se genera en la primera llamada a getPassword () y luego se bloquea, por lo que siempre devuelve la misma contraseña durante la vida útil del objeto. Mi conjunto de pruebas ya comprueba que varias llamadas a getPassword () en la misma instancia de contraseña siempre devuelve la misma cadena de contraseña. En cuanto a la seguridad de subprocesos, eso no es realmente una preocupación en PHP :)
GordonM