Repository: firebase/firebase-admin-java
Pull request #261 (2019-03-15 21:53:42)

Refactoring the FCM API
This PR implements some design changes in order to improve the modularity and testability of the `FirebaseMessaging` implementation.

* Turned `FirebaseMessagingClient` and `InstanceIdClient` into interfaces.
* Implementations of these interfaces are now injected into `FirebaseMessaging` via suppliers. This makes it possible to unit test the `FirebaseMessaging` API surface by mocking out the dependencies.
* Re-org'ed the existing tests, and added a number of new test cases to bring the test coverage of core classes to 100%.

hiranya911: Thanks @schmidt-sebastian. Made the suggested changes, and ready for another round of feedback.

Patch:
diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
index 49b85b2..7330c27 100644
--- a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
+++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
@@ -19,17 +19,17 @@ package com.google.firebase.messaging;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 
-import com.google.api.client.http.HttpResponseInterceptor;
 import com.google.api.core.ApiFuture;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.ImplFirebaseTrampolines;
 import com.google.firebase.internal.CallableOperation;
 import com.google.firebase.internal.FirebaseService;
 import com.google.firebase.internal.NonNull;
-import com.google.firebase.internal.Nullable;
 
 import java.util.List;
 
@@ -46,18 +46,13 @@ public class FirebaseMessaging {
   static final String UNKNOWN_ERROR = "unknown-error";
 
   private final FirebaseApp app;
-  private final FirebaseMessagingClient messagingClient;
-  private final InstanceIdClient instanceIdClient;
+  private final Supplier<? extends FirebaseMessagingClient> messagingClient;
+  private final Supplier<? extends InstanceIdClient> instanceIdClient;
 
-  private FirebaseMessaging(FirebaseApp app) {
-    this(app, null);
-  }
-
-  @VisibleForTesting
-  FirebaseMessaging(FirebaseApp app, @Nullable HttpResponseInterceptor responseInterceptor) {
-    this.app = checkNotNull(app, "app must not be null");
-    this.messagingClient = new FirebaseMessagingClient(app, responseInterceptor);
-    this.instanceIdClient = new InstanceIdClient(app, responseInterceptor);
+  private FirebaseMessaging(Builder builder) {
+    this.app = checkNotNull(builder.firebaseApp);
+    this.messagingClient = Suppliers.memoize(builder.messagingClient);
+    this.instanceIdClient = Suppliers.memoize(builder.instanceIdClient);
   }
 
   /**
@@ -137,6 +132,7 @@ public class FirebaseMessaging {
   private CallableOperation<String, FirebaseMessagingException> sendOp(
       final Message message, final boolean dryRun) {
     checkNotNull(message, "message must not be null");
+    final FirebaseMessagingClient messagingClient = getMessagingClient();
     return new CallableOperation<String, FirebaseMessagingException>() {
       @Override
       protected String execute() throws FirebaseMessagingException {
@@ -290,6 +286,7 @@ public class FirebaseMessaging {
     checkArgument(!immutableMessages.isEmpty(), "messages list must not be empty");
     checkArgument(immutableMessages.size() <= 100,
         "messages list must not contain more than 100 elements");
+    final FirebaseMessagingClient messagingClient = getMessagingClient();
     return new CallableOperation<BatchResponse,FirebaseMessagingException>() {
       @Override
       protected BatchResponse execute() throws FirebaseMessagingException {
@@ -298,6 +295,11 @@ public class FirebaseMessaging {
     };
   }
 
+  @VisibleForTesting
+  FirebaseMessagingClient getMessagingClient() {
+    return messagingClient.get();
+  }
+
   /**
    * Subscribes a list of registration tokens to a topic.
    *
@@ -328,6 +330,7 @@ public class FirebaseMessaging {
       final List<String> registrationTokens, final String topic) {
     checkRegistrationTokens(registrationTokens);
     checkTopic(topic);
+    final InstanceIdClient instanceIdClient = getInstanceIdClient();
     return new CallableOperation<TopicManagementResponse, FirebaseMessagingException>() {
       @Override
       protected TopicManagementResponse execute() throws FirebaseMessagingException {
@@ -367,6 +370,7 @@ public class FirebaseMessaging {
       final List<String> registrationTokens, final String topic) {
     checkRegistrationTokens(registrationTokens);
     checkTopic(topic);
+    final InstanceIdClient instanceIdClient = getInstanceIdClient();
     return new CallableOperation<TopicManagementResponse, FirebaseMessagingException>() {
       @Override
       protected TopicManagementResponse execute() throws FirebaseMessagingException {
@@ -375,7 +379,12 @@ public class FirebaseMessaging {
     };
   }
 
-  private static void checkRegistrationTokens(List<String> registrationTokens) {
+  @VisibleForTesting
+  InstanceIdClient getInstanceIdClient() {
+    return this.instanceIdClient.get();
+  }
+
+  private void checkRegistrationTokens(List<String> registrationTokens) {
     checkArgument(registrationTokens != null && !registrationTokens.isEmpty(),
         "registrationTokens list must not be null or empty");
     checkArgument(registrationTokens.size() <= 1000,
@@ -386,7 +395,7 @@ public class FirebaseMessaging {
     }
   }
 
-  private static void checkTopic(String topic) {
+  private void checkTopic(String topic) {
     checkArgument(!Strings.isNullOrEmpty(topic), "topic must not be null or empty");
     checkArgument(topic.matches("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$"), "invalid topic name");
   }
@@ -396,7 +405,7 @@ public class FirebaseMessaging {
   private static class FirebaseMessagingService extends FirebaseService<FirebaseMessaging> {
 
     FirebaseMessagingService(FirebaseApp app) {
-      super(SERVICE_ID, new FirebaseMessaging(app));
+      super(SERVICE_ID, FirebaseMessaging.fromApp(app));
     }
 
     @Override
@@ -406,4 +415,54 @@ public class FirebaseMessaging {
       // which will throw once the app is deleted.
     }
   }
+
+  private static FirebaseMessaging fromApp(final FirebaseApp app) {
+    return FirebaseMessaging.builder()
+        .setFirebaseApp(app)
+        .setMessagingClient(new Supplier<FirebaseMessagingClient>() {
+          @Override
+          public FirebaseMessagingClient get() {
+            return FirebaseMessagingClientImpl.fromApp(app);
+          }
+        })
+        .setInstanceIdClient(new Supplier<InstanceIdClient>() {
+          @Override
+          public InstanceIdClientImpl get() {
+            return InstanceIdClientImpl.fromApp(app);
+          }
+        })
+        .build();
+  }
+
+  static Builder builder() {
+    return new Builder();
+  }
+
+  static class Builder {
+
+    private FirebaseApp firebaseApp;
+    private Supplier<? extends FirebaseMessagingClient> messagingClient;
+    private Supplier<? extends InstanceIdClient> instanceIdClient;
+
+    private Builder() { }
+
+    Builder setFirebaseApp(FirebaseApp firebaseApp) {
+      this.firebaseApp = firebaseApp;
+      return this;
+    }
+
+    Builder setMessagingClient(Supplier<? extends FirebaseMessagingClient> messagingClient) {
+      this.messagingClient = messagingClient;
+      return this;
+    }
+
+    Builder setInstanceIdClient(Supplier<? extends InstanceIdClient> instanceIdClient) {
+      this.instanceIdClient = instanceIdClient;
+      return this;
+    }
+
+    FirebaseMessaging build() {
+      return new FirebaseMessaging(this);
+    }
+  }
 }
diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java
index 6550852..da04956 100644
--- a/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java
+++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java
@@ -1,243 +1,32 @@
-/*
- * Copyright 2019 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
 package com.google.firebase.messaging;
 
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.api.client.googleapis.batch.BatchCallback;
-import com.google.api.client.googleapis.batch.BatchRequest;
-import com.google.api.client.http.GenericUrl;
-import com.google.api.client.http.HttpHeaders;
-import com.google.api.client.http.HttpRequest;
-import com.google.api.client.http.HttpRequestFactory;
-import com.google.api.client.http.HttpRequestInitializer;
-import com.google.api.client.http.HttpResponse;
-import com.google.api.client.http.HttpResponseException;
-import com.google.api.client.http.HttpResponseInterceptor;
-import com.google.api.client.http.json.JsonHttpContent;
-import com.google.api.client.json.JsonFactory;
-import com.google.api.client.json.JsonObjectParser;
-import com.google.api.client.json.JsonParser;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.firebase.FirebaseApp;
-import com.google.firebase.ImplFirebaseTrampolines;
-import com.google.firebase.internal.ApiClientUtils;
-import com.google.firebase.internal.Nullable;
-import com.google.firebase.internal.SdkUtils;
-import com.google.firebase.messaging.internal.MessagingServiceErrorResponse;
-import com.google.firebase.messaging.internal.MessagingServiceResponse;
-import java.io.IOException;
 import java.util.List;
-import java.util.Map;
 
 /**
- * A helper class for interacting with Firebase Cloud Messaging service.
+ * An interface for sending Firebase Cloud Messaging (FCM) messages.
  */
-final class FirebaseMessagingClient {
-
-  private static final String FCM_URL = "https://fcm.googleapis.com/v1/projects/%s/messages:send";
-
-  private static final String FCM_BATCH_URL = "https://fcm.googleapis.com/batch";
-
-  private static final Map<String, String> FCM_ERROR_CODES =
-      ImmutableMap.<String, String>builder()
-          // FCM v1 canonical error codes
-          .put("NOT_FOUND", "registration-token-not-registered")
-          .put("PERMISSION_DENIED", "mismatched-credential")
-          .put("RESOURCE_EXHAUSTED", "message-rate-exceeded")
-          .put("UNAUTHENTICATED", "invalid-apns-credentials")
-
-          // FCM v1 new error codes
-          .put("APNS_AUTH_ERROR", "invalid-apns-credentials")
-          .put("INTERNAL", FirebaseMessaging.INTERNAL_ERROR)
-          .put("INVALID_ARGUMENT", "invalid-argument")
-          .put("QUOTA_EXCEEDED", "message-rate-exceeded")
-          .put("SENDER_ID_MISMATCH", "mismatched-credential")
-          .put("UNAVAILABLE", "server-unavailable")
-          .put("UNREGISTERED", "registration-token-not-registered")
-          .build();
-
-  private final String fcmSendUrl;
-  private final HttpRequestFactory requestFactory;
-  private final HttpRequestFactory childRequestFactory;
-  private final JsonFactory jsonFactory;
-  private final HttpResponseInterceptor responseInterceptor;
-  private final String clientVersion = "fire-admin-java/" + SdkUtils.getVersion();
-
-  FirebaseMessagingClient(FirebaseApp app, @Nullable HttpResponseInterceptor responseInterceptor) {
-    String projectId = ImplFirebaseTrampolines.getProjectId(app);
-    checkArgument(!Strings.isNullOrEmpty(projectId),
-        "Project ID is required to access messaging service. Use a service account credential or "
-            + "set the project ID explicitly via FirebaseOptions. Alternatively you can also "
-            + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.");
-    this.fcmSendUrl = String.format(FCM_URL, projectId);
-    this.requestFactory = ApiClientUtils.newAuthorizedRequestFactory(app);
-    this.childRequestFactory = ApiClientUtils.newUnauthorizedRequestFactory(app);
-    this.jsonFactory = app.getOptions().getJsonFactory();
-    this.responseInterceptor = responseInterceptor;
-  }
-
-  String send(Message message, boolean dryRun) throws FirebaseMessagingException {
-    try {
-      return sendSingleRequest(message, dryRun);
-    } catch (HttpResponseException e) {
-      throw createExceptionFromResponse(e);
-    } catch (IOException e) {
-      throw new FirebaseMessagingException(
-          FirebaseMessaging.INTERNAL_ERROR, "Error while calling FCM backend service", e);
-    }
-  }
-
-  BatchResponse sendAll(
-      List<Message> messages, boolean dryRun) throws FirebaseMessagingException {
-    try {
-      return sendBatchRequest(messages, dryRun);
-    } catch (HttpResponseException e) {
-      throw createExceptionFromResponse(e);
-    } catch (IOException e) {
-      throw new FirebaseMessagingException(
-          FirebaseMessaging.INTERNAL_ERROR, "Error while calling FCM backend service", e);
-    }
-  }
-
-  private String sendSingleRequest(Message message, boolean dryRun) throws IOException {
-    HttpRequest request = requestFactory.buildPostRequest(
-        new GenericUrl(fcmSendUrl),
-        new JsonHttpContent(jsonFactory, message.wrapForTransport(dryRun)));
-    setCommonFcmHeaders(request.getHeaders());
-    request.setParser(new JsonObjectParser(jsonFactory));
-    request.setResponseInterceptor(responseInterceptor);
-    HttpResponse response = request.execute();
-    try {
-      MessagingServiceResponse parsed = new MessagingServiceResponse();
-      jsonFactory.createJsonParser(response.getContent()).parseAndClose(parsed);
-      return parsed.getMessageId();
-    } finally {
-      ApiClientUtils.disconnectQuietly(response);
-    }
-  }
-
-  private BatchResponse sendBatchRequest(
-      List<Message> messages, boolean dryRun) throws IOException {
-
-    MessagingBatchCallback callback = new MessagingBatchCallback();
-    BatchRequest batch = newBatchRequest(messages, dryRun, callback);
-    batch.execute();
-    return new BatchResponse(callback.getResponses());
-  }
-
-  private BatchRequest newBatchRequest(
-      List<Message> messages, boolean dryRun, MessagingBatchCallback callback) throws IOException {
-
-    BatchRequest batch = new BatchRequest(
-        requestFactory.getTransport(), getBatchRequestInitializer());
-    batch.setBatchUrl(new GenericUrl(FCM_BATCH_URL));
-
-    final JsonObjectParser jsonParser = new JsonObjectParser(this.jsonFactory);
-    final GenericUrl sendUrl = new GenericUrl(fcmSendUrl);
-    for (Message message : messages) {
-      // Using a separate request factory without authorization is faster for large batches.
-      // A simple performance test showed a 400-500ms speed up for batches of 1000 messages.
-      HttpRequest request = childRequestFactory.buildPostRequest(
-          sendUrl,
-          new JsonHttpContent(jsonFactory, message.wrapForTransport(dryRun)));
-      request.setParser(jsonParser);
-      setCommonFcmHeaders(request.getHeaders());
-      batch.queue(
-          request, MessagingServiceResponse.class, MessagingServiceErrorResponse.class, callback);
-    }
-
-    return batch;
-  }
-
-  private void setCommonFcmHeaders(HttpHeaders headers) {
-    headers.set("X-GOOG-API-FORMAT-VERSION", "2");
-    headers.set("X-Firebase-Client", clientVersion);
-  }
-
-  private FirebaseMessagingException createExceptionFromResponse(HttpResponseException e) {
-    MessagingServiceErrorResponse response = new MessagingServiceErrorResponse();
-    if (e.getContent() != null) {
-      try {
-        JsonParser parser = jsonFactory.createJsonParser(e.getContent());
-        parser.parseAndClose(response);
-      } catch (IOException ignored) {
-        // ignored
-      }
-    }
-
-    return newException(response, e);
-  }
-
-  private HttpRequestInitializer getBatchRequestInitializer() {
-    return new HttpRequestInitializer(){
-      @Override
-      public void initialize(HttpRequest request) throws IOException {
-        requestFactory.getInitializer().initialize(request);
-        request.setResponseInterceptor(responseInterceptor);
-      }
-    };
-  }
-
-  private static FirebaseMessagingException newException(MessagingServiceErrorResponse response) {
-    return newException(response, null);
-  }
-
-  private static FirebaseMessagingException newException(
-      MessagingServiceErrorResponse response, @Nullable HttpResponseException e) {
-    String code = FCM_ERROR_CODES.get(response.getErrorCode());
-    if (code == null) {
-      code = FirebaseMessaging.UNKNOWN_ERROR;
-    }
-
-    String msg = response.getErrorMessage();
-    if (Strings.isNullOrEmpty(msg)) {
-      if (e != null) {
-        msg = String.format("Unexpected HTTP response with status: %d; body: %s",
-            e.getStatusCode(), e.getContent());
-      } else {
-        msg = String.format("Unexpected HTTP response: %s", response.toString());
-      }
-    }
-
-    return new FirebaseMessagingException(code, msg, e);
-  }
-
-  private static class MessagingBatchCallback
-      implements BatchCallback<MessagingServiceResponse, MessagingServiceErrorResponse> {
-
-    private final ImmutableList.Builder<SendResponse> responses = ImmutableList.builder();
-
-    @Override
-    public void onSuccess(
-        MessagingServiceResponse response, HttpHeaders responseHeaders) {
-      responses.add(SendResponse.fromMessageId(response.getMessageId()));
-    }
-
-    @Override
-    public void onFailure(
-        MessagingServiceErrorResponse error, HttpHeaders responseHeaders) {
-      responses.add(SendResponse.fromException(newException(error)));
-    }
+interface FirebaseMessagingClient {
+
+  /**
+   * Sends the given message with FCM.
+   *
+   * @param message A non-null {@link Message} to be sent.
+   * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send.
+   * @return A message ID string.
+   * @throws FirebaseMessagingException If an error occurs while handing the message off to FCM for
+   *     delivery.
+   */
+  String send(Message message, boolean dryRun) throws  FirebaseMessagingException;
+
+  /**
+   * Sends all the messages in the given list with FCM.
+   *
+   * @param messages A non-null, non-empty list of messages.
+   * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send.
+   * @return A {@link BatchResponse} indicating the result of the operation.
+   * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for
+   *     delivery.
+   */
+  BatchResponse sendAll(List<Message> messages, boolean dryRun) throws FirebaseMessagingException;
 
-    List<SendResponse> getResponses() {
-      return this.responses.build();
-    }
-  }
 }
diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java
new file mode 100644
index 0000000..92dfd0c
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java
@@ -0,0 +1,330 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.googleapis.batch.BatchCallback;
+import com.google.api.client.googleapis.batch.BatchRequest;
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpHeaders;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpRequestInitializer;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.http.json.JsonHttpContent;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.api.client.json.JsonParser;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.ImplFirebaseTrampolines;
+import com.google.firebase.internal.ApiClientUtils;
+import com.google.firebase.internal.Nullable;
+import com.google.firebase.internal.SdkUtils;
+import com.google.firebase.messaging.internal.MessagingServiceErrorResponse;
+import com.google.firebase.messaging.internal.MessagingServiceResponse;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A helper class for interacting with Firebase Cloud Messaging service.
+ */
+final class FirebaseMessagingClientImpl implements FirebaseMessagingClient {
+
+  private static final String FCM_URL = "https://fcm.googleapis.com/v1/projects/%s/messages:send";
+
+  private static final String FCM_BATCH_URL = "https://fcm.googleapis.com/batch";
+
+  private static final String API_FORMAT_VERSION_HEADER = "X-GOOG-API-FORMAT-VERSION";
+
+  private static final String CLIENT_VERSION_HEADER = "X-Firebase-Client";
+
+  private static final Map<String, String> FCM_ERROR_CODES =
+      ImmutableMap.<String, String>builder()
+          // FCM v1 canonical error codes
+          .put("NOT_FOUND", "registration-token-not-registered")
+          .put("PERMISSION_DENIED", "mismatched-credential")
+          .put("RESOURCE_EXHAUSTED", "message-rate-exceeded")
+          .put("UNAUTHENTICATED", "invalid-apns-credentials")
+
+          // FCM v1 new error codes
+          .put("APNS_AUTH_ERROR", "invalid-apns-credentials")
+          .put("INTERNAL", FirebaseMessaging.INTERNAL_ERROR)
+          .put("INVALID_ARGUMENT", "invalid-argument")
+          .put("QUOTA_EXCEEDED", "message-rate-exceeded")
+          .put("SENDER_ID_MISMATCH", "mismatched-credential")
+          .put("UNAVAILABLE", "server-unavailable")
+          .put("UNREGISTERED", "registration-token-not-registered")
+          .build();
+
+  private final String fcmSendUrl;
+  private final HttpRequestFactory requestFactory;
+  private final HttpRequestFactory childRequestFactory;
+  private final JsonFactory jsonFactory;
+  private final HttpResponseInterceptor responseInterceptor;
+  private final String clientVersion = "fire-admin-java/" + SdkUtils.getVersion();
+
+  private FirebaseMessagingClientImpl(Builder builder) {
+    checkArgument(!Strings.isNullOrEmpty(builder.projectId));
+    this.fcmSendUrl = String.format(FCM_URL, builder.projectId);
+    this.requestFactory = checkNotNull(builder.requestFactory);
+    this.childRequestFactory = checkNotNull(builder.childRequestFactory);
+    this.jsonFactory = checkNotNull(builder.jsonFactory);
+    this.responseInterceptor = builder.responseInterceptor;
+  }
+
+  @VisibleForTesting
+  String getFcmSendUrl() {
+    return fcmSendUrl;
+  }
+
+  @VisibleForTesting
+  HttpRequestFactory getRequestFactory() {
+    return requestFactory;
+  }
+
+  @VisibleForTesting
+  HttpRequestFactory getChildRequestFactory() {
+    return childRequestFactory;
+  }
+
+  @VisibleForTesting
+  JsonFactory getJsonFactory() {
+    return jsonFactory;
+  }
+
+  @VisibleForTesting
+  String getClientVersion() {
+    return clientVersion;
+  }
+
+  public String send(Message message, boolean dryRun) throws FirebaseMessagingException {
+    try {
+      return sendSingleRequest(message, dryRun);
+    } catch (HttpResponseException e) {
+      throw createExceptionFromResponse(e);
+    } catch (IOException e) {
+      throw new FirebaseMessagingException(
+          FirebaseMessaging.INTERNAL_ERROR, "Error while calling FCM backend service", e);
+    }
+  }
+
+  public BatchResponse sendAll(
+      List<Message> messages, boolean dryRun) throws FirebaseMessagingException {
+    try {
+      return sendBatchRequest(messages, dryRun);
+    } catch (HttpResponseException e) {
+      throw createExceptionFromResponse(e);
+    } catch (IOException e) {
+      throw new FirebaseMessagingException(
+          FirebaseMessaging.INTERNAL_ERROR, "Error while calling FCM backend service", e);
+    }
+  }
+
+  private String sendSingleRequest(Message message, boolean dryRun) throws IOException {
+    HttpRequest request = requestFactory.buildPostRequest(
+        new GenericUrl(fcmSendUrl),
+        new JsonHttpContent(jsonFactory, message.wrapForTransport(dryRun)));
+    setCommonFcmHeaders(request.getHeaders());
+    request.setParser(new JsonObjectParser(jsonFactory));
+    request.setResponseInterceptor(responseInterceptor);
+    HttpResponse response = request.execute();
+    try {
+      MessagingServiceResponse parsed = new MessagingServiceResponse();
+      jsonFactory.createJsonParser(response.getContent()).parseAndClose(parsed);
+      return parsed.getMessageId();
+    } finally {
+      ApiClientUtils.disconnectQuietly(response);
+    }
+  }
+
+  private BatchResponse sendBatchRequest(
+      List<Message> messages, boolean dryRun) throws IOException {
+
+    MessagingBatchCallback callback = new MessagingBatchCallback();
+    BatchRequest batch = newBatchRequest(messages, dryRun, callback);
+    batch.execute();
+    return new BatchResponse(callback.getResponses());
+  }
+
+  private BatchRequest newBatchRequest(
+      List<Message> messages, boolean dryRun, MessagingBatchCallback callback) throws IOException {
+
+    BatchRequest batch = new BatchRequest(
+        requestFactory.getTransport(), getBatchRequestInitializer());
+    batch.setBatchUrl(new GenericUrl(FCM_BATCH_URL));
+
+    final JsonObjectParser jsonParser = new JsonObjectParser(this.jsonFactory);
+    final GenericUrl sendUrl = new GenericUrl(fcmSendUrl);
+    for (Message message : messages) {
+      // Using a separate request factory without authorization is faster for large batches.
+      // A simple performance test showed a 400-500ms speed up for batches of 1000 messages.
+      HttpRequest request = childRequestFactory.buildPostRequest(
+          sendUrl,
+          new JsonHttpContent(jsonFactory, message.wrapForTransport(dryRun)));
+      request.setParser(jsonParser);
+      setCommonFcmHeaders(request.getHeaders());
+      batch.queue(
+          request, MessagingServiceResponse.class, MessagingServiceErrorResponse.class, callback);
+    }
+    return batch;
+  }
+
+  private void setCommonFcmHeaders(HttpHeaders headers) {
+    headers.set(API_FORMAT_VERSION_HEADER, "2");
+    headers.set(CLIENT_VERSION_HEADER, clientVersion);
+  }
+
+  private FirebaseMessagingException createExceptionFromResponse(HttpResponseException e) {
+    MessagingServiceErrorResponse response = new MessagingServiceErrorResponse();
+    if (e.getContent() != null) {
+      try {
+        JsonParser parser = jsonFactory.createJsonParser(e.getContent());
+        parser.parseAndClose(response);
+      } catch (IOException ignored) {
+        // ignored
+      }
+    }
+
+    return newException(response, e);
+  }
+
+  private HttpRequestInitializer getBatchRequestInitializer() {
+    return new HttpRequestInitializer() {
+      @Override
+      public void initialize(HttpRequest request) throws IOException {
+        HttpRequestInitializer initializer = requestFactory.getInitializer();
+        if (initializer != null) {
+          initializer.initialize(request);
+        }
+        request.setResponseInterceptor(responseInterceptor);
+      }
+    };
+  }
+
+  static FirebaseMessagingClientImpl fromApp(FirebaseApp app) {
+    String projectId = ImplFirebaseTrampolines.getProjectId(app);
+    checkArgument(!Strings.isNullOrEmpty(projectId),
+        "Project ID is required to access messaging service. Use a service account credential or "
+            + "set the project ID explicitly via FirebaseOptions. Alternatively you can also "
+            + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.");
+    return FirebaseMessagingClientImpl.builder()
+        .setProjectId(projectId)
+        .setRequestFactory(ApiClientUtils.newAuthorizedRequestFactory(app))
+        .setChildRequestFactory(ApiClientUtils.newUnauthorizedRequestFactory(app))
+        .setJsonFactory(app.getOptions().getJsonFactory())
+        .build();
+  }
+
+  static Builder builder() {
+    return new Builder();
+  }
+
+  static final class Builder {
+
+    private String projectId;
+    private HttpRequestFactory requestFactory;
+    private HttpRequestFactory childRequestFactory;
+    private JsonFactory jsonFactory;
+    private HttpResponseInterceptor responseInterceptor;
+
+    private Builder() { }
+
+    Builder setProjectId(String projectId) {
+      this.projectId = projectId;
+      return this;
+    }
+
+    Builder setRequestFactory(HttpRequestFactory requestFactory) {
+      this.requestFactory = requestFactory;
+      return this;
+    }
+
+    Builder setChildRequestFactory(HttpRequestFactory childRequestFactory) {
+      this.childRequestFactory = childRequestFactory;
+      return this;
+    }
+
+    Builder setJsonFactory(JsonFactory jsonFactory) {
+      this.jsonFactory = jsonFactory;
+      return this;
+    }
+
+    Builder setResponseInterceptor(HttpResponseInterceptor responseInterceptor) {
+      this.responseInterceptor = responseInterceptor;
+      return this;
+    }
+
+    FirebaseMessagingClientImpl build() {
+      return new FirebaseMessagingClientImpl(this);
+    }
+  }
+
+  private static FirebaseMessagingException newException(MessagingServiceErrorResponse response) {
+    return newException(response, null);
+  }
+
+  private static FirebaseMessagingException newException(
+      MessagingServiceErrorResponse response, @Nullable HttpResponseException e) {
+    String code = FCM_ERROR_CODES.get(response.getErrorCode());
+    if (code == null) {
+      code = FirebaseMessaging.UNKNOWN_ERROR;
+    }
+
+    String msg = response.getErrorMessage();
+    if (Strings.isNullOrEmpty(msg)) {
+      if (e != null) {
+        msg = String.format("Unexpected HTTP response with status: %d; body: %s",
+            e.getStatusCode(), e.getContent());
+      } else {
+        msg = String.format("Unexpected HTTP response: %s", response.toString());
+      }
+    }
+
+    return new FirebaseMessagingException(code, msg, e);
+  }
+
+  private static class MessagingBatchCallback
+      implements BatchCallback<MessagingServiceResponse, MessagingServiceErrorResponse> {
+
+    private final ImmutableList.Builder<SendResponse> responses = ImmutableList.builder();
+
+    @Override
+    public void onSuccess(
+        MessagingServiceResponse response, HttpHeaders responseHeaders) {
+      responses.add(SendResponse.fromMessageId(response.getMessageId()));
+    }
+
+    @Override
+    public void onFailure(
+        MessagingServiceErrorResponse error, HttpHeaders responseHeaders) {
+      responses.add(SendResponse.fromException(newException(error)));
+    }
+
+    List<SendResponse> getResponses() {
+      return this.responses.build();
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/InstanceIdClient.java b/src/main/java/com/google/firebase/messaging/InstanceIdClient.java
index 0170290..8c1b70a 100644
--- a/src/main/java/com/google/firebase/messaging/InstanceIdClient.java
+++ b/src/main/java/com/google/firebase/messaging/InstanceIdClient.java
@@ -1,166 +1,30 @@
-/*
- * Copyright 2019 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
 package com.google.firebase.messaging;
 
-import com.google.api.client.http.GenericUrl;
-import com.google.api.client.http.HttpRequest;
-import com.google.api.client.http.HttpRequestFactory;
-import com.google.api.client.http.HttpResponse;
-import com.google.api.client.http.HttpResponseException;
-import com.google.api.client.http.HttpResponseInterceptor;
-import com.google.api.client.http.json.JsonHttpContent;
-import com.google.api.client.json.GenericJson;
-import com.google.api.client.json.JsonFactory;
-import com.google.api.client.json.JsonObjectParser;
-import com.google.api.client.json.JsonParser;
-import com.google.api.client.util.Key;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
-import com.google.firebase.FirebaseApp;
-import com.google.firebase.internal.ApiClientUtils;
-import com.google.firebase.internal.Nullable;
-
-import java.io.IOException;
 import java.util.List;
-import java.util.Map;
 
 /**
- * A helper class for interacting with the Firebase Instance ID service. Implements the FCM
- * topic management functionality.
+ * An interface for managing FCM topic subscriptions.
  */
-final class InstanceIdClient {
-
-  private static final String IID_HOST = "https://iid.googleapis.com";
-
-  private static final String IID_SUBSCRIBE_PATH = "iid/v1:batchAdd";
-
-  private static final String IID_UNSUBSCRIBE_PATH = "iid/v1:batchRemove";
-
-  static final Map<Integer, String> IID_ERROR_CODES =
-      ImmutableMap.<Integer, String>builder()
-          .put(400, "invalid-argument")
-          .put(401, "authentication-error")
-          .put(403, "authentication-error")
-          .put(500, FirebaseMessaging.INTERNAL_ERROR)
-          .put(503, "server-unavailable")
-          .build();
-
-  private final HttpRequestFactory requestFactory;
-  private final JsonFactory jsonFactory;
-  private final HttpResponseInterceptor responseInterceptor;
-
-  InstanceIdClient(FirebaseApp app, @Nullable HttpResponseInterceptor responseInterceptor) {
-    this.requestFactory = ApiClientUtils.newAuthorizedRequestFactory(app);
-    this.jsonFactory = app.getOptions().getJsonFactory();
-    this.responseInterceptor = responseInterceptor;
-  }
-
+interface InstanceIdClient {
+
+  /**
+   * Subscribes a list of registration tokens to a topic.
+   *
+   * @param registrationTokens A non-null, non-empty list of device registration tokens.
+   * @param topic Name of the topic to subscribe to. May contain the {@code /topics/} prefix.
+   * @return A {@link TopicManagementResponse}.
+   */
   TopicManagementResponse subscribeToTopic(
-      String topic, List<String> registrationTokens) throws FirebaseMessagingException {
-    try {
-      return sendInstanceIdRequest(topic, registrationTokens, IID_SUBSCRIBE_PATH);
-    } catch (HttpResponseException e) {
-      throw createExceptionFromResponse(e);
-    } catch (IOException e) {
-      throw new FirebaseMessagingException(
-          FirebaseMessaging.INTERNAL_ERROR, "Error while calling IID backend service", e);
-    }
-  }
-
+      String topic, List<String> registrationTokens) throws FirebaseMessagingException;
+
+  /**
+   * Unsubscribes a list of registration tokens from a topic.
+   *
+   * @param registrationTokens A non-null, non-empty list of device registration tokens.
+   * @param topic Name of the topic to unsubscribe from. May contain the {@code /topics/} prefix.
+   * @return A {@link TopicManagementResponse}.
+   */
   TopicManagementResponse unsubscribeFromTopic(
-      String topic, List<String> registrationTokens) throws FirebaseMessagingException {
-    try {
-      return sendInstanceIdRequest(topic, registrationTokens, IID_UNSUBSCRIBE_PATH);
-    } catch (HttpResponseException e) {
-      throw createExceptionFromResponse(e);
-    } catch (IOException e) {
-      throw new FirebaseMessagingException(
-          FirebaseMessaging.INTERNAL_ERROR, "Error while calling IID backend service", e);
-    }
-  }
-
-  private TopicManagementResponse sendInstanceIdRequest(
-      String topic, List<String> registrationTokens, String path) throws IOException {
-    String url = String.format("%s/%s", IID_HOST, path);
-    Map<String, Object> payload = ImmutableMap.of(
-        "to", getPrefixedTopic(topic),
-        "registration_tokens", registrationTokens
-    );
-    HttpResponse response = null;
-    try {
-      HttpRequest request = requestFactory.buildPostRequest(
-          new GenericUrl(url), new JsonHttpContent(jsonFactory, payload));
-      request.getHeaders().set("access_token_auth", "true");
-      request.setParser(new JsonObjectParser(jsonFactory));
-      request.setResponseInterceptor(responseInterceptor);
-      response = request.execute();
-
-      JsonParser parser = jsonFactory.createJsonParser(response.getContent());
-      InstanceIdServiceResponse parsedResponse = new InstanceIdServiceResponse();
-      parser.parse(parsedResponse);
-      return new TopicManagementResponse(parsedResponse.results);
-    } finally {
-      ApiClientUtils.disconnectQuietly(response);
-    }
-  }
-
-  private FirebaseMessagingException createExceptionFromResponse(HttpResponseException e) {
-    InstanceIdServiceErrorResponse response = new InstanceIdServiceErrorResponse();
-    if (e.getContent() != null) {
-      try {
-        JsonParser parser = jsonFactory.createJsonParser(e.getContent());
-        parser.parseAndClose(response);
-      } catch (IOException ignored) {
-        // ignored
-      }
-    }
-    return newException(response, e);
-  }
-
-  private String getPrefixedTopic(String topic) {
-    if (topic.startsWith("/topics/")) {
-      return topic;
-    } else {
-      return "/topics/" + topic;
-    }
-  }
-
-  private static FirebaseMessagingException newException(
-      InstanceIdServiceErrorResponse response, HttpResponseException e) {
-    // Infer error code from HTTP status
-    String code = IID_ERROR_CODES.get(e.getStatusCode());
-    if (code == null) {
-      code = FirebaseMessaging.UNKNOWN_ERROR;
-    }
-    String msg = response.error;
-    if (Strings.isNullOrEmpty(msg)) {
-      msg = String.format("Unexpected HTTP response with status: %d; body: %s",
-          e.getStatusCode(), e.getContent());
-    }
-    return new FirebaseMessagingException(code, msg, e);
-  }
-
-  private static class InstanceIdServiceResponse {
-    @Key("results")
-    private List<GenericJson> results;
-  }
+      String topic, List<String> registrationTokens) throws FirebaseMessagingException;
 
-  private static class InstanceIdServiceErrorResponse {
-    @Key("error")
-    private String error;
-  }
 }
diff --git a/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java b/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java
new file mode 100644
index 0000000..15f8158
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.http.json.JsonHttpContent;
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.api.client.json.JsonParser;
+import com.google.api.client.util.Key;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.internal.ApiClientUtils;
+import com.google.firebase.internal.Nullable;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A helper class for interacting with the Firebase Instance ID service. Implements the FCM
+ * topic management functionality.
+ */
+final class InstanceIdClientImpl implements InstanceIdClient {
+
+  private static final String IID_HOST = "https://iid.googleapis.com";
+
+  private static final String IID_SUBSCRIBE_PATH = "iid/v1:batchAdd";
+
+  private static final String IID_UNSUBSCRIBE_PATH = "iid/v1:batchRemove";
+
+  static final Map<Integer, String> IID_ERROR_CODES =
+      ImmutableMap.<Integer, String>builder()
+          .put(400, "invalid-argument")
+          .put(401, "authentication-error")
+          .put(403, "authentication-error")
+          .put(500, FirebaseMessaging.INTERNAL_ERROR)
+          .put(503, "server-unavailable")
+          .build();
+
+  private final HttpRequestFactory requestFactory;
+  private final JsonFactory jsonFactory;
+  private final HttpResponseInterceptor responseInterceptor;
+
+  InstanceIdClientImpl(HttpRequestFactory requestFactory, JsonFactory jsonFactory) {
+    this(requestFactory, jsonFactory, null);
+  }
+
+  InstanceIdClientImpl(
+      HttpRequestFactory requestFactory,
+      JsonFactory jsonFactory,
+      @Nullable HttpResponseInterceptor responseInterceptor) {
+    this.requestFactory = checkNotNull(requestFactory);
+    this.jsonFactory = checkNotNull(jsonFactory);
+    this.responseInterceptor = responseInterceptor;
+  }
+
+  static InstanceIdClientImpl fromApp(FirebaseApp app) {
+    return new InstanceIdClientImpl(
+        ApiClientUtils.newAuthorizedRequestFactory(app),
+        app.getOptions().getJsonFactory());
+  }
+
+  @VisibleForTesting
+  HttpRequestFactory getRequestFactory() {
+    return requestFactory;
+  }
+
+  @VisibleForTesting
+  JsonFactory getJsonFactory() {
+    return jsonFactory;
+  }
+
+  public TopicManagementResponse subscribeToTopic(
+      String topic, List<String> registrationTokens) throws FirebaseMessagingException {
+    try {
+      return sendInstanceIdRequest(topic, registrationTokens, IID_SUBSCRIBE_PATH);
+    } catch (HttpResponseException e) {
+      throw createExceptionFromResponse(e);
+    } catch (IOException e) {
+      throw new FirebaseMessagingException(
+          FirebaseMessaging.INTERNAL_ERROR, "Error while calling IID backend service", e);
+    }
+  }
+
+  public TopicManagementResponse unsubscribeFromTopic(
+      String topic, List<String> registrationTokens) throws FirebaseMessagingException {
+    try {
+      return sendInstanceIdRequest(topic, registrationTokens, IID_UNSUBSCRIBE_PATH);
+    } catch (HttpResponseException e) {
+      throw createExceptionFromResponse(e);
+    } catch (IOException e) {
+      throw new FirebaseMessagingException(
+          FirebaseMessaging.INTERNAL_ERROR, "Error while calling IID backend service", e);
+    }
+  }
+
+  private TopicManagementResponse sendInstanceIdRequest(
+      String topic, List<String> registrationTokens, String path) throws IOException {
+    String url = String.format("%s/%s", IID_HOST, path);
+    Map<String, Object> payload = ImmutableMap.of(
+        "to", getPrefixedTopic(topic),
+        "registration_tokens", registrationTokens
+    );
+    HttpResponse response = null;
+    try {
+      HttpRequest request = requestFactory.buildPostRequest(
+          new GenericUrl(url), new JsonHttpContent(jsonFactory, payload));
+      request.getHeaders().set("access_token_auth", "true");
+      request.setParser(new JsonObjectParser(jsonFactory));
+      request.setResponseInterceptor(responseInterceptor);
+      response = request.execute();
+
+      JsonParser parser = jsonFactory.createJsonParser(response.getContent());
+      InstanceIdServiceResponse parsedResponse = new InstanceIdServiceResponse();
+      parser.parse(parsedResponse);
+      return new TopicManagementResponse(parsedResponse.results);
+    } finally {
+      ApiClientUtils.disconnectQuietly(response);
+    }
+  }
+
+  private FirebaseMessagingException createExceptionFromResponse(HttpResponseException e) {
+    InstanceIdServiceErrorResponse response = new InstanceIdServiceErrorResponse();
+    if (e.getContent() != null) {
+      try {
+        JsonParser parser = jsonFactory.createJsonParser(e.getContent());
+        parser.parseAndClose(response);
+      } catch (IOException ignored) {
+        // ignored
+      }
+    }
+    return newException(response, e);
+  }
+
+  private String getPrefixedTopic(String topic) {
+    if (topic.startsWith("/topics/")) {
+      return topic;
+    } else {
+      return "/topics/" + topic;
+    }
+  }
+
+  private static FirebaseMessagingException newException(
+      InstanceIdServiceErrorResponse response, HttpResponseException e) {
+    // Infer error code from HTTP status
+    String code = IID_ERROR_CODES.get(e.getStatusCode());
+    if (code == null) {
+      code = FirebaseMessaging.UNKNOWN_ERROR;
+    }
+    String msg = response.error;
+    if (Strings.isNullOrEmpty(msg)) {
+      msg = String.format("Unexpected HTTP response with status: %d; body: %s",
+          e.getStatusCode(), e.getContent());
+    }
+    return new FirebaseMessagingException(code, msg, e);
+  }
+
+  private static class InstanceIdServiceResponse {
+    @Key("results")
+    private List<GenericJson> results;
+  }
+
+  private static class InstanceIdServiceErrorResponse {
+    @Key("error")
+    private String error;
+  }
+}


Test patch:
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingClientImplTest.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingClientImplTest.java
new file mode 100644
index 0000000..87e9499
--- /dev/null
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingClientImplTest.java
@@ -0,0 +1,819 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpHeaders;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestInitializer;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.json.JsonParser;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.auth.MockGoogleCredentials;
+import com.google.firebase.internal.SdkUtils;
+import com.google.firebase.messaging.WebpushNotification.Action;
+import com.google.firebase.messaging.WebpushNotification.Direction;
+import com.google.firebase.testing.TestResponseInterceptor;
+import com.google.firebase.testing.TestUtils;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class FirebaseMessagingClientImplTest {
+
+  private static final String TEST_FCM_URL =
+      "https://fcm.googleapis.com/v1/projects/test-project/messages:send";
+
+  private static final List<Integer> HTTP_ERRORS = ImmutableList.of(401, 404, 500);
+
+  private static final String MOCK_RESPONSE = "{\"name\": \"mock-name\"}";
+
+  private static final String MOCK_BATCH_SUCCESS_RESPONSE = TestUtils.loadResource(
+      "fcm_batch_success.txt");
+
+  private static final String MOCK_BATCH_FAILURE_RESPONSE = TestUtils.loadResource(
+      "fcm_batch_failure.txt");
+
+  private static final Message EMPTY_MESSAGE = Message.builder()
+      .setTopic("test-topic")
+      .build();
+  private static final List<Message> MESSAGE_LIST = ImmutableList.of(EMPTY_MESSAGE, EMPTY_MESSAGE);
+  
+  private static final boolean DRY_RUN_ENABLED = true;
+  private static final boolean DRY_RUN_DISABLED = false;
+
+  private MockLowLevelHttpResponse response;
+  private TestResponseInterceptor interceptor;
+  private FirebaseMessagingClient client;
+
+  @Before
+  public void setUp() {
+    response = new MockLowLevelHttpResponse();
+    interceptor = new TestResponseInterceptor();
+    client = initMessagingClient(response, interceptor);
+  }
+
+  @Test
+  public void testSend() throws Exception {
+    Map<Message, Map<String, Object>> testMessages = buildTestMessages();
+    
+    for (Map.Entry<Message, Map<String, Object>> entry : testMessages.entrySet()) {
+      response.setContent(MOCK_RESPONSE);
+      String resp = client.send(entry.getKey(), DRY_RUN_DISABLED);
+
+      assertEquals("mock-name", resp);
+      checkRequestHeader(interceptor.getLastRequest());
+      checkRequest(interceptor.getLastRequest(),
+          ImmutableMap.<String, Object>of("message", entry.getValue()));
+    }
+  }
+
+  @Test
+  public void testSendDryRun() throws Exception {
+    Map<Message, Map<String, Object>> testMessages = buildTestMessages();
+
+    for (Map.Entry<Message, Map<String, Object>> entry : testMessages.entrySet()) {
+      response.setContent(MOCK_RESPONSE);
+      String resp = client.send(entry.getKey(), DRY_RUN_ENABLED);
+
+      assertEquals("mock-name", resp);
+      checkRequestHeader(interceptor.getLastRequest());
+      checkRequest(interceptor.getLastRequest(),
+          ImmutableMap.of("message", entry.getValue(), "validate_only", true));
+    }
+  }
+
+  @Test
+  public void testSendHttpError() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent("{}");
+
+      try {
+        client.send(EMPTY_MESSAGE, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "unknown-error",
+            "Unexpected HTTP response with status: " + code + "; body: {}");
+      }
+      checkRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendTransportError() {
+    client = initClientWithFaultyTransport();
+
+    try {
+      client.send(EMPTY_MESSAGE, DRY_RUN_DISABLED);
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseMessagingException error) {
+      assertEquals("internal-error", error.getErrorCode());
+      assertEquals("Error while calling FCM backend service", error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
+    }
+  }
+
+  @Test
+  public void testSendSuccessResponseWithUnexpectedPayload() {
+    Map<Message, Map<String, Object>> testMessages = buildTestMessages();
+
+    for (Map.Entry<Message, Map<String, Object>> entry : testMessages.entrySet()) {
+      response.setContent("not valid json");
+
+      try {
+        client.send(entry.getKey(), DRY_RUN_DISABLED);
+        fail("No error thrown for malformed response");
+      } catch (FirebaseMessagingException error) {
+        assertEquals("internal-error", error.getErrorCode());
+        assertEquals("Error while calling FCM backend service", error.getMessage());
+      }
+      checkRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendErrorWithZeroContentResponse() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setZeroContent();
+
+      try {
+        client.send(EMPTY_MESSAGE, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "unknown-error",
+            "Unexpected HTTP response with status: " + code + "; body: null");
+      }
+      checkRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendErrorWithMalformedResponse() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent("not json");
+
+      try {
+        client.send(EMPTY_MESSAGE, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "unknown-error",
+            "Unexpected HTTP response with status: " + code + "; body: not json");
+      }
+      checkRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendErrorWithDetails() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
+
+      try {
+        client.send(EMPTY_MESSAGE, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "invalid-argument");
+      }
+      checkRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendErrorWithCanonicalCode() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"NOT_FOUND\", \"message\": \"test error\"}}");
+
+      try {
+        client.send(EMPTY_MESSAGE, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "registration-token-not-registered");
+      }
+      checkRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendErrorWithFcmError() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\", "
+              + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
+              + ".v1.FcmError\", \"errorCode\": \"UNREGISTERED\"}]}}");
+
+      try {
+        client.send(EMPTY_MESSAGE, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "registration-token-not-registered");
+      }
+      checkRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAll() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessagingClient client = initMessagingClientForBatchRequests(
+        MOCK_BATCH_SUCCESS_RESPONSE, interceptor);
+
+    BatchResponse responses = client.sendAll(MESSAGE_LIST, false);
+
+    assertBatchResponse(responses, interceptor, 2, 0);
+  }
+
+  @Test
+  public void testSendAllDryRun() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessagingClient client = initMessagingClientForBatchRequests(
+        MOCK_BATCH_SUCCESS_RESPONSE, interceptor);
+
+    BatchResponse responses = client.sendAll(MESSAGE_LIST, true);
+
+    assertBatchResponse(responses, interceptor, 2, 0);
+  }
+
+  @Test
+  public void testRequestInitializerAppliedToBatchRequests() throws Exception {
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(getBatchResponse(MOCK_BATCH_SUCCESS_RESPONSE))
+        .build();
+    HttpRequestInitializer initializer = new HttpRequestInitializer() {
+      @Override
+      public void initialize(HttpRequest httpRequest) {
+        httpRequest.getHeaders().set("x-custom-header", "test-value");
+      }
+    };
+    FirebaseMessagingClientImpl client = FirebaseMessagingClientImpl.builder()
+        .setProjectId("test-project")
+        .setJsonFactory(Utils.getDefaultJsonFactory())
+        .setRequestFactory(transport.createRequestFactory(initializer))
+        .setChildRequestFactory(Utils.getDefaultTransport().createRequestFactory())
+        .setResponseInterceptor(interceptor)
+        .build();
+
+    try {
+      client.sendAll(MESSAGE_LIST, DRY_RUN_DISABLED);
+    } finally {
+      HttpRequest request = interceptor.getLastRequest();
+      assertEquals("test-value", request.getHeaders().get("x-custom-header"));
+    }
+  }
+
+  @Test
+  public void testSendAllFailure() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessagingClient client = initMessagingClientForBatchRequests(
+        MOCK_BATCH_FAILURE_RESPONSE, interceptor);
+    List<Message> messages = ImmutableList.of(EMPTY_MESSAGE, EMPTY_MESSAGE, EMPTY_MESSAGE);
+
+    BatchResponse responses = client.sendAll(messages, DRY_RUN_DISABLED);
+
+    assertBatchResponse(responses, interceptor, 1, 2);
+  }
+
+  @Test
+  public void testSendAllHttpError() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent("{}");
+
+      try {
+        client.sendAll(MESSAGE_LIST, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "unknown-error",
+            "Unexpected HTTP response with status: " + code + "; body: {}");
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAllTransportError() {
+    FirebaseMessagingClient client = initClientWithFaultyTransport();
+
+    try {
+      client.sendAll(MESSAGE_LIST, DRY_RUN_DISABLED);
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseMessagingException error) {
+      assertEquals("internal-error", error.getErrorCode());
+      assertEquals("Error while calling FCM backend service", error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
+    }
+  }
+
+  @Test
+  public void testSendAllErrorWithEmptyResponse() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setZeroContent();
+
+      try {
+        client.sendAll(MESSAGE_LIST, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "unknown-error",
+            "Unexpected HTTP response with status: " + code + "; body: null");
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAllErrorWithDetails() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
+
+      try {
+        client.sendAll(MESSAGE_LIST, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "invalid-argument");
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAllErrorWithCanonicalCode() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"NOT_FOUND\", \"message\": \"test error\"}}");
+
+      try {
+        client.sendAll(MESSAGE_LIST, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "registration-token-not-registered");
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAllErrorWithFcmError() {
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\", "
+              + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
+              + ".v1.FcmError\", \"errorCode\": \"UNREGISTERED\"}]}}");
+
+      try {
+        client.sendAll(MESSAGE_LIST, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "registration-token-not-registered");
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAllErrorWithoutMessage() {
+    final String responseBody = "{\"error\": {\"status\": \"INVALID_ARGUMENT\", "
+        + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
+        + ".v1.FcmError\", \"errorCode\": \"UNREGISTERED\"}]}}";
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(responseBody);
+
+      try {
+        client.sendAll(MESSAGE_LIST, DRY_RUN_DISABLED);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, "registration-token-not-registered",
+            "Unexpected HTTP response with status: " + code + "; body: " + responseBody);
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testBuilderNullProjectId() {
+    fullyPopulatedBuilder().setProjectId(null).build();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testBuilderEmptyProjectId() {
+    fullyPopulatedBuilder().setProjectId("").build();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testBuilderNullRequestFactory() {
+    fullyPopulatedBuilder().setRequestFactory(null).build();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testBuilderNullChildRequestFactory() {
+    fullyPopulatedBuilder().setChildRequestFactory(null).build();
+  }
+
+  @Test
+  public void testFromApp() throws IOException {
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setProjectId("test-project")
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options);
+
+    try {
+      FirebaseMessagingClientImpl client = FirebaseMessagingClientImpl.fromApp(app);
+
+      assertEquals(TEST_FCM_URL, client.getFcmSendUrl());
+      assertEquals("fire-admin-java/" + SdkUtils.getVersion(), client.getClientVersion());
+      assertSame(options.getJsonFactory(), client.getJsonFactory());
+
+      HttpRequest request = client.getRequestFactory().buildGetRequest(
+          new GenericUrl("https://example.com"));
+      assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
+
+      request = client.getChildRequestFactory().buildGetRequest(
+          new GenericUrl("https://example.com"));
+      assertNull(request.getHeaders().getAuthorization());
+    } finally {
+      app.delete();
+    }
+  }
+
+  private FirebaseMessagingClientImpl initMessagingClient(
+      MockLowLevelHttpResponse mockResponse, HttpResponseInterceptor interceptor) {
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(mockResponse)
+        .build();
+
+    return FirebaseMessagingClientImpl.builder()
+        .setProjectId("test-project")
+        .setJsonFactory(Utils.getDefaultJsonFactory())
+        .setRequestFactory(transport.createRequestFactory())
+        .setChildRequestFactory(Utils.getDefaultTransport().createRequestFactory())
+        .setResponseInterceptor(interceptor)
+        .build();
+  }
+
+  private FirebaseMessagingClientImpl initMessagingClientForBatchRequests(
+      String responsePayload, TestResponseInterceptor interceptor) {
+    MockLowLevelHttpResponse httpResponse = getBatchResponse(responsePayload);
+    return initMessagingClient(httpResponse, interceptor);
+  }
+
+  private MockLowLevelHttpResponse getBatchResponse(String responsePayload) {
+    return new MockLowLevelHttpResponse()
+        .setContentType("multipart/mixed; boundary=test_boundary")
+        .setContent(responsePayload);
+  }
+
+  private FirebaseMessagingClientImpl initClientWithFaultyTransport() {
+    HttpTransport transport = TestUtils.createFaultyHttpTransport();
+    return FirebaseMessagingClientImpl.builder()
+        .setProjectId("test-project")
+        .setJsonFactory(Utils.getDefaultJsonFactory())
+        .setRequestFactory(transport.createRequestFactory())
+        .setChildRequestFactory(Utils.getDefaultTransport().createRequestFactory())
+        .build();
+  }
+
+  private void checkRequestHeader(HttpRequest request) {
+    assertEquals("POST", request.getRequestMethod());
+    assertEquals(TEST_FCM_URL, request.getUrl().toString());
+    HttpHeaders headers = request.getHeaders();
+    assertEquals("2", headers.get("X-GOOG-API-FORMAT-VERSION"));
+    assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client"));
+  }
+
+  private void checkRequest(
+      HttpRequest request, Map<String, Object> expected) throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    request.getContent().writeTo(out);
+    JsonParser parser = Utils.getDefaultJsonFactory().createJsonParser(out.toString());
+    Map<String, Object> parsed = new HashMap<>();
+    parser.parseAndClose(parsed);
+    assertEquals(expected, parsed);
+  }
+
+  private void assertBatchResponse(
+      BatchResponse batchResponse, TestResponseInterceptor interceptor,
+      int successCount, int failureCount) throws IOException {
+
+    assertEquals(successCount, batchResponse.getSuccessCount());
+    assertEquals(failureCount, batchResponse.getFailureCount());
+
+    List<SendResponse> responses = batchResponse.getResponses();
+    assertEquals(successCount + failureCount, responses.size());
+    for (int i = 0; i < successCount; i++) {
+      SendResponse sendResponse = responses.get(i);
+      assertTrue(sendResponse.isSuccessful());
+      assertEquals("projects/test-project/messages/" + (i + 1), sendResponse.getMessageId());
+      assertNull(sendResponse.getException());
+    }
+
+    for (int i = successCount; i < failureCount; i++) {
+      SendResponse sendResponse = responses.get(i);
+      assertFalse(sendResponse.isSuccessful());
+      assertNull(sendResponse.getMessageId());
+
+      FirebaseMessagingException exception = sendResponse.getException();
+      assertNotNull(exception);
+      assertEquals("invalid-argument", exception.getErrorCode());
+    }
+
+    checkBatchRequestHeader(interceptor.getLastRequest());
+    checkBatchRequest(interceptor.getLastRequest(), successCount + failureCount);
+  }
+
+  private void checkBatchRequestHeader(HttpRequest request) {
+    assertEquals("POST", request.getRequestMethod());
+    assertEquals("https://fcm.googleapis.com/batch", request.getUrl().toString());
+  }
+
+  private void checkBatchRequest(HttpRequest request, int expectedParts) throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    request.getContent().writeTo(out);
+    String[] lines = out.toString().split("\n");
+    assertEquals(expectedParts, countLinesWithPrefix(lines, "POST " + TEST_FCM_URL));
+    assertEquals(expectedParts, countLinesWithPrefix(lines, "x-goog-api-format-version: 2"));
+    assertEquals(expectedParts, countLinesWithPrefix(
+        lines, "x-firebase-client: fire-admin-java/" + SdkUtils.getVersion()));
+  }
+
+  private int countLinesWithPrefix(String[] lines, String prefix) {
+    int matchCount = 0;
+    for (String line : lines) {
+      if (line.trim().startsWith(prefix)) {
+        matchCount++;
+      }
+    }
+    return matchCount;
+  }
+
+  private FirebaseMessagingClientImpl.Builder fullyPopulatedBuilder() {
+    return FirebaseMessagingClientImpl.builder()
+        .setProjectId("test-project")
+        .setJsonFactory(Utils.getDefaultJsonFactory())
+        .setRequestFactory(Utils.getDefaultTransport().createRequestFactory())
+        .setChildRequestFactory(Utils.getDefaultTransport().createRequestFactory());
+  }
+
+  private void checkExceptionFromHttpResponse(
+      FirebaseMessagingException error, String expectedCode) {
+    checkExceptionFromHttpResponse(error, expectedCode, "test error");
+  }
+
+  private void checkExceptionFromHttpResponse(
+      FirebaseMessagingException error, String expectedCode, String expectedMessage) {
+    assertEquals(expectedCode, error.getErrorCode());
+    assertEquals(expectedMessage, error.getMessage());
+    assertTrue(error.getCause() instanceof HttpResponseException);
+  }
+
+  private static Map<Message, Map<String, Object>> buildTestMessages() {
+    // Create a map of FCM messages to their expected JSON serializations.
+    ImmutableMap.Builder<Message, Map<String, Object>> builder = ImmutableMap.builder();
+
+    // Empty message
+    builder.put(
+        EMPTY_MESSAGE,
+        ImmutableMap.<String, Object>of("topic", "test-topic"));
+
+    // Notification message
+    builder.put(
+        Message.builder()
+            .setNotification(new Notification("test title", "test body"))
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "notification", ImmutableMap.of("title", "test title", "body", "test body")));
+
+    // Data message
+    builder.put(
+        Message.builder()
+            .putData("k1", "v1")
+            .putData("k2", "v2")
+            .putAllData(ImmutableMap.of("k3", "v3", "k4", "v4"))
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3", "k4", "v4")));
+
+    // Android message
+    builder.put(
+        Message.builder()
+            .setAndroidConfig(AndroidConfig.builder()
+                .setPriority(AndroidConfig.Priority.HIGH)
+                .setTtl(TimeUnit.SECONDS.toMillis(123))
+                .setRestrictedPackageName("test-package")
+                .setCollapseKey("test-key")
+                .setNotification(AndroidNotification.builder()
+                    .setClickAction("test-action")
+                    .setTitle("test-title")
+                    .setBody("test-body")
+                    .setIcon("test-icon")
+                    .setColor("#112233")
+                    .setTag("test-tag")
+                    .setSound("test-sound")
+                    .setTitleLocalizationKey("test-title-key")
+                    .setBodyLocalizationKey("test-body-key")
+                    .addTitleLocalizationArg("t-arg1")
+                    .addAllTitleLocalizationArgs(ImmutableList.of("t-arg2", "t-arg3"))
+                    .addBodyLocalizationArg("b-arg1")
+                    .addAllBodyLocalizationArgs(ImmutableList.of("b-arg2", "b-arg3"))
+                    .setChannelId("channel-id")
+                    .build())
+                .build())
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "android", ImmutableMap.of(
+                "priority", "high",
+                "collapse_key", "test-key",
+                "ttl", "123s",
+                "restricted_package_name", "test-package",
+                "notification", ImmutableMap.builder()
+                    .put("click_action", "test-action")
+                    .put("title", "test-title")
+                    .put("body", "test-body")
+                    .put("icon", "test-icon")
+                    .put("color", "#112233")
+                    .put("tag", "test-tag")
+                    .put("sound", "test-sound")
+                    .put("title_loc_key", "test-title-key")
+                    .put("title_loc_args", ImmutableList.of("t-arg1", "t-arg2", "t-arg3"))
+                    .put("body_loc_key", "test-body-key")
+                    .put("body_loc_args", ImmutableList.of("b-arg1", "b-arg2", "b-arg3"))
+                    .put("channel_id", "channel-id")
+                    .build()
+            )
+        ));
+
+    // APNS message
+    builder.put(
+        Message.builder()
+            .setApnsConfig(ApnsConfig.builder()
+                .putHeader("h1", "v1")
+                .putAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
+                .putAllCustomData(ImmutableMap.<String, Object>of("k1", "v1", "k2", true))
+                .setAps(Aps.builder()
+                    .setBadge(42)
+                    .setAlert(ApsAlert.builder()
+                        .setTitle("test-title")
+                        .setSubtitle("test-subtitle")
+                        .setBody("test-body")
+                        .build())
+                    .build())
+                .build())
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "apns", ImmutableMap.of(
+                "headers", ImmutableMap.of("h1", "v1", "h2", "v2", "h3", "v3"),
+                "payload", ImmutableMap.of("k1", "v1", "k2", true,
+                    "aps", ImmutableMap.<String, Object>of("badge", new BigDecimal(42),
+                        "alert", ImmutableMap.<String, Object>of(
+                            "title", "test-title", "subtitle", "test-subtitle",
+                            "body", "test-body"))))
+        ));
+
+    // Webpush message (no notification)
+    builder.put(
+        Message.builder()
+            .setWebpushConfig(WebpushConfig.builder()
+                .putHeader("h1", "v1")
+                .putAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
+                .putData("k1", "v1")
+                .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
+                .build())
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "webpush", ImmutableMap.of(
+                "headers", ImmutableMap.of("h1", "v1", "h2", "v2", "h3", "v3"),
+                "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"))
+        ));
+
+    // Webpush message (simple notification)
+    builder.put(
+        Message.builder()
+            .setWebpushConfig(WebpushConfig.builder()
+                .putHeader("h1", "v1")
+                .putAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
+                .putData("k1", "v1")
+                .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
+                .setNotification(new WebpushNotification("test-title", "test-body", "test-icon"))
+                .build())
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "webpush", ImmutableMap.of(
+                "headers", ImmutableMap.of("h1", "v1", "h2", "v2", "h3", "v3"),
+                "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
+                "notification", ImmutableMap.of(
+                    "title", "test-title", "body", "test-body", "icon", "test-icon"))
+        ));
+
+    // Webpush message (all fields)
+    builder.put(
+        Message.builder()
+            .setWebpushConfig(WebpushConfig.builder()
+                .putHeader("h1", "v1")
+                .putAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
+                .putData("k1", "v1")
+                .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
+                .setNotification(WebpushNotification.builder()
+                    .setTitle("test-title")
+                    .setBody("test-body")
+                    .setIcon("test-icon")
+                    .setBadge("test-badge")
+                    .setImage("test-image")
+                    .setLanguage("test-lang")
+                    .setTag("test-tag")
+                    .setData(ImmutableList.of("arbitrary", "data"))
+                    .setDirection(Direction.AUTO)
+                    .setRenotify(true)
+                    .setRequireInteraction(false)
+                    .setSilent(true)
+                    .setTimestampMillis(100L)
+                    .setVibrate(new int[]{200, 100, 200})
+                    .addAction(new Action("action1", "title1"))
+                    .addAllActions(ImmutableList.of(new Action("action2", "title2", "icon2")))
+                    .putCustomData("k4", "v4")
+                    .putAllCustomData(ImmutableMap.<String, Object>of("k5", "v5", "k6", "v6"))
+                    .build())
+                .build())
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "webpush", ImmutableMap.of(
+                "headers", ImmutableMap.of("h1", "v1", "h2", "v2", "h3", "v3"),
+                "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
+                "notification", ImmutableMap.builder()
+                    .put("title", "test-title")
+                    .put("body", "test-body")
+                    .put("icon", "test-icon")
+                    .put("badge", "test-badge")
+                    .put("image", "test-image")
+                    .put("lang", "test-lang")
+                    .put("tag", "test-tag")
+                    .put("data", ImmutableList.of("arbitrary", "data"))
+                    .put("renotify", true)
+                    .put("requireInteraction", false)
+                    .put("silent", true)
+                    .put("dir", "auto")
+                    .put("timestamp", new BigDecimal(100))
+                    .put("vibrate", ImmutableList.of(
+                        new BigDecimal(200), new BigDecimal(100), new BigDecimal(200)))
+                    .put("actions", ImmutableList.of(
+                        ImmutableMap.of("action", "action1", "title", "title1"),
+                        ImmutableMap.of("action", "action2", "title", "title2", "icon", "icon2")))
+                    .put("k4", "v4")
+                    .put("k5", "v5")
+                    .put("k6", "v6")
+                    .build())
+        ));
+
+    return builder.build();
+  }
+}
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
index 7a7fbb2..d5f39ba 100644
--- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2018 Google Inc.
+ * Copyright 2019 Google Inc.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,65 +18,39 @@ package com.google.firebase.messaging;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
-import com.google.api.client.googleapis.util.Utils;
-import com.google.api.client.http.HttpHeaders;
-import com.google.api.client.http.HttpRequest;
-import com.google.api.client.http.HttpResponseException;
-import com.google.api.client.http.HttpResponseInterceptor;
-import com.google.api.client.http.HttpTransport;
-import com.google.api.client.http.LowLevelHttpRequest;
-import com.google.api.client.json.JsonParser;
-import com.google.api.client.testing.http.MockHttpTransport;
-import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.api.client.json.GenericJson;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.TestOnlyImplFirebaseTrampolines;
 import com.google.firebase.auth.MockGoogleCredentials;
-import com.google.firebase.internal.SdkUtils;
-import com.google.firebase.messaging.WebpushNotification.Action;
-import com.google.firebase.messaging.WebpushNotification.Direction;
-import com.google.firebase.testing.GenericFunction;
-import com.google.firebase.testing.TestResponseInterceptor;
-import com.google.firebase.testing.TestUtils;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.math.BigDecimal;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
 import org.junit.After;
 import org.junit.Test;
 
 public class FirebaseMessagingTest {
 
-  private static final String TEST_FCM_URL =
-      "https://fcm.googleapis.com/v1/projects/test-project/messages:send";
-
-  private static final String TEST_IID_SUBSCRIBE_URL =
-      "https://iid.googleapis.com/iid/v1:batchAdd";
-
-  private static final String TEST_IID_UNSUBSCRIBE_URL =
-      "https://iid.googleapis.com/iid/v1:batchRemove";
-
-  private static final List<Integer> HTTP_ERRORS = ImmutableList.of(401, 404, 500);
-
-  private static final String MOCK_RESPONSE = "{\"name\": \"mock-name\"}";
-
-  private static final String MOCK_BATCH_SUCCESS_RESPONSE = TestUtils.loadResource(
-      "fcm_batch_success.txt");
-
-  private static final String MOCK_BATCH_FAILURE_RESPONSE = TestUtils.loadResource(
-      "fcm_batch_failure.txt");
+  private static final FirebaseOptions TEST_OPTIONS = FirebaseOptions.builder()
+      .setCredentials(new MockGoogleCredentials("test-token"))
+      .setProjectId("test-project")
+      .build();
+  private static final Message EMPTY_MESSAGE = Message.builder()
+      .setTopic("test-topic")
+      .build();
+  private static final MulticastMessage TEST_MULTICAST_MESSAGE = MulticastMessage.builder()
+      .addToken("test-fcm-token1")
+      .addToken("test-fcm-token2")
+      .build();
+  private static final FirebaseMessagingException TEST_EXCEPTION =
+      new FirebaseMessagingException("TEST_CODE", "Test error message", new Exception());
 
   private static final ImmutableList.Builder<String> TOO_MANY_IDS = ImmutableList.builder();
 
@@ -96,6 +70,8 @@ public class FirebaseMessagingTest {
       new TopicMgtArgs(ImmutableList.of("id"), ""),
       new TopicMgtArgs(ImmutableList.of("id"), "foo*")
   );
+  private static final TopicManagementResponse TOPIC_MGT_RESPONSE = new TopicManagementResponse(
+      ImmutableList.of(new GenericJson()));
 
   @After
   public void tearDown() {
@@ -104,36 +80,52 @@ public class FirebaseMessagingTest {
 
   @Test
   public void testGetInstance() {
-    FirebaseOptions options = new FirebaseOptions.Builder()
-        .setCredentials(new MockGoogleCredentials("test-token"))
-        .setProjectId("test-project")
-        .build();
-    FirebaseApp.initializeApp(options);
+    FirebaseApp.initializeApp(TEST_OPTIONS);
 
     FirebaseMessaging messaging = FirebaseMessaging.getInstance();
+
     assertSame(messaging, FirebaseMessaging.getInstance());
   }
 
   @Test
   public void testGetInstanceByApp() {
-    FirebaseOptions options = new FirebaseOptions.Builder()
-        .setCredentials(new MockGoogleCredentials("test-token"))
-        .setProjectId("test-project")
-        .build();
-    FirebaseApp app = FirebaseApp.initializeApp(options, "custom-app");
+    FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS, "custom-app");
 
     FirebaseMessaging messaging = FirebaseMessaging.getInstance(app);
+
     assertSame(messaging, FirebaseMessaging.getInstance(app));
   }
 
+  @Test
+  public void testDefaultMessagingClient() {
+    FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS, "custom-app");
+    FirebaseMessaging messaging = FirebaseMessaging.getInstance(app);
+
+    FirebaseMessagingClient client = messaging.getMessagingClient();
+
+    assertTrue(client instanceof FirebaseMessagingClientImpl);
+    assertSame(client, messaging.getMessagingClient());
+    String expectedUrl = "https://fcm.googleapis.com/v1/projects/test-project/messages:send";
+    assertEquals(expectedUrl, ((FirebaseMessagingClientImpl) client).getFcmSendUrl());
+  }
+
+  @Test
+  public void testDefaultInstanceIdClient() {
+    FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS, "custom-app");
+    FirebaseMessaging messaging = FirebaseMessaging.getInstance(app);
+
+    InstanceIdClient client = messaging.getInstanceIdClient();
+
+    assertTrue(client instanceof InstanceIdClientImpl);
+    assertSame(client, messaging.getInstanceIdClient());
+  }
+
   @Test
   public void testPostDeleteApp() {
-    FirebaseOptions options = new FirebaseOptions.Builder()
-        .setCredentials(new MockGoogleCredentials("test-token"))
-        .setProjectId("test-project")
-        .build();
-    FirebaseApp app = FirebaseApp.initializeApp(options, "custom-app");
+    FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS, "custom-app");
+
     app.delete();
+
     try {
       FirebaseMessaging.getInstance(app);
       fail("No error thrown for deleted app");
@@ -143,964 +135,636 @@ public class FirebaseMessagingTest {
   }
 
   @Test
-  public void testNoProjectId() {
-    FirebaseOptions options = new FirebaseOptions.Builder()
+  public void testMessagingClientWithoutProjectId() {
+    FirebaseOptions options = FirebaseOptions.builder()
         .setCredentials(new MockGoogleCredentials("test-token"))
         .build();
     FirebaseApp.initializeApp(options);
+    FirebaseMessaging messaging = FirebaseMessaging.getInstance();
+
     try {
-      FirebaseMessaging.getInstance();
+      messaging.getMessagingClient();
       fail("No error thrown for missing project ID");
     } catch (IllegalArgumentException expected) {
-      // expected
+      String message = "Project ID is required to access messaging service. Use a service "
+          + "account credential or set the project ID explicitly via FirebaseOptions. "
+          + "Alternatively you can also set the project ID via the GOOGLE_CLOUD_PROJECT "
+          + "environment variable.";
+      assertEquals(message, expected.getMessage());
     }
   }
 
   @Test
-  public void testSendNullMessage() {
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initDefaultMessaging(interceptor);
+  public void testInstanceIdClientWithoutProjectId() {
+    FirebaseOptions options = FirebaseOptions.builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .build();
+    FirebaseApp.initializeApp(options);
+    FirebaseMessaging messaging = FirebaseMessaging.getInstance();
+
+    InstanceIdClient client = messaging.getInstanceIdClient();
+
+    assertTrue(client instanceof InstanceIdClientImpl);
+    assertSame(client, messaging.getInstanceIdClient());
+  }
+
+  @Test
+  public void testSendNullMessage() throws FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId(null);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
     try {
-      messaging.sendAsync(null);
+      messaging.send(null);
       fail("No error thrown for null message");
     } catch (NullPointerException expected) {
       // expected
     }
 
-    assertNull(interceptor.getResponse());
+    assertNull(client.lastMessage);
   }
 
   @Test
-  public void testSend() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
-        .setContent(MOCK_RESPONSE);
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    final FirebaseMessaging messaging = initMessaging(response, interceptor);
-    Map<Message, Map<String, Object>> testMessages = buildTestMessages();
-
-    List<GenericFunction<String>> functions = ImmutableList.of(
-        new GenericFunction<String>() {
-          @Override
-          public String call(Object... args) throws Exception {
-            return messaging.sendAsync((Message) args[0]).get();
-          }
-        },
-        new GenericFunction<String>() {
-          @Override
-          public String call(Object... args) throws Exception {
-            return messaging.send((Message) args[0]);
-          }
-        }
-    );
-    for (GenericFunction<String> fn : functions) {
-      for (Map.Entry<Message, Map<String, Object>> entry : testMessages.entrySet()) {
-        response.setContent(MOCK_RESPONSE);
-        String resp = fn.call(entry.getKey());
-        assertEquals("mock-name", resp);
-
-        checkRequestHeader(interceptor.getLastRequest());
-        checkRequest(interceptor.getLastRequest(),
-            ImmutableMap.<String, Object>of("message", entry.getValue()));
-      }
-    }
-  }
+  public void testSend() throws FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId("test");
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-  @Test
-  public void testSendDryRun() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
-        .setContent(MOCK_RESPONSE);
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    final FirebaseMessaging messaging = initMessaging(response, interceptor);
-    Map<Message, Map<String, Object>> testMessages = buildTestMessages();
-
-    List<GenericFunction<String>> functions = ImmutableList.of(
-        new GenericFunction<String>() {
-          @Override
-          public String call(Object... args) throws Exception {
-            return messaging.sendAsync((Message) args[0], true).get();
-          }
-        },
-        new GenericFunction<String>() {
-          @Override
-          public String call(Object... args) throws Exception {
-            return messaging.send((Message) args[0], true);
-          }
-        }
-    );
-
-    for (GenericFunction<String> fn : functions) {
-      for (Map.Entry<Message, Map<String, Object>> entry : testMessages.entrySet()) {
-        response.setContent(MOCK_RESPONSE);
-        String resp = fn.call(entry.getKey());
-        assertEquals("mock-name", resp);
-
-        checkRequestHeader(interceptor.getLastRequest());
-        checkRequest(interceptor.getLastRequest(),
-            ImmutableMap.of("message", entry.getValue(), "validate_only", true));
-      }
-    }
-  }
+    String messageId = messaging.send(EMPTY_MESSAGE);
 
-  @Test
-  public void testSendError() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setContent("{}");
-      try {
-        messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("unknown-error", error.getErrorCode());
-        assertEquals("Unexpected HTTP response with status: " + code + "; body: {}",
-            error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkRequestHeader(interceptor.getLastRequest());
-    }
+    assertEquals("test", messageId);
+    assertSame(EMPTY_MESSAGE, client.lastMessage);
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testSendErrorWithZeroContentResponse() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setZeroContent();
-      try {
-        messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("unknown-error", error.getErrorCode());
-        assertEquals("Unexpected HTTP response with status: " + code + "; body: null",
-            error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkRequestHeader(interceptor.getLastRequest());
-    }
-  }
+  public void testSendDryRun() throws FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId("test");
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-  @Test
-  public void testSendErrorWithDetails() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setContent(
-          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
-      try {
-        messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("invalid-argument", error.getErrorCode());
-        assertEquals("test error", error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkRequestHeader(interceptor.getLastRequest());
-    }
-  }
+    String messageId = messaging.send(EMPTY_MESSAGE, true);
 
-  @Test
-  public void testSendErrorWithCanonicalCode() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setContent(
-          "{\"error\": {\"status\": \"NOT_FOUND\", \"message\": \"test error\"}}");
-      try {
-        messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("registration-token-not-registered", error.getErrorCode());
-        assertEquals("test error", error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkRequestHeader(interceptor.getLastRequest());
-    }
+    assertEquals("test", messageId);
+    assertSame(EMPTY_MESSAGE, client.lastMessage);
+    assertTrue(client.isLastDryRun);
   }
 
   @Test
-  public void testSendErrorWithFcmError() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setContent(
-          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\", "
-              + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
-              + ".v1.FcmError\", \"errorCode\": \"UNREGISTERED\"}]}}");
-      try {
-        messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("registration-token-not-registered", error.getErrorCode());
-        assertEquals("test error", error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkRequestHeader(interceptor.getLastRequest());
-    }
-  }
+  public void testSendFailure() {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-  @Test
-  public void testSendMulticastWithNull() {
-    FirebaseMessaging messaging = initDefaultMessaging();
     try {
-      messaging.sendMulticastAsync(null);
-      fail("No error thrown for null multicast message");
-    } catch (NullPointerException expected) {
-      // expected
+      messaging.send(EMPTY_MESSAGE);
+    } catch (FirebaseMessagingException e) {
+      assertSame(TEST_EXCEPTION, e);
     }
-  }
 
-  @Test
-  public void testSendMulticast() throws Exception {
-    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = getMessagingForBatchRequest(
-        MOCK_BATCH_SUCCESS_RESPONSE, interceptor);
-    MulticastMessage multicast = MulticastMessage.builder()
-        .addToken("token1")
-        .addToken("token2")
-        .build();
-
-    BatchResponse responses = messaging.sendMulticast(multicast);
-
-    assertSendBatchSuccess(responses, interceptor);
+    assertSame(EMPTY_MESSAGE, client.lastMessage);
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testSendMulticastAsync() throws Exception {
-    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = getMessagingForBatchRequest(
-        MOCK_BATCH_SUCCESS_RESPONSE, interceptor);
-    MulticastMessage multicast = MulticastMessage.builder()
-        .addToken("token1")
-        .addToken("token2")
-        .build();
+  public void testSendAsync() throws Exception {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId("test");
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-    BatchResponse responses = messaging.sendMulticastAsync(multicast).get();
+    String messageId = messaging.sendAsync(EMPTY_MESSAGE).get();
 
-    assertSendBatchSuccess(responses, interceptor);
+    assertEquals("test", messageId);
+    assertSame(EMPTY_MESSAGE, client.lastMessage);
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testSendMulticastFailure() throws Exception {
-    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = getMessagingForBatchRequest(
-        MOCK_BATCH_FAILURE_RESPONSE, interceptor);
-    MulticastMessage multicast = MulticastMessage.builder()
-        .addToken("token1")
-        .addToken("token2")
-        .addToken("token3")
-        .build();
+  public void testSendAsyncDryRun() throws Exception {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId("test");
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-    BatchResponse responses = messaging.sendMulticast(multicast);
+    String messageId = messaging.sendAsync(EMPTY_MESSAGE, true).get();
 
-    assertSendBatchFailure(responses, interceptor);
+    assertEquals("test", messageId);
+    assertSame(EMPTY_MESSAGE, client.lastMessage);
+    assertTrue(client.isLastDryRun);
   }
 
   @Test
-  public void testSendMulticastAsyncFailure() throws Exception {
-    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = getMessagingForBatchRequest(
-        MOCK_BATCH_FAILURE_RESPONSE, interceptor);
-    MulticastMessage multicast = MulticastMessage.builder()
-        .addToken("token1")
-        .addToken("token2")
-        .addToken("token3")
-        .build();
+  public void testSendAsyncFailure() throws InterruptedException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-    BatchResponse responses = messaging.sendMulticastAsync(multicast).get();
+    try {
+      messaging.sendAsync(EMPTY_MESSAGE).get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
+    }
 
-    assertSendBatchFailure(responses, interceptor);
+    assertSame(EMPTY_MESSAGE, client.lastMessage);
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testSendAllWithNull() {
-    FirebaseMessaging messaging = initDefaultMessaging();
+  public void testSendAllWithNull() throws  FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId(null);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
     try {
-      messaging.sendAllAsync(null);
+      messaging.sendAll(null);
       fail("No error thrown for null message list");
     } catch (NullPointerException expected) {
       // expected
     }
+
+    assertNull(client.lastBatch);
   }
 
   @Test
-  public void testSendAllWithEmptyList() {
-    FirebaseMessaging messaging = initDefaultMessaging();
+  public void testSendAllWithEmptyList() throws FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId(null);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
     try {
-      messaging.sendAllAsync(ImmutableList.<Message>of());
+      messaging.sendAll(ImmutableList.<Message>of());
       fail("No error thrown for empty message list");
     } catch (IllegalArgumentException expected) {
       // expected
     }
+
+    assertNull(client.lastBatch);
   }
 
   @Test
-  public void testSendAllWithTooManyMessages() {
-    FirebaseMessaging messaging = initDefaultMessaging();
+  public void testSendAllWithTooManyMessages() throws FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId(null);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
     ImmutableList.Builder<Message> listBuilder = ImmutableList.builder();
     for (int i = 0; i < 101; i++) {
       listBuilder.add(Message.builder().setTopic("topic").build());
     }
+
     try {
-      messaging.sendAllAsync(listBuilder.build());
+      messaging.sendAll(listBuilder.build(), false);
       fail("No error thrown for too many messages in the list");
     } catch (IllegalArgumentException expected) {
       // expected
     }
+
+    assertNull(client.lastBatch);
   }
 
   @Test
-  public void testSendAll() throws Exception {
-    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = getMessagingForBatchRequest(
-        MOCK_BATCH_SUCCESS_RESPONSE, interceptor);
-    List<Message> messages = ImmutableList.of(
-        Message.builder().setTopic("topic1").build(),
-        Message.builder().setTopic("topic2").build()
-    );
-
-    BatchResponse responses = messaging.sendAll(messages);
-
-    assertSendBatchSuccess(responses, interceptor);
+  public void testSendAll() throws FirebaseMessagingException {
+    BatchResponse batchResponse = getBatchResponse("test");
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient
+        .fromBatchResponse(batchResponse);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE);
+
+    BatchResponse response = messaging.sendAll(messages);
+
+    assertSame(batchResponse, response);
+    assertSame(messages, client.lastBatch);
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testSendAllAsync() throws Exception {
-    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = getMessagingForBatchRequest(
-        MOCK_BATCH_SUCCESS_RESPONSE, interceptor);
-    List<Message> messages = ImmutableList.of(
-        Message.builder().setTopic("topic1").build(),
-        Message.builder().setTopic("topic2").build()
-    );
+  public void testSendAllDryRun() throws FirebaseMessagingException {
+    BatchResponse batchResponse = getBatchResponse("test");
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient
+        .fromBatchResponse(batchResponse);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE);
 
-    BatchResponse responses = messaging.sendAllAsync(messages).get();
+    BatchResponse response = messaging.sendAll(messages, true);
 
-    assertSendBatchSuccess(responses, interceptor);
+    assertSame(batchResponse, response);
+    assertSame(messages, client.lastBatch);
+    assertTrue(client.isLastDryRun);
   }
 
   @Test
-  public void testSendAllFailure() throws Exception {
-    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = getMessagingForBatchRequest(
-        MOCK_BATCH_FAILURE_RESPONSE, interceptor);
-    List<Message> messages = ImmutableList.of(
-        Message.builder().setTopic("topic1").build(),
-        Message.builder().setTopic("topic2").build(),
-        Message.builder().setTopic("topic3").build()
-    );
-
-    BatchResponse responses = messaging.sendAll(messages);
-
-    assertSendBatchFailure(responses, interceptor);
+  public void testSendAllFailure() {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE);
+
+    try {
+      messaging.sendAll(messages);
+    } catch (FirebaseMessagingException e) {
+      assertSame(TEST_EXCEPTION, e);
+    }
+
+    assertSame(messages, client.lastBatch);
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testSendAllAsyncFailure() throws Exception {
-    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = getMessagingForBatchRequest(
-        MOCK_BATCH_FAILURE_RESPONSE, interceptor);
-    List<Message> messages = ImmutableList.of(
-        Message.builder().setTopic("topic1").build(),
-        Message.builder().setTopic("topic2").build(),
-        Message.builder().setTopic("topic3").build()
-    );
-
-    BatchResponse responses = messaging.sendAllAsync(messages).get();
-
-    assertSendBatchFailure(responses, interceptor);
+  public void testSendAllAsync() throws Exception {
+    BatchResponse batchResponse = getBatchResponse("test");
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient
+        .fromBatchResponse(batchResponse);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE);
+
+    BatchResponse response = messaging.sendAllAsync(messages).get();
+
+    assertSame(batchResponse, response);
+    assertSame(messages, client.lastBatch);
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testSendAllHttpError() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    List<Message> messages = ImmutableList.of(Message.builder()
-        .setTopic("test-topic")
-        .build());
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setContent("{}");
-      try {
-        messaging.sendAllAsync(messages).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("unknown-error", error.getErrorCode());
-        assertEquals("Unexpected HTTP response with status: " + code + "; body: {}",
-            error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkBatchRequestHeader(interceptor.getLastRequest());
-    }
+  public void testSendAllAsyncDryRun() throws Exception {
+    BatchResponse batchResponse = getBatchResponse("test");
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient
+        .fromBatchResponse(batchResponse);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE);
+
+    BatchResponse response = messaging.sendAllAsync(messages, true).get();
+
+    assertSame(batchResponse, response);
+    assertSame(messages, client.lastBatch);
+    assertTrue(client.isLastDryRun);
   }
 
   @Test
-  public void testSendAllTransportError() throws Exception {
-    FirebaseMessaging messaging = initFaultyTransportMessaging();
-    List<Message> messages = ImmutableList.of(Message.builder()
-        .setTopic("test-topic")
-        .build());
+  public void testSendAllAsyncFailure() throws InterruptedException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE);
 
     try {
       messaging.sendAllAsync(messages).get();
-      fail("No error thrown for HTTP error");
     } catch (ExecutionException e) {
-      assertTrue(e.getCause() instanceof FirebaseMessagingException);
-      FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-      assertEquals("internal-error", error.getErrorCode());
-      assertEquals("Error while calling FCM backend service", error.getMessage());
-      assertTrue(error.getCause() instanceof IOException);
+      assertSame(TEST_EXCEPTION, e.getCause());
     }
+
+    assertSame(messages, client.lastBatch);
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testSendAllErrorWithEmptyResponse() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    List<Message> messages = ImmutableList.of(Message.builder()
-        .setTopic("test-topic")
-        .build());
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setZeroContent();
+  public void testSendMulticastWithNull() throws  FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId(null);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-      try {
-        messaging.sendAllAsync(messages).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("unknown-error", error.getErrorCode());
-        assertEquals("Unexpected HTTP response with status: " + code + "; body: null",
-            error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkBatchRequestHeader(interceptor.getLastRequest());
+    try {
+      messaging.sendMulticast(null);
+      fail("No error thrown for null multicast message");
+    } catch (NullPointerException expected) {
+      // expected
     }
+
+    assertNull(client.lastBatch);
   }
 
   @Test
-  public void testSendAllErrorWithDetails() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    List<Message> messages = ImmutableList.of(Message.builder()
-        .setTopic("test-topic")
-        .build());
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setContent(
-          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
+  public void testSendMulticast() throws FirebaseMessagingException {
+    BatchResponse batchResponse = getBatchResponse("test");
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient
+        .fromBatchResponse(batchResponse);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-      try {
-        messaging.sendAllAsync(messages).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("invalid-argument", error.getErrorCode());
-        assertEquals("test error", error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkBatchRequestHeader(interceptor.getLastRequest());
-    }
+    BatchResponse response = messaging.sendMulticast(TEST_MULTICAST_MESSAGE);
+
+    assertSame(batchResponse, response);
+    assertEquals(2, client.lastBatch.size());
+    assertEquals("test-fcm-token1", client.lastBatch.get(0).getToken());
+    assertEquals("test-fcm-token2", client.lastBatch.get(1).getToken());
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testSendAllErrorWithCanonicalCode() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    List<Message> messages = ImmutableList.of(Message.builder()
-        .setTopic("test-topic")
-        .build());
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setContent(
-          "{\"error\": {\"status\": \"NOT_FOUND\", \"message\": \"test error\"}}");
+  public void testSendMulticastDryRun() throws FirebaseMessagingException {
+    BatchResponse batchResponse = getBatchResponse("test");
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient
+        .fromBatchResponse(batchResponse);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-      try {
-        messaging.sendAllAsync(messages).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("registration-token-not-registered", error.getErrorCode());
-        assertEquals("test error", error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkBatchRequestHeader(interceptor.getLastRequest());
-    }
+    BatchResponse response = messaging.sendMulticast(TEST_MULTICAST_MESSAGE, true);
+
+    assertSame(batchResponse, response);
+    assertEquals(2, client.lastBatch.size());
+    assertEquals("test-fcm-token1", client.lastBatch.get(0).getToken());
+    assertEquals("test-fcm-token2", client.lastBatch.get(1).getToken());
+    assertTrue(client.isLastDryRun);
   }
 
   @Test
-  public void testSendAllErrorWithFcmError() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    List<Message> messages = ImmutableList.of(Message.builder()
-        .setTopic("test-topic")
-        .build());
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setContent(
-          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\", "
-              + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
-              + ".v1.FcmError\", \"errorCode\": \"UNREGISTERED\"}]}}");
+  public void testSendMulticastFailure() {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-      try {
-        messaging.sendAllAsync(messages).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("registration-token-not-registered", error.getErrorCode());
-        assertEquals("test error", error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkBatchRequestHeader(interceptor.getLastRequest());
+    try {
+      messaging.sendMulticast(TEST_MULTICAST_MESSAGE);
+    } catch (FirebaseMessagingException e) {
+      assertSame(TEST_EXCEPTION, e);
     }
+
+    assertEquals(2, client.lastBatch.size());
+    assertEquals("test-fcm-token1", client.lastBatch.get(0).getToken());
+    assertEquals("test-fcm-token2", client.lastBatch.get(1).getToken());
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testSendAllErrorWithoutMessage() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    List<Message> messages = ImmutableList.of(Message.builder()
-        .setTopic("test-topic")
-        .build());
-    for (int code : HTTP_ERRORS) {
-      response.setStatusCode(code).setContent(
-          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", "
-              + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
-              + ".v1.FcmError\", \"errorCode\": \"UNREGISTERED\"}]}}");
+  public void testSendMulticastAsync() throws Exception {
+    BatchResponse batchResponse = getBatchResponse("test");
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient
+        .fromBatchResponse(batchResponse);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
 
-      try {
-        messaging.sendAllAsync(messages).get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals("registration-token-not-registered", error.getErrorCode());
-        assertTrue(error.getMessage().startsWith("Unexpected HTTP response"));
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
-      checkBatchRequestHeader(interceptor.getLastRequest());
+    BatchResponse response = messaging.sendMulticastAsync(TEST_MULTICAST_MESSAGE).get();
+
+    assertSame(batchResponse, response);
+    assertEquals(2, client.lastBatch.size());
+    assertEquals("test-fcm-token1", client.lastBatch.get(0).getToken());
+    assertEquals("test-fcm-token2", client.lastBatch.get(1).getToken());
+    assertFalse(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendMulticastAsyncDryRun() throws Exception {
+    BatchResponse batchResponse = getBatchResponse("test");
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient
+        .fromBatchResponse(batchResponse);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response = messaging.sendMulticastAsync(TEST_MULTICAST_MESSAGE, true).get();
+
+    assertSame(batchResponse, response);
+    assertEquals(2, client.lastBatch.size());
+    assertEquals("test-fcm-token1", client.lastBatch.get(0).getToken());
+    assertEquals("test-fcm-token2", client.lastBatch.get(1).getToken());
+    assertTrue(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendMulticastAsyncFailure() throws InterruptedException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    try {
+      messaging.sendMulticastAsync(TEST_MULTICAST_MESSAGE).get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
     }
+
+    assertEquals(2, client.lastBatch.size());
+    assertEquals("test-fcm-token1", client.lastBatch.get(0).getToken());
+    assertEquals("test-fcm-token2", client.lastBatch.get(1).getToken());
+    assertFalse(client.isLastDryRun);
   }
 
   @Test
-  public void testInvalidSubscribe() {
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initDefaultMessaging(interceptor);
+  public void testInvalidSubscribe() throws FirebaseMessagingException {
+    MockInstanceIdClient client = MockInstanceIdClient.fromResponse(null);
+    FirebaseMessaging messaging = getMessagingForTopicManagement(
+        Suppliers.<InstanceIdClient>ofInstance(client));
 
     for (TopicMgtArgs args : INVALID_TOPIC_MGT_ARGS) {
       try {
-        messaging.subscribeToTopicAsync(args.registrationTokens, args.topic);
+        messaging.subscribeToTopic(args.registrationTokens, args.topic);
         fail("No error thrown for invalid args");
       } catch (IllegalArgumentException expected) {
         // expected
       }
+      assertNull(client.lastTopic);
+      assertNull(client.lastBatch);
     }
-
-    assertNull(interceptor.getResponse());
   }
 
   @Test
-  public void testSubscribe() throws Exception {
-    final String responseString = "{\"results\": [{}, {\"error\": \"error_reason\"}]}";
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    final FirebaseMessaging messaging = initMessaging(response, interceptor);
-
-    List<GenericFunction<TopicManagementResponse>> functions = ImmutableList.of(
-        new GenericFunction<TopicManagementResponse>() {
-          @Override
-          public TopicManagementResponse call(Object... args) throws Exception {
-            return messaging.subscribeToTopicAsync(ImmutableList.of("id1", "id2"),
-                "test-topic").get();
-          }
-        },
-        new GenericFunction<TopicManagementResponse>() {
-          @Override
-          public TopicManagementResponse call(Object... args) throws Exception {
-            return messaging.subscribeToTopic(ImmutableList.of("id1", "id2"), "test-topic");
-          }
-        },
-        new GenericFunction<TopicManagementResponse>() {
-          @Override
-          public TopicManagementResponse call(Object... args) throws Exception {
-            return messaging.subscribeToTopic(ImmutableList.of("id1", "id2"),
-                "/topics/test-topic");
-          }
-        }
-    );
-
-    for (GenericFunction<TopicManagementResponse> fn : functions) {
-      response.setContent(responseString);
-      TopicManagementResponse result = fn.call();
-      checkTopicManagementRequestHeader(
-          interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
-      checkTopicManagementRequest(interceptor.getLastRequest(), result);
-    }
+  public void testSubscribeToTopic() throws FirebaseMessagingException {
+    MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE);
+    FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client));
+
+    TopicManagementResponse got = messaging.subscribeToTopic(
+        ImmutableList.of("id1", "id2"), "test-topic");
+
+    assertSame(TOPIC_MGT_RESPONSE, got);
   }
 
   @Test
-  public void testSubscribeError() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    for (int statusCode : HTTP_ERRORS) {
-      response.setStatusCode(statusCode).setContent("{\"error\": \"test error\"}");
-      try {
-        messaging.subscribeToTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals(getTopicManagementErrorCode(statusCode), error.getErrorCode());
-        assertEquals("test error", error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
+  public void testSubscribeToTopicFailure() {
+    MockInstanceIdClient client = MockInstanceIdClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client));
 
-      checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+    try {
+      messaging.subscribeToTopic(ImmutableList.of("id1", "id2"), "test-topic");
+    } catch (FirebaseMessagingException e) {
+      assertSame(TEST_EXCEPTION, e);
     }
   }
 
   @Test
-  public void testSubscribeUnknownError() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    response.setStatusCode(500).setContent("{}");
-    try {
-      messaging.subscribeToTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
-      fail("No error thrown for HTTP error");
-    } catch (ExecutionException e) {
-      assertTrue(e.getCause() instanceof FirebaseMessagingException);
-      FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-      assertEquals(getTopicManagementErrorCode(500), error.getErrorCode());
-      assertEquals("Unexpected HTTP response with status: 500; body: {}", error.getMessage());
-      assertTrue(error.getCause() instanceof HttpResponseException);
-    }
+  public void testSubscribeToTopicAsync() throws Exception {
+    MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE);
+    FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client));
 
-    checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+    TopicManagementResponse got = messaging.subscribeToTopicAsync(
+        ImmutableList.of("id1", "id2"), "test-topic").get();
+
+    assertSame(TOPIC_MGT_RESPONSE, got);
   }
 
   @Test
-  public void testSubscribeTransportError() throws Exception {
-    FirebaseMessaging messaging = initFaultyTransportMessaging();
+  public void testSubscribeToTopicAsyncFailure() throws InterruptedException {
+    MockInstanceIdClient client = MockInstanceIdClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client));
+
     try {
       messaging.subscribeToTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
-      fail("No error thrown for HTTP error");
     } catch (ExecutionException e) {
-      assertTrue(e.getCause() instanceof FirebaseMessagingException);
-      FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-      assertEquals("internal-error", error.getErrorCode());
-      assertEquals("Error while calling IID backend service", error.getMessage());
-      assertTrue(error.getCause() instanceof IOException);
+      assertSame(TEST_EXCEPTION, e.getCause());
     }
   }
 
   @Test
-  public void testInvalidUnsubscribe() {
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initDefaultMessaging(interceptor);
+  public void testInvalidUnsubscribe() throws FirebaseMessagingException {
+    MockInstanceIdClient client = MockInstanceIdClient.fromResponse(null);
+    FirebaseMessaging messaging = getMessagingForTopicManagement(
+        Suppliers.<InstanceIdClient>ofInstance(client));
 
     for (TopicMgtArgs args : INVALID_TOPIC_MGT_ARGS) {
       try {
-        messaging.unsubscribeFromTopicAsync(args.registrationTokens, args.topic);
+        messaging.unsubscribeFromTopic(args.registrationTokens, args.topic);
         fail("No error thrown for invalid args");
       } catch (IllegalArgumentException expected) {
         // expected
       }
+      assertNull(client.lastTopic);
+      assertNull(client.lastBatch);
     }
-
-    assertNull(interceptor.getResponse());
   }
 
   @Test
-  public void testUnsubscribe() throws Exception {
-    final String responseString = "{\"results\": [{}, {\"error\": \"error_reason\"}]}";
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    final FirebaseMessaging messaging = initMessaging(response, interceptor);
-
-    List<GenericFunction<TopicManagementResponse>> functions = ImmutableList.of(
-        new GenericFunction<TopicManagementResponse>() {
-          @Override
-          public TopicManagementResponse call(Object... args) throws Exception {
-            return messaging.unsubscribeFromTopicAsync(ImmutableList.of("id1", "id2"),
-                "test-topic").get();
-          }
-        },
-        new GenericFunction<TopicManagementResponse>() {
-          @Override
-          public TopicManagementResponse call(Object... args) throws Exception {
-            return messaging.unsubscribeFromTopic(ImmutableList.of("id1", "id2"), "test-topic");
-          }
-        },
-        new GenericFunction<TopicManagementResponse>() {
-          @Override
-          public TopicManagementResponse call(Object... args) throws Exception {
-            return messaging.unsubscribeFromTopic(ImmutableList.of("id1", "id2"),
-                "/topics/test-topic");
-          }
-        }
-    );
-
-    for (GenericFunction<TopicManagementResponse> fn : functions) {
-      response.setContent(responseString);
-      TopicManagementResponse result = fn.call();
-      checkTopicManagementRequestHeader(
-          interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
-      checkTopicManagementRequest(interceptor.getLastRequest(), result);
-    }
+  public void testUnsubscribeFromTopic() throws FirebaseMessagingException {
+    MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE);
+    FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client));
+
+    TopicManagementResponse got = messaging.unsubscribeFromTopic(
+        ImmutableList.of("id1", "id2"), "test-topic");
+
+    assertSame(TOPIC_MGT_RESPONSE, got);
   }
 
   @Test
-  public void testUnsubscribeError() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    for (int statusCode : HTTP_ERRORS) {
-      response.setStatusCode(statusCode).setContent("{\"error\": \"test error\"}");
-      try {
-        messaging.unsubscribeFromTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
-        fail("No error thrown for HTTP error");
-      } catch (ExecutionException e) {
-        assertTrue(e.getCause() instanceof FirebaseMessagingException);
-        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-        assertEquals(getTopicManagementErrorCode(statusCode), error.getErrorCode());
-        assertEquals("test error", error.getMessage());
-        assertTrue(error.getCause() instanceof HttpResponseException);
-      }
+  public void testUnsubscribeFromTopicFailure() {
+    MockInstanceIdClient client = MockInstanceIdClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client));
 
-      checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
+    try {
+      messaging.unsubscribeFromTopic(ImmutableList.of("id1", "id2"), "test-topic");
+    } catch (FirebaseMessagingException e) {
+      assertSame(TEST_EXCEPTION, e);
     }
   }
 
   @Test
-  public void testUnsubscribeUnknownError() throws Exception {
-    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    FirebaseMessaging messaging = initMessaging(response, interceptor);
-    response.setStatusCode(500).setContent("{}");
-    try {
-      messaging.unsubscribeFromTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
-      fail("No error thrown for HTTP error");
-    } catch (ExecutionException e) {
-      assertTrue(e.getCause() instanceof FirebaseMessagingException);
-      FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-      assertEquals(getTopicManagementErrorCode(500), error.getErrorCode());
-      assertEquals("Unexpected HTTP response with status: 500; body: {}", error.getMessage());
-      assertTrue(error.getCause() instanceof HttpResponseException);
-    }
+  public void testUnsubscribeFromTopicAsync() throws Exception {
+    MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE);
+    FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client));
 
-    checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
+    TopicManagementResponse got = messaging.unsubscribeFromTopicAsync(
+        ImmutableList.of("id1", "id2"), "test-topic").get();
+
+    assertSame(TOPIC_MGT_RESPONSE, got);
   }
 
   @Test
-  public void testUnsubscribeTransportError() throws Exception {
-    FirebaseMessaging messaging = initFaultyTransportMessaging();
+  public void testUnsubscribeFromTopicAsyncFailure() throws InterruptedException {
+    MockInstanceIdClient client = MockInstanceIdClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client));
+
     try {
       messaging.unsubscribeFromTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
-      fail("No error thrown for HTTP error");
     } catch (ExecutionException e) {
-      assertTrue(e.getCause() instanceof FirebaseMessagingException);
-      FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
-      assertEquals("internal-error", error.getErrorCode());
-      assertEquals("Error while calling IID backend service", error.getMessage());
-      assertTrue(error.getCause() instanceof IOException);
+      assertSame(TEST_EXCEPTION, e.getCause());
     }
   }
 
-  private static FirebaseMessaging initMessaging(
-      MockLowLevelHttpResponse mockResponse, HttpResponseInterceptor interceptor) {
-    MockHttpTransport transport = new MockHttpTransport.Builder()
-        .setLowLevelHttpResponse(mockResponse)
-        .build();
-    FirebaseOptions options = new FirebaseOptions.Builder()
-        .setCredentials(new MockGoogleCredentials("test-token"))
-        .setProjectId("test-project")
-        .setHttpTransport(transport)
+  private FirebaseMessaging getMessagingForSend(
+      Supplier<? extends FirebaseMessagingClient> supplier) {
+    FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS);
+    return FirebaseMessaging.builder()
+        .setFirebaseApp(app)
+        .setMessagingClient(supplier)
+        .setInstanceIdClient(Suppliers.<InstanceIdClient>ofInstance(null))
         .build();
-    FirebaseApp app = FirebaseApp.initializeApp(options);
-
-    return new FirebaseMessaging(app, interceptor);
-  }
-
-  private static FirebaseMessaging initDefaultMessaging() {
-    return initDefaultMessaging(null);
   }
 
-  private static FirebaseMessaging initDefaultMessaging(HttpResponseInterceptor interceptor) {
-    FirebaseOptions options = new FirebaseOptions.Builder()
-        .setCredentials(new MockGoogleCredentials("test-token"))
-        .setProjectId("test-project")
+  private FirebaseMessaging getMessagingForTopicManagement(
+      Supplier<? extends InstanceIdClient> supplier) {
+    FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS);
+    return FirebaseMessaging.builder()
+        .setFirebaseApp(app)
+        .setMessagingClient(Suppliers.<FirebaseMessagingClient>ofInstance(null))
+        .setInstanceIdClient(supplier)
         .build();
-    FirebaseApp app = FirebaseApp.initializeApp(options);
-    return new FirebaseMessaging(app, interceptor);
   }
 
-  private static FirebaseMessaging initFaultyTransportMessaging() {
-    FirebaseOptions options = new FirebaseOptions.Builder()
-        .setCredentials(new MockGoogleCredentials("test-token"))
-        .setProjectId("test-project")
-        .setHttpTransport(new FailingHttpTransport())
-        .build();
-    FirebaseApp app = FirebaseApp.initializeApp(options);
-    return new FirebaseMessaging(app, null);
+  private BatchResponse getBatchResponse(String ...messageIds) {
+    ImmutableList.Builder<SendResponse> listBuilder = ImmutableList.builder();
+    for (String messageId : messageIds) {
+      listBuilder.add(SendResponse.fromMessageId(messageId));
+    }
+    return new BatchResponse(listBuilder.build());
   }
 
-  private void checkRequest(
-      HttpRequest request, Map<String, Object> expected) throws IOException {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    request.getContent().writeTo(out);
-    JsonParser parser = Utils.getDefaultJsonFactory().createJsonParser(out.toString());
-    Map<String, Object> parsed = new HashMap<>();
-    parser.parseAndClose(parsed);
-    assertEquals(expected, parsed);
-  }
+  private static class MockFirebaseMessagingClient implements FirebaseMessagingClient {
 
-  private void checkRequestHeader(HttpRequest request) {
-    assertEquals("POST", request.getRequestMethod());
-    assertEquals(TEST_FCM_URL, request.getUrl().toString());
-    HttpHeaders headers = request.getHeaders();
-    assertEquals("Bearer test-token", headers.getAuthorization());
-    assertEquals("2", headers.get("X-GOOG-API-FORMAT-VERSION"));
-    assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client"));
-  }
+    private String messageId;
+    private BatchResponse batchResponse;
+    private FirebaseMessagingException exception;
 
-  private FirebaseMessaging getMessagingForBatchRequest(
-      String responsePayload, TestResponseInterceptor interceptor) {
-    MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse()
-        .setContentType("multipart/mixed; boundary=test_boundary")
-        .setContent(responsePayload);
-    return initMessaging(httpResponse, interceptor);
-  }
+    private Message lastMessage;
+    private List<Message> lastBatch;
+    private boolean isLastDryRun;
 
-  private void assertSendBatchSuccess(
-      BatchResponse batchResponse, TestResponseInterceptor interceptor) throws IOException {
-
-    assertEquals(2, batchResponse.getSuccessCount());
-    assertEquals(0, batchResponse.getFailureCount());
+    private MockFirebaseMessagingClient(
+        String messageId, BatchResponse batchResponse, FirebaseMessagingException exception) {
+      this.messageId = messageId;
+      this.batchResponse = batchResponse;
+      this.exception = exception;
+    }
 
-    List<SendResponse> responses = batchResponse.getResponses();
-    assertEquals(2, responses.size());
-    for (int i = 0; i < 2; i++) {
-      SendResponse sendResponse = responses.get(i);
-      assertTrue(sendResponse.isSuccessful());
-      assertEquals("projects/test-project/messages/" + (i + 1), sendResponse.getMessageId());
-      assertNull(sendResponse.getException());
+    static MockFirebaseMessagingClient fromMessageId(String messageId) {
+      return new MockFirebaseMessagingClient(messageId, null, null);
     }
-    checkBatchRequestHeader(interceptor.getLastRequest());
-    checkBatchRequest(interceptor.getLastRequest(), 2);
-  }
 
-  private void assertSendBatchFailure(
-      BatchResponse batchResponse, TestResponseInterceptor interceptor) throws IOException {
-
-    assertEquals(1, batchResponse.getSuccessCount());
-    assertEquals(2, batchResponse.getFailureCount());
-
-    List<SendResponse> responses = batchResponse.getResponses();
-    assertEquals(3, responses.size());
-    SendResponse firstResponse = responses.get(0);
-    assertTrue(firstResponse.isSuccessful());
-    assertEquals("projects/test-project/messages/1", firstResponse.getMessageId());
-    assertNull(firstResponse.getException());
-
-    SendResponse secondResponse = responses.get(1);
-    assertFalse(secondResponse.isSuccessful());
-    assertNull(secondResponse.getMessageId());
-    FirebaseMessagingException exception = secondResponse.getException();
-    assertNotNull(exception);
-    assertEquals("invalid-argument", exception.getErrorCode());
-
-    SendResponse thirdResponse = responses.get(2);
-    assertFalse(thirdResponse.isSuccessful());
-    assertNull(thirdResponse.getMessageId());
-    exception = thirdResponse.getException();
-    assertNotNull(exception);
-    assertEquals("invalid-argument", exception.getErrorCode());
-
-    checkBatchRequestHeader(interceptor.getLastRequest());
-    checkBatchRequest(interceptor.getLastRequest(), 3);
-  }
+    static MockFirebaseMessagingClient fromBatchResponse(BatchResponse batchResponse) {
+      return new MockFirebaseMessagingClient(null, batchResponse, null);
+    }
 
-  private void checkBatchRequestHeader(HttpRequest request) {
-    assertEquals("POST", request.getRequestMethod());
-    assertEquals("https://fcm.googleapis.com/batch", request.getUrl().toString());
-    assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
-  }
+    static MockFirebaseMessagingClient fromException(FirebaseMessagingException exception) {
+      return new MockFirebaseMessagingClient(null, null, exception);
+    }
 
-  private void checkBatchRequest(HttpRequest request, int expectedParts) throws IOException {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    request.getContent().writeTo(out);
-    String[] lines = out.toString().split("\n");
-    assertEquals(expectedParts, countLinesWithPrefix(lines, "POST " + TEST_FCM_URL));
-    assertEquals(expectedParts, countLinesWithPrefix(lines, "x-goog-api-format-version: 2"));
-    assertEquals(expectedParts, countLinesWithPrefix(
-        lines, "x-firebase-client: fire-admin-java/" + SdkUtils.getVersion()));
-  }
+    @Override
+    public String send(Message message, boolean dryRun) throws FirebaseMessagingException {
+      lastMessage = message;
+      isLastDryRun = dryRun;
+      if (exception != null) {
+        throw exception;
+      }
+      return messageId;
+    }
 
-  private int countLinesWithPrefix(String[] lines, String prefix) {
-    int matchCount = 0;
-    for (String line : lines) {
-      if (line.trim().startsWith(prefix)) {
-        matchCount++;
+    @Override
+    public BatchResponse sendAll(
+        List<Message> messages, boolean dryRun) throws FirebaseMessagingException {
+      lastBatch = messages;
+      isLastDryRun = dryRun;
+      if (exception != null) {
+        throw exception;
       }
+      return batchResponse;
     }
-    return matchCount;
   }
 
-  private static String getTopicManagementErrorCode(int statusCode) {
-    String code = InstanceIdClient.IID_ERROR_CODES.get(statusCode);
-    if (code == null) {
-      code = "unknown-error";
+  private static class MockInstanceIdClient implements InstanceIdClient {
+
+    private TopicManagementResponse response;
+    private FirebaseMessagingException exception;
+
+    private String lastTopic;
+    private List<String> lastBatch;
+
+    private MockInstanceIdClient(
+        TopicManagementResponse response, FirebaseMessagingException exception) {
+      this.response = response;
+      this.exception = exception;
     }
-    return code;
-  }
 
+    static MockInstanceIdClient fromResponse(TopicManagementResponse response) {
+      return new MockInstanceIdClient(response, null);
+    }
 
-  private void checkTopicManagementRequest(
-      HttpRequest request, TopicManagementResponse result) throws IOException {
-    assertEquals(1, result.getSuccessCount());
-    assertEquals(1, result.getFailureCount());
-    assertEquals(1, result.getErrors().size());
-    assertEquals(1, result.getErrors().get(0).getIndex());
-    assertEquals("unknown-error", result.getErrors().get(0).getReason());
-
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    request.getContent().writeTo(out);
-    Map<String, Object> parsed = new HashMap<>();
-    JsonParser parser = Utils.getDefaultJsonFactory().createJsonParser(out.toString());
-    parser.parseAndClose(parsed);
-    assertEquals(2, parsed.size());
-    assertEquals("/topics/test-topic", parsed.get("to"));
-    assertEquals(ImmutableList.of("id1", "id2"), parsed.get("registration_tokens"));
-  }
+    static MockInstanceIdClient fromException(FirebaseMessagingException exception) {
+      return new MockInstanceIdClient(null, exception);
+    }
 
-  private void checkTopicManagementRequestHeader(
-      HttpRequest request, String expectedUrl) {
-    assertEquals("POST", request.getRequestMethod());
-    assertEquals(expectedUrl, request.getUrl().toString());
-    assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
+    @Override
+    public TopicManagementResponse subscribeToTopic(
+        String topic, List<String> registrationTokens) throws FirebaseMessagingException {
+      this.lastTopic = topic;
+      this.lastBatch = registrationTokens;
+      if (exception != null) {
+        throw exception;
+      }
+      return response;
+    }
+
+    @Override
+    public TopicManagementResponse unsubscribeFromTopic(
+        String topic, List<String> registrationTokens) throws FirebaseMessagingException {
+      this.lastTopic = topic;
+      this.lastBatch = registrationTokens;
+      if (exception != null) {
+        throw exception;
+      }
+      return response;
+    }
   }
 
   private static class TopicMgtArgs {
@@ -1112,224 +776,4 @@ public class FirebaseMessagingTest {
       this.topic = topic;
     }
   }
-
-  private static class FailingHttpTransport extends HttpTransport {
-    @Override
-    protected LowLevelHttpRequest buildRequest(String method, String url) throws IOException {
-      throw new IOException("transport error");
-    }
-  }
-
-  private static Map<Message, Map<String, Object>> buildTestMessages() {
-    ImmutableMap.Builder<Message, Map<String, Object>> builder = ImmutableMap.builder();
-
-    // Empty message
-    builder.put(
-        Message.builder().setTopic("test-topic").build(),
-        ImmutableMap.<String, Object>of("topic", "test-topic"));
-
-    // Notification message
-    builder.put(
-        Message.builder()
-            .setNotification(new Notification("test title", "test body"))
-            .setTopic("test-topic")
-            .build(),
-        ImmutableMap.<String, Object>of(
-            "topic", "test-topic",
-            "notification", ImmutableMap.of("title", "test title", "body", "test body")));
-
-    // Data message
-    builder.put(
-        Message.builder()
-            .putData("k1", "v1")
-            .putData("k2", "v2")
-            .putAllData(ImmutableMap.of("k3", "v3", "k4", "v4"))
-            .setTopic("test-topic")
-            .build(),
-        ImmutableMap.<String, Object>of(
-            "topic", "test-topic",
-            "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3", "k4", "v4")));
-
-    // Android message
-    builder.put(
-        Message.builder()
-            .setAndroidConfig(AndroidConfig.builder()
-                .setPriority(AndroidConfig.Priority.HIGH)
-                .setTtl(TimeUnit.SECONDS.toMillis(123))
-                .setRestrictedPackageName("test-package")
-                .setCollapseKey("test-key")
-                .setNotification(AndroidNotification.builder()
-                    .setClickAction("test-action")
-                    .setTitle("test-title")
-                    .setBody("test-body")
-                    .setIcon("test-icon")
-                    .setColor("#112233")
-                    .setTag("test-tag")
-                    .setSound("test-sound")
-                    .setTitleLocalizationKey("test-title-key")
-                    .setBodyLocalizationKey("test-body-key")
-                    .addTitleLocalizationArg("t-arg1")
-                    .addAllTitleLocalizationArgs(ImmutableList.of("t-arg2", "t-arg3"))
-                    .addBodyLocalizationArg("b-arg1")
-                    .addAllBodyLocalizationArgs(ImmutableList.of("b-arg2", "b-arg3"))
-                    .setChannelId("channel-id")
-                    .build())
-                .build())
-            .setTopic("test-topic")
-            .build(),
-        ImmutableMap.<String, Object>of(
-            "topic", "test-topic",
-            "android", ImmutableMap.of(
-                "priority", "high",
-                "collapse_key", "test-key",
-                "ttl", "123s",
-                "restricted_package_name", "test-package",
-                "notification", ImmutableMap.builder()
-                    .put("click_action", "test-action")
-                    .put("title", "test-title")
-                    .put("body", "test-body")
-                    .put("icon", "test-icon")
-                    .put("color", "#112233")
-                    .put("tag", "test-tag")
-                    .put("sound", "test-sound")
-                    .put("title_loc_key", "test-title-key")
-                    .put("title_loc_args", ImmutableList.of("t-arg1", "t-arg2", "t-arg3"))
-                    .put("body_loc_key", "test-body-key")
-                    .put("body_loc_args", ImmutableList.of("b-arg1", "b-arg2", "b-arg3"))
-                    .put("channel_id", "channel-id")
-                    .build()
-            )
-        ));
-
-    // APNS message
-    builder.put(
-        Message.builder()
-            .setApnsConfig(ApnsConfig.builder()
-                .putHeader("h1", "v1")
-                .putAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
-                .putAllCustomData(ImmutableMap.<String, Object>of("k1", "v1", "k2", true))
-                .setAps(Aps.builder()
-                    .setBadge(42)
-                    .setAlert(ApsAlert.builder()
-                        .setTitle("test-title")
-                        .setSubtitle("test-subtitle")
-                        .setBody("test-body")
-                        .build())
-                    .build())
-                .build())
-            .setTopic("test-topic")
-            .build(),
-        ImmutableMap.<String, Object>of(
-            "topic", "test-topic",
-            "apns", ImmutableMap.of(
-                "headers", ImmutableMap.of("h1", "v1", "h2", "v2", "h3", "v3"),
-                "payload", ImmutableMap.of("k1", "v1", "k2", true,
-                    "aps", ImmutableMap.<String, Object>of("badge", new BigDecimal(42),
-                        "alert", ImmutableMap.<String, Object>of(
-                            "title", "test-title", "subtitle", "test-subtitle",
-                            "body", "test-body"))))
-        ));
-
-    // Webpush message (no notification)
-    builder.put(
-        Message.builder()
-            .setWebpushConfig(WebpushConfig.builder()
-                .putHeader("h1", "v1")
-                .putAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
-                .putData("k1", "v1")
-                .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
-                .build())
-            .setTopic("test-topic")
-            .build(),
-        ImmutableMap.<String, Object>of(
-            "topic", "test-topic",
-            "webpush", ImmutableMap.of(
-                "headers", ImmutableMap.of("h1", "v1", "h2", "v2", "h3", "v3"),
-                "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"))
-        ));
-
-    // Webpush message (simple notification)
-    builder.put(
-        Message.builder()
-            .setWebpushConfig(WebpushConfig.builder()
-                .putHeader("h1", "v1")
-                .putAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
-                .putData("k1", "v1")
-                .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
-                .setNotification(new WebpushNotification("test-title", "test-body", "test-icon"))
-                .build())
-            .setTopic("test-topic")
-            .build(),
-        ImmutableMap.<String, Object>of(
-            "topic", "test-topic",
-            "webpush", ImmutableMap.of(
-                "headers", ImmutableMap.of("h1", "v1", "h2", "v2", "h3", "v3"),
-                "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
-                "notification", ImmutableMap.of(
-                    "title", "test-title", "body", "test-body", "icon", "test-icon"))
-        ));
-
-    // Webpush message (all fields)
-    builder.put(
-        Message.builder()
-            .setWebpushConfig(WebpushConfig.builder()
-                .putHeader("h1", "v1")
-                .putAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
-                .putData("k1", "v1")
-                .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
-                .setNotification(WebpushNotification.builder()
-                    .setTitle("test-title")
-                    .setBody("test-body")
-                    .setIcon("test-icon")
-                    .setBadge("test-badge")
-                    .setImage("test-image")
-                    .setLanguage("test-lang")
-                    .setTag("test-tag")
-                    .setData(ImmutableList.of("arbitrary", "data"))
-                    .setDirection(Direction.AUTO)
-                    .setRenotify(true)
-                    .setRequireInteraction(false)
-                    .setSilent(true)
-                    .setTimestampMillis(100L)
-                    .setVibrate(new int[]{200, 100, 200})
-                    .addAction(new Action("action1", "title1"))
-                    .addAllActions(ImmutableList.of(new Action("action2", "title2", "icon2")))
-                    .putCustomData("k4", "v4")
-                    .putAllCustomData(ImmutableMap.<String, Object>of("k5", "v5", "k6", "v6"))
-                    .build())
-                .build())
-            .setTopic("test-topic")
-            .build(),
-        ImmutableMap.<String, Object>of(
-            "topic", "test-topic",
-            "webpush", ImmutableMap.of(
-                "headers", ImmutableMap.of("h1", "v1", "h2", "v2", "h3", "v3"),
-                "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
-                "notification", ImmutableMap.builder()
-                    .put("title", "test-title")
-                    .put("body", "test-body")
-                    .put("icon", "test-icon")
-                    .put("badge", "test-badge")
-                    .put("image", "test-image")
-                    .put("lang", "test-lang")
-                    .put("tag", "test-tag")
-                    .put("data", ImmutableList.of("arbitrary", "data"))
-                    .put("renotify", true)
-                    .put("requireInteraction", false)
-                    .put("silent", true)
-                    .put("dir", "auto")
-                    .put("timestamp", new BigDecimal(100))
-                    .put("vibrate", ImmutableList.of(
-                        new BigDecimal(200), new BigDecimal(100), new BigDecimal(200)))
-                    .put("actions", ImmutableList.of(
-                        ImmutableMap.of("action", "action1", "title", "title1"),
-                        ImmutableMap.of("action", "action2", "title", "title2", "icon", "icon2")))
-                    .put("k4", "v4")
-                    .put("k5", "v5")
-                    .put("k6", "v6")
-                    .build())
-        ));
-
-    return builder.build();
-  }
 }
diff --git a/src/test/java/com/google/firebase/messaging/InstanceIdClientImplTest.java b/src/test/java/com/google/firebase/messaging/InstanceIdClientImplTest.java
new file mode 100644
index 0000000..f939c83
--- /dev/null
+++ b/src/test/java/com/google/firebase/messaging/InstanceIdClientImplTest.java
@@ -0,0 +1,400 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.json.JsonParser;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import com.google.firebase.auth.MockGoogleCredentials;
+import com.google.firebase.testing.TestResponseInterceptor;
+import com.google.firebase.testing.TestUtils;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.After;
+import org.junit.Test;
+
+public class InstanceIdClientImplTest {
+
+  private static final String TEST_IID_SUBSCRIBE_URL =
+      "https://iid.googleapis.com/iid/v1:batchAdd";
+
+  private static final String TEST_IID_UNSUBSCRIBE_URL =
+      "https://iid.googleapis.com/iid/v1:batchRemove";
+
+  private static final List<Integer> HTTP_ERRORS = ImmutableList.of(401, 404, 500);
+
+  @After
+  public void tearDown() {
+    TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+  }
+
+  @Test
+  public void testSubscribe() throws Exception {
+    final String responseString = "{\"results\": [{}, {\"error\": \"error_reason\"}]}";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent(responseString);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    final InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    TopicManagementResponse result = client.subscribeToTopic(
+        "test-topic", ImmutableList.of("id1", "id2"));
+
+    checkTopicManagementRequestHeader(
+        interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+    checkTopicManagementRequest(interceptor.getLastRequest(), result);
+  }
+
+  @Test
+  public void testSubscribeWithPrefixedTopic() throws Exception {
+    final String responseString = "{\"results\": [{}, {\"error\": \"error_reason\"}]}";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent(responseString);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    final InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    TopicManagementResponse result = client.subscribeToTopic(
+        "/topics/test-topic", ImmutableList.of("id1", "id2"));
+
+    checkTopicManagementRequestHeader(
+        interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+    checkTopicManagementRequest(interceptor.getLastRequest(), result);
+  }
+
+  @Test
+  public void testSubscribeError() {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    for (int statusCode : HTTP_ERRORS) {
+      response.setStatusCode(statusCode).setContent("{\"error\": \"test error\"}");
+
+      try {
+        client.subscribeToTopic("test-topic", ImmutableList.of("id1", "id2"));
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, statusCode, "test error");
+      }
+
+      checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+    }
+  }
+
+  @Test
+  public void testSubscribeEmptyPayloadError() {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(500).setContent("{}");
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    try {
+      client.subscribeToTopic("test-topic", ImmutableList.of("id1", "id2"));
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseMessagingException error) {
+      checkExceptionFromHttpResponse(error, 500,
+          "Unexpected HTTP response with status: 500; body: {}");
+    }
+
+    checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+  }
+
+  @Test
+  public void testSubscribeMalformedError() {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(500).setContent("not json");
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    try {
+      client.subscribeToTopic("test-topic", ImmutableList.of("id1", "id2"));
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseMessagingException error) {
+      checkExceptionFromHttpResponse(error, 500,
+          "Unexpected HTTP response with status: 500; body: not json");
+    }
+
+    checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+  }
+
+  @Test
+  public void testSubscribeZeroContentError() {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(500).setZeroContent();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    try {
+      client.subscribeToTopic("test-topic", ImmutableList.of("id1", "id2"));
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseMessagingException error) {
+      checkExceptionFromHttpResponse(error, 500,
+          "Unexpected HTTP response with status: 500; body: null");
+    }
+
+    checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+  }
+
+  @Test
+  public void testSubscribeTransportError() {
+    InstanceIdClient client = initClientWithFaultyTransport();
+
+    try {
+      client.subscribeToTopic("test-topic", ImmutableList.of("id1", "id2"));
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseMessagingException error) {
+      assertEquals("internal-error", error.getErrorCode());
+      assertEquals("Error while calling IID backend service", error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
+    }
+  }
+
+  @Test
+  public void testUnsubscribe() throws Exception {
+    final String responseString = "{\"results\": [{}, {\"error\": \"error_reason\"}]}";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent(responseString);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    final InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    TopicManagementResponse result = client.unsubscribeFromTopic(
+        "test-topic", ImmutableList.of("id1", "id2"));
+
+    checkTopicManagementRequestHeader(
+        interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
+    checkTopicManagementRequest(interceptor.getLastRequest(), result);
+  }
+
+  @Test
+  public void testUnsubscribeWithPrefixedTopic() throws Exception {
+    final String responseString = "{\"results\": [{}, {\"error\": \"error_reason\"}]}";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent(responseString);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    final InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    TopicManagementResponse result = client.unsubscribeFromTopic(
+        "/topics/test-topic", ImmutableList.of("id1", "id2"));
+
+    checkTopicManagementRequestHeader(
+        interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
+    checkTopicManagementRequest(interceptor.getLastRequest(), result);
+  }
+
+  @Test
+  public void testUnsubscribeError() {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    for (int statusCode : HTTP_ERRORS) {
+      response.setStatusCode(statusCode).setContent("{\"error\": \"test error\"}");
+
+      try {
+        client.unsubscribeFromTopic("test-topic", ImmutableList.of("id1", "id2"));
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseMessagingException error) {
+        checkExceptionFromHttpResponse(error, statusCode, "test error");
+      }
+
+      checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
+    }
+  }
+
+  @Test
+  public void testUnsubscribeEmptyPayloadError() {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(500).setContent("{}");
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    try {
+      client.unsubscribeFromTopic("test-topic", ImmutableList.of("id1", "id2"));
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseMessagingException error) {
+      checkExceptionFromHttpResponse(error, 500,
+          "Unexpected HTTP response with status: 500; body: {}");
+    }
+
+    checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
+  }
+
+  @Test
+  public void testUnsubscribeMalformedError() {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(500).setContent("not json");
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    try {
+      client.unsubscribeFromTopic("test-topic", ImmutableList.of("id1", "id2"));
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseMessagingException error) {
+      checkExceptionFromHttpResponse(error, 500,
+          "Unexpected HTTP response with status: 500; body: not json");
+    }
+
+    checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
+  }
+
+  @Test
+  public void testUnsubscribeZeroContentError() {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(500).setZeroContent();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    InstanceIdClient client = initInstanceIdClient(response, interceptor);
+
+    try {
+      client.unsubscribeFromTopic("test-topic", ImmutableList.of("id1", "id2"));
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseMessagingException error) {
+      checkExceptionFromHttpResponse(error, 500,
+          "Unexpected HTTP response with status: 500; body: null");
+    }
+
+    checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
+  }
+
+  @Test
+  public void testUnsubscribeTransportError() {
+    InstanceIdClient client = initClientWithFaultyTransport();
+
+    try {
+      client.unsubscribeFromTopic("test-topic", ImmutableList.of("id1", "id2"));
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseMessagingException error) {
+      assertEquals("internal-error", error.getErrorCode());
+      assertEquals("Error while calling IID backend service", error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
+    }
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testRequestFactoryIsNull() {
+    new InstanceIdClientImpl(null, Utils.getDefaultJsonFactory());
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testJsonFactoryIsNull() {
+    new InstanceIdClientImpl(Utils.getDefaultTransport().createRequestFactory(), null);
+  }
+
+  @Test
+  public void testFromApp() throws IOException {
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setProjectId("test-project")
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options);
+
+    try {
+      InstanceIdClientImpl client = InstanceIdClientImpl.fromApp(app);
+
+      assertSame(options.getJsonFactory(), client.getJsonFactory());
+      HttpRequest request = client.getRequestFactory().buildGetRequest(
+          new GenericUrl("https://example.com"));
+      assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
+    } finally {
+      app.delete();
+    }
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testTopicManagementResponseWithNullList() {
+    new TopicManagementResponse(null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testTopicManagementResponseWithEmptyList() {
+    new TopicManagementResponse(ImmutableList.<GenericJson>of());
+  }
+
+  private static InstanceIdClientImpl initInstanceIdClient(
+      final MockLowLevelHttpResponse mockResponse,
+      final HttpResponseInterceptor interceptor) {
+
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(mockResponse)
+        .build();
+    return new InstanceIdClientImpl(
+        transport.createRequestFactory(),
+        Utils.getDefaultJsonFactory(),
+        interceptor);
+  }
+
+  private void checkTopicManagementRequest(
+      HttpRequest request, TopicManagementResponse result) throws IOException {
+    assertEquals(1, result.getSuccessCount());
+    assertEquals(1, result.getFailureCount());
+    assertEquals(1, result.getErrors().size());
+    assertEquals(1, result.getErrors().get(0).getIndex());
+    assertEquals("unknown-error", result.getErrors().get(0).getReason());
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    request.getContent().writeTo(out);
+    Map<String, Object> parsed = new HashMap<>();
+    JsonParser parser = Utils.getDefaultJsonFactory().createJsonParser(out.toString());
+    parser.parseAndClose(parsed);
+    assertEquals(2, parsed.size());
+    assertEquals("/topics/test-topic", parsed.get("to"));
+    assertEquals(ImmutableList.of("id1", "id2"), parsed.get("registration_tokens"));
+  }
+
+  private void checkTopicManagementRequestHeader(
+      HttpRequest request, String expectedUrl) {
+    assertEquals("POST", request.getRequestMethod());
+    assertEquals(expectedUrl, request.getUrl().toString());
+  }
+
+  private void checkExceptionFromHttpResponse(FirebaseMessagingException error,
+      int expectedCode, String expectedMessage) {
+    assertEquals(getTopicManagementErrorCode(expectedCode), error.getErrorCode());
+    assertEquals(expectedMessage, error.getMessage());
+    assertTrue(error.getCause() instanceof HttpResponseException);
+  }
+
+  private InstanceIdClient initClientWithFaultyTransport() {
+    return new InstanceIdClientImpl(
+        TestUtils.createFaultyHttpTransport().createRequestFactory(),
+        Utils.getDefaultJsonFactory());
+  }
+
+  private String getTopicManagementErrorCode(int statusCode) {
+    String code = InstanceIdClientImpl.IID_ERROR_CODES.get(statusCode);
+    if (code == null) {
+      code = "unknown-error";
+    }
+    return code;
+  }
+}
diff --git a/src/test/java/com/google/firebase/testing/TestUtils.java b/src/test/java/com/google/firebase/testing/TestUtils.java
index 3e33a52..f8e1b4d 100644
--- a/src/test/java/com/google/firebase/testing/TestUtils.java
+++ b/src/test/java/com/google/firebase/testing/TestUtils.java
@@ -24,6 +24,7 @@ import com.google.api.client.http.GenericUrl;
 import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpRequestFactory;
 import com.google.api.client.http.HttpTransport;
+import com.google.api.client.http.LowLevelHttpRequest;
 import com.google.api.client.json.webtoken.JsonWebSignature;
 import com.google.api.client.testing.http.MockHttpTransport;
 import com.google.api.client.testing.http.MockLowLevelHttpRequest;
@@ -142,4 +143,13 @@ public class TestUtils {
     HttpRequestFactory requestFactory = transport.createRequestFactory();
     return requestFactory.buildPostRequest(TEST_URL, new EmptyContent());
   }
+
+  public static HttpTransport createFaultyHttpTransport() {
+    return new HttpTransport() {
+      @Override
+      protected LowLevelHttpRequest buildRequest(String s, String s1) throws IOException {
+        throw new IOException("transport error");
+      }
+    };
+  }
 }