Teste de unidade em tempo real - ou "como zombar agora"


9

Quando você está trabalhando em um recurso que depende do tempo ... Como você organiza o teste de unidade? Quando seus cenários de testes unitários dependem da maneira como seu programa interpreta "agora", como você os configura?

Segunda edição: após alguns dias lendo sua experiência

Percebo que as técnicas para lidar com essa situação geralmente giram em torno de um desses três princípios:

  • Adicionar (código rígido) uma dependência: adicione uma pequena camada ao longo da função / objeto de tempo e sempre chame sua função de data e hora através dessa camada. Dessa forma, você pode controlar o tempo durante os casos de teste.
  • Use zombarias: seu código permanece exatamente o mesmo. Nos seus testes, você substitui o objeto de tempo por um objeto de tempo falso. Às vezes, a solução envolve modificar o objeto de tempo genuíno fornecido pela sua linguagem de programação.
  • Usar injeção de dependência: construa seu código para que qualquer referência de tempo seja passada como parâmetro. Então, você tem controle dos parâmetros durante os testes.

Técnicas (ou bibliotecas) específicas para um idioma são muito bem-vindas e serão aprimoradas se um trecho de código ilustrativo aparecer. Então, o mais interessante é como princípios semelhantes podem ser aplicados em qualquer plataforma. E sim ... Se eu puder aplicá-lo imediatamente no PHP, melhor do que melhor;)

Vamos começar com um exemplo simples: um aplicativo de reserva básico.

Digamos que temos a API JSON e duas mensagens: uma mensagem de solicitação e uma mensagem de confirmação. Um cenário padrão é assim:

  1. Você faz um pedido. Você obtém uma resposta com um token. O recurso necessário para atender a essa solicitação é bloqueado pelo sistema por 5 minutos.
  2. Você confirma uma solicitação, identificada pelo token. Se o token foi emitido em 5 minutos, ele será aceito (o recurso ainda estará disponível). Se tiverem passado mais de 5 minutos, é necessário fazer uma nova solicitação (o recurso foi liberado. Você precisa verificar sua disponibilidade novamente).

Aqui está o cenário de teste correspondente:

  1. Eu faço um pedido. Confirmo (imediatamente) com o token que recebi. Minha confirmação é aceita.

  2. Eu faço um pedido. Eu espero 3 minutos. Confirmo com o token que recebi. Minha confirmação é aceita.

  3. Eu faço um pedido. Eu espero 6 minutos. Confirmo com o token que recebi. Minha confirmação foi rejeitada.

Como podemos programar esses testes de unidade? Que arquitetura devemos usar para que essas funcionalidades permaneçam testáveis?

Editar Nota: Quando disparamos uma solicitação, o tempo é armazenado em um banco de dados em um formato que perde qualquer informação sobre milissegundos.

