继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

没时间了,我还要写单测呢!

Helenr
关注TA
已关注
手记 246
粉丝 14
获赞 46

1.前言

最近在做一个项目的开发双周一迭代发版这些都还算正常但这里有个比较特别的项目质量要求后台代码的单元测试覆盖率要达到80%并且这个指标作为上线的硬性要求。当时觉得这个要求挺逗的互联网开发还需要写单测私底下请教了一起合作的xx钉事业部小伙伴才知道他们这里推行这样的研发规范有一段时间了而且确实提升了研发质量保障后台的代码与服务质量减少因质量问题导致交付上线的延误其实我是极其认同的。恰好借助这次机会整体梳理下单元测试相关的知识内容。

2.单元测试的相关知识

2.1 单元测试

单元测试是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义 在Java里单元指一个实现类图形化的软件中可以指一个窗口或一个菜单等。

单元测试是在软件开发过程中要进行的最低级别的测试活动软件的独立单元将在与程序的其他部分相隔离的情况下进行测试经常与代码review静动态分析等联系起来。

  • 静态分析: 通过研读项目代码去查找错误或收集一些度量数据通常会使用CheckStyle/FindBugs等工具。
  • 动态分析: 通过观察软件运行时的动作来提供执行跟踪时间分析以及覆盖率等方面的信息。

当今都是以快制胜的年代更快地完成项目就意味着能抢占市场先机所以很多小伙伴都会感到疑惑功能都写不完还写啥单元测试其实编写单元测试还是利大于弊的:

  • 提高开发速度以自动化方式执行测试提升了测试代码的执行效率;
  • 提高软件代码质量它使用小版本发布至集成便于实现人员除错。同时引入重构概念让代码更干净和富有弹性;
  • 提升系统的可信赖度可在一定程度上确保代码的正确性。

目前单元测试可以分为类测试功能测试接口测试其中带来的收益大小关系(提升项目的代码质量)是类测试<功能测试<接口测试所以大家都把大部分重心放到了接口测试上了还有没有必要写类测试与功能测试呢

从项目开发迭代周期来讲Bug越是在后期修复成本会越高所以单元测试会将测试过程左移了让开发人员更早地进行设计与实现的质量把控减少修复成本。

从软件系统来看业务复杂度会越来越高各种关联与依赖层层叠加增加了质量不可控因素所以让系统组成的部分与关联部分都处于稳定状态可大大减少风险因子的叠加从而增强整个系统的稳定与质量。当你明白了这些之后写单元测试就会变成开发的一种习惯如同写功能代码一样自然。

2.2 代码覆盖率

**代码覆盖率是一种通过计算测试过程中被执行的源代码占全部源代码的比例进而间接度量软件质量的方法。**它在保证测试质量的时候潜在保证实际产品的质量可以基于此在程序中寻找没有被测试用例测试过的地方进一步创建新的测试用例来增加覆盖率。按性质它属于白盒测试的范畴即主要依据源代码的内部结构来设计测试用例通过设计不同的输入来测试软件的不同部分。常见的编程语言都有相应的代码覆盖率测试工具。基于代码覆盖率的分析可以有以下作用:

  • 分析未覆盖部分的代码反推在前期测试设计是否充分需求/设计是否清晰测试设计的理解是否有误等等之后进行补充测试用例设计。
  • 检测出程序中的废代码可以逆向反推在代码设计中思维混乱点提醒设计/开发人员理清代码逻辑关系提升代码质量。

在不少开发工具软件如的IDEA都内置了统计覆盖率的工具。

3.单元测试框架

3.1 Java单元测试框架

大部分的单元测试框架都包括了以下几大组件:

  • TestRuner: 负责驱动单元测试用例的执行汇报测试执行的结果;
  • TestFixture: 以测试套件的形式提供setUp()和tearDown()方法保证两个测试用例之间的执行是相互独立互不影响的;
  • TestResult: 这个组件用于收集每个TestCase的执行结果;
  • Test: 作为TestSuiteTestCase的父类暴露run()方法为TestRunner调用;
  • TestCase: 暴露给用户的类用户通过继承TestCase编写自己的测试用例逻辑;
  • TestSuite: 提供suite功能管理testCase.

