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
juniordevelopers 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...
- 2,779