Android 单元测试实践指南
1. 前言
接下来会给大家介绍一些测试 API 的用法 ,包括可以创建模拟对象的 Mockito 、可以模拟静态方法的 PowerMock ,以及与 Android 平台相关的 Robolectric。
写单元测试的第一个原则就是保持简单
,使用的测试 API 越复杂 ,就会降低测试代码的可维护性和可读性,测试的维护成本会变高,在测试失败的时候有可能会被其他人忽略掉,很可能会让人变得不想执行测试,而测试只有持续执行才能为我们的开发持续地保驾护航
,也就是测试代码最重要的是失败后能快速修复
,这里说的简单包括但不限于下面 4 种做法。
-
避免使用静态方法
静态方法需要用到 PowerMock ,测试起来的前置条件准备也更麻烦;
-
避免把成员变量的创建由类自己控制
除非是有封装的需求,否则成员变量的实例化操作应该开放给外部控制,这样实现的灵活性好,而且容易测试;
-
隔离三方库
这里说的三方库包括 Android SDK ,比如 SharedPreferencs 什么的 ,一方面是为了降低业务逻辑对实现细节的依赖,避免以后替换的时候太麻烦,另一方面是用来执行 Android 平台代码的测试的 RobolectricTestRunner 在执行测试时会比如 JUnit 慢,而且如果一个项目长期在不考虑可测试性的情况下开发,这样的代码用 Robolectric 进行测试会是一件很痛苦的事情
-
一个测试只验证一件事
也就是测试代码也要遵循单一职责原则
测试 API 的使用原则
在对于测试 API 的使用上,总的来说原则就是,当测试遇到困难的时候,第一选项就是重构我们的生产代码,让它更容易测试,其次才是考虑怎么找到更合适的测试 API 。
因为巧妙的测试 API 往往意味着更复杂的前置条件,这样写出来的测试维护成本比较高。
2. JUnit
JUnit 是一个 Java 单元测试框架,单元测试中的单元指的是应用程序中可测试的最小单元,具体可以看作是对程序中一个个函数的测试。
下面来看一些 JUnit 提供的注解和断言方法。
使用 JUnit 前先确定 build.gradle 中是都有 JUnit 的依赖,默认是有的。
dependencies {
testImplementation 'junit:junit:4.12'
}
1.1 @Test
1. 简单示例
下面是一个简单的单元测试,测试类要在 test 目录下创建。
只要给测试方法加上 @Test 注解,就能在该方法和该类的左侧看到一个绿色的箭头,如果点击的是类左侧的箭头,就会运行该类中所有的测试,如果点击测试方法左侧的箭头,就会执行该测试,每一个测试都相当于是一个独立的程序,与我们的生产代码无关。
测试执行后,可以在 Run 面板看到执行结果,成功的话是下面这样的。
如果断言失败的话,测试结果是下面这样的。
2. 设定超时时间
如果某个方法的执行时间要在特定时间能完成,可以设定 timeout 超时时间,比如下面这样。
3. 设定期望异常
如果我们的函数中在某些情况下会抛出异常,我们想测试这个异常的抛出,可以设定 expected 参数,比如下面这样。
1.2 断言
JUnit 的断言方法都在 Assert 类中,下面是这些方法的作用,不过就算我们只是简单地使用 assertTrue() ,AS 也会提示我们可以转换为更合适的断言方法,下面是一些常用的断言方法。
方法 | 作用 |
---|---|
assertTrue(message, condition) | 判定结果为 true ,把失败提示换为 message |
assertTrue(condition) | 判定结果为 true |
assertFalse(message, condition) | 判定结果为 true ,把失败提示换为 message |
assertFalse(condition) | 判定结果为 false |
fail(message) | 抛出断言错误,把失败提示换为 message |
fail() | 抛出断言错误 |
assertEquals(message, expected, actual) | 判定 expected 与 actual 相等 ,把失败提示换为 message |
equalsReguardingNull(expected, actual) | 如果 expected 为 空并且 actual 为空,断言通过,否则判定两者是否相等 |
isEquals(expected, actual) | 判定 expected 与 actual 相等 |
assertEquals(expected, actual) | 判定 expected 与 actual 相等 |
assertNotEquals(message, expected, actual) | 判定 expected 与 actual 不相等,把失败提示换为 message |
assertNotEquals(expected, actual) | 判定 expected 与 actual 不相等 |
assertThat(actual, matcher) | 使用 Matcher 判定 actual 是否匹配,Espresso 与 Hamcrest 有很多 Matcher |
1.3 @Before 与 @After
如果想要做一些初始化和销毁资源的操作,比如准备一些测试前的数据,可以声明一个添加了 @Before 注解的 setUp() 方法,如果想在每次测试后销毁会重置某个模拟对象(后面会讲到),可以声明一个添加了 @After 注解的 tearDown() 方法,并在里面做重置操作。
添加了 @Before 注解的方法会在每个测试运行前执行,添加了 @After 注解的方法会在每个测试运行后执行。
@Before
public void setUp() {
// 创建资源
}
@After
public void tearDown() {
// 销毁资源
}
1.4 @Ignore
有的时候遇到一些测试很难修复,想暂时忽略,可以用 @Ignore 注解,这样执行所有测试的过程中该测试就会别忽略,@Ignore 注解中可以说明一下忽略的原因。
@Ignore("要重构,暂时搞不定")
public void testAdd() {
// ...
}
1.5 @Rule
如果我们想同时使用 Robolectric 和 PowerMock 或其他的测试框架,这时可能会面临 TestRunner 只能有一个的问题,而 @Rule 注解提供的就是让测试框架能够在测试方法的执行前后做一些事情,避免这个冲突,比如下面这样。
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
@PowerMockIgnore({ "org.mockito.*", "org.robolectric.*", "android.*" })
@PrepareForTest(Static.class)
public class DeckardActivityTest {
@Rule
public PowerMockRule rule = new PowerMockRule();
@Test
public void testStaticMocking() {
PowerMockito.mockStatic(Static.class);
Mockito.when(Static.staticMethod()).thenReturn("hello mock");
assertTrue(Static.staticMethod().equals("hello mock"));
}
}
2. Mockito
假如我们有一个用于处理登录逻辑的 LoginPresenter ,代码如下。
public class LoginPresenter extends LoginContract.Presenter {
private static final int LENGTH_PHONE = 11;
private static final int MAX_LENGTH_PASSWORD = 20;
private static final int MIN_LENGTH_PASSWORD = 6;
public LoginPresenter(LoginContract.View view, LoginContract.Model model) {
super(view, model);
}
public void onLogin(String phone, String password) {
if (isPhoneInvalid(phone)) {
view.showPhoneInvalidPrompt();
return;
}
if (isPasswordInvalid(password)) {
view.showPasswordInvalidPrompt();
return;
}
model.login(phone, password, new RequestResultCallback<UserBean>() {
@Override
public void onResult(BaseResult<UserBean> result) {
if (result.isFailed()) {
view.showLoginFailPrompt();
return;
}
model.saveUserInfo(result.data);
view.showHomePage();
}
});
}
private boolean isPhoneInvalid(String phone) {
return phone == null || phone.length() != LENGTH_PHONE;
}
private boolean isPasswordInvalid(String password) {
return password == null || password.length() < MIN_LENGTH_PASSWORD
|| password.length() > MAX_LENGTH_PASSWORD;
}
}
当我们在测试 LoginPresenter 的时候,不关心登录页是怎么绘制的,也不关心登录请求的处理逻辑,只关心发起请求前对手机号和密码的校验逻辑,这时我们就要弄个假的(模拟的)View 和 Model ,这时就要用到模拟对象,下面来看下 Java 中常用的 Mock 框架 Mockito。
首先添加 Mockito 的依赖。
dependencies {
testImplementation "org.mockito:mockito-core:3.8.0"
}
2.1 创建模拟对象
1. mock()
下面是 LoginPresenter 对应的测试类,代码中的 mock 就是用来模拟假的对象的,mock 出来的对象我们可以调整它的方法的返回值、操作以及用 verify() 验证模拟对象的某个方法是否被调用。
下面的代码就是验证了模拟对象 view 的 showPohoneInvalidPrompt() 是否被调用的代码示例。
import org.junit.Test;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
public class LoginPresenterTest {
LoginContract.View view = mock(LoginContract.View.class);
LoginContract.Model model = mock(LoginContract.Model.class);
LoginPresenter presenter = new LoginPresenter(view, model);
@Test
public void testLoginWithInvalidPhone() {
presenter.onLogin("152", "123");
// 验证手机号不正确时, view 的 showPhontInvalidPrompt() 方法应该被调用
verify(view).showPhoneInvalidPrompt();
}
}
下面是修改模拟对象的方法的代码示例。
假如我们有一个 MyClass ,像下面这样。
public void MyClass {
public boolean foo() {
return false;
}
}
然后我们想把 foo() 方法的返回值改为 true ,可以用 when() 和 thenReturn() 来修改,比如下面这样。
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class MyClassTest {
@Test
public void testChangeMockObjectBehavior() {
MyClass myClass = mock(MyClass.class);
// 把返回值改为 true
when(myClass.foo()).thenReturn(true);
boolean result = myClass.foo();
assertTrue(result);
}
}
2. spy()
如果我们想验证一个非模拟对象的方法的调用,可以使用 spy() ,比如 ClassA 和 ClassB 的实现如下。
public class ClassA {
public void foo() {
}
}
public class ClassB {
private final ClassA classA;
public ClassB(ClassA classA) {
this.classA = classA;
}
public void bar() {
classA.foo();
}
}
如果不进行 spy() ,想要直接验证 ClassB 是否调用了 ClassA 的 foo() 方法,比如下面这样。
public class ClassBTest {
@Test
public void testBar () {
ClassA classA = new ClassA();
ClassB classB = new ClassB(classA);
classB.bar();
verify(classA).foo();
}
}
那么这个测试运行后,Mockito 就会抛出一个未 Mock 异常。
这时如果对 ClassA 进行 spy() ,再次运行测试,测试就能通过。
如果我们不想要 mock 替换掉真实的实现,但是又想验证方法调用的时候,就要用到 spy() 。
2.1 验证 Mock 对象的调用
1. verify()
verify() 除了可以传入模拟对象,还有另一个有两个参数的重载方法,一个是模拟对象,另一个是验证模式 VerificationMode。
VerificationMode 是一个接口,它的实现类如下。
这些实现类的实例大部分都是通过静态方法创建的,下面是其中一些实现类的作用。
- 调用了 n 次:
times(n)
- 最少 n 次:
atLeast(n)
- 最多 n 次:
atMost(n)
- 最少一次:
atLeastOnce()
- 最多一次:
atMostOnce()
不传 VerificationMode 的话,默认就是 1 次,传的方式像下面这样。
@Test
public void testLoginWithInvalidPhone() {
presenter.onLogin("152", "123");
// 调用了一次
verify(view, Mockito.times(1)).showPhoneInvalidPrompt();
}
2. verifyNoMoreInteraction()
这个方法用来检查是不是还有未验证的调用。
比如 ClassB 的实现如下。
public class ClassB {
private final ClassA classA;
public ClassB(ClassA classA) {
this.classA = classA;
}
public void bar() {
classA.method1();
classA.method2();
}
}
对应的测试代码如下。
public class ClassBTest {
@Test
public void testBar () {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
classB.bar();
Mockito.verify(classA, Mockito.times(1)).method1();
Mockito.verifyNoMoreInteractions(classA);
}
}
上面这个测试执行后悔失败,因为 method2() 被调用了,但是没有对这个方法进行验证。
3. verifyNoInteraction()
verifyNoInteraction() 方法用于验证没有任何方法被调用,比如 ClassB 还是上面那样不变,然后测试代码如下。
public class ClassBTest {
@Test
public void testBar() {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
classB.bar();
verifyNoInteractions(classA);
}
}
这时测试就会失败,因为 bar() 方法调用了 classA 的 metdho1() 和 method2()。
4. inOrder()
假如我们想要验证模拟对象的某些方法的调用是按次序调用的,就可以用 inOrder() 来验证。
假如 ClassA 和 ClassB 的实现如下。
public class ClassA {
public void method1() {
// ...
}
public void method2() {
// ...
}
}
public class ClassB {
private final ClassA classA;
public ClassB(ClassA classA) {
this.classA = classA;
}
public void bar() {
classA.method1();
classA.method2();
}
}
如果调用次序不符合预期的话,测试就会失败,像下面这样
public class ClassBTest {
@Test
public void testBar() {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
classB.bar();
InOrder inOrder = Mockito.inOrder(classA, classA);
inOrder.verify(classA).method2();
inOrder.verify(classA).method1();
}
}
调整一下顺序后,测试就通过了。
5. timeout()
如果想验证某个方法在特定的时间内执行,可以使用 timeout()。
假如 ClassA 和 ClassB 的实现如下。
public class ClassA {
public void method1() {
// ...
}
}
public class ClassB {
private final ClassA classA;
public ClassB(ClassA classA) {
this.classA = classA;
}
public void bar() {
new Thread() {
@Override
public void run() {
callMethod1();
}
}.start();
}
private void callMethod1() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
classA.method1();
}
}
当超时时间设定为 500 毫秒时,测试失败了,
超时时间设为 1100 毫秒后,测试通过了。
-
注意事项
timeout() 是一种测试代码坏味道,因为这会让测试的执行时间变长,能不用尽量少用,测试异步代码还可以使用 CountDownLatch 或自定义同步线程池
6. after()
timeout() 用于验证特定时间内执行,而 after() 则用于验证特定时间后执行。
比如下面这样。
2.2 修改非 void 方法的实现
thenReturns() 就是修改方法的返回值,前面已经介绍过了,下面来看下另外两个修改实现的方法。
1. thenAnswer()
如果我们想修改被插桩(Stubbing)的方法的实现,就要用到 thenAnswer() 。
比如 ClassA 和 ClassB 的实现如下。
public class ClassA {
public boolean method1(String value) {
System.out.println(value);
return false;
}
}
public class ClassB {
private final ClassA classA;
public ClassB(ClassA classA) {
this.classA = classA;
}
public boolean bar() {
return classA.method1("888");
}
}
然后在 when() 后调用 thenAnswer() 修改该方法的实现。
public class ClassBTest {
@Test
public void testBar() {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
when(classA.method1(any())).thenAnswer(new Answer<Object>() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
// 获取第一个参数
String arg1 = invocation.getArgument(0);
System.out.println("arg1: " + arg1);
return true;
}
});
boolean result = classB.bar();
assertTrue(result);
}
}
测试结果如下。
这段测试代码中的 any() 是 Mockito 中的 ArgumentMatchers 的静态方法,any() 表示任意类型的参数,在我们不关心具体传参值的时候可以用 any() 来替代,如果要限定参数类型,可以用 anyBoolean() 等其他的参数匹配方法。
2. thenThrow()
thenThrow() 就是让某个方法被调用后抛出异常。
public class ClassBTest {
@Test
public void testBar() {
ClassA classA = spy(ClassA.class);
ClassB classB = new ClassB(classA);
when(classA.method1()).thenThrow(new Throwable("有个异常"));
classB.bar();
}
}
3. thenCallRealMethod()
thenCallRealMethodI() 就是用来调用 mock 对象的真实方法的,比如 ClassA 和 ClassB 的实现如下。
public class ClassA {
public int method1() {
return 1;
}
public int method2() {
return 2;
}
}
public class ClassB {
private final ClassA classA;
public ClassB(ClassA classA) {
this.classA = classA;
}
public int bar() {
return classA.method1() + classA.method2();
}
}
对应的测试代码如下。
public class ClassBTest {
@Test
public void testBar() {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
when(classA.method1()).thenCallRealMethod();
int result = classB.bar();
assertEquals(1, result);
}
}
如果不调用 thenCallRealMethod() 的话,那么结果就是 0 而不是 1 。
2.3 修改 void 方法的实现
1. doNothing()
对于返回值为 void 的方法,要修改实现的话就要调用 doXXX() 系列的方法,比如 doNothing() 就是把某个方法改为空实现。
对于下面的 ClassA 和 ClassB。
public class ClassA {
public void method1() {
System.out.println("method1");
}
}
public class ClassB {
private final ClassA classA;
public ClassB(ClassA classA) {
this.classA = classA;
}
public boolean bar() {
return classA.method1();
}
}
如果不想要 method1() 打印 method1 ,就要调用 doNothing()
@Test
public void testBar() {
ClassA classA = spy(ClassA.class);
ClassB classB = new ClassB(classA);
// doNothing().when(classA).method1();
classB.bar();
}
2. doAnswer()
前面是 doNothing() 是把方法改为空实现,如果想要修改实现的话就要用 doAnswer() 。
3. doThrow()
如果我们想在一个模拟对象的方法被调用时抛出异常,就可以在 when() 前加上 doThrow() ,比如下面这样。
4. doCallRealMethod()
spy() 和 doCallRealMethod() 都是用来模拟一个对象的部分行为,下面是代码示例。
假如 ClassA 和 ClassB 的实现如下
public class ClassB {
private final ClassA classA;
public ClassB(ClassA classA) {
this.classA = classA;
}
public int bar() {
return classA.method1() + classA.method2();
}
}
public class ClassA {
public int method1() {
return 1;
}
public int method2() {
return 2;
}
}
这时如果我们想对 method1() 进行插桩(Stubbing),method2() 的实现不变,就可以用 doCallRealMethod()。
public class ClassBTest {
@Test
public void testBar () {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
Mockito.when(classA.method1()).thenReturn(3);
Mockito.doCallRealMethod().when(classA).method2();
int result = classB.bar();
assertEquals(5, result);
}
}
5. doReturn()
doReturn() 与 thenReturn() 的作用一样,一般推荐使用 thenReturn() ,因为 thenReturn() 的可读性正常,需要用到 doReturn() 的一个场景是在 spy 一个真实对象会导致其他问题时,比如下面这样。
这时就要改用 doReturn() 。
除了 doReturn() Mockito 还提供了一个 doAnswer() ,作用类似于前面讲到的 thenAnswer() 。
2.4 清理 Mock 数据
1. clearInvocations()
Mockito 会记录 mock 对象的方法的调用次数,假如我们要重复验证一个 mock 对象的方法调用次数,就要调用 clearInvocation() 重置调用次数。
clearInvocations() 的使用场景。
比如有一个这样的测试。
public class ClassBTest {
@Test
public void testBar () {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
classB.bar();
verify(classA, Mockito.times(1)).foo();
classB.bar();
verify(classA, Mockito.times(1)).foo();
}
}
这里重复调用了 bar() 方法,那么 foo() 方法也会被调用两次,所以 Mockito 会抛出下面这样的异常。
如果调用了 clearInvocations() ,测试就会通过。
-
注意事项
clearInvocations() 可以说是一种测试代码的坏味道,严格意义上来说我们应该把需要用到 clearInvocations() 的地方分离成多个测试,每个测试的 mock 对象都是新的,这样子能保持一个测试只验证一个点,单元测试要相互独立,而且这样看测试代码也能看清楚测试的前置条件。
相互依赖的单元测试是难以维护的,因为有可能出现一个测试失败了,还要去修复其他的一个又一个的测试,这样会提升测试的维护成本。
2. reset()
reset() 与 clearInvocations() 类似,不同的是 reset() 还会把之前在 when() 中设定的方法的返回设定为默认值。
比如下面这个测试,reset() 重置 mock 对象后,第二个 verify() 就失败了。
2.5 其他
1. description()
descritpion() 方法是用于输出测试失败的信息的,类似于 assertTrue() 前面填写的信息。
verify() 方法能传入两个参数,一个是 mock 对象,另一个就是 VerificationMode ,而 description() 方法就是用来创建一个 Description 对象的,这个对象实现了 VerificationMode 接口,而且 Description 可以和其他 VerificationMode 结合使用,比如下面这样。
@Test
public void test() {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
classB.bar();
Mockito.verify(classA,
Mockito.times(1).description("classA 的 method1() 方法没有被调用"))
.method1();
}
2. framework()
framework() 方法会返回一个 MockitoFramework 对象,通过这个对象,我们可以监听到测试失败和 Mock 创建事件,比如下面这样。。
Mockito.framework().addListener(new MockitoTestListener() {
@Override
public void testFinished(TestFinishedEvent event) {
}
@Override
public void onMockCreated(Object mock, MockCreationSettings settings) {
}
});
3. ArgumentCaptor
假如我们想要验证下传给 Mock 对象的参数是否正确,就要用到 ArgumentCaptor 来捕获参数的值。
假如 ClassA 和 ClassB 的实现如下。
public class ClassA {
public void method1(String value) {
// ...
}
}
public class ClassB {
private final ClassA classA;
public ClassB(ClassA classA) {
this.classA = classA;
}
public void bar() {
classA.method1("aaa");
}
}
这时如果想验证 bar() 方法中传的参数是 aaa 的话,就要用到 ArgumentCaptor。
@Test
public void test() {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
classB.bar();
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
Mockito.verify(classA).method1(captor.capture());
String result = captor.getValue();
assertEquals("method1() 收到的 value 不是 aaa", "aaa", result);
}
4. ArgumentMatcher
假如我们想验证的参数不是特定的值,比如只要求不为空,那就要用到 ArgumentMatcher 了。
假如 ClassA 和 B 的实现如下。
public class ClassA {
public void method1(String value) {
//...
}
}
public class ClassB {
private final ClassA classA;
public ClassB(ClassA classA) {
this.classA = classA;
}
public void bar() {
classA.method1("666");
}
}
想要测试 ClassB 传给 ClassA 的值不为空,可以像下面这样写。
import static org.mockito.ArgumentMatchers.notNull;
import static org.mockito.Mockito.mock;
public class ClassBTest {
@Test
public void testBar() {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
classB.bar();
Mockito.verify(classA).method1(notNull());
}
}
下面是常用的 ArgumentMatcher 。
- any() : 任意类型参数,包括 null
- any(Class<T>) :类型为 T 的参数
- isA(Class<T>) :与 any(Class<T>) 作用一样
- anyXXX() : 比如 anyBoolean() 、anyList() 等
- eq(xxx) :判断接收到的 xxx 类型参数的值,比如 eq(boolean) ,xxx 只能是基本类型
- same(xxx) :判断接收到的 xxx 类型参数的值,xxx 可以是引用类型
- isNull() :判断参数为空
- notNullI() :判断参数不为空
- contains(String) :判断字符串类型的参数包含另一个字符串
- argThat(ArgumentMatcher) :自定义参数匹配器
5. MockitoAnnotations
除了使用 mock() 和 spy() 等方法创建模拟对象,还可以通过注解做类似的操作,Mockito 支持的注解如下。
- @Mock
- @Spy
- @Captor
- @InjectMocks
其中 @Captor 就是用来创建前面提到的 ArgumentCaptor 的,下面是代码示例。
@RunWith(MockitoJUnitRunner.class)
public class ClassBTest {
@Mock
ClassA classA = mock(ClassA.class);
@Before
public void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
public void test() {
ClassB classB = new ClassB(classA);
classB.bar();
Mockito.verify(classA).method1("aaa");
}
}
如果不想每次都在 setUp() 方法中调用 openMocks() 的话,可以像下面这样定义一个积累。
@RunWith(MockitoJUnitRunner.class)
public class BaseMockitoTest {
@Before
public void setUp() {
MockitoAnnotations.openMocks(this);
}
}
6. BDDMockito
BDDMockito 指的是用跟 Gherkin 语法类似的风格来写测试代码,BDD 指的是行为驱动开发(Behavior-Driven Development),常见的 BDD 框架是 Cucumber ,Cucumber 也可以拿来写单元测试,但是有点重了,因为每个测试都要写一个用 Ghekrin 语法写的特性文件,比如下面这样。
#encoding: utf-8
#language: zh-CN
功能: 登录
场景: 用合法手机号和密码登录
假如用户输入合法的手机号
并且用户输入合法的密码
当用户点击登录
那么用户能看到首页
但是如果要做验收测试的话(后面会讲到),用 Cucumber 就比较合适,Cucumber 这里就不展开了,下面继续看 BDDMockito 的用法。
假如 ClassA 和 ClassB 的实现如下。
public class ClassA {
public boolean foo() {
return false;
}
public void method1() {
}
public void method2() {
}
}
public class ClassB {
public void bar() {
boolean foo = classA.foo();
if (foo) {
classA.method1();
} else {
classA.method2();
}
}
}
然后 bar() 方法的测试如下。
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
public class ClassBTest {
@Test
public void testBar () {
ClassA classA = mock(ClassA.class);
ClassB classB = new ClassB(classA);
// 假如,也就是前置条件
given(classA.foo()).willReturn(true);
// 当
classB.bar();
// 那么
then(classA).should().method1();
}
}
相当于是把之前提到的 when() 改成了 given() ,把 verify() 改成了 then() ,这样读起来顺口一些,其他的差别不大。
3. PowerMock
看完了 Mockito ,我们再来看下可以 Mock 静态方法和构造函数的 PowerMock ,虽然 Mockito 在 3.4.0 也支持 Mock 静态方法,但是我试了下不太好用,所以还是讲下 PowerMock 的用法。
如果我们添加了 PowerMock 的依赖,就不需要再添加 Mockito 的依赖,因为 PowerMock 已经依赖了 Mockito 。
需要依赖的包如下。
dependencies {
testImplementation 'org.powermock:powermock-core:2.0.9'
testImplementation 'org.powermock:powermock-module-junit4:2.0.9'
testImplementation "org.powermock:powermock-api-mockito2:2.0.9"
}
1. mockStatic()
mockStatic() 方法用于模拟静态方法,用法也比较简单。
假如 ClassA 和 ClassB 的实现如下。
public class ClassA {
public static boolean method1() {
return false;
}
}
public class ClassB {
public ClassB() {
}
public void bar() {
return ClassA.method1();
}
}
如果想把 method1() 的返回值改为 true ,就要用到 mockStatic() 方法,比如下面这样。
@RunWith(PowerMockRunner.class)
@PrepareForTest({ClassA.class})
public class ClassBTest {
@Test
public void testBar() {
PowerMockito.mockStatic(ClassA.class);
PowerMockito.when(ClassA.method1()).thenReturn(true);
ClassB classB = new ClassB();
boolean result = classB.bar();
assertTrue(result);
}
}
对于每一个想要对模拟的有静态方法的类,都要填到 @PrepareForTest 注解中。
2. whenNew()
如果我们想要修改模拟对象的构造函数的实现,就可以用 whenNew() 方法。
假如 ClassA 和 ClassB 的实现如下。
public class ClassA {
private final boolean value;
public ClassA() {
value = false;
}
public boolean getValue() {
return value;
}
}
public class ClassB {
public ClassB() {
}
public boolean bar() {
ClassA classA = new ClassA();
return classA.getValue();
}
}
在测试时,要注意的就是使用 whenNew() 的时候,@PrepareForTest 中要填写调用构造函数的类,在这个例子中也就是 ClassB 。
@RunWith(PowerMockRunner.class)
@PrepareForTest({ClassB.class})
public class ClassBTest {
@Test
public void testBar() throws Exception {
ClassA classA = PowerMockito.mock(ClassA.class);
PowerMockito.when(classA.getValue()).thenReturn(true);
PowerMockito.whenNew(ClassA.class).withAnyArguments().thenReturn(classA);
ClassB classB = new ClassB();
boolean result = classB.bar();
assertTrue(result);
}
}
3. verifyStatic()
如果想验证某个静态方法被调用的话,就要用 verifyStatic() ,verifyStatic() 后面要跟上想验证的静态方法。
比如 ClassA 和 ClassB 的实现如下。
public class ClassA {
public static void method1() {
}
public static void method2() {
}
}
public class ClassB {
private ClassA classA;
public ClassB() {
}
public void init() {
classA = new ClassA();
ClassA.method2();
}
}
对应的测试代码如下。
@RunWith(PowerMockRunner.class)
@PrepareForTest({ClassA.class})
public class ClassBTest {
@Test
public void testBar() {
PowerMockito.mockStatic(ClassA.class);
ClassB classB = new ClassB();
classB.init();
PowerMockito.verifyStatic(ClassA.class);
ClassA.method1();
// ClassA.method2();
}
}
这时执行测试后是失败的。
当把一行换成 ClassA.method2() 后,测试才会通过。
3. Robolectric
如果我们的某部分代码是 Android SDK 中的,那我们就需要在 Android 环境下执行,并且不想要在真机或模拟器上执行测试的话,就要用到 Robolectric ,比如 Glide 中的测试很多都是用 Robolectric 执行的。
-
注意事项
再次提醒,对于长期不注意代码可测试性的项目来说,使用 Robolectric 可能会是非常困难,所以可以的话尽量要把 Android SDK 的代码与业务逻辑代码用中间层(比如接口)进行分离,使用 PowerMock 和 Mockito 会轻松很多
下面来看下 Robolectric 的用法,首先在 build.gradle 中测试选项与依赖。
android {
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
testImplementation 'org.robolectric:robolectric:4.4'
}
然后在 @Runwith 注解中填入 RobolectricTestRunner,比如下面这样。
@RunWith(RobolectricTestRunner.class)
@Config(sdk = Build.VERSION_CODES.P)
public class WelcomeActivityTest {
@Test
public void clickingLogin_shouldStartLoginActivity() {
// 打开 WelcomeActivity
WelcomeActivity activity = Robolectric.setupActivity(WelcomeActivity.class);
// 点击登录
activity.findViewById(R.id.login).performClick();
// 获取下一个启动的 Activity
Intent expectedIntent = new Intent(activity, LoginActivity.class);
Intent actual = shadowOf(RuntimeEnvironment.application).getNextStartedActivity();
// 期望该 Activity 为 LoginActivity
assertEquals(expectedIntent.getComponent(), actual.getComponent());
}
}
像上面这个测试,如果没有用 RobolectricTestRunner 的话是无法执行的,因为 Robolectric 是为我们在 JVM 的环境中建立一个 Android 环境沙盒,只有这样才能在我们的机器上执行与 Android 平台有关的代码的测试。
下面我们来看下 Robolectric 的一些 API 的用法。
3.1 启动和创建组件控制器
在 Robolectric 类中有下面几个常用的启动和创建组件控制器的静态方法。
- 启动 Activity:setupActivity()
- 启动 Service:setupService()
- 创建 Activity 控制器:buildActivity()
- 创建 Service 控制器:buildService()
- 创建 ContentProvider 控制器:buildContentProvider()
3.2 controller
Robolectric 的 controller 目录下有下面几个 Controller 。
- ActivityController
- BackupAgentController
- ContentProviderController
- IntentServiceController
- ServiceController
这些 Controller 都继承了 ComponentController 类。
以 ActivityController 为例,通过 buildActivity() 创建 ActivityController 后,可以调用 Activity 的回调方法,比如下面这几个。
ActivityController<MainActivity> activityController = Robolectric.buildActivity(MainActivity.class);
// 获取 Activity 实例
MainActivity activity = activityController.get();
// 调用 onCreate() 方法
activityController.onCreate();
// 调用 onResume() 和 onPause() 方法。
activityController.onResume()
.onPause();
3.3 shadow
shadow 的概念与 Mock 类似,不同的是 Shadow 是针对 Android SDK 中的各个类进行 Mock 。
Robolectric 的 shadow 目录下有很多 Shadow 类,这些类都继承了 Android SDK 中的类,比如下面这些。
有了这些类,这们就可以通过 Shadows 类提供的 shadowOf() 等静态方法创建 Shadow 对象或访问 Shadow 对象的状态,比如下面这样。
// 找出所有已显示的 Toast
List<Toast> toasts = shadowOf(application).getShownToasts();
1. 自定义 Shadow 实现
Robolectric 允许我们自定义沙盒中的类的实现,比如下面这样。
import static org.mockito.Mockito.mock;
import android.os.Looper;
import android.os.MessageQueue;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.Resetter;
import org.robolectric.shadows.ShadowLegacyLooper;
@Implements(Looper.class)
public class GlideShadowLooper extends ShadowLegacyLooper {
public static MessageQueue queue = mock(MessageQueue.class);
@Implementation
public static MessageQueue myQueue() {
return queue;
}
@Resetter
@Override
public void reset() {
queue = mock(MessageQueue.class);
}
}
上面这段代码是用来替换 Android SDK 中的 Looper 的实现的,@Implements 用于声明需要替换的类,@Implementation 用于声明需要替换的方法,@Resetter 则是声明在每次测试完成后需要重置的资源。
这里要注意的是,引用到自定义 Shadow 类的地方要在测试类中添加该 Shadow 类,比如下面这样。
@Config(
shadows = {
GlideShadowLooper.class,
})
public class GlideTest {
}
2. RuntimeEnvironment
RuntimeEnvironment 中有一个 application 静态变量,在我们需要用到 Application 的地方可以拿来用。
3. ShadowLog
使用 ShadowLog 可以把 Logcat 换成 System.out ,把日志打印在控制台中,比如下面这样。
@RunWith(RobolectricTestRunner.class)
@Config(sdk = Build.VERSION_CODES.P)
public class WelcomeActivityTest {
@Before
public void setUp() {
ShadowLog.stream = System.out;
}
@Test
public void clickingLogin_shouldStartLoginActivity() {
// 打开 WelcomeActivity
WelcomeActivity activity = Robolectric.setupActivity(WelcomeActivity.class);
}
}
然后就能在控制台看到日志。
3.4 配置
Robolectric 开放了一些测试配置让我们选,配置的形式有下面几种。
- 模块级配置:src/test/resources/robolectric.properties
- 类级配置:@Config
- 方法级配置:@Config
@Config 可以给类或方法添加,如果测试类和方法都添加了,那就以方法的配置为准,常用的 Robolectric 配置。
- sdk:相当于是修改 Build.VERSIONS.SDK_INT 的值
- minSdk:在多个 API 版本下运行多个测试时的最小 Android SDK 版本
- maxSdk:在多个 API 版本下运行多个测试时的最大 Android SDK 版本
- manifest:就是测试用的 AndroidManifest 文件
- application:就是测试用的 Application 类
3.5 注解
下面来看几个之前没提到的注解的用法:
- 隐藏 API :@HiddenApi
- 注入真实对象:@RealObject
- 文本绘制模式:@TextLayoutMode
1. @HiddenAPI
Android SDK 中有一些是隐藏 API ,如果想替换这些 API 的实现,就要用到 @HiddenAPI 注解,比如下面这样。
@Implements(value = TelcomeManager.class, minSdk = LOLLIPOP)
public class ShowdowTelcomeManager {
@Implementation
@HiddenAPi
public List<phoneaccounthandle> getAllPhoneAccountHandles() {
return ImmutableList.copyOf(accounts.keySet());
}
}
2. @RealObject
添加了 @RealObject 注解的字段会使用真实的实例注入,比如下面的 view 字段。
@Implements(View.class)
public static final class SizedShadowView extends ShadowView {
@RealObject private View view;
private int width;
private int height;
}
3. @TextLayoutMode
TextLayoutMode 分为 REALISTIC 和 LEGACY 两种,默认是 REALISTIC ,想修改的话可以像下面这样给类添加注解。
@RunWith(RobolectricTestRunner.class)
@TextLayoutMode(value = TextLayoutMode.Mode.LEGACY)
public class CustomViewTargetTest {
// ...
}
它们的区别就在于 REALISTIC 更接近真实的文本绘制操作。
@Implements(value = ViewRootImpl.class, isInAndroidSdk = false)
public class ShadowViewRootImpl {
// ...
@Implementation
protected void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
directlyOn(
realObject,
ViewRootImpl.class,
"setView",
ClassParameter.from(View.class, view),
ClassParameter.from(WindowManager.LayoutParams.class, attrs),
ClassParameter.from(View.class, panelParentView));
if (ConfigurationRegistry.get(TextLayoutMode.Mode.class) == REALISTIC) {
Rect winFrame = new Rect();
getDisplay().getRectSize(winFrame);
reflector(_ViewRootImpl_.class, realObject).setWinFrame(winFrame);
}
}
}
3.6 线程模型
Robolectric 的线程模型分为 LEGACY 和 PAUSED 两种,默认是 LEGACY,我们可以给测试类添加 @LooperMode 注解可以修改默认的线程模型 。
@LooperMode(LEGACY)
@RunWith(RobolectricTestRunner.class)
public class ExampleTest {
// ...
}
1. LEGACY
LEGACY 是 Robolectric 在 4.3 前的线程模型,发布到 Looper 中的任务是通过 Robolectric 的 Scheduler 来管理的,我们可以通过 Scheduler 的 setIdleState(IdleState) 方法来修改 Scheduler 的空闲状态,IdleState 分为下面三种。
-
PAUSED
暂停状态,不执行任务
-
UNPAUSED
非暂停状态,自动执行任务,执行时间不会加快,为默认状态
-
CONSTANT_IDLE
空闲状态,自动执行任务,执行时间加快
2. PAUSED
PAUSED 线程模型和 LEGACY 相比更接近 Android 的线程模型的行为,和 LEGACY 相比,PAUSED 模式的优势如下。
- 当测试因为某个任务未执行而失败时,Robolectric 会提醒我们;
- Looper 使用真实的 MessageQueue 保存待处理任务;
4. 统计测试覆盖率
4.1 AS 自带的测试覆盖率统计
AS 有自带的测试覆盖率统计工具,只要点击 Run 旁边的 Run with Coverage 就能运行并统计测试覆盖率。
统计后的结果是上图这样的,执行测试时执行过的代码就是绿色的,否则就是红色的,然后每个类和包的右侧就是该类的测试覆盖率。
使用这种方式统计测试覆盖率存在的问题,就是当团队的人数多的时候, include(包含)和 exclude(排除)等配置分享起来比较麻烦。
4.2 Jacoco
Jacoco 是 Java Code Coverage 的缩写,是一个测试覆盖率统计框架。
下面是 jacoco 在 gradle 的配置,可以新建一个 jacoco.gradle 来放这些配置,下面这个配置在每次执行 gradle sync 完成后都会执行,createCoverageTask 方法就是用来创建 Jacoco 测试覆盖率统计任务的,doLast 代码块中的代码就是用来打开测试报告的。
project.afterEvaluate {
(android.hasProperty('applicationVariants')
? android.'applicationVariants'
: android.'libraryVariants')
.all { variant ->
createCoverageTask(variant)
}
}
void createCoverageTask(variant) {
def variantName = variant.name
def unitTestTask = "test${variantName.capitalize()}UnitTest"
print("unitTestTask: " + unitTestTask);
def androidTestCoverageTask = "create${variantName.capitalize()}CoverageReport"
def taskName = "${unitTestTask}Coverage"
tasks.create(name: "${taskName}", type: JacocoReport, dependsOn: [
"$unitTestTask",
// "$androidTestCoverageTask"
]) {
group = "Reporting"
description = "Generate Jacoco coverage reports for the ${variantName.capitalize()} build"
reports {
html.enabled = true
xml.enabled = true
csv.enabled = true
}
def excludes = [
'**/R.class',
'**/R$*.class',
'**/BuildConfig.*',
'**/Manifest*.*',
'**/*Test*.*',
'android/**/*.*',
// kotlin
'**/*$Result.*',
'**/*$Result$*.*'
]
def javaClasses = fileTree(dir: variant.javaCompileProvider.get().destinationDir,
excludes: excludes)
println(javaClasses)
def kotlinClasses = fileTree(dir: "${buildDir}/tmp/kotlin-classes/${variantName}",
excludes: excludes)
classDirectories.setFrom(files([
javaClasses,
kotlinClasses
]))
def variantSourceSets = variant.sourceSets.java.srcDirs.collect { it.path }.flatten()
sourceDirectories.setFrom(project.files(variantSourceSets))
def androidTestsData = fileTree(
dir: "${buildDir}/outputs/code_coverage/${variantName}AndroidTest/connected/",
includes: ["**/*.ec"])
executionData(files([
"$project.buildDir/jacoco/${unitTestTask}.exec",
androidTestsData
]))
doLast {
exec {
// 注释掉的是 Windows 的
// commandLine 'cmd', '/c', "start $project.buildDir/reports/tests/${taskName}/html/index.html"
commandLine 'bash', '-c', "open $project.buildDir/reports/jacoco/${taskName}/html/index.html"
}
}
}
}
gradle sync 执行完后,就能在右侧的 Gradle task 面板的 reporting 分组下看到覆盖率统计任务。
任务执行后的结果是下面这样的。
一层层点击,进入到特定的类,可以看到也是红色的表示未代码在测试过程中未被执行,绿色的表示已执行。