Tests unitaires locaux pour Java 8

Les tests unitaires permettent de vérifier la qualité de votre code après l'avoir écrit, mais également d'améliorer progressivement votre processus de développement. Au lieu d'écrire les tests une fois que vous avez fini de développer votre application, écrivez-les en même temps. Vous pouvez ainsi concevoir de petites unités de code gérables et réutilisables. Il est également plus simple pour vous de tester complètement et rapidement votre code.

Lorsque vous réalisez un test unitaire local, celui-ci s'exécute dans votre propre environnement de développement sans impliquer de composants distants. App Engine propose des utilitaires de test qui s'appuient sur des mises en œuvre locales du datastore et d'autres services App Engine. De cette façon, vous pouvez exécuter le code en local, sans le déployer sur App Engine, grâce aux simulations de service.

Une simulation de service est une méthode qui simule le comportement du service. Par exemple, la simulation de service du datastore indiquée dans la section Écrire des tests pour le datastore et le cache mémoire vous permet de tester le code de votre datastore, sans effectuer de requête auprès du véritable datastore. Toute entité stockée lors d'un test unitaire du datastore est conservée en mémoire (pas dans le datastore), puis en est supprimée après le test. Vous pouvez effectuer de petits tests rapides sans aucune dépendance sur le datastore.

Ce document fournit des informations sur la configuration d'un framework de test, puis explique comment écrire des tests d'unités sur plusieurs services App Engine locaux.

Configurer un framework de test

Même si les utilitaires de test du SDK ne sont liés à aucun framework en particulier, ce guide utilise JUnit pour les exemples. Ainsi, vous disposez d'éléments de travail concrets et complets. Avant de commencer à écrire des tests, vous devez ajouter le fichier JAR JUnit 4 approprié au paramètre "classpath" de vos tests. Une fois ce fichier ajouté, vous pouvez commencer à écrire un test JUnit simple.


import static org.junit.Assert.assertEquals;

import org.junit.Test;

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

Si vous exécutez Eclipse, sélectionnez le fichier source du test à exécuter. Sélectionnez le menu Run (Exécuter) > Run As (Exécuter en tant que) > JUnit Test (Test JUnit). Les résultats du test apparaissent dans la fenêtre de la console.

Présentation des utilitaires de test Java 8

MyFirstTest illustre la configuration de test la plus simple qui soit. Pour les tests qui ne dépendent pas des API App Engine ou de la mise en œuvre de services locaux, il est possible que vous n'ayez besoin de rien de plus. Toutefois, si vos tests ou le code en cours de test comportent ce type de dépendance, ajoutez les fichiers JAR suivants au paramètre "classpath" de votre test :

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

Ces fichiers permettent de mettre les API d'exécution et les mises en œuvre locales de ces API à disposition de vos tests.

Les services App Engine ont besoin que leur environnement d'exécution leur fournisse un certain nombre d'éléments, et la configuration de ces éléments suppose une certaine quantité de code récurrent. Au lieu de les configurer vous-même, vous pouvez employer les utilitaires du package com.google.appengine.tools.development.testing. Pour pouvoir utiliser ce package, ajoutez le fichier JAR suivant au paramètre "classpath" du test :

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

Prenez une minute pour parcourir le fichier javadoc du package com.google.appengine.tools.development.testing. La classe la plus importante de ce package est LocalServiceTestHelper. Celle-ci gère l'ensemble de la configuration nécessaire de l'environnement, et fournit un point de configuration de niveau supérieur pour tous les services locaux auxquels vous pourriez souhaiter accéder lors de vos tests.

Pour écrire un test qui accède à un service local donné, procédez comme suit :

  • Créez une instance de classe LocalServiceTestHelper avec une mise en œuvre de LocalServiceTestConfig pour ce service local spécifique.
  • Appelez setUp() dans votre instance LocalServiceTestHelper avant chaque test et tearDown() après chaque test.

Écrire des tests pour le datastore et Memcache

L'exemple suivant permet de tester l'utilisation du service du 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();
  }
}