Java单元测试框架在业界非常多大部分是基于Junit框架的设计思路去实现的其中主要常见单元测试框架以Junit4/Junit5/TestNG为多目前这些框架都支持注释与参数化测试在运行期指定不同的测试数值来运行单元测试同时与Maven/Gradle等构建工具搭配。

3.2 一个简单的Junit例子

public class JunitDemoTest {

    @Rule
    public Timeout timeout = new Timeout(1000);

    @Before
    public void init() {
        //...
    }

    @After
    public void destroy() {
        //....
    }

    @Test
    public void testAssertArrayEquals() throws InterruptedException {

        byte[] str1 = "test1".getBytes();
        byte[] str2 = "test2".getBytes();
        byte[] str3 = "test".getBytes();

        assertTrue("true", String.valueOf(str1).equals(String.valueOf(str3)));
        assertFalse("false", String.valueOf(str1).equals(String.valueOf(str2)));
        assertArrayEquals("false - byte arrays not same", str1, str2);

        Thread.sleep(50000);
    }
}
复制代码

3.2 Junit主要实现原理

Junit框架启动时会调用到JunitCore#run(Runner runner)的方法。

其中Runner是一个抽象类针对于不同的平台或版本都做了不同的实现其中有Junit4ClassRunner类继承了ParentRunnerBlockJUnit4ClassRunner类/SpringRunner类/Suit类 其中会调用各自实现的run方法。

在组装Statement中会去初始化运行前后与匹配规则。

在调用childrenInvoker后会使用任务线程池开启单测任务。在getFilteredChildren方法中会拿到单元测试类中所有需要测试的方法循环调用runChild方法将测试方法并放入到任务线程池中执行。

runChild中会再去methodBlock为每个测试方法生成Statement对象

methodBlock中会先去生成测试类的对象然后生成前置/后置/规则判断/真正目标方法执行等组成方法执行链条。

RunBefores/RunAfters等类都继承了Statement类使用链条的方式组成执行链条

使用Java的反射机制调用测试方法。

runLeaf中会去调用生成好的statement对象的evaluate这是个方法执行链条的开始。并将调用结果通过EachTestNotifier对象发送出去这里主要是用监听者模式来实现比如运行结果失败或异常信息都会发送出去。

其他几个框架也都是基于Junit这个基本流程去扩展的 给日常开发中编写单元测试提供了很大的便利但在大型项目开发中也会遇到一些如协同开发的问题如有些依赖的接口或者底层模块并没有完成开发这样写出来的单元测试是无效的还需要“伪造”一些数据/结果来补全这些基础依赖为了解决这些问题Mock技术应运而生。

4.Mock框架

4.1 Mock介绍

Mock通常是指在测试一个对象S时我们构造一些假的对象来模拟与S之间的交互而这些Mock对象的行为是我们事先设定且符合预期。通过这些Mock对象来测试S在正常逻辑异常逻辑或压力情况下工作是否正常。引入Mock最大的优势在于Mock的行为固定它确保当你访问该Mock的某个方法时总是能够获得一个没有任何逻辑的直接就返回的预期结果。 通常会带来以下一些好处

  • 隔绝其他模块出错引起本模块的测试错误。
  • 隔绝其他模块的开发状态只要定义好接口不用管他们开发有没有完成。
  • 一些速度较慢的操作可以用Mock Object代替快速返回。
    对于分布式系统的测试使用Mock Object会有另外两项很重要的收益
  • 通过Mock Object可以将一些分布式测试转化为本地的测试
  • Mock用于压力测试可以解决测试集群无法模拟线上集群大规模下的压力目前常用的

Mock框架有EasyMock/JMock/Mokito/PowerMokito当前项目使用了PowerMokito。

4.2 一个简单的Mock例子

一次完整的Mock包括了设定目标->设置消费条件->预期返回结果->消费并检验返回结果四大步骤我们用Mockito工具来实现一个最简单的的例子 Mockito主要就是通过Stub打桩通过方法名加参数来准确的定位测试桩然后返回预期的值。

