【单元测试】为什么要写单元测试?怎么写?

为什么不想写单元测试

  • 单元测试太浪费时间了。
    随着系统的复杂度增加,你的一次改动可能引发出5个bug,或者你的bug被发现的时间延后了,堆积到了一起,那么一段时间后,别人加班半小时写单元测试,你会加班到天亮改BUG。
  • 有测试人员帮我测,我还写什么单元测试。
    测试分很多阶段,比如单元测试、集成测试、系统测试、验收测试等,只有单元测试属于开发人员的工作,单元测试是开发人员在知道代码内部逻辑的情况下,有目的的测试,为自己的代码编写单元测试并通过是对测试人员负责,更是对自己负责。
  • 代码编译通过就对了,不需要测试。
    趁早告别代码,代码不需要你。
  • 这是个老系统、这个部分代码不是我写的,它们本来就没有单元测试。
    如果你要修改这部分没有单元测试代码,那么首先编写单元测试将会是一个很好了解代码逻辑的过程,并且可以保证你的修改是可测试的,有了足够的单元测试,今后的修改、重构、扩展才会有最基本的保障。
  • 不知道怎么写单元测试。
    你需要学习。

为什么要写单元测试

  • 让我们对自己的代码有信心。
    如果修改一个功能如果没有任何测试代码,只能靠接口测试,当业务逻辑相对简单的时候可能还比较可行,但当业务逻辑非常复杂流程很长的时候,可能一个很小的代码错误就会让这次精心设计的测试场景泡汤,又得重新来过,而这个很小的代码错误在跑整个流程之前可以通过一个简单的单元测试发现。如果没有单元测试,长此以往,我们总是觉得自己的代码可能有问题,但是又无从检查,如果修改多次仍然有问题,慢慢地就失去了耐心和信心。
  • 让BUG发现的时间提前。
    测试时会将一些边界条件作为测试用例,而这些边界条件在实际使用中可能会很晚才发现,到时候程序异常出现BUG,改动起来一定会比一开始就发现这些问题更费时间。如果基于TDD的模式开发,一些代码逻辑的BUG、边界条件的疏忽问题都会提前得到解决。
  • 为代码重构保驾护航。
    重构代码和重写代码的重要区别是:重写可能导致系统暂时不可用,但重构系统一定是随时可用的,那怎么保证重构过程中系统功能不受影响呢?单元测试可以为重构提供一定的帮助,如果我们用提取函数的手法重构了一段代码,直接跑原有的测试用例就可以保证重构没有破坏原来的逻辑结构。
  • 通过单元测试快速熟悉代码。
    单元测试中的用例可以说是一种很好的文档,通过查看单元测试可以很快了解代码逻辑,特别是一些边界条件和历史bug。
  • 优化设计。
    编写单元测试驱动开发从调用者的角度去设计代码,让代码易于调用和测试,特别是使用TDD测试驱动开发的方式,开发者会在开发中不断调整和重构,让代码有一个较好的结构和设计,并解除软件中的耦合。
  • 可快速持续回归。
    结合持续集成,任何代码的修改都可以快速回归,而不是需要通过接口测试覆盖可能修改的路径,后者太麻烦不知能,也不一定每次都能覆盖所有路径。

什么时候写单元测试

  • 写代码之前。
    这正是TDD提倡的开发模式:在写任何业务代码之前,甚至都不用定义接口、方法,先写测试用例,如果测试用例不通过,那么回去修改业务代码,直到所有测试用例写完并通过,其中编译不通过也算一种测试用例不通过。这种方式的好处是测试代码和业务代码先后完成,在不断修改业务代码过程中,重构时刻进行,让代码有更好的结构,并保证代码可以满足所有测试用例。
  • 写代码的时候。
    先编写少量业务代码,然后编写单元测试,测试通过后再继续写业务代码,直到业务代码写完并且所有单元测试通过。这种方式和第一种时间上基本一致,但是侧重点不同,本方法有明显的的缺陷:如果没有完全了解需求,没有足够的测试用例,那么可能会在编写业务代码过程中对之前的逻辑进行修改,这可能会同时修改之前的测试用例及断言。而TDD也可以理解为Task-Driven Development,在开发之前要对问题进行分析并进行任务分解,是基于了解需求的情况下开发,所以重复工作相对较少。
  • 写完代码再写测试。
    这样的好处无非写业务代码的时候不考虑单元测试,不好的地方也比较明显:可能写业务逻辑比较任性,不会站在测试和调用者的角度去改善代码的设计;写完代码再写测试,可能会为了提高单元测试覆盖率而测试,并没有特别多有意义的测试;写完代码后的单元测试可能出现粒度较大的情况,导致测试之间的耦合度较高,可读性较差,可维护性不高;更有可能因为写完业务代码来不及写单元测试甚至懒得写单元测试而不写单元测试。