Dans cet exemple, LocalServiceTestHelper configure et nettoie les parties de l'environnement d'exécution qui sont communes à tous les services locaux et LocalDatastoreServiceTestConfig configure et nettoie les parties de l'environnement d'exécution qui sont propres au service Datastore local. Si vous lisez le fichier javadoc, vous apprendrez que cela implique de configurer le service de datastore local pour conserver toutes les données en mémoire (plutôt que de vider le disque à intervalles réguliers) et d'effacer toutes les données en mémoire à la fin de chaque test. Il s'agit simplement du comportement par défaut pour un test de datastore. Si ce comportement ne correspond pas à vos attentes, vous pouvez le modifier.

Modifier l'exemple pour accéder au cache mémoire au lieu du datastore

Pour créer un test qui accède au service Memcache local, vous pouvez utiliser le code proposé ci-dessus, avec quelques modifications.

Au lieu d'importer des classes relatives au datastore, importez celles concernant le cache mémoire. Vous devez quand même importer LocalServiceTestHelper.

Modifiez le nom de la classe que vous êtes en train de créer, ainsi que l'instance de LocalServiceTestHelper, de sorte qu'ils soient spécifiques au cache mémoire.

public class LocalMemcacheTest {

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

Enfin, modifiez le mode d'exécution du test afin qu'il soit pertinent pour le cache mémoire.

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

Comme dans l'exemple du datastore, LocalServiceTestHelper et la configuration LocalServiceTestConfig spécifique au service (dans ce cas LocalMemcacheServiceTestConfig) gèrent l'environnement d'exécution.

Écrire des tests pour Cloud Datastore

Si votre application utilise Cloud Datastore, vous souhaitez peut-être écrire des tests permettant de vérifier le comportement de votre application en cas de cohérence à terme. LocalDatastoreServiceTestConfig propose des options qui facilitent cette écriture :


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

En définissant le pourcentage de tâches non appliquées sur 100, nous indiquons au datastore local de fonctionner avec la quantité maximale de cohérence à terme. Une cohérence à terme maximale signifie que les écritures seront validées, mais que leur application échouera systématiquement, si bien que les requêtes globales (non ascendantes) ne parviendront jamais à afficher les modifications. Cette quantité de cohérence à terme n'est évidemment pas représentative de celle que votre application affichera lors de son exécution dans l'environnement de production. Toutefois, à des fins de test, il peut s'avérer utile de pouvoir configurer le comportement du datastore local de cette manière à chaque fois.

Si vous souhaitez avoir un contrôle plus précis des transactions qui échouent, vous pouvez enregistrer votre propre règle 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)));
  }
}

Les API de test permettent de vérifier que votre application se comporte correctement face à la cohérence à terme. Toutefois, n'oubliez pas que le modèle local de cohérence de lecture avec réplication avancée est une approximation du modèle dans l'environnement de production, et non une réplique exacte. Dans l'environnement local, l'exécution d'une opération get() d'une entité Entity appartenant à un groupe d'entités avec une écriture non appliquée, rendra toujours les résultats de l'écriture non appliquée visibles pour les requêtes globales suivantes, ce qui n'est pas le cas dans l'environnement de production.

Écrire des tests de file d'attente des tâches

Les tests qui utilisent la file d'attente des tâches locale sont un peu plus complexes. En effet, contrairement au datastore et au cache mémoire, l'API ne propose aucune fonction permettant d'examiner l'état du service. Nous devons alors accéder à la file d'attente des tâches locale pour vérifier qu'une tâche a été planifiée avec les paramètres attendus. Pour ce faire, nous avons besoin 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();
  }
}

Remarquez la façon dont nous demandons à LocalTaskqueueTestConfig de gérer l'instance de service local. Nous examinons ensuite le service local lui-même pour vérifier que la tâche a été planifiée comme prévu. Toutes les mises en œuvre LocalServiceTestConfig proposent une méthode similaire. Vous n'en aurez pas toujours l'utilité, mais elle s'avérera indispensable lorsque vous en aurez besoin.

Définir le fichier de configuration queue.xml

Les bibliothèques de test de la file d'attente des tâches permettent le nombre de configurations queue.xml souhaité, à définir sur une base "LocalServiceTestHelper" via la méthode LocalTaskQueueTestConfig.setQueueXmlPath. Actuellement, tout paramètre de limite de débit dans la file d'attente est ignoré par le serveur local de développement. Il n'est pas possible d'exécuter plusieurs tâches simultanément.

