Android 单元测试

1,303 阅读10分钟

一、什么是单元测试

单元测试是应用测试策略中的基本测试,通过对代码创建和运行单元测试,可以轻松验证各个代码单元的逻辑是否正确。在实际开发中构建并运行单元测试,可帮助我们快速捕捉和修复问题。

单元测试通常是以可重复的方式,运用尽可能小的代码单元(可能是方法、类或组件)的功能。当我们需要验证应用中关键代码的逻辑时,更应该构建单元测试,以免出现重大问题。通常代码单元在隔离的环境中进行测试,也就是单元测试只影响该单元。当前软件环境无法满足我们的需求时,也可以使用 Robolectric(Android 测试框架) 或模拟框架将测试的单元与其依赖项隔离开来。

二、单元测试分类

  • 本地测试:仅在本地计算机上运行的单元测试,需要在 Java 虚拟机 (JVM) 上进行运行。
  • 插桩测试:在 Android 设备或模拟器上运行的单元测试,这些测试可以访问插桩测试信息。

三、单元测试的目的

单元测试是应用开发过程中不可或缺的一部分,通过持续对应用进行测试,保证在公开发布应用之前,我们对应用正确性、功能行为、易用性进行了充分的测试和验证。

单元测试还会提供以下优势:

  • 快速获得故障反馈
  • 在开发周期中尽早进行故障检测
  • 更安全的代码重构,可以优化代码而不必担心回归
  • 稳定的开发速度,提高开发效率

四、本地单元测试-JUnit

JUnit简介

JUnit 是一个开源Java测试框架,用于编写和运行可重复的单元测试。用于单元测试框架体系 xUnit 的一个实例(用于Java语言)。它包括以下特性:

  • 用于测试期望结果的断言(Assertion)
  • 用于共享测试数据的测试工具
  • 用于方便组织和运行测试的套件
  • 图形和文本的测试运行器

Assert 类常用方法

方法名描述
assertEquals断言传入的预期值与实际值是相等的
assertNotEquals断言传入的预期值与实际值是不相等的
assertArrayEquals断言传入的预期数组与实际数组是相等的
assertNull断言传入的对象是为空
assertNotNull断言传入的对象不为空
assertTrue断言条件为真
assertFalse断言条件为假
assertSame断言两个对象相同,相当于‘==’
assertNotSame断言两个对象不相同,相当于‘!=’
assertThat断言实际值是否满足指定条件

JUnit 常用注解

方法名描述
@Test表示此方法为测试方法
@Before在测试方法前执行,可做初始化操作
@After在测试方法后执行,可做资源释放
@Ignore忽略的测试方法
@BeforeClass在类中所有方法前执行,此方法修饰必须是static void
@AfterClass在类中所有方法后执行,此方法修饰必须是static void
@RunWith指定该测试类使用某个运行器
@Parameters指定测试类的测试数据集合
@FixMethodOrder指定测试类的方法执行顺序

开始使用

集成依赖

在 build.grale 配置文件 dependencies {...} 集成 junit,创建项目时,默认会集成。

testImplementation 'junit:junit:4.13.2'

测试通过,左下角会提示测试通过。 image.png

测试失败,编辑器会提示我们失败原因。 image.png

场景1-校验值是否相等

@Test//表示此方法为测试方法
public void addition_isCorrect() {
    //断言传入的预期值与实际值是相等的
    assertEquals(4, 2 + 2);
}

场景2-校验邮箱格式

 @Before
    public void startTest() {
        System.out.println("开始验证邮箱");
    }

    @Test
    public void checkEmail() {
        boolean result = EmailUtils.checkEmail(email);

        if (!result) {
            //抛出指定异常
            assertThrows("发生异常", RuntimeException.class, new ThrowingRunnable() {
                @Override
                public void run() throws Throwable {
                    System.out.println("验证邮箱结果:" + result);
                }
            });
        }
    }

    @After
    public void endTest() {
        System.out.println("结束验证邮箱");
    }

场景3-多参数校验邮箱格式

@RunWith(Parameterized.class)
public static class TestParameters {
    private final String email;

    public TestParameters(String email) {
        super();
        this.email = email;
    }

    @Parameterized.Parameters
    public static Collection<String> addData() {
        String[] objects = new String[]{
                "33qq.com",
                "li@163.com",
                ".//sine.cn"};
        return Arrays.asList(objects);
    }

    @Test()
    public void checkEmail() {
        System.out.println("邮箱输入:" + email);
        boolean result = EmailUtils.checkEmail(email);
        assertTrue(result);
    }
}

五、Android单元测试-Robolectric

Robolectric 简介

Robolectric 是一个专门为 Android 单元测试设计的框架,它提供了一个模拟 Android 运行的环境,这样我们可以在 JVM 上直接运行 Android 单元测试,而无需启动模拟器或连接真实设备。通过 Robolectric 我们可以在测试中创建和操作 Android 组件,比如Activity、Fragment、Service 等。Robolectric 也可以模拟用户的交互,如点击按钮、滑动屏幕等。

开始使用

集成依赖

在 build.gradle 文件 dependencies{...}

testImplementation "org.robolectric:robolectric:4.9"

在 build.gradle 文件中 android{...}

testOptions {
    unitTests.includeAndroidResources = true
}

创建 ActivityTest.java,注意是在 test 目录下。

image.png

添加 @RunWith(RobolectricTestRunner.class) 注解

@RunWith(RobolectricTestRunner.class)
public class ActivityTest {...}

初始化 ActivityController

ActivityController<MainActivity> controller = Robolectric.buildActivity(MainActivity.class);

场景1-校验 Button 文本

/**
 * 校验Button文本
 */
@Test
public void checkButtonText() {
    controller.setup();
    MainActivity activity = controller.get();
    Button button = activity.findViewById(R.id.btn);
    button.performClick();
    Assert.assertEquals("点击", button.getText());
}

场景2-校验 Intent

/**
 * 校验Intent
 */
@Test
public void checkIntent() {
    controller.setup();
    MainActivity activity = controller.get();
    Button button = activity.findViewById(R.id.btn);
    button.performClick();
    //期望的Intent
    Intent exceptedIntent = new Intent(activity, NewsActivity.class);
    //真实的Intent
    ShadowContextWrapper contextWrapper = new ShadowContextWrapper();
    Intent realIntent = contextWrapper.getNextStartedActivity();
    Assert.assertEquals(exceptedIntent.getComponent(), realIntent.getComponent());
}

场景3-校验 Toast

/**
 * 校验Toast
 */
@Test
public void checkToast() {
    controller.setup();
    MainActivity activity = controller.get();
    Button button = activity.findViewById(R.id.btn);
    button.performClick();
    Toast latestToast = ShadowToast.getLatestToast();
    Assert.assertNotNull(latestToast);
    Assert.assertEquals("跳转到新闻页面!", ShadowToast.getTextOfLatestToast());
}

场景4-校验 Res 资源

/**
 * 校验Res资源
 */
@Test
public void checkRes() {
    Application application = RuntimeEnvironment.getApplication();
    String string = application.getResources().getString(R.string.name);
    Assert.assertNotNull(string);
    Assert.assertEquals("123", string);
}

六、Android单元测试-Test

Test 简介

谷歌官方提供的测试组件,也是我们实际项目中在使用的。拥有丰富的的 API,很好的满足我们对单元测试的需求,并且属于 Jetpack 系列。下面我会介绍一些常用 API 的用法,并对 click() 底层实现原理进行剖析。

开始使用

集成依赖

在build.gradle文件dependencies{...}

androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

在build.gradle文件中android{...}

testOptions {
    unitTests.includeAndroidResources = true
}

以上配置文件在创建项目时,Android Studio 会自动帮我们添加依赖进去。

Espresso

Espresso 简介

Android Test 包含一系列组件,这里我们着重介绍一下 Espresso

