Pruebas unitarias locales para Java 8

Las pruebas unitarias te permiten comprobar la calidad del código después de escribirlo, pero también puedes usarlas para mejorar el proceso de desarrollo a medida que avanzas. En lugar de escribir pruebas después de terminar de desarrollar tu aplicación, te recomendamos que las escribas a medida que avanzas. De esta forma, podrás diseñar unidades de código pequeñas, fáciles de mantener y reutilizables. También te permite probar tu código de forma exhaustiva y rápida.

Cuando haces pruebas unitarias locales, ejecutas pruebas que se quedan en tu entorno de desarrollo sin implicar componentes remotos. App Engine proporciona utilidades de prueba que usan implementaciones locales de Datastore y otros servicios de App Engine. Esto significa que puedes probar el uso que hace tu código de estos servicios de forma local, sin desplegarlo en App Engine, mediante stubs de servicio.

A través del código auxiliar de un servicio determinado, se puede simular el comportamiento de ese servicio. Por ejemplo, el stub del servicio de Datastore que se muestra en Escribir pruebas de Datastore y Memcache te permite probar tu código de Datastore sin enviar ninguna solicitud al Datastore real. Las entidades almacenadas durante una prueba unitaria del almacén de datos se guardan en la memoria, no en el almacén de datos, y se eliminan después de la prueba. Puedes realizar pruebas pequeñas y rápidas sin depender de Datastore.

En este documento se proporciona información sobre cómo configurar un framework de pruebas y, a continuación, se describe cómo escribir pruebas unitarias en varios servicios locales de App Engine.

Configurar un marco de pruebas

Aunque las utilidades de prueba del SDK no están vinculadas a ningún framework específico, en esta guía se usa JUnit en los ejemplos para que tengas algo concreto y completo con lo que trabajar. Antes de empezar a escribir pruebas, deberá añadir el archivo JAR de JUnit 4 correspondiente a su classpath de pruebas. Cuando lo hayas hecho, podrás escribir una prueba JUnit muy sencilla.


import static org.junit.Assert.assertEquals;

import org.junit.Test;

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

Si estás usando Eclipse, selecciona el archivo de origen de la prueba que quieras ejecutar. Selecciona el menú Ejecutar > Ejecutar como > Prueba JUnit. Los resultados de la prueba aparecen en la ventana Consola.

Presentamos las utilidades de pruebas de Java 8

MyFirstTest muestra la configuración de prueba más sencilla posible. En el caso de las pruebas que no dependen de las APIs de App Engine ni de las implementaciones de servicios locales, puede que no necesites nada más. Sin embargo, si tus pruebas o el código que se está probando tienen estas dependencias, añade los siguientes archivos JAR a la ruta de clases de prueba:

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

Estos archivos JAR ponen las APIs de tiempo de ejecución y las implementaciones locales de esas APIs a disposición de tus pruebas.

Los servicios de App Engine esperan una serie de elementos de su entorno de ejecución, y la configuración de estos elementos implica una cantidad considerable de código repetitivo. En lugar de configurarlo tú mismo, puedes usar las utilidades del paquete com.google.appengine.tools.development.testing. Para usar este paquete, añade el siguiente archivo JAR a la ruta de clases de prueba:

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

Dedica un minuto a consultar el javadoc del paquete com.google.appengine.tools.development.testing. La clase más importante de este paquete es LocalServiceTestHelper, que gestiona toda la configuración del entorno necesaria y te ofrece un punto de configuración de nivel superior para todos los servicios locales a los que quieras acceder en tus pruebas.

Para escribir una prueba que acceda a un servicio local concreto:

  • Crea una instancia de LocalServiceTestHelper con una implementación de LocalServiceTestConfig para ese servicio local específico.
  • Llama a setUp() en tu instancia de LocalServiceTestHelper antes de cada prueba y tearDown() después de cada prueba.

Escribir pruebas de Datastore y memcache

En el siguiente ejemplo se prueba el uso del servicio 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();
  }
}

En este ejemplo, LocalServiceTestHelper configura y desconfigura las partes del entorno de ejecución que son comunes a todos los servicios locales, y LocalDatastoreServiceTestConfig configura y desconfigura las partes del entorno de ejecución que son específicas del servicio de almacén de datos local. Si lees el javadoc, verás que esto implica configurar el servicio de almacén de datos local para que conserve todos los datos en la memoria (en lugar de volcarlos en el disco a intervalos regulares) y borrar todos los datos de la memoria al final de cada prueba. Este es el comportamiento predeterminado de una prueba de almacén de datos. Si no es lo que quieres, puedes cambiarlo.

Cómo modificar este ejemplo para acceder a Memcache en lugar de acceder al almacén de datos

Para crear una prueba que acceda al servicio de memcache local, puedes usar el código que se muestra arriba con algunos pequeños cambios.

En lugar de importar clases relacionadas con el almacén de datos, importa las relacionadas con la caché de memoria. Aún tienes que importar LocalServiceTestHelper.

Cambia el nombre de la clase que estás creando y la instancia de LocalServiceTestHelper para que sean específicos de memcache.

public class LocalMemcacheTest {

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

Por último, cambia la forma de ejecución de la prueba para adecuarla a Memcache.

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

Al igual que en el ejemplo del almacén de datos, LocalServiceTestHelper y LocalServiceTestConfig (en este caso, LocalMemcacheServiceTestConfig) gestionan el entorno de ejecución.

Escribir pruebas de Cloud Datastore

Si tu aplicación usa Cloud Datastore, te recomendamos que escribas pruebas que verifiquen el comportamiento de tu aplicación ante la coherencia final. LocalDatastoreServiceTestConfig ofrece opciones que facilitan esta tarea:


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

Si se define el porcentaje de trabajos no aplicados en 100, se indica al almacén de datos local que opere con la máxima coherencia final. La coherencia final máxima significa que las escrituras se completarán, pero siempre fallarán al aplicarse, por lo que las consultas globales (no de ancestros) no podrán ver los cambios. Por supuesto, esto no representa la cantidad de coherencia final que verá tu aplicación cuando se ejecute en producción, pero, a efectos de prueba, es muy útil poder configurar el almacén de datos local para que se comporte de esta forma cada vez.

Si quieres tener un control más detallado sobre las transacciones que no se aplican, puedes registrar tu propio 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)));
  }
}

Las APIs de prueba son útiles para verificar que tu aplicación se comporta correctamente en caso de que se produzca una coherencia final, pero ten en cuenta que el modelo de coherencia de lectura de alta replicación local es una aproximación del modelo de coherencia de lectura de alta replicación de producción, no una réplica exacta. En el entorno local, al realizar una get() de un Entity que pertenece a un grupo de entidades con una escritura no aplicada, los resultados de la escritura no aplicada siempre serán visibles para las consultas globales posteriores. En cambio, esto no sucede en un entorno de producción.

Escribir pruebas de colas de tareas

Las pruebas que usan la cola de tareas local son un poco más complejas porque, a diferencia de Datastore y Memcache, la API de la cola de tareas no expone ninguna función para examinar el estado del servicio. Necesitamos acceder a la cola de tareas local para verificar que se ha programado una tarea con los parámetros esperados. Para ello, necesitamos 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();
  }
}

Fíjate en cómo pedimos al LocalTaskqueueTestConfig un identificador de la instancia del servicio local y, a continuación, investigamos el servicio local en sí para asegurarnos de que la tarea se ha programado según lo previsto. Todas las implementaciones de LocalServiceTestConfig exponen un método similar. Puede que no siempre lo necesites, pero tarde o temprano te alegrarás de que esté ahí.

Configurar el archivo queue.xml

Las bibliotecas de pruebas de colas de tareas permiten especificar cualquier número de configuraciones queue.xml por LocalServiceTestHelper mediante el método LocalTaskQueueTestConfig.setQueueXmlPath. Por el momento, el servidor de desarrollo local ignora la configuración del límite de frecuencia de cualquier cola. No es posible ejecutar tareas simultáneas a la vez de forma local.