Par exemple, un projet peut avoir besoin de tester le fichier queue.xml qui sera importé et utilisé par l'application App Engine. En supposant que le fichier queue.xml se trouve à l'emplacement standard, l'exemple de code ci-dessus peut être modifié comme suit pour accorder au test l'accès aux files d'attente spécifiées dans le fichier src/main/webapp/WEB-INF/queue.xml :

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

Modifiez le chemin d'accès au fichier queue.xml pour l'adapter à la structure du fichier de votre projet.

Utilisez la méthode QueueFactory.getQueue pour accéder aux files d'attente par nom :

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

Écrire des tests de tâches différées

Si le code de votre application utilise des tâches différées, les utilitaires de test Java facilitent l'écriture d'un test d'intégration qui vérifie les résultats de ces tâches.


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

Comme dans notre premier exemple de file d'attente de tâches locale, nous utilisons LocalTaskqueueTestConfig, mais cette fois, nous l'initialisons avec des arguments supplémentaires qui nous permettent de vérifier facilement non seulement que la tâche a été planifiée, mais également qu'elle a été exécutée. Nous appelons setDisableAutoTaskExecution(false) pour indiquer à la file d'attente de tâches locale d'exécuter automatiquement les tâches. Nous appelons setCallbackClass(LocalTaskQueueTestConfig.DeferredTaskCallback.class) pour indiquer à la file d'attente de tâches locale d'utiliser une fonction de rappel qui comprenne comment exécuter les tâches différées. Enfin, nous appelons setTaskExecutionLatch(latch) pour indiquer à la file d'attente de tâches locale de réduire le verrou après chaque exécution de tâche. Cette configuration nous permet d'écrire un test dans lequel nous mettons une tâche différée en file d'attente, attendons qu'elle soit exécutée, puis vérifions qu'elle a eu le comportement attendu lors de son exécution.

Écrire des tests des fonctionnalités du service local

Le test des fonctionnalités implique de modifier le statut de certains services, tels que le datastore, le blobstore, le cache mémoire, entre autres, et d'exécuter votre application sur ce service pour déterminer si votre application répond comme prévu dans des conditions différentes. L'état des fonctionnalités peut être modifié à l'aide de la classe LocalCapabilitiesServiceTestConfig.

L'extrait de code suivant permet de modifier l'état des fonctionnalités du service du datastore pour le désactiver, puis de réaliser un test sur le service du datastore. Vous pouvez substituer d'autres services au datastore si nécessaire.


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

L'exemple de test crée d'abord un objet Capability initialisé sur le datastore, puis crée un objet CapabilityStatus défini sur DISABLED (DÉSACTIVÉ). La classe LocalCapabilitiesServiceTestConfig est créée à l'aide de la fonctionnalité et de l'état définis par les objets Capability et CapabilityStatus que vous venez de créer.

La classe LocalServiceHelper est ensuite créée à l'aide de l'objet LocalCapabilitiesServiceTestConfig. Une fois le test configuré, DatastoreService est créé, puis une requête lui est envoyée pour déterminer si le test génère les résultats attendus, dans le cas présent, une exception CapabilityDisabledException.

Écrire des tests pour d'autres services

Des utilitaires de test sont disponibles pour le blobstore et d'autres services App Engine. Pour obtenir la liste de tous les services disposant de mises en œuvre locales pour les tests, consultez la documentation sur LocalServiceTestConfig.

Écrire des tests avec des attentes en matière d'authentification

Cet exemple illustre comment écrire des tests qui vérifient la logique utilisée par UserService pour déterminer si un utilisateur est connecté ou dispose des droits d'administrateur. Notez que tout utilisateur dispose des droits d'administrateur, dès lors qu'il possède le rôle de base "Lecteur", "Éditeur" ou "Propriétaire", ou le rôle prédéfini "Administrateur App Engine".


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

Dans cet exemple, nous configurons LocalServiceTestHelper avec LocalUserServiceTestConfig. Nous pouvons donc utiliser UserService dans notre test, mais nous configurons également des données d'environnement relatives à l'authentification dans LocalServiceTestHelper.

Dans cet exemple, nous configurons LocalServiceTestHelper avec LocalUserServiceTestConfig afin de pouvoir utiliser OAuthService.