Testes de unidades locais para Java 8

Os testes de unidades permitem-lhe verificar a qualidade do código depois de o escrever, mas também pode usar os testes de unidades para melhorar o processo de desenvolvimento à medida que avança. Em vez de escrever testes depois de terminar o desenvolvimento da aplicação, considere escrever os testes à medida que avança. Isto ajuda a criar unidades de código pequenas, fáceis de manter e reutilizáveis. Também lhe permite testar o código de forma exaustiva e rápida.

Quando faz testes unitários locais, executa testes que permanecem no seu próprio ambiente de desenvolvimento sem envolver componentes remotos. O App Engine fornece utilitários de teste que usam implementações locais do armazenamento de dados e outros serviços do App Engine. Isto significa que pode usar o seu código destes serviços localmente, sem implementar o seu código no App Engine, usando stubs de serviço.

Um stub de serviço é um método que simula o comportamento do serviço. Por exemplo, o stub do serviço de armazenamento de dados apresentado em Escrever testes de armazenamento de dados e Memcache permite-lhe testar o seu código de armazenamento de dados sem fazer pedidos ao armazenamento de dados real. Qualquer entidade armazenada durante um teste de unidade da base de dados é mantida na memória, não na base de dados, e é eliminada após a execução do teste. Pode executar testes pequenos e rápidos sem qualquer dependência do próprio arquivo de dados.

Este documento fornece algumas informações sobre a configuração de uma framework de testes e, em seguida, descreve como escrever testes unitários em vários serviços locais do App Engine.

Configurar uma framework de testes

Embora as utilidades de teste do SDK não estejam associadas a nenhuma framework específica, este guia usa o JUnit para os exemplos, para que tenha algo concreto e completo a partir do qual trabalhar. Antes de começar a escrever testes, tem de adicionar o JAR do JUnit 4 adequado ao caminho de classe de teste. Quando terminar, pode escrever um teste JUnit muito simples.


import static org.junit.Assert.assertEquals;

import org.junit.Test;

public class MyFirstTest {
  @Test
  public void testAddition() {
    assertEquals(4, 2 + 2);
  }
}

Se estiver a executar o Eclipse, selecione o ficheiro de origem do teste a executar. Selecione o menu Executar > Executar como > Teste JUnit. Os resultados do teste aparecem na janela da consola.

Apresentamos as utilidades de teste do Java 8

MyFirstTest demonstra a configuração de teste mais simples possível e, para testes que não tenham dependência das APIs App Engine ou das implementações de serviços locais, pode não precisar de mais nada. No entanto, se os seus testes ou código em teste tiverem estas dependências, adicione os seguintes ficheiros JAR ao caminho de classe de teste:

  • ${SDK_ROOT}/lib/impl/appengine-api.jar
  • ${SDK_ROOT}/lib/impl/appengine-api-stubs.jar
  • ${SDK_ROOT}/lib/appengine-tools-api.jar

Estes JARs disponibilizam as APIs de tempo de execução e as implementações locais dessas APIs aos seus testes.

Os serviços do App Engine esperam várias coisas do respetivo ambiente de execução e a configuração destas envolve uma quantidade razoável de código repetitivo. Em vez de a configurar manualmente, pode usar as utilidades no pacote com.google.appengine.tools.development.testing. Para usar este pacote, adicione o seguinte ficheiro JAR ao caminho de classe de teste:

  • ${SDK_ROOT}/lib/testing/appengine-testing.jar

Dedique um minuto a explorar a javadoc do pacote com.google.appengine.tools.development.testing. A classe mais importante neste pacote é LocalServiceTestHelper, que processa toda a configuração do ambiente necessária e oferece um ponto de configuração de nível superior para todos os serviços locais aos quais pode querer aceder nos seus testes.

Para escrever um teste que aceda a um serviço local específico:

  • Crie uma instância de LocalServiceTestHelper com uma implementação de LocalServiceTestConfig para esse serviço local específico.
  • Chame setUp() na sua instância LocalServiceTestHelper antes de cada teste e tearDown() após cada teste.

Escrever testes de Datastore e memcache

O exemplo seguinte testa a utilização do serviço datastore.


import static com.google.appengine.api.datastore.FetchOptions.Builder.withLimit;
import static org.junit.Assert.assertEquals;

import com.google.appengine.api.datastore.DatastoreService;
import com.google.appengine.api.datastore.DatastoreServiceFactory;
import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.Query;
import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig;
import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

public class LocalDatastoreTest {