EXTRA - mas talvez um pouco detalhado: Aqui estão os detalhes sobre o que eu descobri fazendo meu "dever de casa":

  1. Criei meu recurso com uma dependência de uma função de tempo própria. Minha função VirtualDateTime possui um método estático get_time () que eu chamo onde costumava chamar new DateTime (). Essa função de tempo permite simular e controlar que horas são "agora", para que eu possa criar testes como: "Defina agora para 21 de janeiro de 2014 às 16h15. Faça uma solicitação. Siga em frente 3 minutos. Faça a confirmação.". Isso funciona bem, à custa da dependência, e "não é um código tão bonito".

  2. Uma solução um pouco mais integrada seria criar minha própria função myDateTime que estenda o DateTime com funções adicionais de "tempo virtual" (o mais importante, defina agora o que eu quero). Isso está tornando o código da primeira solução um pouco mais elegante (uso do novo myDateTime em vez do novo DateTime), mas acaba sendo muito semelhante: eu tenho que criar meu recurso usando minha própria classe, criando assim uma dependência.

  3. Já pensei em invadir a função DateTime, para fazê-la funcionar com minha dependência quando eu precisar. Existe alguma maneira simples e clara de substituir uma classe por outra? (Acho que recebi uma resposta para isso: veja espaço para nome abaixo).

  4. Em um ambiente PHP, eu li "Runkit" pode me permitir fazer isso (hackear a função DateTime) dinamicamente. O que é legal é que eu seria capaz de verificar se estou executando no ambiente de teste antes de modificar qualquer coisa sobre o DateTime e deixá-lo intocado na produção [1] . Isso parece muito mais seguro e limpo do que qualquer hack manual do DateTime.

  5. Injeção de dependência de uma função de relógio em cada classe que usa o tempo [2] . Isso não é um exagero? Vejo que em alguns ambientes isso se torna muito útil [5] . Nesse caso, eu não gosto muito disso.

  6. Remova a dependência do tempo em todas as funções [2] . Isso é sempre viável? (Veja mais exemplos abaixo)

  7. Usando namespaces [2] [3] ? Isso parece muito bom. Eu poderia zombar de DateTime com minha própria função DateTime que estende \ DateTime ... Use DateTime :: setNow ("2014-01-21 16:15:00") e DateTime :: wait ("+ 3 minutos"). Isso cobre praticamente o que eu preciso. E se usarmos a função time ()? Ou outras funções de tempo? Eu ainda tenho que evitar o uso deles no meu código original. Ou eu precisaria garantir que qualquer função de hora do PHP que eu uso no meu código seja substituída nos meus testes ... Existe alguma biblioteca disponível que faça exatamente isso?

  8. Eu tenho procurado uma maneira de alterar a hora do sistema apenas para um thread. Parece que não há [4] . É uma pena: uma função PHP "simples" para "Definir hora para 21 de janeiro de 2014 às 16h15 para este encadeamento" seria um ótimo recurso para esse tipo de teste.

  9. Altere a data e hora do sistema para o teste, usando exec (). Isso pode funcionar se você não tiver medo de mexer com outras coisas no servidor. E você precisa adiar o tempo do sistema depois de executar o teste. Pode fazer o truque em algumas situações, mas parece bastante "hacky".

Isso parece um problema muito padrão para mim. No entanto, ainda sinto falta de uma maneira simples e genérica de lidar com isso. Talvez eu tenha perdido alguma coisa? Por favor, compartilhe sua experiência!

NOTA: Aqui estão algumas outras situações em que podemos ter necessidades de teste semelhantes.

  • Qualquer recurso que funcione com algum tipo de tempo limite (por exemplo, jogo de xadrez?)

  • processando uma fila que aciona eventos em um determinado momento (no cenário acima, podemos continuar assim: um dia antes do início da reserva, desejo enviar um email ao usuário com todos os detalhes. Cenário de teste ...)

  • Você deseja configurar um ambiente de teste com dados coletados no passado - você gostaria de vê-lo como se fosse agora. [1]

1 /programming/3271735/simulate-different-server-datetimes-in-php

2 /programming/4221480/how-to-change-current-time-for-unit-testing-date-functions-in-php

3 http://www.schmengler-se.de/en/2011/03/php-mocking-built-in-functions-like-time-in-unit-tests/

4 /programming/3923848/change-todays-date-and-time-in-php

5 Código de limite de tempo para teste de unidade


1
Em python (e imagino outras linguagens dinâmicas têm coisas semelhantes) há esta biblioteca legal: github.com/spulec/freezegun
Ismail Badawi

2
Uma vez que você percebe que agora é apenas um ponto no tempo, isso se torna muito mais fácil.
Wyatt Barnett

Em muitos casos, é mais simples passar um valor DateTime nowpara o código em vez de um relógio.
CodesInChaos

@IsmailBadawi - isso parece muito legal mesmo. Portanto, dessa forma, você não precisa alterar a maneira como codifica - você acabou de configurar seus testes usando a biblioteca, certo? Como seria o nosso exemplo de reserva?
Mika8 /

@WyattBarnett Claro. O momento em que perco o controle desse momento é quando eu uso o novo DateTime () sem parâmetro. Você considera um mau hábito usá-lo em uma classe?
Mika8 /

