Java 8 本地单元测试

您可以在编写代码后使用单元测试来检查代码的质量,同时也可以使用该功能来改进开发过程。建议您一边开发一边编写测试,而不是在完成应用的开发之后才编写测试。这样做有助于您设计出可维护、可重复使用的小型代码单元,也方便您迅速而彻底地测试您的代码。

在进行本地单元测试时,您的测试将限制于自己的开发环境中,而不会涉及远程组件。App Engine 提供的测试实用程序使用数据存储区和其他 App Engine 服务的本地实现。这意味着,您可以借助服务存根,在本地运行并测试您的代码对这些服务的使用情况,而无需将您的代码部署到 App Engine。

服务桩是模拟服务行为的一种方法。例如,凭借编写数据存储区和 Memcache 测试中所示的数据存储区服务桩,您可以对您的数据存储区代码进行测试,而不需要向实际数据存储区发送任何请求。在数据存储区单元测试过程中存储的任何实体都会保存在内存中,而不是保存在数据存储区中,并会在测试运行结束后删除。如此一来,您无需依赖数据存储区本身便可快速运行小型测试。

本文档介绍如何设置测试框架,然后对数个本地 App Engine 服务编写单元测试。

设置测试框架

尽管 SDK 的测试实用程序并不依赖于任何特定的框架,但本指南使用 JUnit 作为示例,方便您掌握具体方法并了解全部流程。在开始编写测试之前,您需要将适当的 JUnit 4 JAR 添加到测试类路径中。在完成该操作后,您可以开始编写非常简单的 JUnit 测试。


import static org.junit.Assert.assertEquals;

import org.junit.Test;

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

如果运行的是 Eclipse,请选择要运行的测试的源文件。选择运行菜单 > 运行方式 > JUnit 测试。测试结果会显示在控制台窗口中。

Java 8 测试实用程序简介

MyFirstTest 演示了最简单的测试设置,对于不依赖于 App Engine API 或本地服务实现的测试,您可以直接套用该设置。但是,如果测试或测试的代码依赖于这些 API 或服务,您就需要将以下 JAR 文件添加到测试类路径:

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

通过使用这些 JAR,您可以在测试中使用运行时 API 以及这些 API 的本地实现。

App Engine 服务的执行环境中需要许多内容,而设置这些内容涉及到大量样板代码。无需自行设置,即可使用 com.google.appengine.tools.development.testing 软件包中的实用程序。要使用此包,请将以下 JAR 文件添加到测试类路径中:

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

请先浏览 com.google.appengine.tools.development.testing 软件包的 javadoc。此软件包中最重要的类是 LocalServiceTestHelper,它负责处理所有必要的环境设置,并且是您用于配置要在测试中访问的所有本地服务的关键位置。

要编写访问特定本地服务的测试,请执行以下操作:

编写数据存储区和 memcache 测试

以下示例测试了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();
  }
}

在此示例中,LocalServiceTestHelper 用于设置和删除所有本地服务通用的执行环境部分,而 LocalDatastoreServiceTestConfig 用于设置和删除特定于本地数据存储区服务的执行环境部分。如果您阅读了 javadoc,您就明白这涉及配置本地数据存储区服务以将所有数据保留在内存中(而不是定期刷新到磁盘),并在每次测试结束时擦除所有内存中数据。这正是数据存储区测试的默认行为;如果这不是所需的行为,您可以对其进行更改。

更改示例以访问 memcache 来替代数据存储区

要创建访问本地 Memcache 服务的测试,可以使用上述代码,但需要进行一些细微改动。

首先,请导入与 memcache 相关的类,而不是导入与数据存储区相关的类。您仍然需要导入 LocalServiceTestHelper

更改您创建的类的名称,并更改 LocalServiceTestHelper 的实例,以使其特定于 memcache。

public class LocalMemcacheTest {

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

最后,更改实际运行测试的方式,使之与 Memcache 相关。

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

与数据存储区示例一样,LocalServiceTestHelper 和特定于服务的 LocalServiceTestConfig(在此示例中为 LocalMemcacheServiceTestConfig)用于管理执行环境。

编写 Cloud Datastore 测试

如果您的应用使用 Cloud Datastore,您可能需要编写测试,以验证应用针对最终一致性时的行为。LocalDatastoreServiceTestConfig 公开了可简化这一操作的选项:


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

通过将未应用的作业的百分比设置为 100,我们可以指示本地数据存储区,让它在尽可能保持最高最终一致性的前提下进行操作。最高最终一致性意味着系统会提交编写内容,但始终无法成功应用它们,因此,全局(非祖先)查询始终看不到变化。这当然并不代表应用在生产环境下运行时发现的最终一致性,但对于测试而言,这非常有用,因为这能够将本地数据存储区配置为每次都以此方式执行。

如果您想要更精确地控制无法应用的事务,您可以注册自己的 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)));
  }
}

