测试驱动开发(TDD)是我一直想要尝试和使用开发方法,但是直至今天才有机会第一次将其应用到正式开发阶段。
从开始的模糊,到慢慢了解如何使用,再到借助它将逻辑捋的越来越清楚,再到之后每次跑完所有测试带给我的信心,我知道这就是我想要的,开发过程再也不是碰运气,我拥有了使用代码测试代码的能力。
因为是不完全从测试驱动开发,本片文章有所不准确的地方也请大家指正。
感谢我的团队~
在所有开始之前,需要给大家介绍一些简要的关于TDD的知识,大家可以从如下地址了解到什么是TDD以及为什么需要TDD:
本片文章以一个假设的需求为切入点 - 数据仓库设计(DataRepository),从如下角度来践行TDD:
该篇文章仅设计逻辑测试部分,并不涉及UI测试,请提前知晓。
在Java的世界中,TDD的基础是单元测试,而Junit就是一个非常强大的单元测试库。
当我们初建一个Android项目时,Android Studio就已经帮我们准备了一些专门用于单元测试的目录,一个空项目如下所示:
UnitTestDemo
- app
- src
- androidTest
- main
- test
其中,test
与main
两个目录是我们这次主要关心的:
androidTest
:目录专门用于测试UI逻辑main
目录专门用于编写项目源码test
目录用门用于测试业务逻辑代码在此处Junit就不详细介绍了,更具体的可以参看这里:
https://zh.wikipedia.org/wiki/JUnit
https://junit.org/junit5/
在这个部分,把关注点放在Mock与PowerMock上,之所以这么说是因为Junit为我们提供了测试代码的可能性,但是当项目依赖于其他模块时,我们可以借助Mock来模拟依赖的类,来控制我们的测试流程。
当我们处于Android环境时更是如此,当我们需要依赖Handler
、Broadcast
或者其他与环境相关的代码时,如果不去Mock它,别说测试了,连运行恐怕都运行不起来。
如果说Junit给了我们汽车,那么Mock与PowerMock就是给了我们飞机。
上文中经常提到的Mock实际上指的是Mockito框架,在首页中他是这么介绍自己的:
Tasty mocking framework for unit tests in Java (美味可口的模拟测试框架 - Java)
Mocktio为我们提供了这样一种能力:模拟一个类,不真实的执行它,而是模拟执行并返回我们想要的数据,且可以去验证类的行为。
下面是它官网的一段实例,展示了上面我们说到的能力:模拟、执行、返回、验证。
import static org.mockito.Mockito.*;
// mock creation
// 模拟一个列表
List mockedList = mock(List.class);
// using mock object - it does not throw any "unexpected interaction" exception
// 执行某些行为
mockedList.add("one");
mockedList.clear();
// stubbing appears before the actual execution
// 模拟返回值
when(mockedList.get(0)).thenReturn("first");
// selective, explicit, highly readable verification
// 验证行为是否被执行到
verify(mockedList).add("one");
verify(mockedList).clear();
// the following prints "first"
// 下面会打印出first,与上面模拟返回的一致
System.out.println(mockedList.get(0));
切换到Android场景中,我们可以做到下面做种样子,验证Handler
的post
是否被执行;模拟Handler
的post
方法执行,验证Runnable
的run
方法是否被调用等。
@Test
public void testHandler() {
Handler handler = mock(Handler.class);
// 验证Handler的post方法是否被执行了
handler.post(new Runnable() {
@Override
public void run() {
}
});
verify(handler).post(any(Runnable.class));
// 模拟post方法执行,并验证run方法有没有被执行
when(handler.post(any(Runnable.class))).thenAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
Runnable runnable = invocation.getArgument(0);
runnable.run();
return null;
}
});
Runnable spy = spy(new Runnable() {
@Override
public void run() {
}
});
handler.post(spy);
// 验证run方法是否被执行
verify(spy).run();
}
虽然Mockito已经很强大了,但是它还是有不能做到的事情,它不能模拟类中的静态方法、私有方法、final方法,于是就有了衍生框架PowerMockito。
虽然PowerMockito仅有2000不到的Star,但是它确实还挺好用。
// 被测试的类与静态方法
public class Static {
public static boolean isPass() {
return false;
}
}
// 使用PowerMockito测试静态方法
@Test
public void testStaticMethod() {
PowerMockito.mockStatic(Static.class);
when(Static.isPass()).thenReturn(true);
assertThat(true, is(Static.isPass()));
}
虽然PowerMockito很强大,但是还是不要过多使用,尤其是针对私有方法与final方法。
PowerMockito更多使用方法请查看这里与这里。
Mockito与PowerMockito的环境配置也很简单,在Android Studio工程中加入如下依赖:
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
testImplementation 'junit:junit:4.12'
testImplementation 'org.mockito:mockito-core:2.8.9'
testImplementation 'org.hamcrest:hamcrest-all:1.3'
testImplementation 'org.powermock:powermock-module-junit4:1.7.4'
testImplementation 'org.powermock:powermock-api-mockito2:1.7.4'
}
注意:mockito-core的最近版本是2.18.3,但是请不要随意升级,因为目前powermock还未兼容最新版本。
在编写测试类时,还需要在类头部加上@Runwith(PowerMockRunner.class)
与@PrepareForTest({xxx.class})
等注解,告诉Junit与PowerMock当前的运行环境与想要模拟的含有静态方法的类。
更详细的关于配置PowerMockito
的文档请查看这里。
在开始TDD之前,让我们再看一下要良好的践行TDD都需要注意哪些? (可参阅这里)
红
首要需求分析,将需求分解为任务列表,再从列表中挑选一个任务,转换成一组测试用例,然后不断循环去实现。
绿
快速的让测试用例变绿。
重构
识别坏味道,进行重构。
接下来着手学习TDD吧。
核心的需求点如下:
对外API设计:
List getAllVideoSync();
List getAllVideoAsync();
逻辑层中 :
在本篇文章中,我们从任务列表中选取同步全量获取视频信息这个任务,并转化成为一组测试用例:
getAllVideoSync()
方法做完用例分解后,就让我们一步一步按照“红 - 绿 - 蓝”的节奏来编写用例与逻辑吧。
在Demo中,每完成一个Case的编写与实现都会Commit一次,我会尽量做到完善。
让我们踏上征途吧。
先给我们的SDK起个好听的名字DataRepository
,再把它做成一个单例。
再考虑下如何验证有效性!只需要断言判定getInstance()
获取的数据不为空就好了。
Commit记录
getAllVideoSync()
方法在DataRepository
中创建了getAllVideoSync()
方法并创建了VideoInfo
类。
由于本次的目的只是验证getAllVideoSync()
方法是否能够正确调用,所以我们不关心返回结果。
Commit记录
本次想要从内存中获取缓存数据,所以关心返回结果。
可以直接在TestCase中直接给DataRepository
中mAllVideoInfo
赋值,来达到模拟内存缓存值存在的情况。
Commit记录
从这一个Case开始就变得复杂一些了。虽然Commit记录中详细的写明了我都做了什么,但是还是简要介绍一下做这些操作的思路。
步骤一:重构了获取DataRepository
的方法,原因是每次都写一下获取逻辑很麻烦,还不如封装一下。
步骤二: 由于验证的行为是内存数据为空,但是缓存值存在,那么就需要修改验证结果。
// 验证文件缓存值存在,获取结果不为null
List userInfoList = instance.getAllVideoSync();
assertThat(userInfoList, is(nullValue()));
步骤三:运行单测之后自然是红色错误,我们转而进入DataRepository
的getAllVideoSync
内部去实现获取缓存的逻辑。 自然而然,我们期望有一个帮助类能够直接拿到缓存的结果,从那个文件拿我们并不关心,于是我们有了FileCacheHelper.getAllVideoCache()
,在编写逻辑之后,代码如下:
public List getAllVideoSync() {
// 内存缓存存在时直接返回
if (mAllVideoInfo != null) {
return mAllVideoInfo;
}
// 获取文件缓存,文件缓存存在时赋值给内存缓存并返回数据
mAllVideoInfo = mFileCacheHelper.getAllVideoCache();
if (mAllVideoInfo != null) {
return mAllVideoInfo;
}
return mAllVideoInfo;
}
此时是连编译都无法通过的,原因是还没有mFileCacheHelper
这个变量,而且连FileCacheHelper
类也没有创建,此外,FileCacheHelper
也不能直接在UserInfoManager
中构建出来,如果要构建,那么注意力就转移到FileCacheHelper
的实现上了。
当创建完FileCacheHelper
以及getAllVideoCache
方法,再回到测试用例中。 由于本阶段并不关心FileCacheHelper
类的真是逻辑如何,所以mock
来模拟一个FileCacheHelper
的实例对象,并且希望它的getAllVideoCache()
方法默认返回null
:
// 模拟一个FileCacheHelper实例
@Mock
FileCacheHelper mFileCacheHelper;
// Refactor Get Instance Method
private DataRepository getNewInstance() {
...
// 让FileCacheHelper.getAllUserInfoCache默认返回null
when(mFileCacheHelper.getAllVideoCache()).thenReturn(null);
return instance;
}
再回到getAllVideoSync_memoryCacheNull_diskCacheExist
单元测试中,由于是验证文件缓存存在的情况,所以期望mFileCacheHelper.getAllVideoCache()
返回一个有效值:
// 假设逻辑调用mFileCacheHelper.getAllVideoCache()时,返回一个空列表
when(mFileCacheHelper.getAllVideoCache()).thenReturn(new ArrayList());
完成这一步,单元测试就能跑通了,而getAllVideoSync()
中关于文件缓存获取的逻辑也完成了。
Commit记录 - 重构获取DataRepository
的方法
Commit记录 - 实现文件缓存逻辑
这里的步骤和上面一个非常类似,所以就不重复表述了。
从这两个Case中可以知道,完全可以在仅关心DataRepository
的情况下,把逻辑补充完整,暂时不关心的FileCacheHelper
与NetHelper
可以暂放一边,通过模拟它们来跑通逻辑。
相信不用我说,你也一定知道这多么有用处。
Commit记录
为了确保模拟的FileCacheHelper
类中的方法被调用,可以使用如下的方法:
// 验证行为,保存到缓存中是否被执行了
verify(mFileCacheHelper).saveDataToCache(list);
Commit记录
如果仔细看完上面数据仓库的例子,相信你对测试驱动开发一定有一些认识了,下面就谈谈它的好处与难点。
好处:
难点:
1. 思维方式的转变 - “很多人不懂“意图式编程”,总是习惯先实现一个东西,再去调用它。而测试先行就要求先使用,再实现。这样能少走很多弯路,减少返工。”
2. 测试框架的选型与使用,虽然Mockito与PowerMockito已经很简单了,但是还是有一些学习成本的。
最后引用一段话,也是我想说的:
最后我想说:
TDD不是银弹,不可能适合所有的场景,但这不应该成为我们拒绝它的理由。
也不要轻易否定TDD,如果要否定,起码要在认真实践过之后。
最后,祝好~