  private final LocalServiceTestHelper helper =
      new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig());

  @Before
  public void setUp() {
    helper.setUp();
  }

  @After
  public void tearDown() {
    helper.tearDown();
  }

  // Run this test twice to prove we're not leaking any state across tests.
  private void doTest() {
    DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
    assertEquals(0, ds.prepare(new Query("yam")).countEntities(withLimit(10)));
    ds.put(new Entity("yam"));
    ds.put(new Entity("yam"));
    assertEquals(2, ds.prepare(new Query("yam")).countEntities(withLimit(10)));
  }

  @Test
  public void testInsert1() {
    doTest();
  }

  @Test
  public void testInsert2() {
    doTest();
  }
}

Neste exemplo, LocalServiceTestHelper configura e desativa as partes do ambiente de execução que são comuns a todos os serviços locais, e LocalDatastoreServiceTestConfig configura e desativa as partes do ambiente de execução que são específicas do serviço de armazenamento de dados local. Se ler o javadoc, vai saber que isto envolve a configuração do serviço de armazenamento de dados local para manter todos os dados na memória (em vez de os transferir para o disco a intervalos regulares) e eliminar todos os dados na memória no final de cada teste. Este é apenas o comportamento predefinido de um teste de banco de dados e, se não for o que pretende, pode alterá-lo.

Alterar o exemplo para aceder à cache de memória em vez do armazenamento de dados

Para criar um teste que aceda ao serviço de memcache local, pode usar o código apresentado acima com algumas pequenas alterações.

Em vez de importar classes relacionadas com o armazenamento de dados, importe as relacionadas com a cache de memória. Ainda tem de importar LocalServiceTestHelper.

Altere o nome da classe que está a criar e altere a instância de LocalServiceTestHelper, para que sejam específicas da memcache.

public class LocalMemcacheTest {

  private final LocalServiceTestHelper helper =
      new LocalServiceTestHelper(new LocalMemcacheServiceTestConfig());

Por último, altere a forma como executa o teste para que seja relevante para a memcache.

private void doTest() {
  MemcacheService ms = MemcacheServiceFactory.getMemcacheService();
  assertFalse(ms.contains("yar"));
  ms.put("yar", "foo");
  assertTrue(ms.contains("yar"));
}

Tal como no exemplo do arquivo de dados, o LocalServiceTestHelper e o LocalServiceTestConfig específico do serviço (neste caso, LocalMemcacheServiceTestConfig) gerem o ambiente de execução.

Escrever testes do Cloud Datastore

Se a sua app usar o Cloud Datastore, é recomendável escrever testes que validem o comportamento da sua aplicação perante a consistência final. LocalDatastoreServiceTestConfig expõe opções que facilitam esta tarefa:


import static com.google.appengine.api.datastore.FetchOptions.Builder.withLimit;
import static org.junit.Assert.assertEquals;

import com.google.appengine.api.datastore.DatastoreService;
import com.google.appengine.api.datastore.DatastoreServiceFactory;
import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.Key;
import com.google.appengine.api.datastore.KeyFactory;
import com.google.appengine.api.datastore.Query;
import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig;
import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

public class LocalHighRepDatastoreTest {

  // Maximum eventual consistency.
  private final LocalServiceTestHelper helper =
      new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig()
          .setDefaultHighRepJobPolicyUnappliedJobPercentage(100));

  @Before
  public void setUp() {
    helper.setUp();
  }

  @After
  public void tearDown() {
    helper.tearDown();
  }

  @Test
  public void testEventuallyConsistentGlobalQueryResult() {
    DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
    Key ancestor = KeyFactory.createKey("foo", 3);
    ds.put(new Entity("yam", ancestor));
    ds.put(new Entity("yam", ancestor));
    // Global query doesn't see the data.
    assertEquals(0, ds.prepare(new Query("yam")).countEntities(withLimit(10)));
    // Ancestor query does see the data.
    assertEquals(2, ds.prepare(new Query("yam", ancestor)).countEntities(withLimit(10)));
  }
}

Ao definir a percentagem de tarefas não aplicadas como 100, estamos a dar instruções ao armazenamento de dados local para operar com a quantidade máxima de consistência eventual. A consistência eventual máxima significa que as escritas são confirmadas, mas falham sempre na aplicação, pelo que as consultas globais (não ancestrais) falham sempre na deteção de alterações. Claro que isto não é representativo da quantidade de consistência eventual que a sua aplicação vai ver quando estiver em produção, mas, para fins de teste, é muito útil poder configurar o armazenamento de dados local para se comportar desta forma sempre.