Por ejemplo, un proyecto puede necesitar probar el archivo queue.xml que se subirá y usará en la aplicación de App Engine. Si el archivo queue.xml se encuentra en la ubicación estándar, el código de ejemplo anterior se puede modificar de la siguiente manera para conceder acceso de prueba a las colas especificadas en el archivo src/main/webapp/WEB-INF/queue.xml:

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

Modifica la ruta del archivo queue.xml para que se ajuste a la estructura de archivos de tu proyecto.

Usa el método QueueFactory.getQueue para acceder a las colas por nombre:

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

Escribir pruebas de tareas diferidas

Si el código de tu aplicación usa tareas diferidas, las utilidades de prueba de Java facilitan la escritura de una prueba de integración que verifica los resultados de estas tareas.


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

Al igual que en nuestro primer ejemplo de cola de tareas local, usamos un LocalTaskqueueTestConfig, pero esta vez lo inicializamos con algunos argumentos adicionales que nos permiten verificar fácilmente no solo que la tarea se ha programado, sino que se ha ejecutado. Llamamos a setDisableAutoTaskExecution(false) para indicar a la cola de tareas local que ejecute las tareas automáticamente. Llamamos a setCallbackClass(LocalTaskQueueTestConfig.DeferredTaskCallback.class) para indicar a la cola de tareas local que use una retrollamada que sepa cómo ejecutar tareas diferidas. Por último, llamamos a setTaskExecutionLatch(latch) para indicar a la cola de tareas locales que disminuya el latch después de cada ejecución de la tarea. Esta configuración nos permite escribir una prueba en la que ponemos en cola una tarea aplazada, esperamos a que se ejecute y, a continuación, verificamos que la tarea se ha comportado como se esperaba cuando se ha ejecutado.

Escribir pruebas de las funciones de los servicios locales

Las pruebas de funciones implican cambiar el estado de algunos servicios, como el almacén de datos, el almacén de blobs, Memcache, etc., y ejecutar la aplicación en ese servicio para determinar si responde como se espera en diferentes condiciones. El estado de la función se puede cambiar mediante la clase LocalCapabilitiesServiceTestConfig.

El siguiente fragmento de código cambia el estado de la función del servicio de almacén de datos a inhabilitado y, a continuación, ejecuta una prueba en el servicio de almacén de datos. Puedes sustituir otros servicios por el almacén de datos según sea necesario.


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

La prueba de ejemplo primero crea un objeto Capability inicializado en el almacén de datos y, a continuación, crea un objeto CapabilityStatus definido como DISABLED. El objeto LocalCapabilitiesServiceTestConfig se crea con la capacidad y el estado definidos mediante los objetos Capability y CapabilityStatus que acabamos de crear.

A continuación, se crea el LocalServiceHelper usando el objeto LocalCapabilitiesServiceTestConfig. Ahora que se ha configurado la prueba, se crea el DatastoreService y se le envía una consulta para determinar si la prueba genera los resultados esperados, en este caso, un CapabilityDisabledException.

Escribir pruebas para otros servicios

Las herramientas para pruebas están disponibles para el almacén de blobs y otros servicios de App Engine. Para ver una lista de todos los servicios que tienen implementaciones locales para las pruebas, consulta la documentación de LocalServiceTestConfig.

Escribir pruebas con expectativas de autenticación

En este ejemplo se muestra cómo escribir pruebas que verifiquen la lógica que usa UserService para determinar si un usuario ha iniciado sesión o tiene privilegios de administrador. Ten en cuenta que cualquier usuario con el rol básico de lector, editor o propietario, o con el rol predefinido de administrador de aplicaciones de App Engine, tiene privilegios 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());
  }
}

En este ejemplo, configuramos el LocalServiceTestHelper con el LocalUserServiceTestConfig para poder usar el UserService en nuestra prueba, pero también configuramos algunos datos de entorno relacionados con la autenticación en el propio LocalServiceTestHelper.

En este ejemplo, vamos a configurar el LocalServiceTestHelper con el LocalUserServiceTestConfig para poder usar el OAuthService.