Android - 不完全测试驱动开发实践 - 初级篇

前言

测试驱动开发(TDD)是我一直想要尝试和使用开发方法,但是直至今天才有机会第一次将其应用到正式开发阶段。

从开始的模糊,到慢慢了解如何使用,再到借助它将逻辑捋的越来越清楚,再到之后每次跑完所有测试带给我的信心,我知道这就是我想要的,开发过程再也不是碰运气,我拥有了使用代码测试代码的能力。

因为是不完全从测试驱动开发,本片文章有所不准确的地方也请大家指正。

感谢我的团队~

导读

在所有开始之前,需要给大家介绍一些简要的关于TDD的知识,大家可以从如下地址了解到什么是TDD以及为什么需要TDD:

  1. 维基百科 - 测试驱动开发
  2. 维基百科 - Test-driven development
  3. 读《推行TDD的思考》有感
  4. 你今天写了自动化测试吗

本片文章以一个假设的需求为切入点 - 数据仓库设计(DataRepository),从如下角度来践行TDD:

  1. 介绍JUnit、Mock与PowerMock
  2. 配置环境
  3. 数据仓库设计思路
  4. 数据仓库测试开发思路
  5. 带来的好处与缺陷

该篇文章仅设计逻辑测试部分,并不涉及UI测试,请提前知晓。

介绍JUnit、Mock与PowerMock

在Java的世界中,TDD的基础是单元测试,而Junit就是一个非常强大的单元测试库。

当我们初建一个Android项目时,Android Studio就已经帮我们准备了一些专门用于单元测试的目录,一个空项目如下所示:


UnitTestDemo
    - app
        - src
            - androidTest
            - main
            - test

其中,testmain两个目录是我们这次主要关心的:

  • androidTest:目录专门用于测试UI逻辑
  • main目录专门用于编写项目源码
  • test目录用门用于测试业务逻辑代码

在此处Junit就不详细介绍了,更具体的可以参看这里:

https://zh.wikipedia.org/wiki/JUnit
https://junit.org/junit5/

在这个部分,把关注点放在MockPowerMock上,之所以这么说是因为Junit为我们提供了测试代码的可能性,但是当项目依赖于其他模块时,我们可以借助Mock来模拟依赖的类,来控制我们的测试流程。

当我们处于Android环境时更是如此,当我们需要依赖HandlerBroadcast或者其他与环境相关的代码时,如果不去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场景中,我们可以做到下面做种样子,验证Handlerpost是否被执行;模拟Handlerpost方法执行,验证Runnablerun方法是否被调用等。

@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更多使用方法请查看这里与这里。

配置环境

MockitoPowerMockito的环境配置也很简单,在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都需要注意哪些? (可参阅这里)

Android - 不完全测试驱动开发实践 - 初级篇_第1张图片


首要需求分析,将需求分解为任务列表,再从列表中挑选一个任务,转换成一组测试用例,然后不断循环去实现。

绿
快速的让测试用例变绿。

重构
识别坏味道,进行重构。

接下来着手学习TDD吧。

数据仓库(DataRepository)设计

核心的需求点如下:

  1. 能够多级缓存复用数据(内存、文件、网络)
  2. 能够同步、异步获取数据

Android - 不完全测试驱动开发实践 - 初级篇_第2张图片

对外API设计

  1. 同步全量获取视频信息 List getAllVideoSync();
  2. 异步全量获取视频信息 List getAllVideoAsync();

逻辑层中 :

  1. 获取数据时,需要按照先内存、再文件缓存、最后网络数据的顺序来获取数据。

在本篇文章中,我们从任务列表中选取同步全量获取视频信息这个任务,并转化成为一组测试用例:

  1. 获取SDK实例
  2. 调用getAllVideoSync()方法
  3. 从内存中获取数据
  4. 先从内存拿数据,为空再从文件缓存中获取数据
  5. 先从内存拿数据,为空再从文件缓存中拿数据,为空再从网络中获取数据
  6. 校验网络数据是否被存储到文件缓存中
  7. 校验数据是否正确

做完用例分解后,就让我们一步一步按照“红 - 绿 - 蓝”的节奏来编写用例与逻辑吧。

数据仓库(DataRepository) - 用例编写与实现

在Demo中,每完成一个Case的编写与实现都会Commit一次,我会尽量做到完善。

让我们踏上征途吧。

CI - 获取SDK实例

先给我们的SDK起个好听的名字DataRepository,再把它做成一个单例。

再考虑下如何验证有效性!只需要断言判定getInstance()获取的数据不为空就好了。

Commit记录

CI - 调用getAllVideoSync()方法

DataRepository中创建了getAllVideoSync()方法并创建了VideoInfo类。

由于本次的目的只是验证getAllVideoSync()方法是否能够正确调用,所以我们不关心返回结果。

Commit记录

CI - 从内存中获取数据

本次想要从内存中获取缓存数据,所以关心返回结果。

可以直接在TestCase中直接给DataRepositorymAllVideoInfo赋值,来达到模拟内存缓存值存在的情况。

Commit记录

CI - 先从内存拿数据,为空再从文件缓存中获取数据

从这一个Case开始就变得复杂一些了。虽然Commit记录中详细的写明了我都做了什么,但是还是简要介绍一下做这些操作的思路。

步骤一:重构了获取DataRepository的方法,原因是每次都写一下获取逻辑很麻烦,还不如封装一下。

步骤二: 由于验证的行为是内存数据为空,但是缓存值存在,那么就需要修改验证结果。

// 验证文件缓存值存在,获取结果不为null
List userInfoList = instance.getAllVideoSync();
assertThat(userInfoList, is(nullValue()));

步骤三:运行单测之后自然是红色错误,我们转而进入DataRepositorygetAllVideoSync内部去实现获取缓存的逻辑。 自然而然,我们期望有一个帮助类能够直接拿到缓存的结果,从那个文件拿我们并不关心,于是我们有了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记录 - 实现文件缓存逻辑

CI - 先从内存拿数据,为空再从文件缓存中拿数据,为空再从网络中获取数据

这里的步骤和上面一个非常类似,所以就不重复表述了。

从这两个Case中可以知道,完全可以在仅关心DataRepository的情况下,把逻辑补充完整,暂时不关心的FileCacheHelperNetHelper可以暂放一边,通过模拟它们来跑通逻辑。

相信不用我说,你也一定知道这多么有用处。

Commit记录

CI - 校验网络数据是否被存储到文件缓存中

为了确保模拟的FileCacheHelper类中的方法被调用,可以使用如下的方法:

// 验证行为,保存到缓存中是否被执行了
 verify(mFileCacheHelper).saveDataToCache(list);

Commit记录

好处与缺陷

如果仔细看完上面数据仓库的例子,相信你对测试驱动开发一定有一些认识了,下面就谈谈它的好处与难点。

好处:

  1. 给予开发者信心,让你知道自己写的代码是可靠的
  2. 提升对项目需求分析、分解任务、安排优先级的能力
  3. 提高重构的能力
  4. 将焦点聚集在当前关注的地方

难点:
1. 思维方式的转变 - “很多人不懂“意图式编程”,总是习惯先实现一个东西,再去调用它。而测试先行就要求先使用,再实现。这样能少走很多弯路,减少返工。”
2. 测试框架的选型与使用,虽然MockitoPowerMockito已经很简单了,但是还是有一些学习成本的。

最后引用一段话,也是我想说的:

最后我想说:
TDD不是银弹,不可能适合所有的场景,但这不应该成为我们拒绝它的理由。
也不要轻易否定TDD,如果要否定,起码要在认真实践过之后。

最后,祝好~

你可能感兴趣的:(Android)