一、什么是单元测试
单元测试是应用测试策略中的基本测试,通过对代码创建和运行单元测试,可以轻松验证各个代码单元的逻辑是否正确。在实际开发中构建并运行单元测试,可帮助我们快速捕捉和修复问题。
单元测试通常是以可重复的方式,运用尽可能小的代码单元(可能是方法、类或组件)的功能。当我们需要验证应用中关键代码的逻辑时,更应该构建单元测试,以免出现重大问题。通常代码单元在隔离的环境中进行测试,也就是单元测试只影响该单元。当前软件环境无法满足我们的需求时,也可以使用 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'
测试通过,左下角会提示测试通过。
测试失败,编辑器会提示我们失败原因。
场景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 目录下。
添加 @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 下。当然创建项目时,会默认创建该包,无需自行创建。
下面 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 向下分发事件,达到点击效果。