Respostas:


8

Vou usar o pseudocódigo semelhante ao Python, baseado nos seus casos de teste, para (espero) ajudar a explicar um pouco essa resposta, em vez de apenas expor. Mas, resumindo: esta resposta é basicamente apenas um exemplo de injeção de dependência de nível de função única / inversão de dependência , em vez de aplicar o princípio a uma classe / objeto inteiro.

No momento, parece que seus testes estão estruturados da seguinte maneira:

def test_3_minute_confirm():
    req = make_request()
    sleep(3 * 60)  # Baaaad
    assertTrue(confirm_request(req))

Com implementações, algo como:

def make_request():
    return { 'request_time': datetime.now(), }

def confirm_request(req):
    return req['request_time'] >= (datetime.now() - timedelta(minutes=5))

Em vez disso, tente esquecer o "agora" e informe a ambas as funções que horas usar. Se a maior parte do tempo for "agora", você pode fazer uma das duas coisas principais para manter o código de chamada simples:

  • Faça da hora um argumento opcional que padrão seja "agora". Em Python (e PHP, agora que reli a pergunta), isso é simples - o "tempo" padrão para None( nullem PHP) e defina-o como "agora" se nenhum valor for fornecido.
  • Em outras linguagens sem argumentos padrão (ou no PHP, se ficar pesado), pode ser mais fácil extrair as entranhas das funções em um auxiliar que é testado.

Por exemplo, as duas funções acima reescritas na segunda versão podem ser assim:

def make_request():
    return make_request_at(datetime.now())

def make_request_at(when):
    return { 'request_time': when, }

def confirm_request(req):
    return confirm_request_at(req, datetime.now())

def confirm_request_at(req, check_time):
    return req['request_time'] >= (check_time - timedelta(minutes=5))

Dessa forma, partes do código existentes não precisam ser modificadas para passar "agora", enquanto as partes que você realmente deseja testar podem ser testadas com muito mais facilidade:

def test_3_minute_confirm():
    current_time = datetime.now()
    later_time = current_time + timedelta(minutes=3)

    req = make_request_at(current_time)
    assertTrue(confirm_request_at(req, later_time))

Ele também cria flexibilidade adicional no futuro, portanto, menos mudanças serão necessárias se as partes importantes precisarem ser reutilizadas. Para dois exemplos que vêm à mente: Uma fila em que as solicitações talvez precisem ser criadas momentos tarde demais, mas ainda usem a hora correta, ou as confirmações precisam acontecer com as solicitações anteriores, como parte das verificações de integridade dos dados.

Tecnicamente, pensar no futuro dessa maneira pode violar o YAGNI , mas na verdade não estamos construindo para esses casos teóricos - essa mudança seria apenas para testes mais fáceis, que apenas suportam esses casos teóricos.


Existe algum motivo para você recomendar uma dessas soluções em detrimento de outra?

Pessoalmente, tento evitar qualquer tipo de zombaria, tanto quanto possível, e prefiro funções puras como acima. Dessa forma, o código sendo testado é idêntico ao código sendo executado fora dos testes, em vez de substituir partes importantes por zombarias.

Tivemos uma ou duas regressões que ninguém notou por um bom mês porque essa parte específica do código não era frequentemente testada no desenvolvimento (não estávamos trabalhando ativamente nele), foi lançada um pouco quebrada (portanto, não houve nenhum erro relatórios) e as zombarias usadas escondiam um bug na interação entre as funções auxiliares. Algumas pequenas modificações para que as simulações não fossem necessárias e muito mais poderia ser facilmente testado, incluindo a correção dos testes em torno dessa regressão.

Altere a data e hora do sistema para o teste, usando exec (). Isso pode funcionar se você não tiver medo de mexer com outras coisas no servidor.

Esse é o que deve ser evitado a todo custo para testes de unidade. Não há realmente nenhuma maneira de saber que tipos de inconsistências ou condições de corrida podem ser introduzidas para causar falhas intermitentes (para aplicativos não relacionados ou colegas de trabalho na mesma caixa de desenvolvimento), e um teste com falha / erro pode não atrasar o relógio corretamente.