@Data
public void TestDao {
    public User getUser(String uid) { return new User("testUser"); }

@Data
public void TestService { 
   private TestDao testDao;
    
    public User getUser(String uid) { return testDao.getUser(uid); }}


@RunWith(PowerMokitoRunner.class)
public void MockDemoTest {
   
  @InjectMock
  private TestService testService

  @Mock
  private TestDao testDao;
  
  @Before
  public void init() {
      Mockito.when(testDao.getUser(any())).thenReturn(new User("test2"));      
  }

  @Test
  public void test1() {
      //调用方法时会调用到代理对象返回刚才的Mock数据
      User userInfo  = testService.getUser("uid");
  }

}
复制代码

主要对应下面的四大步骤

  • 设定目标 > User user

  • 设置消费条件 -> testDao.getUser(any())

  • 预期返回结果 -> thenReturn(…)

  • 消费并检验返回结果 -> testDao.getUser(“uid”)

4.3 PowerMockito主要实现原理

PowerMockito框架核心原理是基于代理模式使用CGLIGB字节码工具去实现生成目标对象或方法的代理当使用when调用初始化时会去匹配然后根据return去返回。

PowerMockitoRunner

继承Junit#Runner类启动的时候会去调用runwith方法Junit框架就会去加载PowerMokito#PowerMockJUnitRunnerDelegateImpl类。

Whitebox#findSingleFieldUsingStrategy会去把带有@Mock/@InjectMocks注解的参数找出来并进行初始化。

最后在Whitebox#newInstance会生成Mock对象。

一般都会在单元测试初始化的时候进行“打桩”比如when...thenReturn....生成Stubbing

在返回时会生成后就会更新打桩进度并生成好相应的代理。

performStubbing会去调用createMock生成代理对象。

在做代理时会调用MockMaker类生成Mock对象。

PowerMockMaker/CglibMockMaker都实现了MockMaker接口 这里PowerMockito支持多种字节码增强改写方式。目前使用的CGLIB方式。

ClassImposterizer#createProxyClass使用CGLIB字节码工具的Enhance类对单元测试目标类进行代理并插入方法拦截类对象。

定义的方法拦截类。

这样以后就新建了一个代理类对象当匹配到某个方法执行时就会将预期的结果返回因为整个PowerMockito框架实现还是较多的这里还搜到了一个比较简单的例子方便理解:

public class PowerMockito {

    private static Map<Invocation, Object> results = 
        new HashMap<Invocation, Object>();
    private static Invocation lastInvocation;

    public static <T> T mock(Class<T> clazz) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(clazz);
        enhancer.setCallback(new MockInterceptor());
        return (T)enhancer.create();
    }

    private static class MockInterceptor implements MethodInterceptor {
        public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
            Invocation invocation = new Invocation(proxy, method, args, proxy);
            lastInvocation = invocation;
            if (results.containsKey(invocation)) {
                return results.get(invocation);
            }
            return null;
        }
    }

    public static <T> When<T> when(T o) {
        return new When<T>();
    }

    public static class When<T> {
        public void thenReturn(T retObj) {
            results.put(lastInvocation, retObj);
        }
    }
}
复制代码

5. 单元测试编写建议

以下是一些编写单元测试的建议:

  • 心态要摆正写单测不是浪费时间而是为了更好地提升设计与实现的质量
  • 后台开发都是分层次架构需要将测试用例分包管理分层编写单测
  • 要尽可能提高代码的覆盖率想清楚测试数据集如边界/异常/失败等;
  • 不要滥用Mock该调用的就调用不能为了达到方法覆盖率而忽略写单测最初的目的;
  • 多使用单测生成工具提升编写效率为此笔者还封装了一个生成组件.

6.总结

本文是基于最近在项目开发中使用到单测的知识整理主要讲述了单测的定义与作用现在使用到的Java后台开发的Junit与Mock框架的基本使用与实现原理最后是讲述了笔者自己的编写单元测试一些心得与建议。

参考文献

作者代码的色彩
链接https://juejin.cn/post/6954645626961264653
著作权归作者所有。商业转载请联系作者获得授权非商业转载请注明出处。

打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP