Testing support library

2 years later...

Android Engineer - Scalable Capital

The real title

An                     guide to testing on Android

opinionated

opinionated

IO 2017 talk [1]

IO 2017 talk

Pyramid of testing

Pyramid of testing [2]

Ain't nobody got time fo' that

Suggested approach

Architecture

   - MV*

Unit tests

   -   As little as Powermock as possible

Instrumentation tests

   -   AndroidJUnitRunner

E2E
   -   Espresso
test recorder

   -   Espresso-intents

Architecture

JUnit test

public class ExampleUnitTest {
    @Test
    public void addition_isCorrect() throws Exception {
        assertEquals(4, 2 + 2);
    }
}

  -  Fastest

  -  Most robust

Problem

public static void main(String [] args) {
    HomeActivity activity = new HomeActivity();
    activity.onCreate();
    activity.onStart();
    activity.onResume();
    assertNotNull("Activity was null", activity);
}

You can't instantiate Activity*, the system does it for you

*Or service, content provider, broadcast receiver

Solution

public class HomeActivity extends Activity {
    private HomeState state;
    private Button button;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        state = new HomeState();
        button.setOnClickListener(new View.OnClickListener() {
            public void onClick(View v) { state.buttonClicked(); }
        });
    }
}
public class HomeState {
    public buttonClicked() {
        //Decide what to do with the button click
    }
}

Decouple the state of the UI from the UI

Separation of concerns

public class HomeState {
    private HomeActivity activity;
    public HomeState(HomeActivity activity) { this.activity = activity; }

    public buttonClicked() { 
        activity.showData(new String {"Item"}); 
    }
}

public class HomeActivity extends Activity {
    private HomeState state;
    private Button button;
    
    protected void onCreate(Bundle savedInstanceState) {
        //
        state = new HomeState(this);
    }
    public void showData(String [] items) {
         //Populate
    }
}

MVP is born

public class HomePresenter {
    private WeakReference<HomeView> view;
    public HomePresenter(HomeView v) { 
        this.view = new WeakReference(v); 
    }
    public buttonClicked() { 
        view.showData(new String[]{"Item"});
    }
}

public interface HomeView {
    void showData(String [] items);
}

public class HomeActivity extends Activity implements HomeView {
    public void showData(String [] items) {
         //Populate
    }
}

Testing MVP

public static void main(String [] args) {
    TestHomeView view = new TestHomeView();
    HomePresenter presenter = new HomePresenter(view);
    presenter.buttonClicked();
    assertEquals(1, view.getItems().size()); //Tests all the logic
}

public class TestHomeView implements HomeView {
    private List<String> items = new ArrayList();

    @Override
    public void populateWithData(String[] items) {
        this.items = new ArrayList(Arrays.asList(items));
    }
}

Unit tests

Mockito

@RunWith(MockitoJUnitRunner.class)
public class HomePresenterUnitTest {
    @Mock TestHomeView view;
    @Test
    public void viewShowsAllItems() throws Exception {
        HomePresenter presenter = new HomePresenter(view);
        presenter.buttonClicked();
        assertEquals(1, view.getItems().size());
    }
}

Mockito

Mockito 2.1

  -  Mocking final classes

Mockito 2.3

  -  Strict Stubbing

Mockito 2.6

  - Mockito 2 inside instrumentation tests

Real-world use case

Real-world use case

@RunWith(MockitoJUnitRunner.class)
public class PaymentPresenterUnitTest {
    @Mock AuthProvider authProvider;
    @Mock PaymentView view;
    @Mock RequestQueue requestQueue;
    @Test
    public void testInstructPaymentSuccessful() {
        doReturn(true).when(authProvider).isAuthenticated();

        presenter.onDepositRequest(portfolio, 1000);
        presenter.instructPayment();

        verify(view).showLoading();
        verify(requestQueue, mode).enqueueRequest(any(Request.class), any());

        presenter.getPaymentHandler().onErrorResponse();

        verify(view).hideLoading();
        ArgumentCaptor<Instruction> captor = ArgumentCaptor.forClass(Instruction.class);
        verify(view).showInstructionFailed(captor.capture());
        assertThat(captor.getValue().getType(), is(Type.DEPOSIT)); 
        assertThat(captor.getValue().getAmount(), is(1000));
    }
}

Say no to PowerMock

Please note that PowerMock is mainly intended for people with expert knowledge in unit testing. Putting it in the hands of junior developers may cause more harm than good.

From PowerMock's github page [5]

Say no to PowerMock