Muito bom caso para a injeção de dependência. Obrigado pelos detalhes, realmente ajuda a ter uma imagem completa.
mika

8

Pelo meu dinheiro, manter uma dependência em alguma classe de relógio é a maneira mais clara de lidar com testes dependentes do tempo. No PHP, pode ser tão simples quanto uma classe com uma única função nowque retorna a hora atual usando time()ou new DateTime().

A injeção dessa dependência permite que você manipule o tempo em seus testes e use o tempo real na produção, sem precisar escrever muito código extra.


É isso que está sendo executado atualmente na minha caixa :) E está indo muito bem. Obviamente, você precisa escrever um código pensando em sua classe extra, esse é o custo.
Mika

1
@Mika: A sobrecarga é mínima, por isso não é tão ruim. Eu direi, no entanto, que minha real preferência é passar datas \ times \ datetime como parâmetros para um método.
Andy Hunt

3

A primeira coisa que você precisa fazer é remover os números mágicos do seu código. Nesse caso, seu número mágico "5 minutos". Você não apenas precisará inevitavelmente alterá-las mais tarde, quando algum cliente exigir, como também torna os testes de unidade muito melhores. Quem vai esperar 5 minutos para que o teste seja executado?

Segundo, se você ainda não o fez, use o UTC para os registros de data e hora reais. Isso evitará problemas com o horário de verão e a maioria dos outros soluços do relógio.

No teste de unidade, mude a duração configurada para algo como 500ms e faça seu teste normal: funciona de uma maneira antes do tempo limite e outra depois.

~ 1s ainda é bastante lento para testes de unidade, e você provavelmente precisará de algum tipo de limite para levar em consideração o desempenho da máquina e as mudanças de relógio devido ao NTP (ou outro desvio). Mas, na minha experiência, esta é a melhor maneira prática de testar o tempo de teste unitário na maioria dos sistemas. Simples, rápido, confiável. A maioria das outras abordagens (como você descobriu) exige muito trabalho e / ou é muito propensa a erros.


Esta é uma abordagem interessante. "5" representa aqui a título de exemplo, eu sempre me certificar de que pode mudá-lo em um arquivo de configuração :)
mika

@mika - Pessoalmente, tenho a tendência de odiar arquivos. Eles não se saem bem com testes de unidade (que devem ser altamente simultâneos).
Telastyn

I tendem a usar yaml bastante extensa ...
mika

3

Quando eu aprendi TDD, eu estava trabalhando em um ambiente . A maneira como aprendi a fazer isso é ter um ambiente de IoC completo, que também inclui um ITimeServiceresponsável por todos os serviços de tempo e que pode ser facilmente ridicularizado nos testes.

Atualmente estou trabalhando em , e o tempo de stub é muito mais fácil - você simplesmente stub time :

describe 'some time based module' do

  before(:each) do
    @now = Time.now
    allow(Time).to receive(:now) { @now }
  end

  it 'times out after five minutes' do
    subject.do_something

    @now += 5.minutes

    expect { subject.do_another_thing }.to raise TimeoutError
  end
end

1
Ah, mágica de rubi! Apenas adorável ... eu teria que voltar ao meu livro para pegar o código completamente;) Os stubs funcionam muito bem em muitos casos.
Mika

3

Use o Microsoft Fakes - em vez de alterar seu código para se adequar à ferramenta, a ferramenta altera seu código em tempo de execução, injetando um novo objeto Time (que você define em seu teste) no lugar do relógio padrão.

Se você estiver executando uma linguagem dinâmica - o Runkit é exatamente o mesmo tipo de coisa.

Por exemplo, com Fakes:

[TestMethod]
public void MyDateProvider_ShouldReturnDateAsJanFirst1970()
{
    using (Microsoft.QualityTools.Testing.Fakes.ShimsContext.Create())
    {
        // Arrange: set up a shim to our own date object instead of System.DateTime.Now
        System.Fakes.ShimDateTime.NowGet = () => new DateTime(1970, 1, 1);

        //Act: call the method under test
        var curentDate = MyDateTimeProvider.GetTheCurrentDate();

        //Assert: that we have a moustache and a brown suit.
        Assert.IsTrue(curentDate == new DateTime(1970, 1, 1));
    }
}

public class MyDateTimeProvider
{
    public static DateTime GetTheCurrentDate()
    {
        return DateTime.Now;
    }
}

Portanto, quando o método de texto chama, return DateTime.Nowele retornará o tempo que você injetou, e não o tempo atual real.


Fakes, Runkit ... No final, todo idioma tem seu caminho para criar stubs. Você poderia postar um pequeno exemplo para que possamos ter uma idéia de como as coisas funcionam?
Mika8 /

3

Eu explorei a opção namespaces em PHP. Os espaços para nome nos dão a oportunidade de adicionar algum comportamento de teste à função DateTime. Assim, nossos objetos originais permanecem intocados.

Primeiro, configuramos um objeto DateTime falso em nosso espaço para nome, para que possamos, em nossos testes, gerenciar o horário considerado "agora".

Em nossos testes, podemos gerenciar esse tempo por meio das funções estáticas DateTime :: set_now ($ my_time) e DateTime :: modify_now ("add some time").

Primeiro vem um objeto de reserva falso . Este objeto é o que queremos testar - observe o uso do novo DateTime () sem parâmetros em dois lugares. Uma breve visão geral:

<?php

namespace BookingNamespace;
/**
* Simulate expiration process with two public methods:
*      - book will return a token
*      - confirm will take a token, and return
*                       -> true if called within 5 minutes of corresponding book call
*                       -> false if more than 5 minutes have passed
*/
class MyBookingObject
{
    ...

    private function init_token($token){ $this->tokens[$token] = new DateTime(); }

    ...

    private function has_timed_out(DateTime $booking_time)
    {
            $now = new DateTime();
            ....
    }
}

Nosso objeto DateTime substituindo o objeto DateTime principal se parece com isso . Todas as chamadas de função permanecem inalteradas. Nós apenas "enganchamos" o construtor para simular nosso "agora é sempre que eu escolho" o comportamento. Mais importante,

namespace BookingNamespace;

/**
 * extend DateTime functionalities with static functions so that we are able 
 * to define what should be considered to be "now"
 *   - set_now allows to define "now"
 *   - modify_now applies the modify function on what is defined as "now"
 *   - reset is for going back to original DateTime behaviour
 *  
 * @author mika
 */
 class DateTime extends \DateTime
 {
     private static $fake_time=null;

     ...

     public function __construct($time = "now", DateTimeZone $timezone = null)
     {
          if($this->is_virtual()&&$this->is_relative_date($time))
          {
           ....
          }
          else
            parent::__construct($time, $timezone);
     }
 }

Nossos testes são então muito diretos. É assim com a unidade php (versão completa aqui ):

....
require_once "DateTime.class.php"; // Override DateTime in current namespace
....

class MyBookingObjectTest extends \PHPUnit_Framework_TestCase
{
    ....

    /**
     * Create test subject before test
     */
    protected function setUp()
    {
        ....
        DateTime::set_now("2014-04-02 16:15");
        ....
    }

   ...

    public function testBookAndConfirmThreeMinutesLater()
    {
        $token = $this->booking_object->book();
        DateTime::modify_now("+3 minutes");
        $this->assertTrue($this->booking_object->confirm($token));
    }

    ...
}

Essa estrutura pode ser facilmente reproduzida onde quer que eu precise gerenciar o "agora" manualmente: só preciso incluir o novo objeto DateTime e usar seus métodos estáticos nos meus testes.


Definir uma instância DateTime zombada para testar a classe de destino deve ser um plano melhor, mas precisa de uma pequena alteração na classe de destino: coloque new Datetimeum método separado para ser zombeteiro.
Fwolf
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.