测试 API 对于检验应用是否针对最终一致性正常运行非常有用,但请切记,本地强复制读取一致性模型只是与生产强复制读取一致性模型非常近似,并不完全相同。在本地环境中,如果对一个 Entity 执行 get(),而其属于一个带有未应用写入操作的实体组,则未应用写入的结果将对后续全局查询始终可见。在生产环境中,不会出现这种情况。

编写任务队列测试

使用本地任务队列的测试略微复杂一些,因为与数据存储区和 Memcache 不同,任务队列 API 没有公开检查服务状态的实用程序。因此,我们需要访问本地任务队列本身,以验证是否已安排了任务并包含所需的参数。为此,我们需要 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();
  }
}

请注意我们是如何向 LocalTaskqueueTestConfig 请求本地服务实例句柄的,然后调查本地服务本身,确保已按预期方式对任务进行了安排。所有 LocalServiceTestConfig 实现都公开一个类似的方法。虽然您此时可能并不需要该方法,但迟早会用到它。

设置 queue.xml 配置文件

任务队列测试库允许通过 LocalTaskQueueTestConfig.setQueueXmlPath 方法在 LocalServiceTestHelper 的基础上指定任意数量的 queue.xml 配置。目前,本地开发服务器会忽略任何队列的速率限制设置。因此,无法在本地运行同时执行的任务。

例如,项目可能需要根据将由 App Engine 应用上载和使用的 queue.xml 文件进行测试。假设 queue.xml 文件位于标准位置,可以按如下方式修改上面的示例代码,以授予对 src/main/webapp/WEB-INF/queue.xml 文件中指定的队列的测试访问权限:

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

修改 queue.xml 文件的路径以适合项目的文件结构。

使用 QueueFactory.getQueue 方法按名称访问队列:

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

编写延迟任务测试

如果您的应用代码使用延迟任务,Java 测试实用程序可以助您轻松编写验证这些任务结果的集成测试。


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

与我们的第一个本地任务队列示例一样,我们使用的是 LocalTaskqueueTestConfig,但这次我们使用一些额外的参数对其进行初始化,这些参数使我们能够轻松地验证任务是否已安排以及任务是否已执行,即我们调用 setDisableAutoTaskExecution(false) 来指示本地任务队列自动执行任务;以及我们调用 setCallbackClass(LocalTaskQueueTestConfig.DeferredTaskCallback.class) 来指示本地任务队列使用了解如何执行延迟任务的回调;最后,我们调用 setTaskExecutionLatch(latch) 来指示本地任务队列在每次执行任务后递减锁存器。该配置允许我们编写一个测试,在其中将延迟任务排入队列,等待该任务运行,然后验证任务在运行时的行为是否符合预期。

编写本地服务功能测试

功能测试涉及更改某些服务(例如数据存储区、blobstore,memcache 等)的状态,并针对该服务运行应用,以确定您的应用是否在不同条件下按预期响应。另外,您可以使用 LocalCapabilitiesServiceTestConfig 类更改功能状态。

以下代码段会将数据存储区服务的功能状态更改为已停用,然后对数据存储区服务运行测试。您可以根据需要将数据存储区替换为其他服务。


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

样本测试首先创建一个初始化为数据存储区的 Capability 对象,然后创建一个设置为已停用的 CapabilityStatus 对象。创建 LocalCapabilitiesServiceTestConfig 时使用了刚创建的 CapabilityCapabilityStatus 对象来设置功能和状态。

然后,使用 LocalCapabilitiesServiceTestConfig 对象创建 LocalServiceHelper。现在已经设置好测试,接着将创建 DatastoreService 并向其发送查询以确定测试是否生成预期结果,在本例中为 CapabilityDisabledException

编写其他服务的测试

测试实用程序可用于 blobstore 和其他 App Engine 服务。如需查看包含待测试的本地实现的所有服务列表,请参阅 LocalServiceTestConfig 文档。

编写具有身份验证要求的测试

此示例介绍了如何编写测试以验证使用 UserService 来确定用户是否已登录或是否具有管理员权限的逻辑。请注意,具有 Viewer、Editor 或 Owner 基本角色或者 App Engine App Admin 预定义角色的任何用户都拥有管理员权限。


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

在此示例中,我们使用 LocalUserServiceTestConfig 配置 LocalServiceTestHelper,以便我们可以在测试中使用 UserService,但我们还在 LocalServiceTestHelper 本身上配置了一些与身份验证相关的环境数据。

在此示例中,我们使用 LocalUserServiceTestConfig 配置 LocalServiceTestHelper,以便我们可以使用 OAuthService