Testing approach in android

Ce37cf75fa85b89a33916545978c64de?s=47 @hotchemi
August 08, 2015

Testing approach in android

For android all stars in shibuya.

Ce37cf75fa85b89a33916545978c64de?s=128

@hotchemi

August 08, 2015
Tweet

Transcript

  1. Testing approach in Android Shintaro Katafuchi

  2. I am… • Shintaro Katafuchi • GitHub: hotchemi • Recruit

    Technologies Co.,Ltd. • Scrum Engineering Group • kyobashi.dex
  3. Do you write automated tests?

  4. None
  5. That’s it.

  6. Do you know Android testing framework?

  7. TestCase AndroidTestCase ActivityTestCase ActivityInstrumentationTestCase ActivityInstrumentationTestCase2 ProviderTestCase ServiceTestCase ProviderTestCase2 LoaderTestCase ApplicationTestCase

    ActivityUnitTestCase InstrumentationTestCase SingleLaunchActivityTestCase
  8. That’s it.

  9. Testing is “tough” part Most developers don’t know… • Why

    you do testing • What is the test target • Which frameworks you should use Actually there is no standard yet…
  10. Why do you do testing?

  11. To keep the quality Alright, what is the “quality” ?

    I think there are mainly two stuff. • App quality (external) • Code quality (internal) Which is important? It depends.
  12. We are scrum • From waterfall • 8 developers (app

    + api) and 2 testers • Release the app about twice a week • Legacy code and no automated tests! We need tests to speed up the development.
  13. What is the test target ?

  14. AndroidJUnitRunner Small Medium Large has 3 annotations • @SmalTest •

    @MediumTest • @LargeTest
  15. @SmallTest • It’s unit test. • Test target is just

    “business logic”. • Utils, Entity, UseCase, Model… • Use mocks for external dependencies. • Execution time: < 100ms • Running on JVM (Ideally).
  16. Using… • Must: Robolectric 3, JUnit 4 • Running fast

    on the JVM • Easy to integrate with grade and Android Studio • Provides external data mock(http, preference, db) • Binds shadow objects to Android classes(JavaAssist) • Option: Mocito, PowerMock, AssertJ etc…
  17. package  toybox.robolectric;
 
 import  android.content.Intent;
 import  android.net.Uri;
 import  android.os.Bundle;
 import

     android.support.v7.app.ActionBarActivity;
 import  android.text.TextUtils;
 
 public  final  class  IntentHandler  {
        /**
          *  Handle  external  intent  and  return  destination  class.
          *
          *  @param  intent  external  intent.
          *  @return  activity  class  that  should  launch
          */
        public  static  Class<?  extends  AppCompatActivity>  handleIntent(Intent  intent)  {
                String  action  =  intent.getAction();
                Uri  uri  =  intent.getData();
                if  (!TextUtils.equals(Intent.ACTION_VIEW,  action)  ||  uri  ==  null)  {
                        return  LoginActivity.class;
                }
                if  (TextUtils.equals(uri.getScheme(),  "https")  &&  TextUtils.equals(uri.getHost(),  "twitter.com"))
                        return  TwitterActivity.class;
                }
                return  LoginActivity.class;
        }   }
  18. package  toybox.robolectric;
 
 import  org.junit.After;
 import  org.junit.Before;
 import  org.junit.runner.RunWith;
 import

     org.robolectric.RobolectricGradleTestRunner;
 import  org.robolectric.RuntimeEnvironment;
 import  org.robolectric.annotation.Config;
 
 import  android.content.Context;
 
 @Config(constants  =  BuildConfig.class,  sdk  =  19)
 @RunWith(RobolectricGradleTestRunner.class)
 public  abstract  class  AbstractRobolectricTestCase  {
 
        protected  Context  context;
 
        @Before
        public  void  setUp()  {
                context  =  RuntimeEnvironment.application.getApplicationContext();
        }
 
        @After
        public  void  tearDown()  {
                context  =  null;
        }
 
 }
  19. package  toybox.robolectric;
 
 import  android.content.Intent;
 import  android.net.Uri;
 
 import  static

     org.assertj.core.api.Assertions.assertThat;
 
 import  static  org.robolectric.Shadows.shadowOf;
 import  static  toybox.robolectric.IntentHandler.handleIntent;
 import  static  toybox.robolectric.IntentHandler.isTwitterUri;
 
 import  org.junit.Test;
 import  org.robolectric.Robolectric;
 
 public  class  IntentHandlerTest  extends  AbstractRobolectricTestCase  {
        @Test
        public  void  wrong_approach()  {
                SplashActivity  activity  =  Robolectric.setupActivity(SplashActivity.class);
                Intent  expectedIntent  =  new  Intent(activity,  LoginActivity.class);
                assertThat(shadowOf(activity).getNextStartedActivity()).isEqualTo(expectedIntent);
        }
 }   public  class  SplashActivity  extends  AppCompatActivity  {
        @Override
        protected  void  onCreate(Bundle  savedInstanceState)  {
                super.onCreate(savedInstanceState);
                setContentView(R.layout.activity_main);
                Class<?>  destination  =  handleIntent(getIntent());
                startActivity(new  Intent(this,  destination));
        }   }
  20. package  toybox.robolectric;
 
 import  android.content.Intent;
 import  android.net.Uri;
 
 import  static

     org.assertj.core.api.Assertions.assertThat;
 
 import  static  org.robolectric.Shadows.shadowOf;
 import  static  toybox.robolectric.IntentHandler.handleIntent;
 import  static  toybox.robolectric.IntentHandler.isTwitterUri;
 
 import  org.junit.Test;
 import  org.robolectric.Robolectric;
 
 public  class  IntentHandlerTest  extends  AbstractRobolectricTestCase  {
        @Test
        public  void  wrong_approach()  {
                SplashActivity  activity  =  Robolectric.setupActivity(SplashActivity.class);
                Intent  expectedIntent  =  new  Intent(activity,  LoginActivity.class);
                assertThat(shadowOf(activity).getNextStartedActivity()).isEqualTo(expectedIntent);
        }
 }   public  class  SplashActivity  extends  AppCompatActivity  {
        @Override
        protected  void  onCreate(Bundle  savedInstanceState)  {
                super.onCreate(savedInstanceState);
                setContentView(R.layout.activity_main);
                Class<?>  destination  =  handleIntent(getIntent());
                startActivity(new  Intent(this,  destination));
        }   } Wrong approach • It’s not unit test. • You are not shadow class master. • Just test business logic itself.
  21. package  toybox.robolectric;
 
 import  android.content.Intent;
 import  android.net.Uri;
 import  android.os.Bundle;
 import

     android.support.v7.app.ActionBarActivity;
 import  android.text.TextUtils;
 
 public  final  class  IntentHandler  {
        /**
          *  Handle  external  intent  and  return  destination  class.
          *
          *  @param  intent  external  intent.
          *  @return  activity  class  that  should  launch
          */
        public  static  Class<?  extends  ActionBarActivity>  handleIntent(Intent  intent)  {
                String  action  =  intent.getAction();
                Uri  uri  =  intent.getData();
                if  (!TextUtils.equals(Intent.ACTION_VIEW,  action)  ||  uri  ==  null)  {
                        return  LoginActivity.class;
                }
                if  (TextUtils.equals(uri.getScheme(),  "https")  &&  TextUtils.equals(uri.getHost(),  "twitter.com"))
                        return  TwitterActivity.class;
                }
                return  LoginActivity.class;
        }   }
  22. package  toybox.robolectric;
 
 import  android.content.Intent;
 import  android.net.Uri;
 import  android.os.Bundle;
 import

     android.support.v7.app.ActionBarActivity;
 import  android.text.TextUtils;
 
 public  final  class  IntentHandler  {
        /**
          *  Handle  external  intent  and  return  destination  class.
          *
          *  @param  intent  external  intent.
          *  @return  activity  class  that  should  launch
          */
        public  static  Class<?  extends  ActionBarActivity>  handleIntent(Intent  intent)  {
                String  action  =  intent.getAction();
                Uri  uri  =  intent.getData();
                if  (!TextUtils.equals(Intent.ACTION_VIEW,  action)  ||  uri  ==  null)  {
                        return  LoginActivity.class;
                }
                if  (isTwitterUri(uri.getScheme(),  uri.getHost()))  {
                        return  TwitterActivity.class;
                }
                return  LoginActivity.class;
        }   static  boolean  isTwitterUri(String  scheme,  String  host)  {
        return  TextUtils.equals(scheme,  "https")  &&  TextUtils.equals(host,  "twitter.com");
 }   }
  23. package  toybox.robolectric;
 
 import  android.content.Intent;
 import  android.net.Uri;
 
 import  static

     org.assertj.core.api.Assertions.assertThat;
 
 import  static  org.robolectric.Shadows.shadowOf;
 import  static  toybox.robolectric.IntentHandler.handleIntent;
 import  static  toybox.robolectric.IntentHandler.isTwitterUri;
 
 import  org.junit.Test;
 import  org.robolectric.Robolectric;
 
 public  class  IntentHandlerTest  extends  AbstractRobolectricTestCase  {
        @Test
        public  void  validCase_IsTwitterUri()  {
                assertThat(isTwitterUri("https",  "twitter.com")).isTrue();
        }
        @Test
        public  void  invalidCase_IsTwitterUri()  {
                assertThat(isTwitterUri("http",  "twitter.com")).isFalse();
                assertThat(isTwitterUri("https",  "facebook.com")).isFalse();
                assertThat(isTwitterUri(null,  null)).isFalse();
        }
 }
  24. package  toybox.robolectric;
 
 import  android.content.Intent;
 import  android.net.Uri;
 
 import  static

     org.assertj.core.api.Assertions.assertThat;
 
 import  static  org.robolectric.Shadows.shadowOf;
 import  static  toybox.robolectric.IntentHandler.handleIntent;
 import  static  toybox.robolectric.IntentHandler.isTwitterUri;
 
 import  org.junit.Test;
 import  org.robolectric.Robolectric;
 
 public  class  IntentHandlerTest  extends  AbstractRobolectricTestCase  {
        @Test
        public  void  validCase_HandleIntent()  {
                Intent  intent  =  new  Intent();
                intent.setAction(Intent.ACTION_VIEW);
                intent.setData(Uri.parse("https://twitter.com"));
                assertThat(handleIntent(intent)).isEqualTo(TwitterActivity.class);
        }
        @Test
        public  void  invalidCase_HandleIntent()  {
                {  Intent  intent  =  new  Intent();
                        assertThat(handleIntent(intent)).isEqualTo(LoginActivity.class);  }
                {  Intent  intent  =  new  Intent();
                        intent.setAction(Intent.ACTION_SEND);
                        assertThat(handleIntent(intent)).isEqualTo(LoginActivity.class);  }
                {  Intent  intent  =  new  Intent();
                        intent.setAction(Intent.ACTION_VIEW);
                        assertThat(handleIntent(intent)).isEqualTo(LoginActivity.class);  }
        }
 }
  25. package  toybox.robolectric;
 
 import  android.content.Intent;
 import  android.net.Uri;
 
 import  static

     org.assertj.core.api.Assertions.assertThat;
 
 import  static  org.robolectric.Shadows.shadowOf;
 import  static  toybox.robolectric.IntentHandler.handleIntent;
 import  static  toybox.robolectric.IntentHandler.isTwitterUri;
 
 import  org.junit.Test;
 import  org.robolectric.Robolectric;
 
 public  class  IntentHandlerTest  extends  AbstractRobolectricTestCase  {
        @Test
        public  void  validCase_HandleIntent()  {
                Intent  intent  =  new  Intent();
                intent.setAction(Intent.ACTION_VIEW);
                intent.setData(Uri.parse("https://twitter.com"));
                assertThat(handleIntent(intent)).isEqualTo(TwitterActivity.class);
        }
        @Test
        public  void  invalidCase_HandleIntent()  {
                {  Intent  intent  =  new  Intent();
                        assertThat(handleIntent(intent)).isEqualTo(LoginActivity.class);  }
                {  Intent  intent  =  new  Intent();
                        intent.setAction(Intent.ACTION_SEND);
                        assertThat(handleIntent(intent)).isEqualTo(LoginActivity.class);  }
                {  Intent  intent  =  new  Intent();
                        intent.setAction(Intent.ACTION_VIEW);
                        assertThat(handleIntent(intent)).isEqualTo(LoginActivity.class);  }
        }
 } Good approach • Export pure java part if you can. • Just pull in arguments and assert return stuff. • AssertJ helps you writing readable test code.
  26. @MediumTest • It’s integration test. • Test target is ui

    logic and view state. • Activity, Custom View… • Use mock dependency. • Execution time: < 2s. • Where to test? It depends.
  27. Using… • Must: Espresso 2, JUnit 4, DI framework, AndroidTestCase

    • Espresso has some components • core, contrib, intents, idling, web • Using DI and makes app testable (Dagger 2 is awesome!!) • Option: assertj-android, Spoon, Mockito • Use AndroidTestCase for simple custom view • Spoon manages multiple devices screenshot
  28. public  class  PlannedAndCompleteLessonViewTest  extends  AndroidTestCase  {
 
    private  PlannedAndCompleteLessonView

     customView;
 
    private  TextView  plannedCountTextView;
 
    private  TextView  completeCountTextView;
 
    @Override
    protected  void  setUp()  throws  Exception  {
        super.setUp();
        customView  =  new  PlannedAndCompleteLessonView(getContext());
        plannedCountTextView  =  (TextView)  customView
                        .findViewById(R.id.view_planned_and_complete_lesson_planned_count);
        completeCountTextView  =  (TextView)  customView
                        .findViewById(R.id.view_planned_and_complete_lesson_complete_count);
    }
 
    public  void  testInitialState()  {
        assertEquals(getColor(R.color.very_light_grey),  plannedCountTextView.getCurrentTextColor());
        assertEquals(getColor(R.color.navy_blue),  completeCountTextView.getCurrentTextColor());
        assertEquals("",  plannedCountTextView.getText());
        assertEquals(getContext().getString(R.string.label_home_planned_lesson_hyphen),  plannedCountTextView.getHint());
        assertEquals("0",  completeCountTextView.getText());
    }   } Custom View
  29. @Test
 public  void  changeText_sameActivity()  {
        //  Type

     text  and  then  press  the  button.
        onView(withId(R.id.editTextUserInput))
                        .perform(typeText(STRING_TO_BE_TYPED),  closeSoftKeyboard());
        onView(withId(R.id.changeTextBt)).perform(click());
 
        //  Check  that  the  text  was  changed.
        onView(withId(R.id.textToBeChanged)).check(matches(withText(STRING_TO_BE_TYPED)));
 }
 
 @Test
 public  void  changeText_newActivity()  {
        //  Type  text  and  then  press  the  button.
        onView(withId(R.id.editTextUserInput)).perform(typeText(STRING_TO_BE_TYPED),
                        closeSoftKeyboard());
        onView(withId(R.id.activityChangeTextBtn)).perform(click());
 
        //  This  view  is  in  a  different  Activity,  no  need  to  tell  Espresso.
        onView(withId(R.id.show_text_view)).check(matches(withText(STRING_TO_BE_TYPED)));
 }   Espresso-core
  30. public  class  HintMatcher  {
 
        static  Matcher<View>

     withHint(final  String  substring)  {
                return  withHint(is(substring));
        }
 
        static  Matcher<View>  withHint(final  Matcher<String>  stringMatcher)  {
                checkNotNull(stringMatcher);
                return  new  BoundedMatcher<View,  EditText>(EditText.class)  {
 
                        @Override
                        public  boolean  matchesSafely(EditText  view)  {
                                final  CharSequence  hint  =  view.getHint();
                                return  hint  !=  null  &&  stringMatcher.matches(hint.toString());
                        }
 
                        @Override
                        public  void  describeTo(Description  description)  {
                                description.appendText("with  hint:  ");
                                stringMatcher.describeTo(description);
                        }
                };
        }
 }   Custom Matcher
  31. @Rule
 public  IntentsTestRule<DialerActivity>  mActivityRule  =  new  IntentsTestRule<>(
      

             DialerActivity.class);
 
 @Before
 public  void  stubAllExternalIntents()  {
        //  By  default  Espresso  Intents  does  not  stub  any  Intents.  Stubbing  needs  to  be  setup  before
        //  every  test  run.  In  this  case  all  external  Intents  will  be  blocked.
        intending(not(isInternal())).respondWith(new  ActivityResult(Activity.RESULT_OK,  null));
 }
 
 @Test
 public  void  typeNumber_ValidInput_InitiatesCall()  {
        //  Types  a  phone  number  into  the  dialer  edit  text  field  and  presses  the  call  button.
        onView(withId(R.id.edit_text_caller_number))
                        .perform(typeText(VALID_PHONE_NUMBER),  closeSoftKeyboard());
        onView(withId(R.id.button_call_number)).perform(click());
 
        //  Verify  that  an  intent  to  the  dialer  was  sent  with  the  correct  action,  phone
        //  number  and  package.  Think  of  Intents  intended  API  as  the  equivalent  to  Mockito's  verify.
        intended(allOf(
                        hasAction(Intent.ACTION_CALL),
                        hasData(INTENT_DATA_PHONE_NUMBER),
                        toPackage(PACKAGE_ANDROID_DIALER)));
 }   Espresso-intents
  32. @Rule
        public  ActivityTestRule<ShowWebViewActivity>  activityRule  =  new  ActivityTestRule<>(


                           ShowWebViewActivity.class);
 
        @Test
        public  void  initial_state()  {
                onWebView().check(webContent(hasElementWithId("email_input")));
                onWebView().check(webContent(hasElementWithId("password_input")));
                onWebView().check(webContent(hasElementWithId("submit_button")));
        }
 
        @Test
        public  void  enter_email_and_password_submit()  {
                onWebView().withElement(findElement(Locator.ID,  "email_input"))
                                .perform(webKeys(EMAIL_TO_BE_TYPED));
                onWebView().withElement(findElement(Locator.ID,  "password_input"))
                                .perform(webKeys(PASSWORD_TO_BE_TYPED));
                onWebView().withElement(findElement(Locator.ID,  "submit_button"))
                                .perform(webClick());
 
                //  This  view  is  in  a  different  Activity,  no  need  to  tell  Espresso.
                onView(withId(R.id.email)).check(matches(withText(EMAIL_TO_BE_TYPED)));
                onView(withId(R.id.password)).check(matches(withText(PASSWORD_TO_BE_TYPED)));
        }   Espresso-web
  33. @Rule
        public  ActivityTestRule<ShowWebViewActivity>  activityRule  =  new  ActivityTestRule<>(


                           ShowWebViewActivity.class);
 
        @Test
        public  void  initial_state()  {
                onWebView().check(webContent(hasElementWithId("email_input")));
                onWebView().check(webContent(hasElementWithId("password_input")));
                onWebView().check(webContent(hasElementWithId("submit_button")));
        }
 
        @Test
        public  void  enter_email_and_password_submit()  {
                signInWithIdAndPassword(EMAIL_TO_BE_TYPED,  PASSWORD_TO_BE_TYPED);
                //  This  view  is  in  a  different  Activity,  no  need  to  tell  Espresso.
                onView(withId(R.id.email)).check(matches(withText(EMAIL_TO_BE_TYPED)));
                onView(withId(R.id.password)).check(matches(withText(PASSWORD_TO_BE_TYPED)));
        }   private  static  void  signInWithIdAndPassword(String  id,  String  password)  {
        onWebView().withElement(findElement(Locator.ID,  "email_input"))
                        .perform(webKeys(id));
        onWebView().withElement(findElement(Locator.ID,  "password_input"))
                        .perform(webKeys(password));
        onWebView().withElement(findElement(Locator.ID,  "submit_button"))
                        .perform(webClick());
 }   Espresso-web
  34. @Rule
        public  ActivityTestRule<ShowWebViewActivity>  activityRule  =  new  ActivityTestRule<>(


                           ShowWebViewActivity.class);
 
        @Test
        public  void  initial_state()  {
                onWebView().check(webContent(hasElementWithId("email_input")));
                onWebView().check(webContent(hasElementWithId("password_input")));
                onWebView().check(webContent(hasElementWithId("submit_button")));
        }
 
        @Test
        public  void  enter_email_and_password_submit()  {
                signInWithIdAndPassword(EMAIL_TO_BE_TYPED,  PASSWORD_TO_BE_TYPED);
                //  This  view  is  in  a  different  Activity,  no  need  to  tell  Espresso.
                onView(withId(R.id.email)).check(matches(withText(EMAIL_TO_BE_TYPED)));
                onView(withId(R.id.password)).check(matches(withText(PASSWORD_TO_BE_TYPED)));
        }   private  static  void  signInWithIdAndPassword(String  id,  String  password)  {
        onWebView().withElement(findElement(Locator.ID,  "email_input"))
                        .perform(webKeys(id));
        onWebView().withElement(findElement(Locator.ID,  "password_input"))
                        .perform(webKeys(password));
        onWebView().withElement(findElement(Locator.ID,  "submit_button"))
                        .perform(webClick());
 }   Espresso-web Good approach • Don’t write onView each test case • Create your own DSL
  35. Spoon

  36. @Module
 public  class  MockClockModule  {
    @Provides
    @Singleton
  

     Clock  provideClock()  {
        return  Mockito.mock(Clock.class);
    }
 }   @Inject
 Clock  clock;
 
 @Singleton
 @Component(modules  =  MockClockModule.class)
 public  interface  TestComponent  extends  DemoComponent  {
    void  inject(MainActivityTest  mainActivityTest);
 }   
 @Before
 public  void  setUp()  {
    Instrumentation  instrumentation  =  InstrumentationRegistry.getInstrumentation();
    DemoApplication  app
            =  (DemoApplication)  instrumentation.getTargetContext().getApplicationContext();
    TestComponent  component  =  DaggerMainActivityTest_TestComponent.builder()
            .mockClockModule(new  MockClockModule())
            .build();
    app.setComponent(component);
    component.inject(this);
 }   
 @Test
 public  void  today()  {
    Mockito.when(clock.getNow()).thenReturn(new  DateTime(2008,  9,  23,  0,  0,  0));
    activityRule.launchActivity(new  Intent());
    onView(withId(R.id.date))
            .check(matches(withText("2008-­‐09-­‐23")));
 } DI
  37. u2020

  38. package  com.jakewharton.u2020.data;
 
 import  android.content.Intent;
 import  android.net.Uri;
 
 /**  Creates

     {@link  Intent}s  for  launching  into  external  applications.  */
 public  interface  IntentFactory  {
        Intent  createUrlIntent(String  url);
 
        IntentFactory  REAL  =  new  IntentFactory()  {
                @Override  public  Intent  createUrlIntent(String  url)  {
                        Intent  intent  =  new  Intent(Intent.ACTION_VIEW);
                        intent.setData(Uri.parse(url));
                        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
 
                        return  intent;
                }
        };
 }   u2020
  39. package  com.jakewharton.u2020.data;
 
 import  android.content.Intent;
 import  com.jakewharton.u2020.data.prefs.BooleanPreference;
 import  com.jakewharton.u2020.ui.ExternalIntentActivity;
 


    /**
  *  An  {@link  IntentFactory}  implementation  that  wraps  all  {@code  Intent}s  with  a  debug  action,  which
  *  launches  an  activity  that  allows  you  to  inspect  the  content.
  */
 public  final  class  DebugIntentFactory  implements  IntentFactory  {
        private  final  IntentFactory  realIntentFactory;
        private  final  boolean  isMockMode;
        private  final  BooleanPreference  captureIntents;
 
        public  DebugIntentFactory(IntentFactory  realIntentFactory,  boolean  isMockMode,
                                                            BooleanPreference  captureIntents)  {
                this.realIntentFactory  =  realIntentFactory;
                this.isMockMode  =  isMockMode;
                this.captureIntents  =  captureIntents;
        }
 
        @Override  public  Intent  createUrlIntent(String  url)  {
                Intent  baseIntent  =  realIntentFactory.createUrlIntent(url);
                if  (!isMockMode  ||  !captureIntents.get())  {
                        return  baseIntent;
                }  else  {
                        return  ExternalIntentActivity.createIntent(baseIntent);
                }
        }
 } u2020
  40. @LargeTest • It’s E2E test. • Test target is that

    “scenario is done”. • Use real server. • It’s also regression test. • Execution time: < 120s.
  41. Using… • Umm, it depends. • If you have test

    engineer and iOS app… • Appium • If you have only qa testers… • Calabash-android • If you focus on modern android… • UI automator • Other than that… • robotium-recorder
  42. Road to automation Small

  43. Road to automation Small Large

  44. Road to automation Small Medium Large

  45. Conclusion • Don’t be a code monkey. • Plan and

    do retrospective. • Automation is not the silver bullet • We can’t test animation, usability • Do I have to write full set of tests? • It depends on your service level.
  46. Thanks, Any questions?