Se quiser um controlo mais detalhado sobre as transações que não são aplicadas, pode registar o seu próprio HighRepJobPolicy:


import static com.google.appengine.api.datastore.FetchOptions.Builder.withLimit;
import static org.junit.Assert.assertEquals;

import com.google.appengine.api.datastore.DatastoreService;
import com.google.appengine.api.datastore.DatastoreServiceFactory;
import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.Key;
import com.google.appengine.api.datastore.Query;
import com.google.appengine.api.datastore.dev.HighRepJobPolicy;
import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig;
import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

public class LocalCustomPolicyHighRepDatastoreTest {
  private static final class CustomHighRepJobPolicy implements HighRepJobPolicy {
    static int newJobCounter = 0;
    static int existingJobCounter = 0;

    @Override
    public boolean shouldApplyNewJob(Key entityGroup) {
      // Every other new job fails to apply.
      return newJobCounter++ % 2 == 0;
    }

    @Override
    public boolean shouldRollForwardExistingJob(Key entityGroup) {
      // Every other existing job fails to apply.
      return existingJobCounter++ % 2 == 0;
    }
  }

  private final LocalServiceTestHelper helper =
      new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig()
          .setAlternateHighRepJobPolicyClass(CustomHighRepJobPolicy.class));

  @Before
  public void setUp() {
    helper.setUp();
  }

  @After
  public void tearDown() {
    helper.tearDown();
  }

  @Test
  public void testEventuallyConsistentGlobalQueryResult() {
    DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
    ds.put(new Entity("yam")); // applies
    ds.put(new Entity("yam")); // does not apply
    // First global query only sees the first Entity.
    assertEquals(1, ds.prepare(new Query("yam")).countEntities(withLimit(10)));
    // Second global query sees both Entities because we "groom" (attempt to
    // apply unapplied jobs) after every query.
    assertEquals(2, ds.prepare(new Query("yam")).countEntities(withLimit(10)));
  }
}

As APIs de teste são úteis para verificar se a sua aplicação se comporta corretamente perante a consistência eventual, mas tenha em atenção que o modelo de consistência de leitura de alta replicação local é uma aproximação do modelo de consistência de leitura de alta replicação de produção e não uma réplica exata. No ambiente local, a execução de uma get() de um Entity que pertence a um grupo de entidades com uma gravação não aplicada torna sempre os resultados da gravação não aplicada visíveis para consultas globais subsequentes. Na produção, não é o caso.

Escrever testes de filas de tarefas

Os testes que usam a fila de tarefas local são um pouco mais complexos porque, ao contrário do datastore e da cache de memória, a API task queue não expõe uma funcionalidade para examinar o estado do serviço. Precisamos de aceder à fila de tarefas local para verificar se uma tarefa foi agendada com os parâmetros esperados. Para o fazer, precisamos de com.google.appengine.api.taskqueue.dev.LocalTaskQueue.


import static org.junit.Assert.assertEquals;

import com.google.appengine.api.taskqueue.QueueFactory;
import com.google.appengine.api.taskqueue.TaskOptions;
import com.google.appengine.api.taskqueue.dev.LocalTaskQueue;
import com.google.appengine.api.taskqueue.dev.QueueStateInfo;
import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
import com.google.appengine.tools.development.testing.LocalTaskQueueTestConfig;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

public class TaskQueueTest {

  private final LocalServiceTestHelper helper =
      new LocalServiceTestHelper(new LocalTaskQueueTestConfig());

  @Before
  public void setUp() {
    helper.setUp();
  }

  @After
  public void tearDown() {
    helper.tearDown();
  }

  // Run this test twice to demonstrate we're not leaking state across tests.
  // If we _are_ leaking state across tests we'll get an exception on the
  // second test because there will already be a task with the given name.
  private void doTest() throws InterruptedException {
    QueueFactory.getDefaultQueue().add(TaskOptions.Builder.withTaskName("task29"));
    // Give the task time to execute if tasks are actually enabled (which they
    // aren't, but that's part of the test).
    Thread.sleep(1000);
    LocalTaskQueue ltq = LocalTaskQueueTestConfig.getLocalTaskQueue();
    QueueStateInfo qsi = ltq.getQueueStateInfo().get(QueueFactory.getDefaultQueue().getQueueName());
    assertEquals(1, qsi.getTaskInfo().size());
    assertEquals("task29", qsi.getTaskInfo().get(0).getTaskName());
  }

  @Test
  public void testTaskGetsScheduled1() throws InterruptedException {
    doTest();
  }

  @Test
  public void testTaskGetsScheduled2() throws InterruptedException {
    doTest();
  }
}

Repare como pedimos ao LocalTaskqueueTestConfig um identificador para a instância do serviço local e, em seguida, investigamos o próprio serviço local para garantir que a tarefa foi agendada conforme esperado. Todas as implementações de LocalServiceTestConfig expõem um método semelhante. Pode nem sempre precisar dele, mas, mais cedo ou mais tarde, vai ficar feliz por ele estar lá.

Definir o ficheiro de configuração queue.xml

As bibliotecas de teste de filas de tarefas permitem especificar qualquer número de configurações com base no LocalServiceTestHelper através do método LocalTaskQueueTestConfig.setQueueXmlPath.queue.xml Atualmente, as definições de limite de taxa de qualquer fila são ignoradas pelo servidor de programação local. Não é possível executar tarefas simultâneas localmente.

Por exemplo, um projeto pode ter de ser testado em relação ao ficheiro queue.xml que vai ser carregado e usado pela aplicação do App Engine. Partindo do princípio de que o ficheiro queue.xml se encontra na localização padrão, o exemplo de código acima pode ser modificado da seguinte forma para conceder ao teste acesso às filas especificadas no ficheiro src/main/webapp/WEB-INF/queue.xml:

private final LocalServiceTestHelper helper =
    new LocalServiceTestHelper(new LocalTaskQueueTestConfig()
        .setQueueXmlPath("src/main/webapp/WEB-INF/queue.xml"));

Modifique o caminho para o ficheiro queue.xml de forma a ajustar-se à estrutura de ficheiros do seu projeto.

Use o método QueueFactory.getQueue para aceder às filas por nome:

QueueFactory.getQueue("my-queue-name").add(TaskOptions.Builder.withTaskName("task29"));

Escrever testes de tarefas diferidas

Se o código da sua aplicação usar tarefas diferidas, as utilidades de teste Java facilitam a escrita de um teste de integração que valida os resultados destas tarefas.


import static org.junit.Assert.assertTrue;

import com.google.appengine.api.taskqueue.DeferredTask;
import com.google.appengine.api.taskqueue.QueueFactory;
import com.google.appengine.api.taskqueue.TaskOptions;
import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
import com.google.appengine.tools.development.testing.LocalTaskQueueTestConfig;
import java.util.concurrent.TimeUnit;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

public class DeferredTaskTest {

  // Unlike CountDownLatch, TaskCountDownlatch lets us reset.
  private final LocalTaskQueueTestConfig.TaskCountDownLatch latch =
      new LocalTaskQueueTestConfig.TaskCountDownLatch(1);

  private final LocalServiceTestHelper helper =
      new LocalServiceTestHelper(new LocalTaskQueueTestConfig()
          .setDisableAutoTaskExecution(false)
          .setCallbackClass(LocalTaskQueueTestConfig.DeferredTaskCallback.class)
          .setTaskExecutionLatch(latch));

  private static class MyTask implements DeferredTask {
    private static boolean taskRan = false;

    @Override
    public void run() {
      taskRan = true;
    }
  }

  @Before
  public void setUp() {
    helper.setUp();
  }

  @After
  public void tearDown() {
    MyTask.taskRan = false;
    latch.reset();
    helper.tearDown();
  }

  @Test
  public void testTaskGetsRun() throws InterruptedException {
    QueueFactory.getDefaultQueue().add(
        TaskOptions.Builder.withPayload(new MyTask()));
    assertTrue(latch.await(5, TimeUnit.SECONDS));
    assertTrue(MyTask.taskRan);
  }
}

Tal como no nosso primeiro exemplo de fila de tarefas local, estamos a usar um LocalTaskqueueTestConfig, mas desta vez estamos a inicializá-lo com alguns argumentos adicionais que nos dão uma forma fácil de verificar não só se a tarefa foi agendada, mas também se foi executada: chamamos setDisableAutoTaskExecution(false) para indicar à fila de tarefas local que execute automaticamente as tarefas. Chamamos setCallbackClass(LocalTaskQueueTestConfig.DeferredTaskCallback.class) para dizer à fila de tarefas local para usar um callback que compreenda como executar tarefas diferidas. Por fim, chamamos setTaskExecutionLatch(latch) para indicar à fila de tarefas locais que diminua o trinco após a execução de cada tarefa. Esta configuração permite-nos escrever um teste no qual colocamos uma tarefa Deferred na fila, aguardamos até que essa tarefa seja executada e, em seguida, verificamos se a tarefa se comportou conforme esperado quando foi executada.

Escrever testes de capacidades de serviços locais

Os testes de capacidades envolvem a alteração do estado de alguns serviços, como datastore, blobstore, memcache, etc., e a execução da sua aplicação em relação a esse serviço para determinar se a sua aplicação está a responder como esperado em diferentes condições. O estado da capacidade pode ser alterado através da classe LocalCapabilitiesServiceTestConfig.

O fragmento de código seguinte altera o estado de capacidade do serviço datastore para desativado e, em seguida, executa um teste no serviço datastore. Pode substituir outros serviços pelo datastore, conforme necessário.


import static org.junit.Assert.assertEquals;

import com.google.appengine.api.capabilities.Capability;
import com.google.appengine.api.capabilities.CapabilityStatus;
import com.google.appengine.api.datastore.DatastoreService;
import com.google.appengine.api.datastore.DatastoreServiceFactory;
import com.google.appengine.api.datastore.FetchOptions;
import com.google.appengine.api.datastore.Query;
import com.google.appengine.tools.development.testing.LocalCapabilitiesServiceTestConfig;
import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
import com.google.apphosting.api.ApiProxy;
import org.junit.After;
import org.junit.Test;

public class ShortTest {
  private LocalServiceTestHelper helper;

  @After
  public void tearDown() {
    helper.tearDown();
  }

  @Test(expected = ApiProxy.CapabilityDisabledException.class)
  public void testDisabledDatastore() {
    Capability testOne = new Capability("datastore_v3");
    CapabilityStatus testStatus = CapabilityStatus.DISABLED;
    // Initialize the test configuration.
    LocalCapabilitiesServiceTestConfig config =
        new LocalCapabilitiesServiceTestConfig().setCapabilityStatus(testOne, testStatus);
    helper = new LocalServiceTestHelper(config);
    helper.setUp();
    FetchOptions fo = FetchOptions.Builder.withLimit(10);
    DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
    assertEquals(0, ds.prepare(new Query("yam")).countEntities(fo));
  }
}

O teste de exemplo cria primeiro um objeto Capability inicializado para datastore e, em seguida, cria um objeto CapabilityStatus definido como DISABLED. O LocalCapabilitiesServiceTestConfig é criado com a capacidade e o estado definidos através dos objetos Capability e CapabilityStatus que acabam de ser criados.

Em seguida, o LocalServiceHelper é criado através do objeto LocalCapabilitiesServiceTestConfig. Agora que o teste foi configurado, o DatastoreService é criado e é enviada uma consulta para determinar se o teste gera os resultados esperados, neste caso, um CapabilityDisabledException.

Escrever testes para outros serviços

As utilidades de teste estão disponíveis para o Blobstore e outros serviços do App Engine. Para uma lista de todos os serviços que têm implementações locais para testes, consulte a LocalServiceTestConfig documentação.

Escrever testes com expetativas de autenticação

Este exemplo mostra como escrever testes que validam a lógica que usa UserService para determinar se um utilizador tem sessão iniciada ou tem privilégios de administrador. Tenha em atenção que qualquer utilizador com a função básica de visitante, editor ou proprietário, ou a função predefinida de administrador da app do App Engine tem privilégios de administrador.


import static org.junit.Assert.assertTrue;

import com.google.appengine.api.users.UserService;
import com.google.appengine.api.users.UserServiceFactory;
import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
import com.google.appengine.tools.development.testing.LocalUserServiceTestConfig;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

public class AuthenticationTest {

  private final LocalServiceTestHelper helper =
      new LocalServiceTestHelper(new LocalUserServiceTestConfig())
          .setEnvIsAdmin(true).setEnvIsLoggedIn(true);

  @Before
  public void setUp() {
    helper.setUp();
  }

  @After
  public void tearDown() {
    helper.tearDown();
  }

  @Test
  public void testIsAdmin() {
    UserService userService = UserServiceFactory.getUserService();
    assertTrue(userService.isUserAdmin());
  }
}

Neste exemplo, estamos a configurar o LocalServiceTestHelper com o LocalUserServiceTestConfig para podermos usar o UserService no nosso teste, mas também estamos a configurar alguns dados do ambiente relacionados com a autenticação no próprio LocalServiceTestHelper.

Neste exemplo, estamos a configurar o LocalServiceTestHelper com o LocalUserServiceTestConfig para podermos usar o OAuthService.