Java 8 本機單元測試

單元測試可讓您在撰寫程式碼之後檢查品質,但也可以在開發期間使用單元測試來改進開發流程。您不需在開發完成後才撰寫測試,而可以在開發過程中即撰寫測試。這麼做可協助您設計可管理且可重複使用的小單元程式碼。測試程式碼也可以更輕鬆且快速徹底。

進行本機單元測試時,可在擁有的開發環境中執行測試,而不需遠端元件的輔助。App Engine 提供的測試公用程式採用了資料儲存庫及其他 App Engine 服務的本機實作。換句話說,您可以在本機執行程式碼的服務用途,無需透過服務虛設常式將程式碼部署至 App Engine。

服務虛設常式是一種模擬服務行為的方法。例如,撰寫 Datastore 與 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,請選取測試的來源檔案執行。依序選取「Run」選單 >「Run As」 >「JUnit Test」。測試結果會顯示在「主控台」視窗中。

Java 8 測試公用程式簡介

MyFirstTest 示範了最簡單的可能測試設定,如果是對 App Engine 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,這個類別可以處理所有必要的環境設定,並為您想要在測試中存取的所有本機服務提供最重要的設定。

如何撰寫存取特定本機服務的測試:

  • 使用特定本機服務的 LocalServiceTestConfig 實作項目,建立 LocalServiceTestHelper 的例項。
  • 在每次測試之前,對您的 LocalServiceTestHelper 例項呼叫 setUp(),並在每次測試後呼叫 tearDown()

編寫資料儲存庫和 Memcache 測試

以下範例對資料儲存庫服務的使用進行測試。


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 非常實用。不過,請記住,本機的「高複製」讀取一致性模型只能說相當接近正式作業「高複製」的讀取一致性模型,而非完全相同。在本機環境中,如果執行的 get() 函式所屬 Entity 屬於的實體群組有未套用的寫入,未套用寫入的結果一律可見於後續全域查詢中。但在正式作業環境中就不是這樣。

撰寫工作佇列測試

使用本機工作佇列的測試有一點複雜,因為與資料儲存庫和 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 物件,然後建立設定為 DISABLED 的 CapabilityStatus 物件。LocalCapabilitiesServiceTestConfig 建立時所使用的功能與狀態,是透過剛建立的 CapabilityCapabilityStatus 物件設定的。

接著,使用 LocalCapabilitiesServiceTestConfig 物件建立 LocalServiceHelper。現在測試已經設定完成,DatastoreService 已經建立,也已經為其傳送「查詢」來確認測試是否產生了預期的結果,在本例中,就是 CapabilityDisabledException

撰寫其他服務的測試

測試公用程式適用於 BlobStore 和其他 App Engine 服務。如需含有本機測試部署項目的完整服務清單,請參閱 LocalServiceTestConfig 說明文件。

使用驗證預期項目撰寫測試

這個範例顯示如何撰寫測試來驗證使用 UserService 確認使用者是否已登入或是否具有管理員權限的邏輯。請注意,擁有檢視者、編輯者或擁有者基本角色,或 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());
  }
}

在這個範例中,我們使用 LocalUserServiceTestConfig 設定 LocalServiceTestHelper,這樣我們就可以在測試中使用 UserService,但我們也在 LocalServiceTestHelper 本身之上設定了一些驗證相關的環境資料。

在這個範例中,我們會使用 LocalUserServiceTestConfig 設定 LocalServiceTestHelper,以便使用 OAuthService