Please note that PowerMock is mainly intended for people with expert knowledge in unit testing. Putting it in the hands of junior developers may cause more harm than good.

From PowerMock's github page [5]

busy

The case for PowerMock

Allows mocking:

  -  Static classes (Uri, TextUtils, Patterns, etc.)

  -  private methods

Uses black magic (bytecode manipulation)

The case against PowerMock

KISS

For 95% of cases, PowerMock is not needed

Keep it simple, stupid!

aircraft engineer Kelly Johnson, US Navy, 1960

Breaks test coverage

./gradlew mockableAndroidJar

public class Uri {
    private String url;
    private Uri(String url) {
        this.url = url;
    }
    @Override
    public String toString() {
        return url;
    }
    public static Uri parse(String url) {
        return new Uri(url);
    }
    //...
}

Non-mockable static classes

public class SystemClockInstance {
    public long elapsedRealtime() {
        return SystemClock.elapsedRealtime();
    }
}

public class AuthProvider {
    public AuthProvider(@NonNull SystemClockInstance clockInstance) {
        clockInstance = clockInstance;
    }
    void setToken(Token token) {
        this.token = token.getAccess_token();
        this.authTokenExpireMs = clockInstance.elapsedRealtime() + token.getExpiry();
    }
}

public class AuthProviderUnitTest {
    @Mock
    SystemClockInstance clockInstance;
}

5% of cases

@RunWith(PowerMockRunner.class)
@PrepareOnlyThisForTest({Realm.class})
public class DbUnitTest {

    @Mock
    Realm realm;

    @Before
    public void setupMock() {
        PowerMockito.spy(Realm.class);
    }

    @Test
    public void testManagedCopyManagedObject() throws Exception {
        PowerMockito.doReturn(realm).when(Realm.class, "getDefaultInstance");
        Db db = new Db();

        RealmObject managed = mock(RealmObject.class);
        doReturn(true).when(managed).isManaged();
        assertThat(db.managedCopy(managed), is(managed));
    }
}

Unit tests for business logic

Integration tests

AndroidJUnitRunner

*

90% of bugs have nothing to do with fragmentation

Stephan Linzner, Droidcon Italy 2014

AndroidJUnitRunner (in 1.0)

   -  No shared state

   -  Isolated crashes

   -  Isolated debugging

AES key generation

The 10%

@Test
public void testComputeKeyConsistent() {
    final int KEY_LEN_BITS = 128;
    final String pin = "1234";
    final byte[] salt = "hello salt".getBytes();
    SecretKey secretKey1 = Secure.computeKey(pin, salt, KEY_LEN_BITS);
    SecretKey secretKey2 = Secure.computeKey(pin, salt, KEY_LEN_BITS);

    assertThat(KEY_LEN_BITS / 8, equalTo(secretKey1.getEncoded().length));
    assertThat(secretKey1.getEncoded(), equalTo(secretKey2.getEncoded()));
}

Spot the difference

SecretKey secretKey = computeKey(pin, iv, KEY_LENGTH_BITS_AES_CBC_PKCS5);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(opmode, 
   new SecretKeySpec(secretKey.getEncoded(), "AES"), 
   new IvParameterSpec(iv)
);
SecretKey secretKey = computeKey(pin, iv, KEY_LENGTH_BITS_AES_CBC_PKCS5);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(opmode, 
   secretKey, 
   new IvParameterSpec(iv)
);

The case against Robolectric

You are not testing against the SDK, so what's the point?

   -  SDK Support is late (6 months to release API 24)

   -  If it is related to the system, use the system

      Unit tests cover the rest

e2e tests

Just use Espresso test recorder [4]

Auto-generated Espresso tests

@RunWith(AndroidJUnit4.class)
public class MainActivityTest5 {

    @Rule
    public ActivityTestRule<MainActivity> mActivityTestRule = new ActivityTestRule<>(MainActivity.class);

