搜索
您的当前位置:首页正文

写单元测试是个技术活

来源:二三娱乐

关于单元测试一直是一个想写的话题,在真实的开发中,测试往往是容易被忽视或者说是被低估的环节。以前在友盟的时候,大约12年左右公司开始提倡写单元测试,也是从那个时候开始接触到了单元测试,可惜的是那会对测试了解的太少,公司也没有相关的培训,自己也是片面的理解单元测试,所拥有的相关知识也是从网上看到的只言片语,这些错误的认识导致的直接结果是 - 单元测试写不下去!

当时有几个比较严重的误解:

  1. 测试是对产品代码的补充
  2. 单元测试即功能测试
  3. Android无法做单元测试

在从业的这几年接触到的大部分研发同学所写的测试代码中也能看到这种误解。在交流的过程中,还有很多人是不写单元测试的,而借口都是一致的统一 - 太忙了!业务代码都还没有写完,哪有时间写单元测试呢?这种对测试的赤裸裸的歧视很大一部分原因是由于对测试的认识不正确 - 测试是对产品代码的补充,写测试是业务代码写完后空余时间干的事情。另外一种认识就是TDD了,它认为测试时加快开发进度的,因为不断的跑TestCase可以让已经写下的产品逻辑更有保证,方便快速的Debug。这种争论在很多年前已经有了结论,这便是TDD - TestDriveDevelopment(测试驱动开发)。从TDD的角度来看,测试和产品代码是平等重要的,通常情况下测试代码和产品代码大约是1:1的关系。

以上有用TDD来证明TDD的嫌疑

第二个误解是把单元测试误做功能测试。什么是单元测试?单元测试英文是 "unit-test",对某个单元或功能模块的测试,具体的来说,单元测试是对某个类或者方法的测试。一个简单的例子比如:测试 boolean StringUtil.isEmpty(String str) 是否能能正常工作(判断输入字符串是否为空),那么我可以输入一些参数并通过返回值来得到预期的结果:

assertTrue(StringUtil.isEmpty(null))
assertTrue(StringUtil.isEmpty(""))
assertFalse(StringUtil.isEmpty("any string"))
...

这种简单的例子经常被大家拿来作为书写单元测试的示例,这个示例简单却不够好,因为一旦略微复杂的情况,大家就把单元测试做成功能测试了。什么是功能测试呢?功能测试是从用户角度来观察的测试,测试系统中发生的一个行为,这个行为会导致所有的真实依赖都会被执行,而单元测试却要求Mock掉所有的依赖,让依赖变得可预期。比如下面的例子:

// 服务路由表,根据一个请求方法返回一个服务
public class ServiceTableImpl implements ServiceTable {
    // 根据服务Id查询一个服务
    private ClientFactory referenceClient;
    // 访问数据库的DAO类,数据库保存了服务ID和请求方法的映射信息
    private AppInfoDAO appInfoDAO;
   
    @Override
    public Service findService(String method) throws Exception {
        if (method == null) {
            logger.info("request method is null");
            return null;
        }
        // 查询数据库得到ServiceID
        AppInfo appInfo = appInfoDAO.getAppInfoByMethod(method);
        if (appInfo == null || appInfo.getServiceId() == null) {
            raiseException("service-id not found");
        }       
        String serviceId = appInfo.getServiceId();

        // 根据ServiceID查找服务
        service = referenceClient.getService(serviceId);
        if (service == null) {
            raiseException("can't find service");
        }

        return service;
    }
}

注1:ServiceTablel 是一个请求方法路由服务,会按照请求的方法(method)返回对应的服务(Service)。
注2:通常单元测试至少要提供两个TestCase来分别测试正确和错误的情况

如果依然按照测试 boolean StringUtil.isEmpty(String str) 的方式来写测试代码会是这样的:

ServiceTable servcieTable = new ServiceTableImpl();
Service service = servcieTable.findService("a exist method");
// expect not null
assertNotNull(service);
// expect exception
Service service = servcieTable.findService("not exist method");
fail();

幸好 findService 方法还有一个返回值,可以让我们可以像 StringUtil.isEmpty 一样验证返回结果。为了测试正确的结果,我们还需要在数据库中提前插入一对method-service信息,并保证 ClientFactory 可以找到这个服务。同时还需要在 ClientFactoryAppInfoDAO 的实现代码里面打一些log,这样可以观察到查询服务和读数据库时候的一些信息,以保证这个测试的正常运行。测试错误情况比较简单,只需要随便给 findService 方法输入一个字符串参数,观察返回结果或者异常情况即可。这种测试就是典型的功能测试,事实上,整个过程是在模拟一个正常的业务流程,但是这种测试时非常脆弱和不稳定的,ClientFactoryAppInfoDAO 内部发生的变化是不可预期的,再加上ServiceTableImpl 自身的逻辑,会导致整个行为可能性变化太大,输出结果不可预期。而且测试的范围也从原来的 ServiceTableImpl 扩展到 ServiceTableImpl + ClientFactory + AppInfoDAO

单元测试的目的在于测试一个局部的模块,在这里指的是 ServiceTableImpl,那么我们需要让 ClientFactoryAppInfoDAO的行为变得可预期,这样就可以专心的测试ServiceTableImpl的内部逻辑,这才是单元测试!如何让 ClientFactoryAppInfoDAO 的行为变得可预期的呢,比如我需要让appInfoDAO.getAppInfoByMethod(method) 方法一定可以返回一个 serviceid, 让 referenceClient.getService(serviceId) 一定可以查找到一个服务呢?

这就用到了Mock技术 - 创建一个虚假的 AppInfoDAO 实现,让它返回需要的结果。对于上面的例子可以继承 AppInfoDAO 并覆盖它的 getAppInfoByMethod 方法:

// 继承AppInfoDAO并覆盖getAppInfoByMethod方法,返回需要的结果
public class MockAppInfoDAO extends AppInfoDAO {
    AppInfo getAppInfoByMethod(String method) {
        AppInfo appInfo = new AppInfo();
        appInfo.setServiceId("a service id");
        return appInfo;
    }
}
//把 MockAppInfoDAO 注入到ServiceTableImpl
ServiceTableImpl.setAppInfoDAO(new MockAppInfoDAO());

同样的道理可以Mock掉ClientFactory,如此便可以把 ServiceTableImpl 的依赖变得可控,这样就可以单独的测试自己的内部逻辑了。

对于第三点,我以前是非常坚持的 - Android很难做单元测试。Android确实很难做单元测试,尤其是Android是有界面的,有界面的系统往往都不容做单元测试。但是这并不对,测试不好做,往往是代码写的不够好并且没有用到合适的测试工具。先看一个简单的例子:

public class ShortLifeMemoCache<T> implements CacheService<T> {
    private static final long DEFAULT_TTL = 1L*60*60*1000;
    
    /**
     * 生存时间(Time to live),单位:毫秒
     */
    private long ttl = DEFAULT_TTL;

    private final Map<String, Value<T>> cache =  new HashMap<String, Value<T>>();

    @Override
    public void put(String key, T t) {
        long life = System.currentTimeMillis() + ttl;
        cache.put(key, new Value<T>(t, life));
    }

    @Override
    public T get(String key) {
        Value<T> v = cache.get(key);
        if (v != null){
            if(v.getLife() > System.currentTimeMillis()) {
                return v.getValue();
            } else {
                cache.remove(key);
            }
        }

        return null;
    }

    @Override
    public T remove(String key) {
        return cache.remove(key).getValue();
    }

    @Override
    public int size() {
        return cache.size();
    }
    
    // 省略getter和setter方法
    ...

    // 封装一个Value和一个时间戳,如果超时则丢弃
    static class Value<T> {
        // 生存时间
        private long life;

        // 真实的Value
        private T t;

        public Value() {}

        public Value(T t, long life) {
            setValue(t, life);
        }
        // 省略getter和setter方法
        ...
    }
}

注1:上面这个例子实现了一个内存缓存,被缓存的K-V对在内存中最多存活一个小时,超出一个小时后会认为缓存过期并从内存中移除。
注2:可能有同学会修改ttl值,把ttl设置成一个比较小的值,那么就可以等待了,但是这样是非常脆弱的,处理临界值的时候容易出错

稍微看了一下这段代码实现就会发现这个系统是难以测试的,为什呢?因为内存中的K-V对会缓存一个小时,如果要测试缓存时间1个小时的逻辑对不对,那么就要等上1个小时才可以,这显然是不可能的。System.currentTimeMillis() 是JDK中一个著名的方法,用来获取当前时间的毫秒值,这里我用来获取当前时间以便判断是否超出1个小时。问题就出在这里,如果能够让事件变得可控制,那么不就容易测试了吗?于是有了新的设计:

// 设计时间接口
public interface SystemClock {
    public long getCurrentTime();
}
// 业务代码中的实现
public class SystemClockImpl implements SystemClock {

    @Override
    public long getCurrentTime() {
        return System.currentTimeMillis();
    }
}
// 改进后的 ShortLifeMemoCache<T>
public class ShortLifeMemoCache<T> implements CacheService<T> {
    private static final long DEFAULT_TTL = 1L*60*60*1000;
    /**
     * 系统时间,单位:毫秒
     */
    private SystemClock systemClock;

    /**
     * 生存时间(Time to live),单位:毫秒
     */
    private long ttl = DEFAULT_TTL;

    private final Map<String, Value<T>> cache =  new HashMap<String, Value<T>>();

    @Override
    public void put(String key, T t) {
        long life = systemClock.getCurrentTime() + ttl;
        cache.put(key, new Value<T>(t, life));
    }

    @Override
    public T get(String key) {
        Value<T> v = cache.get(key);
        if (v != null){
            if(v.getLife() > systemClock.getCurrentTime()) {
                return v.getValue();
            } else {
                cache.remove(key);
            }
        }
        return null;
    }
}

有了这样的设计就可以随意的控制时间了,比如我们的单元测试代码可以写成这样(并且瞬间执行完测试用例):

// 测试用例:设置ttl=10,时间由FakeSystemClock控制
public void testValueTimeout() {
    long timeToLive = 10;

    FakeSystemClock systemClock = new FakeSystemClock();
    systemClock.setCurTime(0);

    ShortLifeMemoCache<String> cacheService = new ShortLifeMemoCache<String>();
    cacheService.setSystemClock(systemClock);
    cacheService.setTtl(timeToLive);

    cacheService.put("key1", "value1");

    // after 10 seconds
    systemClock.setCurTime(10);
    assertNull(cacheService.get("key1"));
}
// Mock 时间接口
static class FakeSystemClock implements SystemClock {
    private long curTime;

    @Override
    public long getCurrentTime() {
        return curTime;
    }
    public long getCurTime() {
        return curTime;
    }
    public void setCurTime(long curTime) {
        this.curTime = curTime;
    }
}

对于Android的UI测试还需要多说几句,因为大部分时候我们还会把UI布局/效果和UI/业务逻辑混淆了,这样会导致测试很难进行。通常情况下需要把逻辑(无论是UI逻辑还是业务逻辑)抽象到Model里面(Model是个动词,意指ModelingTheWorld),Model不依赖于系统框架容易测试。

PS:依赖注入
在上面的方案中我们默认省略了 gettersetter 方法的代码,但是这是必须要有的。我们通常会听到一个名词叫“依赖注入”,那么什么是依赖注入呢?看下面这段代码:

public class Foo {
    private String message = "Hello World";
    private String View view = new View();

    public void print() {
        System.out.println(message);
    }
    public void showMessage() {
        view.display(message);
    }
}

这段代码中 messageview 变量在Foo初始化的时候就确定了,无法再改变 - 这就是典型的 依赖无法注入 的例子。解决办法有很多种,比如:

// 提供一个可以传参的构造方法
public Foo(String message, View view) {
        this.message = message;
        this.view = view;
}
// 或者提供 setter 方法
public void setMessage(String message) {
    this.message = message;
}
public void setView(View view) {
    this.view = view;
}

这是关于测试的第一篇文章 - FirstBlood

Top