综上所述,写单元测试最好的时机是写业务代码之前;如果做不到这样,可以先写部分业务代码,再写部分单元测试,两者几乎同时完成;相对不好的时机是写完整个业务代码之后,但对于没有单元测试的代码,可能只有通过这种方式增加单元测试。

怎么写单元测试

  • 使用单元测试框架。
    Java中最流行的莫过于JUnit,这些工具远比自己写一个执行、验证流程来得方便,比如JUnit支持注解、允许忽略某些测试、允许特定顺序执行某些测试、支持初始化和清理、支持在不同运行环境中测试、可以通过Maven等构建工具来做持续集成和自动执行测试。
  • 强烈建议使用测试驱动开发模式(TDD)。
    TDD会专门在后面的一个小节总结。
  • 使它们短。
    简单明了,一个有意义的单元测试不需要太长。
  • 不要写重复代码。
    复制粘贴并不是好事,尽量使用setup/teardown或辅助方法减少重复代码。
  • 组合好过继承。
    base class可能包含一些常用代码,除非规定base class不能再添加功能,否则base class会越来越臃肿,难以管理和共享。
  • 快。
    尽量移除外部依赖,比如用h2内存数据库代替真实数据库、mock其他服务的接口、mock文件操作等,否则执行一次单元测试可能花费巨长的时间,这让测试和开发无法快速切换。
  • 评估代码覆盖率。
    高代码覆盖率并不能保证不出BUG,甚至不能保证代码逻辑是完美的,但绝对可以保证更多的代码被某一处执行过,至少在现有的测试用中不会抛出异常,断言关注的点也没有问题。所以写单元测试的时候结合jacoco、sonar或IDEA覆盖率工具评估覆盖率,可以查看自己是否遗漏了某些测试用例,是否有一些逻辑从未到达过。
  • 尽可能将测试数据外置化。
    JUnit测试可以通过@Parameterized将入参和断言等作为参数传入,当需要添加用例的时候就不要添加代码了,直接添加用例即可:
    package github.clyoudu.util;
     
    import org.junit.Assert;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.junit.runners.Parameterized;
     
    import java.util.Collection;
     
    /**
     * Create by IntelliJ IDEA
     *
     * @author chenlei
     * @dateTime 2019/8/6 20:23
     * @description FileUtil1Test
     */
    @RunWith(Parameterized.class)
    public class FileUtil1Test {
     
        private String path;
        private String expected;
     
        public FileUtil1Test(String path, String expected) {
            this.path = path;
            this.expected = expected;
        }
     
        @Parameterized.Parameters
        public static Collection<Object[]> getTestData() {
            return ArraysUtil.asList(new Object[][]{
                    {"///a//b//c/d/e.txt", "/a/b/c/d/e.txt"},
                    {"C:\\a\\\\b\\\\\\\\\\c\\d\\\\\\e.txt", "C:\\a\\b\\c\\d\\e.txt"}
            });
        }
     
        @Test
        public void test() {
            Assert.assertEquals(expected, FileUtil.formatFilePath(path));
        }
     
    }
    
  • 使用断言而不是打印语句。
    使用JUnit工具就是要通过断言来自动判断执行结果和期望是否一致,千万不要使用log打印到控制台来肉眼判断。
  • 生成具有确定性结果的测试。
    如果一个方法每次返回的结果是不可预期的,比如一个生成随机数的方法,那为这个方法编写单元测试几乎没有意义,因为不能得到可以预测的结果,这时候可以针对返回值的范围等作出断言,让返回值是有可比较的。
  • 不要忽略测试。
    测试跑不过了,发现是环境问题,直接加@Ignore注解忽略掉,这不是解决测试不通过的方法,应该从其他方面来让这个测试重新通过,比如剔除外部依赖、mock其他服务的接口等等。
  • 测试错误情景、边界情景和正常情景。
    比如雪球规定某个字段只能输入2-30个字符,那么长度小于2、大于30、2-30、2、30的字符串都应该被作为输入验证,同时null值也应当作为异常情景测试用例。
  • 设计测试。
    测试并不是不需要设计的,业务代码中的坏味道在测试代码中一样存在,需要清除这些坏味道,让测试代码理解和维护起来一样顺心顺手。