Espresso 是用来编写简洁、美观且可靠的 Android 界面测试。Espresso 测试会清楚地说明预期、交互和断言,不受样板内容、自定义基础架构或杂乱的实现细节干扰。Espresso 测试运行速度极快!当它在静止状态下对应用界面进行操纵和断言时,无需等待、同步、休眠和轮询。

这是谷歌官方的介绍,其实就是用来写测试 UI 相关的组件库。

Espresso 常用方法

方法名描述
ViewInteraction onView(final Matcher viewMatcher)创建视图交互
Matcher withId(final int id)根据资源ID匹配对应视图
ViewInteraction perform(final ViewAction... viewActions)对当前视图进行既定操作(如果提供了多个操作,则会按照提供的顺序执行操作,并在每个操作之前运行前提条件检查)
ViewInteraction check(final ViewAssertion viewAssert)检查当前视图匹配器所选视图上的给定ViewAssertion。
ViewAssertion matches(final Matcher<? super View> viewMatcher)返回一个泛型ViewAssertion,它断言视图层次结构中存在一个视图,并由给定的视图匹配器匹配
Matcher withText返回一个匹配器,该匹配器根据TextView的文本属性值匹配TextView
ViewAction typeText(String stringToBeTyped)返回一个操作,该操作选择视图(通过单击视图)并将提供的字符串键入视图。在字符串末尾附加\n将转换为ENTER键事件。注:此方法在键入之前对视图执行轻敲以强制视图对焦,如果视图已经包含文本,点击此按钮可以将光标放置在文本中的任意位置
ViewAction click()点击操作

Espresso 示例

下图是 demo 项目结构,当我们编写 Android 测试代码时,要在指定包 AndroidTest 下。当然创建项目时,会默认创建该包,无需自行创建。 image.png

下面 demo 演示,关键地方做了注释,还是比较简单的。

package com.example.unittest;

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;

import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;

import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

/**
 * @author: guangwuchen
 * @date: 2022/10/27
 * @describe: Android Test
 */
//当一个类用@RunWith注释或扩展一个用@RunWeth注释的类时,
//JUnit将调用它引用的类来运行该类中的测试,而不是JUnit中内置的运行器。
@RunWith(AndroidJUnit4.class)
@LargeTest
public class AndroidUnitTest {

    public static final String STRING_TO_BE_TYPED = "Hi Developer!";

    /**
     * 使用 {@link ActivityScenarioRule} 来创建MainActivity, 并在测试完成之后关闭
     */
    @Rule
    public ActivityScenarioRule<MainActivity> activityScenarioRule
            = new ActivityScenarioRule<>(MainActivity.class);

    /**
     * 在EditText自动输入文本,然后校验输入文本是否满足条件并更新文本.
     */
    @Test
    public void changeTextSameActivity() {
        //键入文本,然后按按钮。
        onView(withId(R.id.editTextUserInput))
                .perform(typeText(STRING_TO_BE_TYPED), closeSoftKeyboard());
        onView(withId(R.id.changeTextBt)).perform(click());
        //检查文本是否已更改
        onView(withId(R.id.textToBeChanged)).check(matches(withText(STRING_TO_BE_TYPED)));
    }

    /**
     * 校验MainActivity跳转到NewsActivity,验证Bundle传值是否正确.
     */
    @Test
    public void changeTextNewActivity() {
        //跳转
        onView(withId(R.id.activityChangeTextBtn)).perform(click());
        //匹配文本
        onView(withId(R.id.show_text_view)).check(matches(withText(STRING_TO_BE_TYPED)));
    }

}

Espresso click() 源码分析

现在我们知道如何使用 Test 提供的 API 来完成单元测试,抱着好奇的心态,我们看一下 click() 方法内部是如何实现的。 在分析点击事件之前,我们先思考一下,不依赖 Android 原生点击事件机制,采用其他方式模拟实现点击事件,该如何实现?


/**
 * Same as {@code click(int inputDevice, int buttonState)}, but uses {@link
 * InputDevice#SOURCE_UNKNOWN} as the inputDevice and {@link MotionEvent#BUTTON_PRIMARY} as the
 * buttonState.
 */
