Bug 1182193 - Part 1: Add Gradle-based Robolectric JUnit 4 tests r=nalexander
authorvivek <vivekb.balakrishnan@gmail.com>
Sat, 29 Aug 2015 00:26:36 +0300
changeset 294877 c02886680b09ced8b1c359ebf698b7f1ba827085
parent 294876 9212e656007346226fea7f34c5c28c59d2bdb3f0
child 294878 45d1ea755875ff11ebf461206c707dc46e3d2b04
push id5245
push userraliiev@mozilla.com
push dateThu, 29 Oct 2015 11:30:51 +0000
treeherdermozilla-beta@dac831dc1bd0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander
bugs1182193
milestone43.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1182193 - Part 1: Add Gradle-based Robolectric JUnit 4 tests r=nalexander
mobile/android/gradle/base/build.gradle
mobile/android/gradle/build.gradle
mobile/android/mach_commands.py
mobile/android/tests/background/junit4/resources/robolectric.properties
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WaitHelper.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/TestSkewHandler.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestFxAccountLoginStateMachine.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestStateFactory.java
--- a/mobile/android/gradle/base/build.gradle
+++ b/mobile/android/gradle/base/build.gradle
@@ -23,17 +23,16 @@ android {
             minifyEnabled false
             proguardFile getDefaultProguardFile('proguard-android.txt')
         }
     }
 
     sourceSets {
         main {
             java {
-                exclude 'org/mozilla/gecko/tests/**'
                 exclude 'org/mozilla/gecko/resources/**'
 
                 if (!mozconfig.substs.MOZ_CRASHREPORTER) {
                     exclude 'org/mozilla/gecko/CrashReporter.java'
                 }
 
                 if (!mozconfig.substs.MOZ_NATIVE_DEVICES) {
                     exclude 'org/mozilla/gecko/ChromeCast.java'
@@ -52,16 +51,26 @@ android {
             }
 
             res {
                 if (mozconfig.substs.MOZ_CRASHREPORTER) {
                     srcDir "src/crashreporter/res"
                 }
             }
         }
+
+        test {
+            java {
+                srcDir "src/background_junit4"
+            }
+
+            resources {
+                srcDir "resources/background_junit4"
+            }
+        }
     }
 }
 
 dependencies {
     compile 'com.android.support:support-v4:22.2.0'
     compile 'com.android.support:recyclerview-v7:22.2.0'
 
     if (mozconfig.substs.MOZ_NATIVE_DEVICES) {
@@ -70,18 +79,20 @@ dependencies {
         compile 'com.google.android.gms:play-services-base:7.8.0'
         compile 'com.google.android.gms:play-services-cast:7.8.0'
     }
 
     compile project(':branding')
     compile project(':preprocessed_code')
     compile project(':preprocessed_resources')
     compile project(':thirdparty')
+
+    testCompile 'junit:junit:4.12'
+    testCompile 'org.robolectric:robolectric:3.0'
 }
 
 apply plugin: 'idea'
 
 idea {
     module {
         excludeDirs += file('org/mozilla/gecko/resources')
-        excludeDirs += file('org/mozilla/gecko/tests')
     }
 }
--- a/mobile/android/gradle/build.gradle
+++ b/mobile/android/gradle/build.gradle
@@ -25,20 +25,19 @@ buildscript {
         }
         // For spoon SNAPSHOT releases.
         maven {
             url 'https://oss.sonatype.org/content/repositories/snapshots'
         }
     }
 
     dependencies {
-        // IntelliJ 14.0.2 wants 0.14.4; IntelliJ 14.0.3 and Android Studio want
-        // 1.0.0.  There are major issues with the combination of 0.14.4, Gradle
-        // 2.2.1, and IntelliJ 14.0.2: see Bug 1120032.
-        classpath 'com.android.tools.build:gradle:1.0.0'
+        // Unit testing support was added in 1.1.0. IntelliJ 14.1.4 and Android
+        // Studio 1.2.1.1 appear to work fine with plugin version 1.3.0.
+        classpath 'com.android.tools.build:gradle:1.3.0'
         classpath('com.stanfy.spoon:spoon-gradle-plugin:1.0.3-SNAPSHOT') {
             // Without these, we get errors linting.
             exclude module: 'guava'
         }
     }
 }
 
 task generateCodeAndResources(type:Exec) {
--- a/mobile/android/mach_commands.py
+++ b/mobile/android/mach_commands.py
@@ -131,17 +131,17 @@ class MachCommands(MachCommandBase):
         srcdir('app/build.gradle', 'mobile/android/gradle/app/build.gradle')
         objdir('app/src/main/AndroidManifest.xml', 'mobile/android/base/AndroidManifest.xml')
         objdir('app/src/androidTest/AndroidManifest.xml', 'build/mobile/robocop/AndroidManifest.xml')
         srcdir('app/src/androidTest/res', 'build/mobile/robocop/res')
         srcdir('app/src/androidTest/assets', 'mobile/android/tests/browser/robocop/assets')
         # Test code.
         srcdir('app/src/robocop_harness/org/mozilla/gecko', 'build/mobile/robocop')
         srcdir('app/src/robocop/org/mozilla/gecko/tests', 'mobile/android/tests/browser/robocop')
-        srcdir('app/src/background/org/mozilla/gecko', 'mobile/android/tests/background/junit3/src')
+        srcdir('app/src/background/org/mozilla/gecko/background', 'mobile/android/tests/background/junit3/src')
         srcdir('app/src/browser', 'mobile/android/tests/browser/junit3/src')
         srcdir('app/src/javaaddons', 'mobile/android/tests/javaaddons/src')
         # Test libraries.
         srcdir('app/libs', 'build/mobile/robocop')
 
         srcdir('base/build.gradle', 'mobile/android/gradle/base/build.gradle')
         srcdir('base/lint.xml', 'mobile/android/gradle/base/lint.xml')
         srcdir('base/src/main/AndroidManifest.xml', 'mobile/android/gradle/base/AndroidManifest.xml')
@@ -150,16 +150,19 @@ class MachCommands(MachCommandBase):
         srcdir('base/src/main/java/org/mozilla/search', 'mobile/android/search/java/org/mozilla/search')
         srcdir('base/src/main/java/org/mozilla/javaaddons', 'mobile/android/javaaddons/java/org/mozilla/javaaddons')
         srcdir('base/src/webrtc_audio_device/java', 'media/webrtc/trunk/webrtc/modules/audio_device/android/java/src')
         srcdir('base/src/webrtc_video_capture/java', 'media/webrtc/trunk/webrtc/modules/video_capture/android/java/src')
         srcdir('base/src/webrtc_video_render/java', 'media/webrtc/trunk/webrtc/modules/video_render/android/java/src')
         srcdir('base/src/main/res', 'mobile/android/base/resources')
         srcdir('base/src/main/assets', 'mobile/android/app/assets')
         srcdir('base/src/crashreporter/res', 'mobile/android/base/crashreporter/res')
+        # JUnit 4 test code.
+        srcdir('base/src/background_junit4', 'mobile/android/tests/background/junit4/src')
+        srcdir('base/resources/background_junit4', 'mobile/android/tests/background/junit4/resources')
 
         manifest_path = os.path.join(self.topobjdir, 'mobile', 'android', 'gradle.manifest')
         with FileAvoidWrite(manifest_path) as f:
             m.write(fileobj=f)
 
         self.virtualenv_manager.ensure()
         code = self.run_process([
                 self.virtualenv_manager.python_path,
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/resources/robolectric.properties
@@ -0,0 +1,2 @@
+sdk=21
+constants=org.mozilla.gecko.BuildConfig
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WaitHelper.java
@@ -0,0 +1,171 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+/**
+ * Implements waiting for asynchronous test events.
+ *
+ * Call WaitHelper.getTestWaiter() to get the unique instance.
+ *
+ * Call performWait(runnable) to execute runnable synchronously.
+ * runnable *must* call performNotify() on all exit paths to signal to
+ * the TestWaiter that the runnable has completed.
+ *
+ * @author rnewman
+ * @author nalexander
+ */
+public class WaitHelper {
+
+  public static final String LOG_TAG = "WaitHelper";
+
+  public static class Result {
+    public Throwable error;
+    public Result() {
+      error = null;
+    }
+
+    public Result(Throwable error) {
+      this.error = error;
+    }
+  }
+
+  public static abstract class WaitHelperError extends Error {
+    private static final long serialVersionUID = 7074690961681883619L;
+  }
+
+  /**
+   * Immutable.
+   *
+   * @author rnewman
+   */
+  public static class TimeoutError extends WaitHelperError {
+    private static final long serialVersionUID = 8591672555848651736L;
+    public final int waitTimeInMillis;
+
+    public TimeoutError(int waitTimeInMillis) {
+      this.waitTimeInMillis = waitTimeInMillis;
+    }
+  }
+
+  public static class MultipleNotificationsError extends WaitHelperError {
+    private static final long serialVersionUID = -9072736521571635495L;
+  }
+
+  public static class InterruptedError extends WaitHelperError {
+    private static final long serialVersionUID = 8383948170038639308L;
+  }
+
+  public static class InnerError extends WaitHelperError {
+    private static final long serialVersionUID = 3008502618576773778L;
+    public Throwable innerError;
+
+    public InnerError(Throwable e) {
+      innerError = e;
+      if (e != null) {
+        // Eclipse prints the stack trace of the cause.
+        this.initCause(e);
+      }
+    }
+  }
+
+  public BlockingQueue<Result> queue = new ArrayBlockingQueue<Result>(1);
+
+  /**
+   * How long performWait should wait for, in milliseconds, with the
+   * convention that a negative value means "wait forever".
+   */
+  public static int defaultWaitTimeoutInMillis = -1;
+
+  public void performWait(Runnable action) throws WaitHelperError {
+    this.performWait(defaultWaitTimeoutInMillis, action);
+  }
+
+  public void performWait(int waitTimeoutInMillis, Runnable action) throws WaitHelperError {
+    Logger.debug(LOG_TAG, "performWait called.");
+
+    Result result = null;
+
+    try {
+      if (action != null) {
+        try {
+          action.run();
+          Logger.debug(LOG_TAG, "Action done.");
+        } catch (Exception ex) {
+          Logger.debug(LOG_TAG, "Performing action threw: " + ex.getMessage());
+          throw new InnerError(ex);
+        }
+      }
+
+      if (waitTimeoutInMillis < 0) {
+        result = queue.take();
+      } else {
+        result = queue.poll(waitTimeoutInMillis, TimeUnit.MILLISECONDS);
+      }
+      Logger.debug(LOG_TAG, "Got result from queue: " + result);
+    } catch (InterruptedException e) {
+      // We were interrupted.
+      Logger.debug(LOG_TAG, "performNotify interrupted with InterruptedException " + e);
+      final InterruptedError interruptedError = new InterruptedError();
+      interruptedError.initCause(e);
+      throw interruptedError;
+    }
+
+    if (result == null) {
+      // We timed out.
+      throw new TimeoutError(waitTimeoutInMillis);
+    } else if (result.error != null) {
+      Logger.debug(LOG_TAG, "Notified with error: " + result.error.getMessage());
+
+      // Rethrow any assertion with which we were notified.
+      InnerError innerError = new InnerError(result.error);
+      throw innerError;
+    }
+    // Success!
+  }
+
+  public void performNotify(final Throwable e) {
+    if (e != null) {
+      Logger.debug(LOG_TAG, "performNotify called with Throwable: " + e.getMessage());
+    } else {
+      Logger.debug(LOG_TAG, "performNotify called.");
+    }
+
+    if (!queue.offer(new Result(e))) {
+      // This could happen if performNotify is called multiple times (which is an error).
+      throw new MultipleNotificationsError();
+    }
+  }
+
+  public void performNotify() {
+    this.performNotify(null);
+  }
+
+  public static Runnable onThreadRunnable(final Runnable r) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        new Thread(r).start();
+      }
+    };
+  }
+
+  private static WaitHelper singleWaiter = new WaitHelper();
+  public static WaitHelper getTestWaiter() {
+    return singleWaiter;
+  }
+
+  public static void resetTestWaiter() {
+    singleWaiter = new WaitHelper();
+  }
+
+  public boolean isIdle() {
+    return queue.isEmpty();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/TestSkewHandler.java
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa;
+
+import org.mozilla.gecko.background.fxa.SkewHandler;
+import org.mozilla.gecko.sync.net.BaseResource;
+import ch.boye.httpclientandroidlib.impl.cookie.DateUtils;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricGradleTestRunner;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(RobolectricGradleTestRunner.class)
+public class TestSkewHandler {
+  public TestSkewHandler() {
+  }
+
+  @Test
+  public void testSkewUpdating() throws Throwable {
+    SkewHandler h = new SkewHandler("foo.com");
+    assertEquals(0L, h.getSkewInSeconds());
+    assertEquals(0L, h.getSkewInMillis());
+
+    long server = 1390101197865L;
+    long local = server - 4500L;
+    h.updateSkewFromServerMillis(server, local);
+    assertEquals(4500L, h.getSkewInMillis());
+    assertEquals(4L, h.getSkewInSeconds());
+
+    local = server;
+    h.updateSkewFromServerMillis(server, local);
+    assertEquals(0L, h.getSkewInMillis());
+    assertEquals(0L, h.getSkewInSeconds());
+
+    local = server + 500L;
+    h.updateSkewFromServerMillis(server, local);
+    assertEquals(-500L, h.getSkewInMillis());
+    assertEquals(0L, h.getSkewInSeconds());
+
+    String date = "Sat, 18 Jan 2014 19:16:52 PST";
+    long dateInMillis = 1390101412000L;              // Obviously this can differ somewhat due to precision.
+    long parsed = DateUtils.parseDate(date).getTime();
+    assertEquals(parsed, dateInMillis);
+
+    h.updateSkewFromHTTPDateString(date, dateInMillis);
+    assertEquals(0L, h.getSkewInMillis());
+    assertEquals(0L, h.getSkewInSeconds());
+
+    h.updateSkewFromHTTPDateString(date, dateInMillis + 1100L);
+    assertEquals(-1100L, h.getSkewInMillis());
+    assertEquals(Math.round(-1100L / 1000L), h.getSkewInSeconds());
+  }
+
+  @Test
+  public void testSkewSingleton() throws Exception {
+    SkewHandler h1 = SkewHandler.getSkewHandlerFromEndpointString("http://foo.com/bar");
+    SkewHandler h2 = SkewHandler.getSkewHandlerForHostname("foo.com");
+    SkewHandler h3 = SkewHandler.getSkewHandlerForResource(new BaseResource("http://foo.com/baz"));
+    assertTrue(h1 == h2);
+    assertTrue(h1 == h3);
+
+    SkewHandler.getSkewHandlerForHostname("foo.com").updateSkewFromServerMillis(1390101412000L, 1390001412000L);
+    final long actual = SkewHandler.getSkewHandlerForHostname("foo.com").getSkewInMillis();
+    assertEquals(100000000L, actual);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
@@ -0,0 +1,204 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.fxa.login;
+
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountClient10.RequestDelegate;
+import org.mozilla.gecko.background.fxa.FxAccountClient10.StatusResponse;
+import org.mozilla.gecko.background.fxa.FxAccountClient10.TwoKeys;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.LoginResponse;
+import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
+import org.mozilla.gecko.background.fxa.FxAccountRemoteError;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.fxa.PasswordStretcher;
+import org.mozilla.gecko.browserid.MockMyIDTokenFactory;
+import org.mozilla.gecko.browserid.RSACryptoImplementation;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+
+import ch.boye.httpclientandroidlib.HttpStatus;
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.entity.StringEntity;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+
+public class MockFxAccountClient implements FxAccountClient {
+  protected static MockMyIDTokenFactory mockMyIdTokenFactory = new MockMyIDTokenFactory();
+
+  public final String serverURI = "http://testServer.com";
+
+  public final Map<String, User> users = new HashMap<String, User>();
+  public final Map<String, String> sessionTokens = new HashMap<String, String>();
+  public final Map<String, String> keyFetchTokens = new HashMap<String, String>();
+
+  public static class User {
+    public final String email;
+    public final byte[] quickStretchedPW;
+    public final String uid;
+    public boolean verified;
+    public final byte[] kA;
+    public final byte[] wrapkB;
+
+    public User(String email, byte[] quickStretchedPW) {
+      this.email = email;
+      this.quickStretchedPW = quickStretchedPW;
+      this.uid = "uid/" + this.email;
+      this.verified = false;
+      this.kA = Utils.generateRandomBytes(FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES);
+      this.wrapkB = Utils.generateRandomBytes(FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES);
+    }
+  }
+
+  protected LoginResponse addLogin(User user, byte[] sessionToken, byte[] keyFetchToken) {
+    // byte[] sessionToken = Utils.generateRandomBytes(8);
+    if (sessionToken != null) {
+      sessionTokens.put(Utils.byte2Hex(sessionToken), user.email);
+    }
+    // byte[] keyFetchToken = Utils.generateRandomBytes(8);
+    if (keyFetchToken != null) {
+      keyFetchTokens.put(Utils.byte2Hex(keyFetchToken), user.email);
+    }
+    return new LoginResponse(user.email, user.uid, user.verified, sessionToken, keyFetchToken);
+  }
+
+  public void addUser(String email, byte[] quickStretchedPW, boolean verified, byte[] sessionToken, byte[] keyFetchToken) {
+    User user = new User(email, quickStretchedPW);
+    users.put(email, user);
+    if (verified) {
+      verifyUser(email);
+    }
+    addLogin(user, sessionToken, keyFetchToken);
+  }
+
+  public void verifyUser(String email) {
+    users.get(email).verified = true;
+  }
+
+  public void clearAllUserTokens() throws UnsupportedEncodingException {
+    sessionTokens.clear();
+    keyFetchTokens.clear();
+  }
+
+  protected BasicHttpResponse makeHttpResponse(int statusCode, String body) {
+    BasicHttpResponse httpResponse = new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), statusCode, body);
+    httpResponse.setEntity(new StringEntity(body, "UTF-8"));
+    return httpResponse;
+  }
+
+  protected <T> void handleFailure(RequestDelegate<T> requestDelegate, int code, int errno, String message) {
+    requestDelegate.handleFailure(new FxAccountClientRemoteException(makeHttpResponse(code, message),
+        code, errno, "Bad authorization", message, null, new ExtendedJSONObject()));
+  }
+
+  @Override
+  public void status(byte[] sessionToken, RequestDelegate<StatusResponse> requestDelegate) {
+    String email = sessionTokens.get(Utils.byte2Hex(sessionToken));
+    User user = users.get(email);
+    if (email == null || user == null) {
+      handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid sessionToken");
+      return;
+    }
+    requestDelegate.handleSuccess(new StatusResponse(email, user.verified));
+  }
+
+  @Override
+  public void loginAndGetKeys(byte[] emailUTF8, final PasswordStretcher passwordStretcher, final Map<String, String> queryParameters, RequestDelegate<LoginResponse> requestDelegate) {
+    User user;
+    try {
+      user = users.get(new String(emailUTF8, "UTF-8"));
+    } catch (UnsupportedEncodingException e) {
+      user = null;
+    }
+    if (user == null) {
+      handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS, "invalid emailUTF8");
+      return;
+    }
+    byte[] quickStretchedPW;
+    try {
+      quickStretchedPW = passwordStretcher.getQuickStretchedPW(emailUTF8);
+    } catch (Exception e) {
+      handleFailure(requestDelegate, HttpStatus.SC_INTERNAL_SERVER_ERROR, 999, "error stretching password");
+      return;
+    }
+    if (user.quickStretchedPW == null || !Arrays.equals(user.quickStretchedPW, quickStretchedPW)) {
+      handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.INCORRECT_PASSWORD, "invalid quickStretchedPW");
+      return;
+    }
+    LoginResponse loginResponse = addLogin(user, Utils.generateRandomBytes(8), Utils.generateRandomBytes(8));
+    requestDelegate.handleSuccess(loginResponse);
+  }
+
+  @Override
+  public void keys(byte[] keyFetchToken, RequestDelegate<TwoKeys> requestDelegate) {
+    String email = keyFetchTokens.get(Utils.byte2Hex(keyFetchToken));
+    User user = users.get(email);
+    if (email == null || user == null) {
+      handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid keyFetchToken");
+      return;
+    }
+    if (!user.verified) {
+      handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT, "user is unverified");
+      return;
+    }
+    requestDelegate.handleSuccess(new TwoKeys(user.kA, user.wrapkB));
+  }
+
+  @Override
+  public void sign(byte[] sessionToken, ExtendedJSONObject publicKey, long certificateDurationInMilliseconds, RequestDelegate<String> requestDelegate) {
+    String email = sessionTokens.get(Utils.byte2Hex(sessionToken));
+    User user = users.get(email);
+    if (email == null || user == null) {
+      handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid sessionToken");
+      return;
+    }
+    if (!user.verified) {
+      handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT, "user is unverified");
+      return;
+    }
+    try {
+      final long iat = System.currentTimeMillis();
+      final long dur = certificateDurationInMilliseconds;
+      final long exp = iat + dur;
+      String certificate = mockMyIdTokenFactory.createMockMyIDCertificate(RSACryptoImplementation.createPublicKey(publicKey), "test", iat, exp);
+      requestDelegate.handleSuccess(certificate);
+    } catch (Exception e) {
+      requestDelegate.handleError(e);
+    }
+  }
+
+  @Override
+  public void resendCode(byte[] sessionToken, RequestDelegate<Void> requestDelegate) {
+    String email = sessionTokens.get(Utils.byte2Hex(sessionToken));
+    User user = users.get(email);
+    if (email == null || user == null) {
+      handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid sessionToken");
+      return;
+    }
+    requestDelegate.handleSuccess(null);
+  }
+
+  @Override
+  public void resendUnlockCode(byte[] emailUTF8, RequestDelegate<Void> requestDelegate) {
+    User user;
+    try {
+      user = users.get(new String(emailUTF8, "UTF-8"));
+    } catch (UnsupportedEncodingException e) {
+      user = null;
+    }
+    if (user == null) {
+      handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST, "invalid emailUTF8");
+      return;
+    }
+    requestDelegate.handleSuccess(null);
+  }
+
+  @Override
+  public void createAccountAndGetKeys(byte[] emailUTF8, PasswordStretcher passwordStretcher, final Map<String, String> queryParameters, RequestDelegate<LoginResponse> delegate) {
+    delegate.handleError(new RuntimeException("Not yet implemented"));
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestFxAccountLoginStateMachine.java
@@ -0,0 +1,207 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.fxa.login;
+
+import java.security.NoSuchAlgorithmException;
+import java.util.LinkedList;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.BuildConfig;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.RSACryptoImplementation;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.sync.Utils;
+import org.robolectric.RobolectricGradleTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricGradleTestRunner.class)
+public class TestFxAccountLoginStateMachine {
+  // private static final String TEST_AUDIENCE = "http://testAudience.com";
+  private static final String TEST_EMAIL = "test@test.com";
+  private static byte[] TEST_EMAIL_UTF8;
+  private static final String TEST_PASSWORD = "testtest";
+  private static byte[] TEST_PASSWORD_UTF8;
+  private static byte[] TEST_QUICK_STRETCHED_PW;
+  private static byte[] TEST_UNWRAPKB;
+  private static final byte[] TEST_SESSION_TOKEN = Utils.generateRandomBytes(32);
+  private static final byte[] TEST_KEY_FETCH_TOKEN = Utils.generateRandomBytes(32);
+
+  protected MockFxAccountClient client;
+  protected FxAccountLoginStateMachine sm;
+
+  @Before
+  public void setUp() throws Exception {
+    if (TEST_EMAIL_UTF8 == null) {
+      TEST_EMAIL_UTF8 = TEST_EMAIL.getBytes("UTF-8");
+    }
+    if (TEST_PASSWORD_UTF8 == null) {
+      TEST_PASSWORD_UTF8 = TEST_PASSWORD.getBytes("UTF-8");
+    }
+    if (TEST_QUICK_STRETCHED_PW == null) {
+      TEST_QUICK_STRETCHED_PW = FxAccountUtils.generateQuickStretchedPW(TEST_EMAIL_UTF8, TEST_PASSWORD_UTF8);
+    }
+    if (TEST_UNWRAPKB == null) {
+      TEST_UNWRAPKB = FxAccountUtils.generateUnwrapBKey(TEST_QUICK_STRETCHED_PW);
+    }
+    client = new MockFxAccountClient();
+    sm = new FxAccountLoginStateMachine();
+  }
+
+  protected static class Trace {
+    public final LinkedList<State> states;
+    public final LinkedList<Transition> transitions;
+
+    public Trace(LinkedList<State> states, LinkedList<Transition> transitions) {
+      this.states = states;
+      this.transitions = transitions;
+    }
+
+    public void assertEquals(String string) {
+      Assert.assertArrayEquals(string.split(", "), toString().split(", "));
+    }
+
+    @Override
+    public String toString() {
+      final LinkedList<State> states = new LinkedList<State>(this.states);
+      final LinkedList<Transition> transitions = new LinkedList<Transition>(this.transitions);
+      LinkedList<String> names = new LinkedList<String>();
+      State state;
+      while ((state = states.pollFirst()) != null) {
+        names.add(state.getStateLabel().name());
+        Transition transition = transitions.pollFirst();
+        if (transition != null) {
+          names.add(">" + transition.toString());
+        }
+      }
+      return names.toString();
+    }
+
+    public String stateString() {
+      LinkedList<String> names = new LinkedList<String>();
+      for (State state : states) {
+        names.add(state.getStateLabel().name());
+      }
+      return names.toString();
+    }
+
+    public String transitionString() {
+      LinkedList<String> names = new LinkedList<String>();
+      for (Transition transition : transitions) {
+        names.add(transition.toString());
+      }
+      return names.toString();
+    }
+  }
+
+  protected Trace trace(final State initialState, final StateLabel desiredState) {
+    final LinkedList<Transition> transitions = new LinkedList<Transition>();
+    final LinkedList<State> states = new LinkedList<State>();
+    states.add(initialState);
+
+    WaitHelper.getTestWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        sm.advance(initialState, desiredState, new LoginStateMachineDelegate() {
+          @Override
+          public void handleTransition(Transition transition, State state) {
+            transitions.add(transition);
+            states.add(state);
+          }
+
+          @Override
+          public void handleFinal(State state) {
+            WaitHelper.getTestWaiter().performNotify();
+          }
+
+          @Override
+          public FxAccountClient getClient() {
+            return client;
+          }
+
+          @Override
+          public long getCertificateDurationInMilliseconds() {
+            return 30 * 1000;
+          }
+
+          @Override
+          public long getAssertionDurationInMilliseconds() {
+            return 10 * 1000;
+          }
+
+          @Override
+          public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException {
+            return RSACryptoImplementation.generateKeyPair(512);
+          }
+        });
+      }
+    });
+
+    return new Trace(states, transitions);
+  }
+
+  @Test
+  public void testEnagedUnverified() throws Exception {
+    client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, false, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+    Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+    trace.assertEquals("[Engaged, >AccountNeedsVerification, Engaged]");
+  }
+
+  @Test
+  public void testEngagedTransitionToAccountVerified() throws Exception {
+    client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+    Trace trace = trace(new Engaged(TEST_EMAIL, "uid", false, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+    trace.assertEquals("[Engaged, >AccountVerified, Cohabiting, >LogMessage('sign succeeded'), Married]");
+  }
+
+  @Test
+  public void testEngagedVerified() throws Exception {
+    client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+    Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+    trace.assertEquals("[Engaged, >LogMessage('keys succeeded'), Cohabiting, >LogMessage('sign succeeded'), Married]");
+  }
+
+  @Test
+  public void testPartial() throws Exception {
+    client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+    // What if we stop at Cohabiting?
+    Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Cohabiting);
+    trace.assertEquals("[Engaged, >LogMessage('keys succeeded'), Cohabiting]");
+  }
+
+  @Test
+  public void testBadSessionToken() throws Exception {
+    client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+    client.sessionTokens.clear();
+    Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+    trace.assertEquals("[Engaged, >LogMessage('keys succeeded'), Cohabiting, >Log(<FxAccountClientRemoteException 401 [110]: invalid sessionToken>), Separated, >PasswordRequired, Separated]");
+  }
+
+  @Test
+  public void testBadKeyFetchToken() throws Exception {
+    client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+    client.keyFetchTokens.clear();
+    Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+    trace.assertEquals("[Engaged, >Log(<FxAccountClientRemoteException 401 [110]: invalid keyFetchToken>), Separated, >PasswordRequired, Separated]");
+  }
+
+  @Test
+  public void testMarried() throws Exception {
+    client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+    Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+    trace.assertEquals("[Engaged, >LogMessage('keys succeeded'), Cohabiting, >LogMessage('sign succeeded'), Married]");
+    // What if we're already in the desired state?
+    State married = trace.states.getLast();
+    Assert.assertEquals(StateLabel.Married, married.getStateLabel());
+    trace = trace(married, StateLabel.Married);
+    trace.assertEquals("[Married]");
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestStateFactory.java
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.fxa.login;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.BuildConfig;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.DSACryptoImplementation;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+import org.robolectric.RobolectricGradleTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricGradleTestRunner.class)
+public class TestStateFactory {
+  @Test
+  public void testGetStateV3() throws Exception {
+    MigratedFromSync11 migrated = new MigratedFromSync11("email", "uid", true, "password");
+
+    // For the current version, we expect to read back what we wrote.
+    ExtendedJSONObject o;
+    State state;
+
+    o = migrated.toJSONObject();
+    Assert.assertEquals(3, o.getLong("version").intValue());
+    state = StateFactory.fromJSONObject(migrated.stateLabel, o);
+    Assert.assertEquals(StateLabel.MigratedFromSync11, state.stateLabel);
+    Assert.assertEquals(o, state.toJSONObject());
+
+    // Null passwords are OK.
+    MigratedFromSync11 migratedNullPassword = new MigratedFromSync11("email", "uid", true, null);
+
+    o = migratedNullPassword.toJSONObject();
+    Assert.assertEquals(3, o.getLong("version").intValue());
+    state = StateFactory.fromJSONObject(migratedNullPassword.stateLabel, o);
+    Assert.assertEquals(StateLabel.MigratedFromSync11, state.stateLabel);
+    Assert.assertEquals(o, state.toJSONObject());
+  }
+
+  @Test
+  public void testGetStateV2() throws Exception {
+    byte[] sessionToken = Utils.generateRandomBytes(32);
+    byte[] kA = Utils.generateRandomBytes(32);
+    byte[] kB = Utils.generateRandomBytes(32);
+    BrowserIDKeyPair keyPair = DSACryptoImplementation.generateKeyPair(512);
+    Cohabiting cohabiting = new Cohabiting("email", "uid", sessionToken, kA, kB, keyPair);
+    String certificate = "certificate";
+    Married married = new Married("email", "uid", sessionToken, kA, kB, keyPair, certificate);
+
+    // For the current version, we expect to read back what we wrote.
+    ExtendedJSONObject o;
+    State state;
+
+    o = married.toJSONObject();
+    Assert.assertEquals(3, o.getLong("version").intValue());
+    state = StateFactory.fromJSONObject(married.stateLabel, o);
+    Assert.assertEquals(StateLabel.Married, state.stateLabel);
+    Assert.assertEquals(o, state.toJSONObject());
+
+    o = cohabiting.toJSONObject();
+    Assert.assertEquals(3, o.getLong("version").intValue());
+    state = StateFactory.fromJSONObject(cohabiting.stateLabel, o);
+    Assert.assertEquals(StateLabel.Cohabiting, state.stateLabel);
+    Assert.assertEquals(o, state.toJSONObject());
+  }
+
+  @Test
+  public void testGetStateV1() throws Exception {
+    // We can't rely on generating correct V1 objects (since the generation code
+    // may change); so we hard code a few test examples here. These examples
+    // have RSA key pairs; when they're parsed, we return DSA key pairs.
+    ExtendedJSONObject o = new ExtendedJSONObject("{\"uid\":\"uid\",\"sessionToken\":\"4e2830da6ce466ddb401fbca25b96a621209eea83851254800f84cc4069ef011\",\"certificate\":\"certificate\",\"keyPair\":{\"publicKey\":{\"e\":\"65537\",\"n\":\"7598360104379019497828904063491254083855849024432238665262988260947462372141971045236693389494635158997975098558915846889960089362159921622822266839560631\",\"algorithm\":\"RS\"},\"privateKey\":{\"d\":\"6807533330618101360064115400338014782301295929300445938471117364691566605775022173055292460962170873583673516346599808612503093914221141089102289381448225\",\"n\":\"7598360104379019497828904063491254083855849024432238665262988260947462372141971045236693389494635158997975098558915846889960089362159921622822266839560631\",\"algorithm\":\"RS\"}},\"email\":\"email\",\"verified\":true,\"kB\":\"0b048f285c19067f200da7bfbe734ed213cefcd8f543f0fdd4a8ccab48cbbc89\",\"kA\":\"59a9edf2d41de8b24e69df9133bc88e96913baa75421882f4c55d842d18fc8a1\",\"version\":1}");
+    // A Married state is regressed to a Cohabited state.
+    Cohabiting state = (Cohabiting) StateFactory.fromJSONObject(StateLabel.Married, o);
+
+    Assert.assertEquals(StateLabel.Cohabiting, state.stateLabel);
+    Assert.assertEquals("uid", state.uid);
+    Assert.assertEquals("4e2830da6ce466ddb401fbca25b96a621209eea83851254800f84cc4069ef011", Utils.byte2Hex(state.sessionToken));
+    Assert.assertEquals("DS128", state.keyPair.getPrivate().getAlgorithm());
+
+    o = new ExtendedJSONObject("{\"uid\":\"uid\",\"sessionToken\":\"4e2830da6ce466ddb401fbca25b96a621209eea83851254800f84cc4069ef011\",\"keyPair\":{\"publicKey\":{\"e\":\"65537\",\"n\":\"7598360104379019497828904063491254083855849024432238665262988260947462372141971045236693389494635158997975098558915846889960089362159921622822266839560631\",\"algorithm\":\"RS\"},\"privateKey\":{\"d\":\"6807533330618101360064115400338014782301295929300445938471117364691566605775022173055292460962170873583673516346599808612503093914221141089102289381448225\",\"n\":\"7598360104379019497828904063491254083855849024432238665262988260947462372141971045236693389494635158997975098558915846889960089362159921622822266839560631\",\"algorithm\":\"RS\"}},\"email\":\"email\",\"verified\":true,\"kB\":\"0b048f285c19067f200da7bfbe734ed213cefcd8f543f0fdd4a8ccab48cbbc89\",\"kA\":\"59a9edf2d41de8b24e69df9133bc88e96913baa75421882f4c55d842d18fc8a1\",\"version\":1}");
+    state = (Cohabiting) StateFactory.fromJSONObject(StateLabel.Cohabiting, o);
+
+    Assert.assertEquals(StateLabel.Cohabiting, state.stateLabel);
+    Assert.assertEquals("uid", state.uid);
+    Assert.assertEquals("4e2830da6ce466ddb401fbca25b96a621209eea83851254800f84cc4069ef011", Utils.byte2Hex(state.sessionToken));
+    Assert.assertEquals("DS128", state.keyPair.getPrivate().getAlgorithm());
+  }
+}