    @Test
    public void mainActivityTest5() {
        ViewInteraction appCompatButton = onView(
                allOf(withId(R.id.button1), withText("launch ShowDialogActivity"),
                        withParent(allOf(withId(R.id.activity_main),
                                withParent(withId(android.R.id.content)))),
                        isDisplayed()));
        appCompatButton.perform(click());

        ViewInteraction appCompatButton2 = onView(
                allOf(withId(R.id.button), withText("show dialog"), isDisplayed()));
        appCompatButton2.perform(click());

        ViewInteraction appCompatButton3 = onView(
                allOf(withId(android.R.id.button2), withText("Cancel"),
                        withParent(allOf(withClassName(is("com.android.internal.widget.ButtonBarLayout")),
                                withParent(withClassName(is("android.widget.LinearLayout"))))),
                        isDisplayed()));
        appCompatButton3.perform(click());

        ViewInteraction appCompatButton4 = onView(
                allOf(withId(R.id.button), withText("show dialog"), isDisplayed()));
        appCompatButton4.perform(click());

        ViewInteraction appCompatButton5 = onView(
                allOf(withId(android.R.id.button1), withText("OK"),
                        withParent(allOf(withClassName(is("com.android.internal.widget.ButtonBarLayout")),
                                withParent(withClassName(is("android.widget.LinearLayout"))))),
                        isDisplayed()));
        appCompatButton5.perform(click());

    }
}

External apps use case

Inter-app communication

@Test
public void testAlertDialogAppear() throws Throwable {
    rule.runOnUiThread(() -> rule.getActivity().showUpdateMandatory());

    InstrumentationRegistry.getInstrumentation().waitForIdleSync();
    onView(withText(R.string.UpdateNecessary))
            .check(matches(isDisplayed()));
    onView(withText(R.string.Update))
            .perform(click());

    intended(hasData(new CustomMatcher<Uri>("Matches if app market link") {
        public boolean matches(Object item) {
            if (!(item instanceof Uri)) {
                return false;
            }
            String url = item.toString();
            return (url.contains("market://") 
                     || url.contains("play.google.com/store"))
                    && url.contains("capital.scalable.droid");
        }
    }));
}

Tips

#1

Run instrumentation tests against minified build

How to #1

android {
    //...
    testBuildType 'instrumentation'
    buildTypes {
        instrumentation.initWith(buildTypes.release)
        instrumentation {
            applicationIdSuffix ".instrumentation"
            testProguardFiles 'proguard-test-rules.pro'
        }
    }
}

testProguardFiles should be copied from [5]

#2

Shared test utils folder

//Sharing utils between test and androidTest
sourceSets {
    test {
        java.srcDir 'src/sharedTest/java'
    }
    androidTest {
        java.srcDir 'src/sharedTest/java'
    }
}

#3

Start using MockitoJUnitRunner.StrictStubs

Will become default in Mockito 3.0

#4

testOptions {
    unitTests.returnDefaultValues = true
}

Return defaults = true

Error: "Method ... not mocked"

#5

Debug instrumentation tests from cmd line

#6

Use Robots [6]

class PaymentViewRobot {
    PaymentViewRobot failedInstructionShown() {
        instructionCaptor = ArgumentCaptor.forClass(PaymentInstruction.class);
        verify(view).showInstructionFailed(instructionCaptor.capture());
        return this;
    }
    PaymentViewRobot instructionTypeMatches(
         Matcher<PaymentInstruction.Type> matcher) {
        assertThat(instructionCaptor.getValue().getType(), matcher);
        return this;
    }
}

@Test
public void testInstructPaymentFailed() {
    doReturn(true).when(authProvider).isAuthenticated();

    presenter.onDepositRequest(portfolio, transactionAmount);
    presenter.instructPayment();
    presenter.getPaymentHandler().onErrorResponse(new VolleyError());

    viewRobot.failedInstructionShown()
            .instructionTypeMatches(is(PaymentInstruction.Type.DEPOSIT));
}

#7

Avoid android.* imports in business logic

#8

package android.os;

/**
 * Run AsyncTask synchronously.
 */
public abstract class AsyncTask<Params, Progress, Result> {

  protected abstract Result doInBackground(Params... params);

  protected void onPostExecute(Result result) {}

  protected void onProgressUpdate(Progress... values) {}

  public AsyncTask<Params, Progress, Result> execute(Params... params) {
    Result result = doInBackground(params);
    onPostExecute(result);
    return this;
  }
}

Say no!
Be
opinionated

References

[1]: https://www.youtube.com/watch?v=pK7W5npkhho

[2]: https://medium.com/android-testing-daily/the-3-tiers-of-the-android-test-pyramid-c1211b359acd

[X]: https://github.com/powermock/powermock

[4]: http://engineer.recruit-lifestyle.co.jp/techblog/2016-06-20-android-try-espressotestrecorder/

[5]: https://github.com/googlesamples/android-testing-templates/blob/master/AndroidTestingBlueprint/app/proguard-test-rules.pro

[6]: http://jakewharton.com/testing-robots/

Thank you!

Testing support library: 2 years later...

By Anas Ambri

Testing support library: 2 years later...

  • 1,314

More from Anas Ambri