Métodos de teste de unidade com saída indeterminada

37

Eu tenho uma classe que visa gerar uma senha aleatória de um comprimento que também é aleatório, mas limitada a estar entre um comprimento mínimo e máximo definido.

Estou construindo testes de unidade e me deparei com um pequeno problema interessante com essa classe. A idéia por trás de um teste de unidade é que ele deve ser repetível. Se você executar o teste cem vezes, ele deverá fornecer os mesmos resultados cem vezes. Se você estiver dependendo de algum recurso que pode ou não estar lá ou que esteja ou não no estado inicial que você espera, deverá zombar do recurso em questão para garantir que seu teste seja sempre sempre repetível.

Mas e nos casos em que o SUT deve gerar uma saída indeterminada?

Se eu fixar o comprimento mínimo e máximo no mesmo valor, posso verificar facilmente se a senha gerada tem o comprimento esperado. Mas se eu especificar um intervalo de comprimentos aceitáveis ​​(digamos 15 - 20 caracteres), agora você terá o problema de poder executar o teste centenas de vezes e obter 100 passes, mas na 101ª execução, poderá recuperar uma sequência de 9 caracteres.

No caso da classe de senha, que é bastante simples em sua essência, ela não deve ser um grande problema. Mas isso me fez pensar no caso geral. Qual é a estratégia geralmente aceita como a melhor a ser adotada ao lidar com SUTs que estão gerando resultados indeterminados por design?

GordonM
fonte
9
Por que os votos próximos? Eu acho que é uma pergunta perfeitamente válida.
Mark Baker
Huh, obrigado pelo comentário. Nem percebi isso, mas agora estou me perguntando a mesma coisa. A única coisa em que pude pensar é que se trata de um caso geral e não de um caso específico, mas eu poderia postar o código-fonte da classe de senha mencionada acima e perguntar "Como faço para testar essa classe?" em vez de "Como faço para testar qualquer classe indeterminada?"
GordonM
1
@ MarkBaker Porque a maioria das perguntas não contidas no programmers.se. É um voto para a migração, não para encerrar a questão.
Ikke

Respostas:

20

A saída "não determinística" deve ter uma maneira de se tornar determinística para fins de teste de unidade. Uma maneira de lidar com a aleatoriedade é permitir a substituição do mecanismo aleatório. Aqui está um exemplo (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);

Você pode fazer uma versão de teste especializada da função que retorne qualquer sequência de números que desejar para garantir que o teste seja totalmente repetível. No programa real, você pode ter uma implementação padrão que pode ser o substituto, se não for substituído.

bobbymcr
fonte
1
Todas as respostas dadas tiveram boas sugestões que eu usei, mas essa é a que eu acho que trata da questão principal para que ela seja aceita.
GordonM
1
Prega praticamente na cabeça. Embora não determinístico, ainda existem limites.
surfasb
21

A senha de saída real pode não ser determinada sempre que o método for executado, mas ainda terá recursos determinados que podem ser testados, como comprimento mínimo, caracteres que caem dentro de um conjunto de caracteres determinado etc.

Você também pode testar se a rotina retorna um resultado determinado a cada vez, propagando seu gerador de senhas com o mesmo valor cada vez.

Mark Baker
fonte
A classe PW mantém uma constante que é essencialmente o conjunto de caracteres de onde a senha deve ser gerada. Subclassificando-a e substituindo a constante por um único caractere, consegui eliminar uma área de não-determinação para fins de teste. Então obrigado.
GordonM
14

Teste contra "o contrato". Quando o método é definido como "gera senhas de 15 a 20 caracteres com az", teste-o desta maneira

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

Além disso, você pode extrair a geração, para que tudo que depende dela possa ser testado usando outra classe de gerador "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
fonte
O regex que você forneceu se mostrou útil, então incluí uma versão aprimorada no meu teste. Obrigado.
GordonM
6

Você tem um Password generatore precisa de uma fonte aleatória.

Como você afirmou na pergunta, a randomproduz resultados não determinísticos, pois é um estado global . Isso significa que ele acessa algo fora do sistema para gerar valores.

Você nunca pode se livrar de algo assim para todas as suas classes, mas pode separar a geração de senha para a criação de valores aleatórios.

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

}

Se você estruturar o código dessa maneira, poderá zombar dos RandomSourcetestes.

Você não poderá testar 100%, RandomSourcemas as sugestões obtidas para testar os valores nesta pergunta podem ser aplicadas a ele (como testes que rand->(1,26);sempre retornam um número de 1 a 26).

edoriano
fonte
Essa é uma ótima resposta.
Nick Hodges
3

No caso de Monte Carlo, um físico de partículas, escrevi "testes de unidade" {*} que invocam a rotina não determinística com uma semente aleatória predefinida e depois executam um número estatístico de vezes e verificam se há violações de restrições (níveis de energia acima da energia de entrada deve estar inacessível, todas as passagens devem selecionar algum nível, etc.) e regressões em relação aos resultados registrados anteriormente.


{*} Esse teste viola o princípio "tornar o teste rápido" para testes de unidade; portanto, você pode se sentir melhor caracterizando-os de alguma outra maneira: testes de aceitação ou testes de regressão, por exemplo. Ainda assim, usei minha estrutura de teste de unidade.

dmckee
fonte
3

Eu tenho que discordar da resposta aceita , por duas razões:

  1. Overfitting
  2. Impraticabilidade

(Observe que pode ser uma boa resposta em muitas circunstâncias, mas não em todas, e talvez não na maioria.)

Então, o que eu quero dizer com isso? Bem, por sobreajuste, quero dizer um problema típico de teste estatístico: a sobreajuste acontece quando você testa um algoritmo estocástico contra um conjunto de dados excessivamente restrito. Se você voltar e refinar seu algoritmo, implicitamente fará com que ele se ajuste muito bem aos dados de treinamento (você acidentalmente ajusta seu algoritmo aos dados de teste), mas todos os outros dados talvez não o sejam (porque você nunca testa contra ele) .

(Aliás, esse é sempre um problema oculto nos testes de unidade. É por isso que bons testes são completos ou, pelo menos, representativos. para uma determinada unidade, e isso geralmente é difícil.)

Se você tornar seus testes determinísticos, tornando o gerador de números aleatórios conectável, sempre teste no mesmo conjunto de dados muito pequeno e (geralmente) não representativo . Isso distorce seus dados e pode causar viés em sua função.

O segundo ponto, impraticabilidade, surge quando você não tem controle sobre a variável estocástica. Isso geralmente não acontece com geradores de números aleatórios (a menos que você precise de uma fonte aleatória "real"), mas pode acontecer quando os estocásticos se infiltram no seu problema de outras maneiras. Por exemplo, ao testar código simultâneo: as condições da corrida são sempre estocásticas, você não pode (facilmente) torná-las determinísticas.

A única maneira de aumentar a confiança nesses casos é testar muito . Espuma, enxágüe, repita. Isso aumenta a confiança, até um certo nível (quando a troca por testes adicionais se torna insignificante).

Konrad Rudolph
fonte
2

Você realmente tem várias responsabilidades aqui. O teste de unidade e, em particular, o TDD são ótimos para destacar esse tipo de coisa.

As responsabilidades são:

1) Gerador de números aleatórios. 2) Formatador de senha.

O formatador de senha usa o gerador de números aleatórios. Injete o gerador no seu formatador através do construtor como uma interface. Agora você pode testar completamente o seu gerador de números aleatórios (teste estatístico) e o formatador injetando um gerador de números aleatórios simulados.

Você não apenas obtém um código melhor, mas também obtém melhores testes.

Rob Smyth
fonte
2

Como os outros já mencionaram, seu teste de unidade desse código removendo a aleatoriedade.

Você também pode querer ter um teste de nível superior que deixe o gerador de números aleatórios no lugar, teste apenas o contrato (tamanho da senha, caracteres permitidos, ...) e, em caso de falha, despeja informações suficientes para permitir que você reproduza o sistema estado na única instância em que o teste aleatório falhou.

Não importa que o teste em si não seja repetível - desde que você possa encontrar a razão pela qual ele falhou desta vez.

Simon Richter
fonte
2

Muitas dificuldades de teste de unidade tornam-se triviais quando você refatora seu código para separar dependências. Um banco de dados, um sistema de arquivos, o usuário ou, no seu caso, uma fonte de aleatoriedade.

Outra maneira de ver é que os testes de unidade devem responder à pergunta "esse código faz o que pretendo fazer?". No seu caso, você não sabe o que pretende que o código faça porque não é determinístico.

Com essa mente, separe sua lógica em partes pequenas, fáceis de entender e facilmente testadas em isolamento. Especificamente, você cria um método distinto (ou classe!) Que usa uma fonte de aleatoriedade como entrada e produz a senha como saída. Esse código é claramente determinístico.

No seu teste de unidade, você alimenta sempre a mesma entrada não muito aleatória. Para fluxos aleatórios muito pequenos, apenas codifique os valores em seu teste. Caso contrário, forneça uma semente constante ao RNG em seu teste.

Em um nível mais alto de teste (chame de "aceitação" ou "integração" ou o que for), você permitirá que o código seja executado com uma verdadeira fonte aleatória.

Jay Bazuzi
fonte
Essa resposta o acertou em cheio: eu realmente tinha duas funções em uma: o gerador de números aleatórios e a função que fazia algo com esse número aleatório. Simplesmente refatorei e agora posso testar facilmente a parte não determinística do código e alimentar parâmetros gerados pela parte aleatória. O bom é que eu posso alimentá-lo (diferentes conjuntos de) parâmetros fixos no meu teste de unidade (estou usando um gerador de números aleatórios da biblioteca padrão, para não fazer o teste de unidade de qualquer maneira).
Neuronet
1

A maioria das respostas acima indica que zombar do gerador de números aleatórios é o caminho a seguir, no entanto, eu estava simplesmente usando a função mt_rand incorporada. Permitir zombaria significaria reescrever a classe para exigir que um gerador de números aleatórios fosse injetado no momento da construção.

Ou assim eu pensei!

Uma das conseqüências da adição de namespaces é que a zombaria incorporada nas funções do PHP passou de incrivelmente difícil para trivialmente simples. Se o SUT estiver em um determinado espaço para nome, tudo o que você precisa fazer é definir sua própria função mt_rand no teste de unidade nesse espaço para nome e ele será usado em vez da função PHP incorporada durante o teste.

Aqui está o conjunto de testes finalizado:

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

Eu pensei em mencionar isso, porque substituir as funções internas do PHP é outro uso para namespaces que simplesmente não me ocorreram. Obrigado a todos pela ajuda.

GordonM
fonte
0

Há um teste adicional que você deve incluir nessa situação, que garante que chamadas repetidas para o gerador de senhas produzam senhas diferentes. Se você precisar de um gerador de senha com segurança para threads, também deve testar chamadas simultâneas usando vários threads.

Isso basicamente garante que você esteja usando sua função aleatória corretamente e não reproduza novamente todas as chamadas.

Torbjørn
fonte
Na verdade, a classe é projetada de forma que a senha seja gerada na primeira chamada para getPassword () e depois trava, para que ela sempre retorne a mesma senha durante a vida útil do objeto. Meu conjunto de testes já verifica se várias chamadas para getPassword () na mesma instância de senha sempre retornam a mesma string de senha. Quanto a thread-segurança, isso não é realmente uma preocupação em PHP :)
GordonM