如何编写优秀的单元测试

  • 编写可靠的测试
    • 依据实际情况合理地删除或修改单元测试: 如果确定是测试缺陷,而不是产品缺陷(被测试代码缺陷)时,需要立刻修改相关单元测试代码;如果被测试的产品代码的语义或者API变更导致测试失败,这时是需要修改测试,使用新的语义;如果看到测试名含义不清或者单元测试的可维护性差就应该在保证单元测试基本功能前提下修改测试名称或者重构测试;如果同一个功能多个单元测试,请删除重复测试。
    • 避免在单元测试代码中包含逻辑: 包含逻辑的测试是指测试代码中包含switch、if/else、for/while等控制流语句。这样的测试可读性差,代码脆弱,测试代码的复杂度高,容易包含缺陷,测试结果不容易重现。
    • 每个单元测试只测试一个关注点: 所谓的一个关注点就是指一个工作单元的一个最终结果:一个返回值、系统状态的一个改变、对第三方对象的一个调用。测试多个关注点一方面不利于测试命名,另一方面很多单元测试框架中,一个失败断言就会抛出一个特殊类型的异常,后面代码不会继续执行,这样不利于收集测试失败原因。
    • 区分单元测试和集成测试
    • 用代码审查确保代码覆盖率: 如果你做了代码审查、测试审查、确保测试优秀而且覆盖了所有代码,那么就可以避免犯简单愚蠢的错误,同时也可以从持续的学习中获益。
  • 编写可读的测试
    • 单元测试的命名标准: 合理地命名测试,主要目的是为了使后来的开发者从为了理解测试而阅读代码的负担中解脱出来。测试名应该包含三部分:被测试方法名、测试场景(即测试使用的条件)、预期行为(即被测试方法的最终结果)。
    • 单元测试中的变量命名规范: 单元测试除了主要的测试功能之外,它还为API提供某种形式的文档。通过合理命名变量,帮助阅读测试的人可以尽快理解你要验证什么(从而更加理解产品代码中想要实现什么功能)。
    • 断言和操作分离
    • 避免滥用setup和teardown(before和after): 比如在setup中准备stub和mock对象,这种情况就会导致阅读测试的人意识不到测试中使用了模拟对象,也不知道这些模拟对象预期是什么。
  • 编写可维护的测试
    • 只测试公共契约,避免测试私有或者受保护的方法: 私有方法可以看做是系统内部契约,这个内部契约是动态,在系统重构时可能会被随时修改,因此针对这些内部契约的单元测试也很可能会失败。而内部契约最终都会被一个公共契约(公共方法、整体功能)所调用,也就是说任何私有方法通常都是一个更大的工作单元的一部分。
    • 去除重复代码: 可以使用辅助方法或者setup来去除重复代码的问题
    • 实施测试隔离: 测试隔离是指每个测试都只生活在自己的小世界中,它与其他测试之间没有任何依赖关系,甚至不知道其他测试存在。
      几种常见的测试隔离的反模式:
      • 测试结果依赖测试执行的顺序
      • 测试调用其他测试方法
      • 测试中使用的共享资源(内存或外部资源)没有得到清理或回滚
    • 避免对不同关注点多次断言,尽量使用参数化测试或者对每个关注点设计单独的测试用例
    • 避免过度指定
      常见的过度指定例子
      • 对系统内部契约进行断言
      • 使用过多的模拟对象
      • 精确匹配
  • 1
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值