Android单元测试介绍
处于高速迭代开发中的Android项目往往需要除黑盒测试外更加可靠的质量保障,这正是单元测试的用武之地。单元测试周期性对项目进行函数级别的测试,在良好的覆盖率下,能够持续维护代码逻辑,从而支持项目从容应对快速的版本更新。
单元测试是参与项目开发的工程师在项目代码之外建立的白盒测试工程,用于执行项目中的目标函数并验证其状态或者结果,其中,单元指的是测试的最小模块,通常指函数。如图1所示的绿色文件夹即是单元测试工程。这些代码能够检测目标代码的正确性,打包时单元测试的代码不会被编译进入APK中。
与Java单元测试相同,Android单元测试也是维护代码逻辑的白盒工程,但由于Android运行环境的不同,Android单元测试的环境配置以及实施流程均有所不同。
Java单元测试
在传统Java单元测试中,我们需要针对每个函数进行设计单元测试用例。如图2便是一个典型的单元测试的用例。
上述示例中,针对函数dosomething(Boolean param)的每个分支,我们都需要构造相应的参数并验证结果。单元测试的目标函数主要有三种:
- 有明确的返回值,如上图的
dosomething(Boolean param)
,做单元测试时,只需调用这个函数,然后验证函数的返回值是否符合预期结果。 - 这个函数只改变其对象内部的一些属性或者状态,函数本身没有返回值,就验证它所改变的属性和状态。
- 一些函数没有返回值,也没有直接改变哪个值的状态,这就需要验证其行为,比如点击事件。
既没有返回值,也没有改变状态,又没有触发行为的函数是不可测试的,在项目中不应该存在。当存在同时具备上述多种特性时,本文建议采用多个case来针对每一种特性逐一验证,或者采用一个case,逐一执行目标函数并验证其影响。
构造用例的原则是测试用例与函数一对一,实现条件覆盖与路径覆盖。Java单元测试中,良好的单元测试是需要保证所有函数执行正确的,即所有边界条件都验证过,一个用例只测一个函数,便于维护。在Android单元测试中,并不要求对所有函数都覆盖到,像Android SDK中的函数回调则不用测试。
Android单元测试
在Android中,单元测试的本质依旧是验证函数的功能,测试框架也是JUnit。在Java中,编写代码面对的只有类、对象、函数,编写单元测试时可以在测试工程中创建一个对象出来然后执行其函数进行测试,而在Android中,编写代码需要面对的是组件、控件、生命周期、异步任务、消息传递等,虽然本质是SDK主动执行了一些实例的函数,但创建一个Activity并不能让它执行到resume的状态,因此需要JUnit之外的框架支持。
当前主流的单元测试框架AndroidTest和Robolectric,前者需要运行在Android环境上,后者可以直接运行在JVM上,速度也更快,可以直接由Jenkins周期性执行,无需准备Android环境。因此我们的单元测试基于Robolectric。对于一些测试对象依赖度较高而需要解除依赖的场景,我们可以借助Mock框架。
在维基百科上这样描述Mock:In object-oriented programming, mock objects are simulated objects that mimic the behavior of real objects in controlled ways. A computer programmer typically creates a mock object to test the behavior of some other object, in much the same way that a car designer uses a crash test dummy to simulate the dynamic behavior. of a human in vehicle impacts.
Mock通常是指,在测试一个对象A时,我们构造一些假的对象来模拟与A之间的交互,而这些Mock对象的行为是我们事先设定且符合预期。通过这些Mock对象来测试A在正常逻辑,异常逻辑或压力情况下工作是否正常。
引入Mock最大的优势在于:Mock的行为固定,它确保当你访问该Mock的某个方法时总是能够获得一个没有任何逻辑的直接就返回的预期结果。
Mock Object的使用通常会带来以下一些好处:
隔绝其他模块出错引起本模块的测试错误。
隔绝其他模块的开发状态,只要定义好接口,不用管他们开发有没有完成。
一些速度较慢的操作,可以用Mock Object代替,快速返回。
对于分布式系统的测试,使用Mock Object会有另外两项很重要的收益:
通过Mock Object可以将一些分布式测试转化为本地的测试
将Mock用于压力测试,可以解决测试集群无法模拟线上集群大规模下的压力
Mock的应用场景
在使用Mock的过程中,发现Mock是有一些通用性的,对于一些应用场景,是非常适合使用Mock的:
真实对象具有不可确定的行为(产生不可预测的结果,如股票的行情)
真实对象很难被创建(比如具体的web容器)
真实对象的某些行为很难触发(比如网络错误)
真实情况令程序的运行速度很慢
真实对象有用户界面
测试需要询问真实对象它是如何被调用的(比如测试可能需要验证某个回调函数是否被调用了)
真实对象实际上并不存在(当需要和其他开发小组,或者新的硬件系统打交道的时候,这是一个普遍的问题)
当然,也有一些不得不Mock的场景:
一些比较难构造的Object:这类Object通常有很多依赖,在单元测试中构造出这样类通常花费的成本太大。
执行操作的时间较长Object:有一些Object的操作费时,而被测对象依赖于这一个操作的执行结果,例如大文件写操作,数据的更新等等,出于测试的需求,通常将这类操作进行Mock。
异常逻辑:一些异常的逻辑往往在正常测试中是很难触发的,通过Mock可以人为的控制触发异常逻辑。
在一些压力测试的场景下,也不得不使用Mock,例如在分布式系统测试中,通常需要测试一些单点(如namenode,jobtracker)在压力场景下的工作是否正常。而通常测试集群在正常逻辑下无法提供足够的压力(主要原因是受限于机器数量),这时候就需要应用Mock去满足。
在这些场景下,我们应该如何去做Mock的工作了,一些现有的Mock工具可以帮助我们进行Mock工作。
Android单元测试环境配置
Robolectric环境配置
Android单元测试依旧需要JUnit框架的支持,Robolectric只是提供了Android代码的运行环境。如果使用Robolectric 3.0,依赖配置如下
testCompile 'junit:junit:4.12'
testCompile "org.robolectric:robolectric:3.0"
Robolectric使用介绍
Robolectric单元测试编写结构
单元测试代码写在项目的test(也可能是androidTest,该目录在项目中会呈浅绿色)目录下。单元测试也是一个标准的Java工程,以类为文件单位编写,执行的最小单位是函数,测试用例(以下简称case)是带有@Test注解的函数,单元测试里面带有case的类由Robolectric框架执行,需要为该类添加注解@RunWith(RobolectricTestRunner.class)。基于Robolectric的代码结构如下:
@RunWith(RobolectricTestRunner.class)
public class MyActivityTest {
@Before
public void setUp() {
//执行初始化的操作
}
@Test
public void testCase() {
//执行各种测试逻辑判断
}
@Test
public void clickingButton_shouldChangeResultsViewText() throws Exception {
MyActivity activity = Robolectric.setupActivity(MyActivity.class);
Button button = (Button) activity.findViewById(R.id.button);
TextView results = (TextView) activity.findViewById(R.id.results);
button.performClick();
assertThat(results.getText().toString()).isEqualTo("Robolectric Rocks!");
}
}
上述结构中,带有@Before注解的函数在该类实例化后,会立即执行,通常用于执行一些初始化的操作,比如构造网络请求和构造Activity。带有@test注解的是单元测试的case,由Robolectric执行,这些case本身也是函数,可以在其他函数中调用,因此,case也是可以复用的。每个case都是独立的,case不会互相影响,即便是相互调用也不会存在多线程干扰的问题。
Robolectric支持单元测试范围从Activity的跳转、Activity展示View(包括菜单)和Fragment到View的点击触摸以及事件响应,同时Robolectric也能测试Toast和Dialog。对于需要网络请求数据的测试,Robolectric可以模拟网络请求的response。对于一些Robolectric不能测试的对象,比如ConcurrentTask,可以通过自定义Shadow的方式现实测试。下面将着重介绍Robolectric的常见用法。
Robolectric 2.4模拟网络请求
由于商业App的多数Activity界面数据都是通过网络请求获取,因为网络请求是大多数App首要处理的模块,测试依赖网络数据的Activity时,可以在@Before标记的函数中准备网络数据,进行网络请求的模拟。准备网络请求的代码如下:
public void prepareHttpResponse(String filePath) throws IOException {
String netData = FileUtils.readFileToString(FileUtils.
toFile(getClass().getResource(filePath)), HTTP.UTF_8);
Robolectric.setDefaultHttpResponse(200, netData);
}//代码适用于Robolectric 2.4,3.0需要注意网络请求的包的位置
由于Robolectric 2.4并不会发送网络请求,因此需要本地创建网络请求所返回的数据,上述函数的filePath便是本地数据的文件的路径,setDefaultHttpResponse()则创建了该请求的Response。上述函数执行后,单元测试工程便拥有了与本地数据数据对应的网络请求,在这个函数执行后展示的Activity便是有数据的Activity。
在Robolectric 3.0环境下,单元测试可以发真的请求,并且能够请求到数据,本文依旧建议采用mock的办法构造网络请求,而不要依赖网络环境。
Activity展示测试与跳转测试
创建网络请求后,便可以测试Activity了。测试代码如下:
@Test
public void testSampleActivity(){
SampleActivity sampleActivity=Robolectric.buildActivity(SampleActivity.class).
create().resume().get();
assertNotNull(sampleActivity);
assertEquals("Activity的标题", sampleActivity.getTitle());
}
@Test
public void testLifecycle() {
ActivityController<SampleActivity> activityController = Robolectric.buildActivity(SampleActivity.class).create().start();
Activity activity = activityController.get();
TextView textview = (TextView) activity.findViewById(R.id.tv_lifecycle_value);
assertEquals("onCreate",textview.getText().toString());
activityController.resume();
assertEquals("onResume", textview.getText().toString());
activityController.destroy();
assertEquals("onDestroy", textview.getText().toString());
}
Robolectric.buildActivity()用于构造Activity,create()函数执行后,该Activity会运行到onCreate周期,resume()则对应onResume周期。assertNotNull和assertEquals是JUnit中的断言,Robolectric只提供运行环境,逻辑判断还是需要依赖JUnit中的断言。
Activity跳转是Android开发的重要逻辑,其测试方法如下:
@Test
public void testStartActivity() {
//按钮点击后跳转到下一个Activity
forwardBtn.performClick();
Intent expectedIntent = new Intent(sampleActivity, LoginActivity.class);
Intent actualIntent = ShadowApplication.getInstance().getNextStartedActivity();
assertEquals(expectedIntent, actualIntent);
}
注:Robolectric 3.1 之后,不建议用 Intent.equals() 的方式来比对两个 Intent ,因此以上代码将无法正常执行。目前建议用类似代码来断言:
assertEquals(expectedIntent.getComponent(), actualIntent.getComponent());
当然,Intent 有很多属性,如果需要分别断言的话比较麻烦,因此可以用一些第三方库,比如 assertj-android 的工具类 IntentAssert。
UI组件状态
@Test
public void testViewState(){
CheckBox checkBox = (CheckBox) sampleActivity.findViewById(R.id.checkbox);
Button inverseBtn = (Button) sampleActivity.findViewById(R.id.btn_inverse);
assertTrue(inverseBtn.isEnabled());
checkBox.setChecked(true);
//点击按钮,CheckBox反选
inverseBtn.performClick();
assertTrue(!checkBox.isChecked());
inverseBtn.performClick();
assertTrue(checkBox.isChecked());
}
Dialog
@Test
public void testDialog(){
//点击按钮,出现对话框
dialogBtn.performClick();
AlertDialog latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
assertNotNull(latestAlertDialog);
}
Toast
@Test
public void testToast(){
//点击按钮,出现吐司
toastBtn.performClick();
assertEquals(ShadowToast.getTextOfLatestToast(),"we love UT");
}
Fragment的测试
如果使用support的Fragment,需添加以下依赖
testCompile "org.robolectric:shadows-support-v4:3.0"
shadow-support包提供了将Fragment主动添加到Activity中的方法:SupportFragmentTestUtil.startFragment(),简易的测试代码如下
@Test
public void testFragment(){
SampleFragment sampleFragment = new SampleFragment();
//此api可以主动添加Fragment到Activity中,因此会触发Fragment的onCreateView()
SupportFragmentTestUtil.startFragment(sampleFragment);
assertNotNull(sampleFragment.getView());
}
访问资源文件
public void testResources() {
Application application = RuntimeEnvironment.application;
String appName = application.getString(R.string.app_name);
String activityTitle = application.getString(R.string.title_activity_simple);
assertEquals("LoveUT", appName);
assertEquals("SimpleActivity",activityTitle);
}
BroadcastReceiver的测试
首先看下广播接收者的代码
public class MyReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
SharedPreferences.Editor editor = context.getSharedPreferences(
"account", Context.MODE_PRIVATE).edit();
String name = intent.getStringExtra("EXTRA_USERNAME");
editor.putString("USERNAME", name);
editor.apply();
}
}
广播的测试点可以包含两个方面,一是应用程序是否注册了该广播,二是广播接受者的处理逻辑是否正确,关于逻辑是否正确,可以直接人为的触发onReceive()方法,验证执行后所影响到的数据。
@Test
public void testBoradcast(){
ShadowApplication shadowApplication = ShadowApplication.getInstance();
String action = "com.geniusmart.loveut.login";
Intent intent = new Intent(action);
intent.putExtra("EXTRA_USERNAME", "geniusmart");
//测试是否注册广播接收者
assertTrue(shadowApplication.hasReceiverForIntent(intent));
//以下测试广播接受者的处理逻辑是否正确
MyReceiver myReceiver = new MyReceiver();
myReceiver.onReceive(RuntimeEnvironment.application,intent);
SharedPreferences preferences = shadowApplication.getSharedPreferences("account", Context.MODE_PRIVATE);
assertEquals( "geniusmart",preferences.getString("USERNAME", ""));
}
Service的测试
Service的测试类似于BroadcastReceiver,以IntentService为例,可以直接触发onHandleIntent()方法,用来验证Service启动后的逻辑是否正确。
public class SampleIntentService extends IntentService {
public SampleIntentService() {
super("SampleIntentService");
}
@Override
protected void onHandleIntent(Intent intent) {
SharedPreferences.Editor editor = getApplicationContext().getSharedPreferences(
"example", Context.MODE_PRIVATE).edit();
editor.putString("SAMPLE_DATA", "sample data");
editor.apply();
}
}
以上代码的单元测试用例:
@Test
public void addsDataToSharedPreference() {
Application application = RuntimeEnvironment.application;
RoboSharedPreferences preferences = (RoboSharedPreferences) application
.getSharedPreferences("example", Context.MODE_PRIVATE);
SampleIntentService registrationService = new SampleIntentService();
registrationService.onHandleIntent(new Intent());
assertEquals(preferences.getString("SAMPLE_DATA", ""), "sample data");
}
Shadow的使用
Shadow是Robolectric的立足之本,如其名,作为影子,一定是变幻莫测,时有时无,且依存于本尊。因此,框架针对Android SDK中的对象,提供了很多影子对象(如Activity和ShadowActivity、TextView和ShadowTextView等),这些影子对象,丰富了本尊的行为,能更方便的对Android相关的对象进行测试。
1.使用框架提供的Shadow对象
@Test
public void testDefaultShadow(){
MainActivity mainActivity = Robolectric.setupActivity(MainActivity.class);
//通过Shadows.shadowOf()可以获取很多Android对象的Shadow对象
ShadowActivity shadowActivity = Shadows.shadowOf(mainActivity);
ShadowApplication shadowApplication = Shadows.shadowOf(RuntimeEnvironment.application);
Bitmap bitmap = BitmapFactory.decodeFile("Path");
ShadowBitmap shadowBitmap = Shadows.shadowOf(bitmap);
//Shadow对象提供方便我们用于模拟业务场景进行测试的api
assertNull(shadowActivity.getNextStartedActivity());
assertNull(shadowApplication.getNextStartedActivity());
assertNotNull(shadowBitmap);
}
2.如何自定义Shadow对象
首先,创建原始对象Person
public class Person {
private String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
其次,创建Person的Shadow对象
@Implements(Person.class)
public class ShadowPerson {
@Implementation
public String getName() {
return "geniusmart";
}
}
Mock配置
如果要测试的目标对象依赖关系较多,需要解除依赖关系,以免测试用例过于复杂,用Robolectric的Shadow是个办法,但是推荐更加简单的Mock框架,比如Mockito,该框架可以模拟出对象来,而且本身提供了一些验证函数执行的功能。Mockito配置如下:
repositories { jcenter() }
dependencies { testCompile "org.mockito:mockito-core:2.+" }
Mockitio使用介绍
1.验证行为
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();
// selective, explicit, highly readable verification
verify(mockedList).add("one");
verify(mockedList).clear();
一旦创建 mock 将会记得所有的交互。你可以选择验证你感兴趣的任何交互
2.stubbing
// you can mock concrete classes, not only interfaces
LinkedList mockedList = mock(LinkedList.class);
// stubbing appears before the actual execution
when(mockedList.get(0)).thenReturn("first");
// the following prints "first"
System.out.println(mockedList.get(0));
// the following prints "null" because get(999) was not stubbed
System.out.println(mockedList.get(999));
- 默认情况下,所有方法都会返回值,一个 mock 将返回要么 null,一个原始/基本类型的包装值或适当的空集。例如,对于一个 int/Integer 就是 0,而对于 boolean/Boolean 就是 false。
- Stubbing 可以被覆盖。
- 一旦 stub,该方法将始终返回一个 stub 的值,无论它有多少次被调用。
- 最后的 stubbing 是很重要的 - 当你使用相同的参数 stub 多次同样的方法。换句话说:stubbing 的顺序是重要的,但它唯一有意义的却很少,例如当 stubbing 完全相同的方法调用,或者有时当参数匹配器的使用,等等。
3.参数匹配器
Mockito 验证参数值使用 Java 方式:通过使用 equals() 方法。有时,当需要额外的灵活性,可以使用参数匹配器:
//stubbing using built-in anyInt() argument matcher
when(mockedList.get(anyInt())).thenReturn("element");
//stubbing using custom matcher (let's say isValid() returns your own matcher implementation):
when(mockedList.contains(argThat(isValid()))).thenReturn("element");
//following prints "element"
System.out.println(mockedList.get(999));
//you can also verify using an argument matcher
verify(mockedList).get(anyInt());
参数匹配器允许灵活的验证或 stubbing。点击 这里 查看更多内置的匹配器和自定义的参数匹配器/ hamcrest匹配器的例子。
自定义参数的匹配信息,请查看 Javadoc 中 ArgumentMatcher 类。
如果你正在使用参数的匹配,所有的参数都由匹配器来提供。
下面的示例演示验证,但同样适用于 stubbing:
verify(mock).someMethod(anyInt(), anyString(), eq("third argument"));
//above is correct - eq() is also an argument matcher
verify(mock).someMethod(anyInt(), anyString(), "third argument");
//above is incorrect - exception will be thrown because third argument is given without an argument matcher.
4.调用额外的调用数字/at least x / never
//using mock
mockedList.add("once");
mockedList.add("twice");
mockedList.add("twice");
mockedList.add("three times");
mockedList.add("three times");
mockedList.add("three times");
//following two verifications work exactly the same - times(1) is used by default
verify(mockedList).add("once");
verify(mockedList, times(1)).add("once");
//exact number of invocations verification
verify(mockedList, times(2)).add("twice");
verify(mockedList, times(3)).add("three times");
//verification using never(). never() is an alias to times(0)
verify(mockedList, never()).add("never happened");
//verification using atLeast()/atMost()
verify(mockedList, atLeastOnce()).add("three times");
verify(mockedList, atLeast(2)).add("five times");
verify(mockedList, atMost(5)).add("three times");
5.Stubbing void 方法处理异常
doThrow(new RuntimeException()).when(mockedList).clear();
//following throws RuntimeException:
mockedList.clear();
6.有序的验证
// A. Single mock whose methods must be invoked in a particular order
List singleMock = mock(List.class);
//using a single mock
singleMock.add("was added first");
singleMock.add("was added second");
//create an inOrder verifier for a single mock
InOrder inOrder = inOrder(singleMock);
//following will make sure that add is first called with "was added first, then with "was added second"
inOrder.verify(singleMock).add("was added first");
inOrder.verify(singleMock).add("was added second");
// B. Multiple mocks that must be used in a particular order
List firstMock = mock(List.class);
List secondMock = mock(List.class);
//using mocks
firstMock.add("was called first");
secondMock.add("was called second");
//create inOrder object passing any mocks that need to be verified in order
InOrder inOrder = inOrder(firstMock, secondMock);
//following will make sure that firstMock was called before secondMock
inOrder.verify(firstMock).add("was called first");
inOrder.verify(secondMock).add("was called second");
// Oh, and A + B can be mixed together at will
有序验证是为了灵活 - 你不必一个接一个验证所有的交互。
此外,您还可以通过创建 InOrder 对象传递只与有序验证相关的 mock 。
在实际项目中使用Robolectric构建单元测试 单元测试的范围
在Android项目中,单元测试的对象是组件状态、控件行为、界面元素和自定义函数。本文并不推荐对每个函数进行一对一的测试,像onStart()、onDestroy()这些周期函数并不需要全部覆盖到。商业项目多采用Scrum模式,要求快速迭代,有时候未必有较多的时间写单元测试,不再要求逐个函数写单元测试。
本文单元测试的case多来源于一个简短的业务逻辑,单元测试case需要对这段业务逻辑进行验证。在验证的过程中,开发人员可以深度了解业务流程,同时新人来了看一下项目单元测试就知道哪个逻辑跑了多少函数,需要注意哪些边界——是的,单元测试需要像文档一样具备业务指导能力。
在大型项目中,遇到需要改动基类中代码的需求时,往往不能准确快速地知道改动后的影响范围,紧急时多采用创建子类覆盖父类函数的办法,但这不是长久之计,在足够覆盖率的单元测试支持下,跑一下单元测试就知道某个函数改动后的影响,可以放心地修改基类。
单元测试的流程
实际项目中,单元测试对象与页面是一对一的,并不建议跨页面,这样的单元测试藕合度太大,维护困难。单元测试需要找到页面的入口,分析项目页面中的元素、业务逻辑,这里的逻辑不仅仅包括界面元素的展示以及控件组件的行为,还包括代码的处理逻辑。然后可以创建单元测试case列表(列表用于纪录项目中单元测试的范围,便于单元测试的管理以及新人了解业务流程),列表中记录单元测试对象的页面,对象中的case逻辑以及名称等。工程师可以根据这个列表开始写单元测试代码。
单元测试是工程师代码级别的质量保证工程,上述流程并不能完全覆盖重要的业务逻辑以及边界条件,因此,需要写完后,看覆盖率,找出单元测试中没有覆盖到的函数分支条件等,然后继续补充单元测试case列表,并在单元测试工程代码中补上case。
直到规划的页面中所有逻辑的重要分支、边界条件都被覆盖,该项目的单元测试结束。单元测试流程如图5所示。
上述分析页面入口所得到结果便是@Before标记的函数中的代码,之后的循环便是所有的case(@Test标记的函数)。
单元测试项目实践
为了系统的介绍单元测试的实施过程,本文创建了一个小型的demo项目作为测试对象。demo的功能是供用户发布所见的新闻到服务端,并浏览所有已经发表的新闻,是个典型的自媒体应用。该demo的开发和测试涉及到TextView、EditView、ListView、Button以及自定义View,包含了网络请求、多线程、异步任务以及界面跳转等。能够为多数商业项目提供参照样例。项目页面如图6所示。
首先需要分析App的每个页面,针对页面提取出简短的业务逻辑,提取出的业务逻辑如图6绿色圈图所示。根据这些逻辑来设计单元测试的case(带有@Test注解的那个函数),这里的业务逻辑不仅指需求中的业务,还包括其他需要维护的代码逻辑。业务流程不允许跨页面,以免增加单元测试case的维护成本。针对demo中界面的单元测试case设计如下:
目标页面 | 业务覆盖 | 界面元素 | 逻辑描述 | 最小断言数 | case名称 |
创建新闻页面 | 编写新闻 | 1.标题框 | 1.向标题框输入内容 | 3 | testWriteNews() |
NewsCreatedActivity | 2.内容框 | 2.向内容框输入内容 | |||
3.发布按钮 | 3.当标题和内容都存在的时候,上传按钮可点击 | ||||
输入新闻的金额 | 1.Checkbox | 1.选中免费发布时,金额输入框消失 | 3 | testValue() | |
2.金额控件 | 2.不选免费时可以输入金额 | ||||
3.金额输入框只接受小数点后最多两位 | |||||
菜单跳转至新闻列表 | 1.菜单按钮 | 1.点击菜单跳转到新闻列表页面 | 1 | testMenuForTrunNewsList() | |
发布新闻 | 1.发布按钮 | 1.当标题或者内容为空时,发布按钮不可点击 | 5 | testNewsPush()、 | |
2.Toast | 2.编写了新闻的前提下,点击发布按钮 | testPushNewsFailed() | |||
3.新闻发布成功,弹出Toast提示 “新闻已提交” | |||||
4.没有标题或者内容时,新闻发布失败,弹出Toast提示“新闻提交失败” | |||||
新闻列表页面 | 浏览新闻列表 | 1.列表 | 1.进入此页面后会出现新闻列表 | 6 | testNewsListNoNetwork()、 |
NewsListActivity | 2.有网络情况下,能发起网络请求 | testGetnewsWhenNetwork()、 | |||
3.网络请求需要用Mock解除偶和,单独验证页面对数据的响应,后端返回一项时,列表只有一条数据 | testSetNews() | ||||
菜单跳转至创建新闻页面 | 1.菜单按钮 | 1.点击菜单跳转到创建新闻页面 | 1 | testMenuForTrunCreatNews() | |
查看详细新闻 | 1.有内容的列表 | 1.有新闻的前提下,列表可点击,点击弹出Dialog | 1 | testNewsDialog() | |
2.Dialog | |||||
接下来需要在单元测试工程中实现上述case,最小断言数是业务逻辑上的判断,并不是代码的边界条件,真实的case需要考虑代码的边界条件,比如数组为空等条件,因此,最终的断言数量会大于等于最小断言数。在需求业务上,最小断言数也是该需求的业务条件。
写完case后需要跑一遍单元测试并检查覆盖率报告,当覆盖率报告中缺少有些单元测试case列表中没有但是实际逻辑中会有的逻辑时,需要更新单元测试case列表,添加遗漏的逻辑,并将对应的代码补上。直到所有需要维护的逻辑都被覆盖,该项目中的单元测试才算完成。单元测试并不是QA的黑盒测试,需要保证对代码逻辑的覆盖。
对表1分析,第一个页面的“发布新闻”的case可以直接调用“编写新闻”的case,以满足条件“2.编写了新闻的前提下,点击发布按钮”,在JUnit框架下,case(带@Test注解的那个函数)也是个函数,直接调用这个函数就不是case,和case是无关的,两者并不会相互影响,可以直接调用以减少重复代码。第二个页面不同于第一个,一进入就需要网络请求,后续业务都需要依赖这个网络请求,单元测试不应该对某一个条件过度耦合,因此,需要用mock解除耦合,直接mock出网络请求得到的数据,单独验证页面对数据的响应。
总结
单元测试并不是一个能直接产生回报的工程,它的运行以及覆盖率也不能直接提升代码质量,但其带来的代码控制力能够大幅度降低大规模协同开发的风险。现在的商业App开发都是大型团队协作开发,不断会有新人加入,无论新人是刚入行的应届生还是工作多年,在代码存在一定业务耦合度的时候,修改代码就有一定风险,可能会影响之前比较隐蔽的业务逻辑,或者是丢失曾经的补丁,如果有高覆盖率的单元测试工程,就能很快定位到新增代码对现有项目的影响,与QA验收不同,这种影响是代码级的。
在本文所设计的单元测试流程中,单元测试的case和具体页面的具体业务流程以及该业务的代码逻辑紧密联系,单元测试如同技术文档一般,能够体现出一个业务逻辑运行了多少函数,需要注意什么样的条件。这是一种新人了解业务流程、对业务进行代码级别融入的好办法,看一下以前的单元测试case,就能知道与该case对应的那个页面上的那个业务逻辑会执行多少函数,以及这些函数可能出现的结果。