public static ViewAction click() {
  return actionWithAssertions(
      new GeneralClickAction(
          Tap.SINGLE,
          GeneralLocation.VISIBLE_CENTER,
          Press.FINGER,
          InputDevice.SOURCE_UNKNOWN,
          MotionEvent.BUTTON_PRIMARY));
}


  /**
   * Performs all assertions before the {@code ViewAction}s in this class and then performs the
   * given {@code ViewAction}
   *
   * @param viewAction the {@code ViewAction} to perform after the assertions
   */
  public static ViewAction actionWithAssertions(final ViewAction viewAction) {
    if (globalAssertions.isEmpty()) {
      return viewAction;
    }
    return new ViewAction() {
      @Override
      public String getDescription() {
        StringBuilder msg = new StringBuilder("Running view assertions[");
        for (Pair<String, ViewAssertion> vaPair : globalAssertions) {
          msg.append(vaPair.first);
          msg.append(", ");
        }
        msg.append("] and then running: ");
        msg.append(viewAction.getDescription());
        return msg.toString();
      }

      @Override
      public Matcher<View> getConstraints() {
        return viewAction.getConstraints();
      }

      @Override
      public void perform(UiController uic, View view) {
        for (Pair<String, ViewAssertion> vaPair : globalAssertions) {
          Log.i("ViewAssertion", "Asserting " + vaPair.first);
          vaPair.second.check(view, null);
        }
        viewAction.perform(uic, view);
      }
    };
  }

click() 方法返回的是一个 ViewAction 对象,ViewAction 就是 UI 操作的一个集合。通过actionWithAssertions() 方法实例化出 ViewAction,我们关注 perform() 方法,去找对应的实现类GeneralClickAction

  @Override
  public void perform(UiController uiController, View view) {
    float[] coordinates = coordinatesProvider.calculateCoordinates(view);
    float[] precision = precisionDescriber.describePrecision();

    Tapper.Status status = Tapper.Status.FAILURE;
    int loopCount = 0;
    while (status != Tapper.Status.SUCCESS && loopCount < 3) {
      try {
        status = tapper.sendTap(uiController, coordinates, precision, inputDevice, buttonState);
      } catch (RuntimeException re) {
        ...
      }
      ...
    }
  }

这里只需注意 sendTap() 方法,该方法会将需要的参数打包传递下去,我们去找 tapper 的实现类 Tap。

    @Override
    public Tapper.Status sendTap(
        UiController uiController,
        float[] coordinates,
        float[] precision,
        int inputDevice,
        int buttonState) {
      checkNotNull(uiController);
      checkNotNull(coordinates);
      checkNotNull(precision);

      MotionEvent downEvent =
          MotionEvents.sendDown(uiController, coordinates, precision, inputDevice, buttonState)
              .down;
      ....
    }

到这里源码已经很清晰的告诉我们了,MotionEvents.sendDown() 会下发 down 事件,所以继续往下看。

  private static Tapper.Status sendSingleTap(
      UiController uiController,
      float[] coordinates,
      float[] precision,
      int inputDevice,
      int buttonState) {
    checkNotNull(uiController);
    checkNotNull(coordinates);
    checkNotNull(precision);
    DownResultHolder res =
        MotionEvents.sendDown(uiController, coordinates, precision, inputDevice, buttonState);
    ...
  }


在 MotionEvents 类中,我们关注一下 injectMotionEvent() 方法,其实现类是 UiControllerImpl。

@Override
  public boolean injectMotionEvent(final MotionEvent event) throws InjectEventSecurityException {
    ...
    FutureTask<Boolean> injectTask =
        new SignalingTask<Boolean>(
            new Callable<Boolean>() {
              @Override
              public Boolean call() throws Exception {
                return eventInjector.injectMotionEvent(event);
              }
            },
            IdleCondition.MOTION_INJECTION_HAS_COMPLETED,
            generation);
    ...
  }

在 call() 方法中,我们注意到 eventInjector.injectMotionEvent(event),继续往下看。

boolean injectMotionEvent(MotionEvent event) throws InjectEventSecurityException {
    return injectionStrategy.injectMotionEvent(event, true);
}      
 @Override
  public boolean injectMotionEvent(MotionEvent motionEvent, boolean sync)
      throws InjectEventSecurityException {
    return innerInjectMotionEvent(motionEvent, true, sync);
  }
private boolean innerInjectMotionEvent(MotionEvent motionEvent, boolean shouldRetry, boolean sync)
      throws InjectEventSecurityException {
    try {
      ...
      int eventMode = sync ? syncEventMode : asyncEventMode;
      return (Boolean)
          injectInputEventMethod.invoke(instanceInputManagerObject, motionEvent, eventMode);
    } catch (IllegalAccessException e) {
      throw new RuntimeException(e);
    } catch (IllegalArgumentException e) {
      throw e;
    } catch (InvocationTargetException e) {
      ..
    } catch (SecurityException e) {
      throw new InjectEventSecurityException(e);
    }
    return false;
  }

到这里我们就明白了,click() 究竟是如何相应我们的原生点击事件。其实还是通过反射的方式,达到目的。

void initialize() {
    if (initComplete) {
      return;
    }

    try {
      // 获取InputManager类对象,必要时进行初始化。
      Class<?> inputManagerClassObject = Class.forName("android.hardware.input.InputManager");
      Method getInstanceMethod = inputManagerClassObject.getDeclaredMethod("getInstance");
      getInstanceMethod.setAccessible(true);

      instanceInputManagerObject = getInstanceMethod.invoke(inputManagerClassObject);
      //反射调用injectInputEvent,下发点击事件
      injectInputEventMethod =
          instanceInputManagerObject
              .getClass()
              .getDeclaredMethod("injectInputEvent", InputEvent.class, Integer.TYPE);
      injectInputEventMethod.setAccessible(true);

      //将事件模式设置为INJECT_INPUT_event_mode_WAIT_FOR_FINISH,
      //以确保我们已经调度了事件,并且它对视图层次结构的任何副作用都已发生。
      Field motionEventModeField =
          inputManagerClassObject.getField("INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH");
      motionEventModeField.setAccessible(true);
      syncEventMode = motionEventModeField.getInt(inputManagerClassObject);

      ...
    } catch (ClassNotFoundException e) {
      throw new RuntimeException(e);
    } catch (IllegalAccessException e) {
      throw new RuntimeException(e);
    } catch (InvocationTargetException e) {
      throw new RuntimeException(e);
    } catch (NoSuchMethodException e) {
      throw new RuntimeException(e);
    } catch (NoSuchFieldException e) {
      throw new RuntimeException(e);
    }
  }

上述代码是 InputManagerEventInjectionStrategy 初始化方法,我们可以清楚的看到,通过反射构建出InputManager,最终调用 injectInputEvent() 方法。injectInputEvent() 这个方法就是我们触摸事件的入口,可以看一下 InputManager 源码。

@RequiresPermission(Manifest.permission.INJECT_EVENTS)
    public boolean injectInputEvent(InputEvent event, int mode, int targetUid) {
        if (event == null) {
            throw new IllegalArgumentException("event must not be null");
        }
        if (mode != InputEventInjectionSync.NONE
                && mode != InputEventInjectionSync.WAIT_FOR_FINISHED
                && mode != InputEventInjectionSync.WAIT_FOR_RESULT) {
            throw new IllegalArgumentException("mode is invalid");
        }

        try {
            return mIm.injectInputEventToTarget(event, mode, targetUid);
        } catch (RemoteException ex) {
            throw ex.rethrowFromSystemServer();
        }
    }

所以总结一下 click() 源码实现原理,Click() 源码通过包装一系列数据,反射构建出 InputManager,并且调用 injectInputEvent 向下分发事件,达到点击效果。

参考

juejin.cn/post/684490…

developer.android.google.cn/training/te…