关于测试:Spock单元测试框架介绍以及在美团优选的实践

70次阅读

共计 28180 个字符,预计需要花费 71 分钟才能阅读完成。

Spock 是一款国外优良的测试框架,基于 BDD(行为驱动开发)思维实现,性能十分弱小。Spock 联合 Groovy 动静语言的特点,提供了各种标签,并采纳简略、通用、结构化的描述语言,让编写测试代码更加简洁、高效。目前,美团优选物流绝大部分后端服务曾经采纳了 Spock 作为测试框架,在开发效率、可读性和维护性方面均获得了不错的收益。

1. 背景

XML 之父 Tim Bray 最近在博客里有个好玩的说法:“代码不写测试就像上了厕所不洗手……单元测试是对软件将来的一项必不可少的投资。”具体来说,单元测试有哪些收益呢?

  • 它是最容易保障代码覆盖率达到 100% 的测试。
  • 能够⼤幅升高上线时的缓和指数。
  • 单元测试能更快地发现问题(见下图左)。
  • 单元测试的性价比最高,因为谬误发现的越晚,修复它的老本就越高,而且难度呈指数式增长,所以咱们要尽早地进行测试(见下图右)。
  • 编码人员,个别也是单元测试的次要执行者,是惟一可能做到生产出无缺点程序的人,其余任何人都无奈做到这一点。
  • 有助于源码的优化,使之更加标准,疾速反馈,能够释怀进行重构。
这张图来自微软的统计数据:Bug 在单元测试阶段被发现,均匀耗时 3.25 小时,如果漏到零碎测试阶段,要花费 11.5 小时。 这张图,旨在阐明两个问题:85% 的缺点都在代码设计阶段产生,而发现 Bug 的阶段越靠后,消耗老本就越高,指数级别的增高。

只管单元测试有如此的收益,但在咱们日常的工作中,依然存在不少我的项目它们的单元测试要么是不残缺要么是缺失的。常见的起因总结如下:代码逻辑过于简单;写单元测试时消耗的工夫较长;工作重、工期紧,或者罗唆就不写了。

基于以上问题,相较于传统的 JUnit 单元测试,明天为大家举荐一款名为 Spock 的测试框架。目前,美团优选物流技术团队绝大部分后端服务曾经采纳了 Spock 作为测试框架,在开发效率、可读性和维护性方面获得了不错的收益。

不过网上 Spock 材料比较简单,甚至包含官网的 Demo,无奈解决咱们我的项目中简单业务场景面临的问题,通过深刻学习和实际之后,本文会将一些教训分享进去,心愿可能帮忙大家进步开发测试的效率。

2. Spock 是什么?和 JUnit、jMock 有什么区别?

Spock 是一款国外优良的测试框架,基于 BDD(行为驱动开发)思维实现,性能十分弱小。Spock 联合 Groovy 动静语言的特点,提供了各种标签,并采纳简略、通用、结构化的描述语言,让编写测试代码更加简洁、高效。官网的介绍如下:

What is it?
Spock is a testing and specification framework for Java and Groovy applications. What makes it stand out from the crowd is its beautiful and highly expressive specification language. Thanks to its JUnit runner, Spock is compatible with most IDEs, build tools, and continuous integration servers. Spock is inspired from JUnit, RSpec, jMock, Mockito, Groovy, Scala, Vulcans, and other fascinating life forms.

Spock 是一个 Java 和 Groovy` 利用的测试和标准框架。之所以可能在泛滥测试框架中怀才不遇,是因为它柔美而富裕表现力的标准语言。Spock 的灵感来自 JUnit、RSpec、jMock、Mockito、Groovy、Scala、Vulcans。

简略来讲,Spock 次要特点如下:

  • 让测试代码更标准,内置多种标签来标准单元测试代码的语义,测试代码构造清晰,更具可读性,升高前期保护难度。
  • 提供多种标签,比方:givenwhenthenexpectwherewiththrown…… 帮忙咱们应答简单的测试场景。
  • 应用 Groovy 这种动静语言来编写测试代码,能够让咱们编写的测试代码更简洁,适宜麻利开发,进步编写单元测试代码的效率。
  • 听从 BDD(行为驱动开发)模式,有助于晋升代码的品质。
  • IDE 兼容性好,自带 Mock 性能。

为什么应用 Spock?Spock 和 JUnit、jMock、Mockito 的区别在哪里?

总的来说,JUnit、jMock、Mockito 都是绝对独立的工具,只是针对不同的业务场景提供特定的解决方案。其中 JUnit 单纯用于测试,并不提供 Mock 性能。

咱们的服务大部分是散布式微服务架构。服务与服务之间通常都是通过接口的形式进行交互。即便在同一个服务内也会分为多个模块,业务性能须要依赖上游接口的返回数据,能力持续前面的解决流程。这里的上游不限于接口,还包含中间件数据存储比方 Squirrel、DB、MCC 配置核心等等,所以如果想要测试本人的代码逻辑,就必须把这些依赖项 Mock 掉。因为如果上游接口不稳固可能会影响咱们代码的测试后果,让上游接口返回指定的后果集(当时筹备好的数据),这样能力验证咱们的代码是否正确,是否合乎逻辑后果的预期。

只管 jMock、Mockito 提供了 Mock 性能,能够把接口等依赖屏蔽掉,但不能对静态方法 Mock。尽管 PowerMock、jMockit 可能提供静态方法的 Mock,但它们之间也须要配合(JUnit + Mockito PowerMock)应用,并且语法上比拟繁琐。工具多了就会导致不同的人写出的单元测试代码“形形色色”,格调相差较大。

Spock 通过提供规范性的形容,定义多种标签(givenwhenthenwhere等),去形容代码“应该做什么”,“输出条件是什么”,“输入是否合乎预期”,从语义层面标准了代码的编写。

Spock 自带 Mock 性能,应用简略不便(也反对扩大其余 Mock 框架,比方 PowerMock),再加上 Groovy 动静语言的弱小语法,能写出简洁高效的测试代码,同时能不便直观地验证业务代码的行为流转,加强工程师对代码执行逻辑的可控性。

3. 应用 Spock 解决单元测试开发中的痛点

如果在(if/else)分支很多的简单场景下,编写单元测试代码的老本会变得十分高,失常的业务代码可能只有几十行,但为了测试这个性能笼罩大部分的分支场景,编写的测试代码可能远不止几十行。

之前有遇到过某个性能上线很久始终都很失常,没有呈现过问题,但起初有个调用申请的数据不一样,走到了代码中一个不罕用的逻辑分支时,呈现了 Bug。过后写这段代码的同学也认为只有很小几率能力走到这个分支,只管过后写了单元测试,但因为工夫比拟缓和,分支又多,就漏掉了这个分支的测试。

只管应用 JUnit 的 @Parametered 参数化注解或者 DataProvider 形式能够解决多数据分支问题,但不够直观,而且如果其中某一次分支测试 Case 出错了,它的报错信息也不够详尽。

这就须要一种编写测试用例高效、可读性强、占用工时少、保护成本低的测试框架。首先不能让业务人员排挤编写单元测试,更不能让工程师感觉写单元测试是在浪费时间。而且应用 JUnit 做测试工作量不算小。据初步统计,采纳 JUnit 的话,它的测试代码行和业务代码行能到 3:1。如果采纳 Spock 作为测试框架的话,它的比例可缩减到 1:1,可能大大提高编写测试用例的效率。

上面借用《编程珠玑》中一个计算税金的例子。

public double calc(double income) {
        BigDecimal tax;
        BigDecimal salary = BigDecimal.valueOf(income);
        if (income <= 0) {return 0;}
        if (income > 0 && income <= 3000) {BigDecimal taxLevel = BigDecimal.valueOf(0.03);
            tax = salary.multiply(taxLevel);
        } else if (income > 3000 && income <= 12000) {BigDecimal taxLevel = BigDecimal.valueOf(0.1);
            BigDecimal base = BigDecimal.valueOf(210);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 12000 && income <= 25000) {BigDecimal taxLevel = BigDecimal.valueOf(0.2);
            BigDecimal base = BigDecimal.valueOf(1410);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 25000 && income <= 35000) {BigDecimal taxLevel = BigDecimal.valueOf(0.25);
            BigDecimal base = BigDecimal.valueOf(2660);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 35000 && income <= 55000) {BigDecimal taxLevel = BigDecimal.valueOf(0.3);
            BigDecimal base = BigDecimal.valueOf(4410);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 55000 && income <= 80000) {BigDecimal taxLevel = BigDecimal.valueOf(0.35);
            BigDecimal base = BigDecimal.valueOf(7160);
            tax = salary.multiply(taxLevel).subtract(base);
        } else {BigDecimal taxLevel = BigDecimal.valueOf(0.45);
            BigDecimal base = BigDecimal.valueOf(15160);
            tax = salary.multiply(taxLevel).subtract(base);
        }
        return tax.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();}

可能看到下面的代码中有大量的 if-else 语句,Spock 提供了 where 标签,能够让咱们通过表格的形式来测试多种分支。

@Unroll
def "个税计算, 支出:#income, 个税:#result"() {
  expect: "when + then 的组合"
  CalculateTaxUtils.calc(income) == result

  where: "表格形式测试不同的分支逻辑"
  income || result
  -1     || 0
  0      || 0
  2999   || 89.97
  3000   || 90.0
  3001   || 90.1
  11999  || 989.9
  12000  || 990.0
  12001  || 990.2
  24999  || 3589.8
  25000  || 3590.0
  25001  || 3590.25
  34999  || 6089.75
  35000  || 6090.0
  35001  || 6090.3
  54999  || 12089.7
  55000  || 12090
  55001  || 12090.35
  79999  || 20839.65
  80000  || 20840.0
  80001  || 20840.45
}

上图中右边应用 Spock 写的单元测试代码,语法简洁,表格形式测试笼罩分支场景更加直观,开发效率高,更适宜麻利开发。

单元测试代码的可读性和前期保护

咱们微服务场景很多时候须要依赖其余接口返回的后果,能力验证本人的代码逻辑。Mock 工具是必不可少的。但 jMock、Mockito 的语法比拟繁琐,再加上单元测试代码不像业务代码那么直观,又不能齐全依照业务流程的思路写单元测试,这就让不少同学对单元测试代码可读性不够器重,最终导致测试代码难以浏览,保护起来更是难上加难。甚至很多同学本人写的单元测试,过几天再看也一样感觉“云里雾里”的。也有改了原来的代码逻辑导致单元测试执行失败的;或者新增了分支逻辑,单元测试没有笼罩到的;最终随着业务的疾速迭代单元测试代码越来越难以保护。

Spock 提供多种语义标签,如:givenwhenthenexpectwherewithand等,从行为上标准了单元测试代码,每一种标签对应一种语义,让单元测试代码构造具备层次感,功能模块划分更加清晰,也便于前期的保护。

Spock 自带 Mock 性能,应用上简略不便(Spock 也反对扩大第三方 Mock 框架,比方 PowerMock)。咱们能够再看一个样例,对于如下的代码逻辑进行单元测试:

public StudentVO getStudentById(int id) {List<StudentDTO> students = studentDao.getStudentInfo();
        StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
        StudentVO studentVO = new StudentVO();
        if (studentDTO == null) {return studentVO;}
        studentVO.setId(studentDTO.getId());
        studentVO.setName(studentDTO.getName());
        studentVO.setSex(studentDTO.getSex());
        studentVO.setAge(studentDTO.getAge());
        // 邮编
        if ("上海".equals(studentDTO.getProvince())) {studentVO.setAbbreviation("沪");
            studentVO.setPostCode("200000");
        }
        if ("北京".equals(studentDTO.getProvince())) {studentVO.setAbbreviation("京");
            studentVO.setPostCode("100000");
        }
        return studentVO;
    }

比拟显著,右边的 JUnit 单元测试代码冗余,短少构造档次,可读性差,随着后续的迭代,势必会导致代码的沉积,保护老本会变得越来越高。左边的单元测试代码 Spock 会强制要求应用 givenwhenthen 这样的语义标签(至多一个),否则编译不通过,这样就能保障代码更加标准,构造模块化,边界范畴清晰,可读性强,便于扩大和保护。而且应用了自然语言形容测试步骤,让非技术人员也能看懂测试代码(given示意输出条件,when触发动作,then验证输入后果)。

Spock 自带的 Mock 语法也非常简单:dao.getStudentInfo() >> [student1, student2]

两个右箭头 >> 示意模仿 getStudentInfo 接口的返回后果,再加上应用的 Groovy 语言,能够间接应用 [] 中括号示意返回的是 List 类型。

单元测试不仅仅是为了统计代码覆盖率,更重要的是验证业务代码的健壮性、业务逻辑的严谨性以及设计的合理性

在我的项目初期阶段,可能为了追赶进度而没有工夫写单元测试,或者这个期间写的单元测试只是为了达到覆盖率的要求(比方为了满足新增代码行或者分支覆盖率统计要求)。

很多工程师写的单元测试根本都是采纳 Java 这种强类型语言编写,各种底层接口的 Mock 写起来不仅繁琐而且耗时。这时的单元测试代码可能就写得比拟毛糙,有粒度过大的,也有短少单元测试后果验证的。这样的单元测试对代码的品质帮忙不大,更多是为了测试而测试。最初工夫没少花,可成果却没有达到。

针对无效测试用例方面,咱们测试根底组件组开发了一些检测工具(作为抓手),比方去扫描大家写的单元测试,检测单元测试的断言有效性等。另外在后果校验方面,Spock 体现也是非常优异的。咱们能够来看接下来的场景:void办法,没有返回后果,如何写测试这段代码的逻辑是否正确?

如何确保单元测试代码是否执行到了 for 循环外面的语句,循环外面的打折计算又是否正确呢?

  public void calculatePrice(OrderVO order){
        BigDecimal amount = BigDecimal.ZERO;
        for (SkuVO sku : order.getSkus()) {Integer skuId = sku.getSkuId();
            BigDecimal skuPrice = sku.getSkuPrice();
            BigDecimal discount = BigDecimal.valueOf(discountDao.getDiscount(skuId));
            BigDecimal price = skuPrice * discount;
            amount = amount.add(price);
        }
        order.setAmount(amount.setScale(2, BigDecimal.ROUND_HALF_DOWN));
    }

如果用 Spock 写的话,就会不便很多,如下图所示:

这里,2 * discountDao.getDiscount(_) >> 0.95 >> 0.8for 循环中一共调用了 2 次,第一次返回后果 0.95,第二次返回后果 0.8,最初再进行验证,相似于 JUnit 中的 Assert 断言。

这样的收益还是比拟显著的,不仅进步了单元测试的可控性,而且不便验证业务代码的逻辑正确性和合理性,这也是 BDD 思维的一种体现。

4. Mock 模仿

思考如下场景,代码如下:

@Service
public class StudentService {
    @Autowired
    private StudentDao studentDao;
    public StudentVO getStudentById(int id) {List<StudentDTO> students = studentDao.getStudentInfo();
        StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
        StudentVO studentVO = new StudentVO();
        if (studentDTO == null) {return studentVO;}
        studentVO.setId(studentDTO.getId());
        studentVO.setName(studentDTO.getName());
        studentVO.setSex(studentDTO.getSex());
        studentVO.setAge(studentDTO.getAge());
        // 邮编
        if ("上海".equals(studentDTO.getProvince())) {studentVO.setAbbreviation("沪");
            studentVO.setPostCode("200000");
        }
        if ("北京".equals(studentDTO.getProvince())) {studentVO.setAbbreviation("京");
            studentVO.setPostCode("100000");
        }
        return studentVO;
    }
}

其中 studentDao 是应用 Spring 注入的实例对象,咱们只有拿到了返回的 students,能力持续上面的逻辑(依据id 筛选学生,DTOVO 转换,邮编等)。所以失常的做法是把 studentDaogetStudentInfo()办法 Mock 掉,模仿一个指定的值,因为咱们真正关怀的是拿到 students 后本人代码的逻辑,这是须要重点验证的中央。依照下面的思路应用 Spock 编写的测试代码如下:

class StudentServiceSpec extends Specification {def studentDao = Mock(StudentDao)
    def tester = new StudentService(studentDao: studentDao)

    def "test getStudentById"() {
        given: "设置申请参数"
        def student1 = new StudentDTO(id: 1, name: "张三", province: "北京")
        def student2 = new StudentDTO(id: 2, name: "李四", province: "上海")

        and: "mock studentDao 返回值"
        studentDao.getStudentInfo() >> [student1, student2]

        when: "获取学生信息"
        def response = tester.getStudentById(1)

        then: "后果验证"
        with(response) {
            id == 1
            abbreviation == "京"
            postCode == "100000"
        }
    }
}

这里次要解说 Spock 的代码(从上往下)。

def studentDao = Mock(StudentDao) 这一行代码应用 Spock 自带的 Mock 办法,结构一个 studentDao 的 Mock 对象,如果要模仿 studentDao 办法的返回,只需 studentDao. 办法名() >> "模仿值" 的形式,两个右箭头的形式即可。test getStudentById办法是单元测试的次要办法,能够看到分为 4 个模块:givenandwhenthen,用来辨别不同单元测试代码的作用:

  • given:输出条件(前置参数)。
  • when:执行行为(Mock接口、实在调用)。
  • then:输入条件(验证后果)。
  • and:连接上个标签,补充的作用。

每个标签前面的双引号里能够增加形容,阐明这块代码的作用(非强制),如 when:"获取信息"。因为 Spock 应用 Groovy 作为单元测试开发语言,所以代码量上比应用 Java 写的会少很多,比方given 模块里通过构造函数的形式创立申请对象。

实际上 StudentDTO.java 这个类并没有 3 个参数的构造方法,是 Groovy 帮咱们实现的。Groovy 默认会提供一个蕴含所有对象属性的构造方法。而且调用形式上能够指定属性名,相似于key:value 的语法,十分人性化,不便在属性多的状况下结构对象,如果应用 Java 写,可能就要调用很多的 setXxx() 办法,能力实现对象初始化的工作。

这个就是 Spock 的 Mock 用法,当调用 studentDao.getStudentInfo() 办法时返回一个 ListList 的创立也很简略,中括号 [] 即示意 List,Groovy 会依据办法的返回类型,主动匹配是数组还是List,而List 里的对象就是之前 given 块里结构的 user 对象,其中 >> 就是指定返回后果,相似 Mockitowhen().thenReturn()语法,但更简洁一些。

如果要指定返回多个值的话,能够应用 3 个右箭头>>>,比方:studentDao.getStudentInfo() >>> [[student1,student2],[student3,student4],[student5,student6]]

也能够写成这样:studentDao.getStudentInfo() >> [student1,student2] >> [student3,student4] >> [student5,student6]

每次调用 studentDao.getStudentInfo() 办法返回不同的值。

public List<StudentDTO> getStudentInfo(String id){List<StudentDTO> students = new ArrayList<>();
    return students;
}

这个 getStudentInfo(String id) 办法,有个参数 id,这种状况下如果应用 Spock 的 Mock 模仿调用的话,能够应用下划线_ 匹配参数,示意任何类型的参数,多个逗号隔开,相似于 Mockitoany()办法。如果类中存在多个同名办法,能够通过 _ as 参数类型 的形式区别调用,如上面的语法:

// _ 示意匹配任意类型参数
List<StudentDTO> students = studentDao.getStudentInfo(_);

// 如果有同名的办法,应用 as 指定参数类型辨别
List<StudentDTO> students = studentDao.getStudentInfo(_ as String);

when模块里是真正调用要测试方法的入口 tester.getStudentById()then 模块作用是验证被测办法的后果是否正确,合乎预期值,所以这个模块里的语句必须是 boolean 表达式,相似于 JUnit 的 assert 断言机制,但不用显示地写 assert,这也是一种约定优于配置的思维。then 块中应用了 Spock 的 with 性能,能够验证返回后果 response 对象外部的多个属性是否合乎预期值,这个绝对于 JUnit 的 assertNotNullassertEquals的形式更简略一些。

弱小的 Where

下面的业务代码有 2 个 if 判断,是对邮编解决逻辑:

  // 邮编
  if ("上海".equals(studentDTO.getProvince())) {studentVO.setAbbreviation("沪");
       studentVO.setPostCode("200000");
   }
   if ("北京".equals(studentDTO.getProvince())) {studentVO.setAbbreviation("京");
       studentVO.setPostCode("100000");
   }

如果要齐全笼罩这 2 个分支就须要结构不同的申请参数,屡次调用被测试方法能力走到不同的分支。在后面,咱们介绍了 Spock 的 where 标签能够很不便的实现这种性能,代码如下所示:

   @Unroll
   def "input 学生 id:#id, 返回的邮编:#postCodeResult, 返回的省份简称:#abbreviationResult"() {
        given: "Mock 返回的学生信息"
        studentDao.getStudentInfo() >> students

        when: "获取学生信息"
        def response = tester.getStudentById(id)

        then: "验证返回后果"
        with(response) {
            postCode == postCodeResult
            abbreviation == abbreviationResult
        }
        where: "经典之处:表格形式验证学生信息的分支场景"
        id | students                    || postCodeResult | abbreviationResult
        1  | getStudent(1, "张三", "北京") || "100000"       | "京"
        2  | getStudent(2, "李四", "上海") || "200000"       | "沪"
    }

    def getStudent(def id, def name, def province) {return [new StudentDTO(id: id, name: name, province: province)]
    }

where模块第一行代码是表格的列名,多个列应用 | 单竖线隔开,||双竖线辨别输出和输入变量,即右边是输出值,左边是输入值。格局如下:

输出参数 1 | 输出参数 2 || 输入后果 1 | 输入后果 2

而且 IntelliJ IDEA 反对 format 格式化快捷键,因为表格列的长度不一样,手动对齐比拟麻烦。表格的每一行代表一个测试用例,即被测办法执行了 2 次,每次的输出和输入都不一样,刚好能够笼罩全副分支状况。比方 idstudents 都是输出条件,其中 students 对象的结构调用了 getStudent 办法,每次测试业务代码传入不同的 student 值,postCodeResultabbreviationResult示意对返回的 response 对象的属性判断是否正确。第一行数据的作用是验证返回的邮编是否是 100000,第二行是验证邮编是否是200000。这个就是where+with 的用法,更合乎咱们理论测试的场景,既能笼罩多种分支,又能够对简单对象的属性进行验证,其中在定义的测试方法名,应用了 Groovy 的字面值个性:

即把申请参数值和返回后果值的字符串动静替换掉,#id#postCodeResult#abbreviationResult# 号前面的变量是在办法外部定义的,实现占位符的性能。

@Unroll注解,能够把每一次调用作为一个独自的测试用例运行,这样运行后的单元测试后果更加直观:

而且如果其中某行测试后果不对,Spock 的谬误提示信息也很具体,不便进行排查(比方咱们把第 1 条测试用例返回的邮编改成100001):

能够看出,第 1 条测试用例失败,错误信息是 postCodeResult 的预期后果和理论后果不符,业务代码逻辑返回的邮编是100000,而咱们预期的邮编是100001,这样就能够排查是业务代码逻辑有问题,还是咱们的断言不对。

5. 异样测试

咱们再看下异样方面的测试,例如上面的代码:

 public void validateStudent(StudentVO student) throws BusinessException {if(student == null){throw new BusinessException("10001", "student is null");
        }
        if(StringUtils.isBlank(student.getName())){throw new BusinessException("10002", "student name is null");
        }
        if(student.getAge() == null){throw new BusinessException("10003", "student age is null");
        }
        if(StringUtils.isBlank(student.getTelephone())){throw new BusinessException("10004", "student telephone is null");
        }
        if(StringUtils.isBlank(student.getSex())){throw new BusinessException("10005", "student sex is null");
        }
    }

BusinessException是封装的业务异样,次要蕴含 codemessage 属性:

/**
 * 自定义业务异样
 */
public class BusinessException extends RuntimeException {
    private String code;
    private String message;

    setXxx...
    getXxx...
}

这个大家应该都很相熟,针对这种抛出多个不同错误码和错误信息的异样。如果应用 JUnit 的形式测试,会比拟麻烦。如果是单个异样还好,如果是多个的话,测试代码就不太好写。

    @Test
    public void testException() {
        StudentVO student = null;
        try {service.validateStudent(student);
        } catch (BusinessException e) {Assert.assertEquals(e.getCode(), "10001");
            Assert.assertEquals(e.getMessage(), "student is null");
        }

        student = new StudentVO();
        try {service.validateStudent(student);
        } catch (BusinessException e) {Assert.assertEquals(e.getCode(), "10002");
            Assert.assertEquals(e.getMessage(), "student name is null");
        }
    }

当然能够应用 JUnit 的 ExpectedException 形式:

@Rule
public ExpectedException exception = ExpectedException.none();
exception.expect(BusinessException.class); // 验证异样类型
exception.expectMessage("xxxxxx"); // 验证异样信息

或者应用@Test(expected = BusinessException.class) 注解,但这两种形式都有缺点。

@Test形式不能指定断言的异样属性,比方 codemessageExpectedException 的形式也只提供了 expectMessage 的 API,对自定义的 code 不反对,尤其像下面的有很多分支抛出多种不同异样码的状况。接下来咱们看下 Spock 是如何解决的。Spock 内置 thrown() 办法,能够捕捉调用业务代码抛出的预期异样并验证,再联合 where 表格的性能,能够很不便地笼罩多种自定义业务异样,代码如下:

    @Unroll
    def "validate student info: #expectedMessage"() {
        when: "校验"
        tester.validateStudent(student)

        then: "验证"
        def exception = thrown(expectedException)
        exception.code == expectedCode
        exception.message == expectedMessage

        where: "测试数据"
        student           || expectedException | expectedCode | expectedMessage
        getStudent(10001) || BusinessException | "10001"      | "student is null"
        getStudent(10002) || BusinessException | "10002"      | "student name is null"
        getStudent(10003) || BusinessException | "10003"      | "student age is null"
        getStudent(10004) || BusinessException | "10004"      | "student telephone is null"
        getStudent(10005) || BusinessException | "10005"      | "student sex is null"
    }

    def getStudent(code) {def student = new StudentVO()
        def condition1 = {student.name = "张三"}
        def condition2 = {student.age = 20}
        def condition3 = {student.telephone = "12345678901"}
        def condition4 = {student.sex = "男"}

        switch (code) {
            case 10001:
                student = null
                break
            case 10002:
                student = new StudentVO()
                break
            case 10003:
                condition1()
                break
            case 10004:
                condition1()
                condition2()
                break
            case 10005:
                condition1()
                condition2()
                condition3()
                break
        }
        return student
    }

then 标签里用到了 Spock 的 thrown() 办法,这个办法能够捕捉咱们要测试的业务代码里抛出的异样。thrown()办法的入参 expectedException,是咱们本人定义的异样变量,这个变量放在where 标签里就能够实现验证多种异常情况的性能(Intellij Idea格式化快捷键,能够主动对齐表格)。expectedException类型调用 validateUser 办法里定义的 BusinessException 异样,能够验证它所有的属性,codemessage是否合乎预期值。

6. Spock 静态方法测试

接下来,咱们一起看下 Spock 如何扩大第三方 PowerMock 对静态方法进行测试。

Spock 的单元测试代码继承自 Specification 基类,而 Specification 又是基于 JUnit 的注解 @RunWith() 实现的,代码如下:

PowerMock 的 PowerMockRunner 也是继承自 JUnit,所以应用 PowerMock 的 @PowerMockRunnerDelegate() 注解,能够指定 Spock 的父类 Sputnik 去代理运行 PowerMock,这样就能够在 Spock 里应用 PowerMock 去模仿静态方法、final办法、公有办法等。其实 Spock 自带的 GroovyMock 能够对 Groovy 文件的静态方法 Mock,但对 Java 代码反对不残缺,只能 Mock 以后 Java 类的静态方法,官网给出的解释如下:

如下代码:

 public StudentVO getStudentByIdStatic(int id) {List<StudentDTO> students = studentDao.getStudentInfo();

        StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
        StudentVO studentVO = new StudentVO();
        if (studentDTO == null) {return studentVO;}
        studentVO.setId(studentDTO.getId());
        studentVO.setName(studentDTO.getName());
        studentVO.setSex(studentDTO.getSex());
        studentVO.setAge(studentDTO.getAge());

        // 静态方法调用
        String abbreviation = AbbreviationProvinceUtil.convert2Abbreviation(studentDTO.getProvince());
        studentVO.setAbbreviation(abbreviation);
        studentVO.setPostCode(studentDTO.getPostCode());

        return studentVO;
    }

下面应用了 AbbreviationProvinceUtil.convert2Abbreviation() 静态方法,对应的测试用例代码如下:

@RunWith(PowerMockRunner.class)
@PowerMockRunnerDelegate(Sputnik.class)
@PrepareForTest([AbbreviationProvinceUtil.class])
@SuppressStaticInitializationFor(["example.com.AbbreviationProvinceUtil"])
class StudentServiceStaticSpec extends Specification {def studentDao = Mock(StudentDao)
    def tester = new StudentService(studentDao: studentDao)

    void setup() {
        // mock 动态类
        PowerMockito.mockStatic(AbbreviationProvinceUtil.class)
    }

    def "test getStudentByIdStatic"() {
        given: "创建对象"
        def student1 = new StudentDTO(id: 1, name: "张三", province: "北京")
        def student2 = new StudentDTO(id: 2, name: "李四", province: "上海")

        and: "Mock 掉接口返回的学生信息"
        studentDao.getStudentInfo() >> [student1, student2]

        and: "Mock 静态方法返回值"
        PowerMockito.when(AbbreviationProvinceUtil.convert2Abbreviation(Mockito.any())).thenReturn(abbreviationResult)

        when: "调用获取学生信息办法"
        def response = tester.getStudentByIdStatic(id)

        then: "验证返回后果是否合乎预期值"
        with(response) {abbreviation == abbreviationResult}
        where:
        id || abbreviationResult
        1  || "京"
        2  || "沪"
    }
}

StudentServiceStaticSpec 类的头部应用 @PowerMockRunnerDelegate(Sputnik.class) 注解,交给 Spock 代理执行,这样既能够应用 Spock +Groovy 的各种性能,又能够应用 PowerMock 的对动态,final等办法的 Mock。@SuppressStaticInitializationFor(["example.com.AbbreviationProvinceUtil"]),这行代码的作用是限度 AbbreviationProvinceUtil 类里的动态代码块初始化,因为 AbbreviationProvinceUtil 类在第一次调用时可能会加载一些本地资源配置,所以能够应用 PowerMock 禁止初始化。而后在 setup() 办法里对动态类进行 Mock 设置,PowerMockito.mockStatic(AbbreviationProvinceUtil.class)。最初在 test getStudentByIdStatic 测试方法里对 convert2Abbreviation() 办法指定返回默认值:PowerMockito.when(AbbreviationProvinceUtil.convert2Abbreviation(Mockito.any())).thenReturn(abbreviationResult)

运行时在控制台会输入:

<font color=”#dd0000″>Notifications are not supported for behaviour ALL_TESTINSTANCES_ARE_CREATED_FIRST</font>

这是 Powermock 的正告信息,不影响运行后果。

如果单元测试代码不须要对静态方法、final办法 Mock,就没必要应用 PowerMock,应用 Spock 自带的 Mock() 就足够了。因为 PowerMock 的原理是在编译期通过 ASM 字节码批改工具批改代码,而后应用本人的 ClassLoader 加载,而加载的静态方法越多,测试耗时就会越长。

7. 动静 Mock 静态方法

思考场景,让静态方法每次调用返回不同的值。

以下代码:

public List<OrderVO> getOrdersBySource(){List<OrderVO> orderList = new ArrayList<>();
        OrderVO order = new OrderVO();
        if ("APP".equals(HttpContextUtils.getCurrentSource())) {if("CNY".equals(HttpContextUtils.getCurrentCurrency())){System.out.println("source -> APP, currency -> CNY");
            } else {System.out.println("source -> APP, currency -> !CNY");
            }
            order.setType(1);
        } else if ("WAP".equals(HttpContextUtils.getCurrentSource())) {System.out.println("source -> WAP");
            order.setType(2);
        } else if ("ONLINE".equals(HttpContextUtils.getCurrentSource())) {System.out.println("source -> ONLINE");
            order.setType(3);
        }
        orderList.add(order);
        return orderList;
}

这段代码的 if else 分支逻辑,次要是根据 HttpContextUtils 这个工具类的静态方法 getCurrentSource()getCurrentCurrency()的返回值来决定流程。这样的业务代码也是咱们平时写单元测试时常常遇到的场景,如果能让 HttpContextUtils.getCurrentSource() 静态方法每次 Mock 出不同的值,就能够很不便地笼罩 if else 的全副分支逻辑。Spock 的 where 标签能够不便地和 PowerMock 联合应用,让 PowerMock 模仿的静态方法每次返回不同的值,代码如下:

PowerMock 的 thenReturn 办法返回的值是 sourcecurrency等 2 个变量,不是具体的数据,这 2 个变量对应 where 标签里的前两列 source|currency。这样的写法,就能够在每次测试业务办法时,让HttpContextUtils.getCurrentSource()HttpContextUtils.getCurrentCurrency()返回不同的起源和币种,就能轻松的笼罩 ifelse的分支代码。即 Spock 应用 where 表格的形式让 PowerMock 具备了动静 Mock 的性能。接下来,咱们再看一下如何对于 final 变量进行 Mock。

public List<OrderVO> convertOrders(List<OrderDTO> orders){List<OrderVO> orderList = new ArrayList<>();
        for (OrderDTO orderDTO : orders) {OrderVO orderVO = OrderMapper.INSTANCE.convert(orderDTO);
            if (1 == orderVO.getType()) {orderVO.setOrderDesc("App 端订单");
            } else if(2 == orderVO.getType()) {orderVO.setOrderDesc("H5 端订单");
            } else if(3 == orderVO.getType()) {orderVO.setOrderDesc("PC 端订单");
            }
            orderList.add(orderVO);
        }
        return orderList;
}

这段代码里的 for 循环第一行调用了 OrderMapper.INSTANCE.convert() 转换方法,将 orderDTO 转换为 orderVO,而后依据type 值走不同的分支,而 OrderMapper 是一个接口,代码如下:

@Mapper
public interface OrderMapper {
    // 即便不必 static final 润饰,接口里的变量默认也是动态、final 的
    static final OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class);

    @Mappings({})
    OrderVO convert(OrderDTO requestDTO);
}

INSTANCE是接口 OrderMapper 里定义的变量,接口里的变量默认都是 static final 的,所以咱们要先把这个 INSTANCE 动态 final 变量 Mock 掉,这样能力调用它的办法 convert() 返回咱们想要的值。OrderMapper这个接口是 mapstruct 工具的用法,mapstruct是做对象属性映射的一个工具,它会主动生成 OrderMapper 接口的实现类,生成对应的 setget 办法,把 orderDTO 的属性值赋给 orderVO 属性,通常状况下会比应用反射的形式好不少。看下 Spock 如何写这个单元测试:

@Unroll
def "test convertOrders"() {
  given: "Mock 掉 OrderMapper 的动态 final 变量 INSTANCE,并联合 Spock 设置动静返回值"
  def orderMapper = Mock(OrderMapper.class)
  Whitebox.setInternalState(OrderMapper.class, "INSTANCE", orderMapper)
  orderMapper.convert(_) >> order

  when: 
  def orders = service.convertOrders([new OrderDTO()])

  then: "验证后果"
  with(orders) {it[0].orderDesc == desc
  }

  where: "测试数据"
  order                || desc
  new OrderVO(type: 1) || "App 端订单"
  new OrderVO(type: 2) || "H5 端订单"
  new OrderVO(type: 3) || "PC 端订单"
}
  • 首先应用 Spock 自带的 Mock() 办法,将 OrderMapper 类 Mock 为一个模仿对象orderMapperdef orderMapper = Mock(OrderMapper.class)
  • 而后应用 PowerMock 的 Whitebox.setInternalState(),对OrderMapper 接口的 static final 常量 INSTANCE 赋值 (Spock 不反对动态常量的Mock),赋的值正是应用 SpockMock 的对象orderMapper
  • 应用 Spock 的 Mock 模仿 convert() 办法调用,orderMapper.convert(_) >> order,再联合 where 表格,实现动静 Mock 接口的性能。

次要是这 3 行代码:

def orderMapper = Mock(OrderMapper.class) // 先应用 Spock 的 Mock
Whitebox.setInternalState(OrderMapper.class, "INSTANCE", orderMapper) // 通过 PowerMock 把 Mock 对象 orderMapper 赋值给动态常量 INSTANCE
orderMapper.convert(_) >> order // 联合 where 模仿不同的返回值

这样就能够应用 Spock 联合 PowerMock 测试动态常量,达到笼罩 if else 不同分支逻辑的性能。

8. 覆盖率

Jacoco 是统计单元测试覆盖率的一种工具,当然 Spock 也自带了覆盖率统计的性能,这里应用第三方 Jacoco 的起因次要是国内公司应用的比拟多一些,包含美团很多技术团队当初应用的也是 Jacoco,所以为了兼容就以 Jacoco 来查看单元测试覆盖率。这里说下如何通过 Jacoco 确认分支是否齐全笼罩到。

在 pom 文件里援用 Jacoco 的插件:jacoco-maven-plugin,而后执行mvn package 命令,胜利后会在 target 目录下生成单元测试覆盖率的报告,点开报告找到对应的被测试类查看笼罩状况。

绿色背景示意齐全笼罩,黄色是局部笼罩,红色没有笼罩到。比方第 34 行黄色背景的 else if() 判断,提醒有二分之一的分支缺失,尽管它上面的代码也被笼罩了(显示为绿色),这种状况跟具体应用哪种单元测试框架没关系,因为这只是分支覆盖率统计的规定,只不过应用 Spock 的话,解决起来会更简略,只需在where 下减少一行针对的测试数据即可。

9. DAO 层测试

DAO 层的测试有些不太一样,不能再应用 Mock,否则无奈验证 SQL 是否正确。对于 DAO 测试有个别最简的形式是间接应用 @SpringBootTest 注解启动测试环境,通过 Spring 创立 Mybatis、Mapper 实例,但这种形式并不属于单元测试,而是集成测试领域了,因为当启用 @SpringBootTest 时,会把整个利用的上下文加载进来。不仅耗时工夫长,而且一旦依赖环境上有任何问题,可能会影响启动,进而影响 DAO 层的测试。最初,须要到数据库尽可能隔离,因为如果大家都应用同一个 Test 环境的数据的话,一旦测试用例编写有问题,就可能会净化 Test 环境的数据。

针对以上场景,可采纳以下计划:

  1. 通过 MyBatis 的 SqlSession 启动 mapper 实例(防止通过 Spring 启动加载上下文信息)。
  2. 通过内存数据库(如 H2)隔离大家的数据库连贯(齐全隔离不会存在相互烦扰的景象)。
  3. 通过 DBUnit 工具,用作对于数据库层的操作拜访工具。
  4. 通过扩大 Spock 的注解,提供对于数据库 Schema 创立和数据 Data 加载的形式。如 csv、xml 或间接 Closure 编写等。

在 pom 文件减少相应的依赖。

<dependency>
     <groupId>com.h2database</groupId>
     <artifactId>h2</artifactId>
     <version>1.4.200</version>
     <scope>test</scope>
 </dependency>
 <dependency>
     <groupId>org.dbunit</groupId>
     <artifactId>dbunit</artifactId>
     <version>2.5.1</version>
     <scope>test</scope>
 </dependency>

减少 Groovy 的 maven 插件、资源文件拷贝以及测试覆盖率统计插件。

<!-- 测试插件 -->
<plugin>
  <groupId>org.codehaus.gmavenplus</groupId>
  <artifactId>gmavenplus-plugin</artifactId>
  <version>1.8.1</version>
  <executions>
    <execution>
      <goals>
        <goal>addSources</goal>
        <goal>addTestSources</goal>
        <goal>generateStubs</goal>
        <goal>compile</goal>
        <goal>generateTestStubs</goal>
        <goal>compileTests</goal>
        <goal>removeStubs</goal>
        <goal>removeTestStubs</goal>
      </goals>
    </execution>
  </executions>
</plugin>
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.0.0-M3</version>
  <configuration>
    <useFile>false</useFile>
    <includes>
      <include>**/*Spec.java</include>
    </includes>
    <parallel>methods</parallel>
    <threadCount>10</threadCount>
    <testFailureIgnore>true</testFailureIgnore>
  </configuration>
</plugin>
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-resources-plugin</artifactId>
  <version>2.6</version>
  <executions>
    <execution>
      <id>copy-resources</id>
      <phase>compile</phase>
      <goals>
        <goal>copy-resources</goal>
      </goals>
      <configuration>
        <outputDirectory>${basedir}/target/resources</outputDirectory>
        <resources>
          <resource>
            <directory>${basedir}/src/main/resources</directory>
            <filtering>true</filtering>
          </resource>
        </resources>
      </configuration>
    </execution>
  </executions>
</plugin>
<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.2</version>
  <executions>
    <execution>
      <id>prepare-agent</id>
      <goals>
        <goal>prepare-agent</goal>
      </goals>
    </execution>
    <execution>
      <id>report</id>
      <phase>prepare-package</phase>
      <goals>
        <goal>report</goal>
      </goals>
    </execution>
    <execution>
      <id>post-unit-test</id>
      <phase>test</phase>
      <goals>
        <goal>report</goal>
      </goals>
      <configuration>
        <dataFile>target/jacoco.exec</dataFile>
        <outputDirectory>target/jacoco-ut</outputDirectory>
      </configuration>
    </execution>
  </executions>
</plugin>

退出对于 Spock 扩大的主动解决框架(用于数据 SchemaData初始化操作)。

这里介绍一下次要内容,注解@MyDbUnit

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@ExtensionAnnotation(MyDbUnitExtension.class)
@interface MyDbUnit {
    /**
     * <pre>
     * content = {*    your_table_name(id: 1, name: 'xxx', age: 21)
     *    your_table_name(id: 2, name: 'xxx', age: 22)
     * })
     </pre>
     * @return
     */
    Class<? extends Closure> content() default Closure.class;
    /**
     * xml 寄存门路(绝对于测试类)
     * @return
     */
    String xmlLocation() default "";
    /**
     * csv 寄存门路(绝对于测试类)
     * @return
     */
    String csvLocation() default "";}

思考以下代码的测试:

@Repository("personInfoMapper")
public interface PersonInfoMapper {@Delete("delete from person_info where id=#{id}")
    int deleteById(Long id);

    @Select("select count(*) from person_info")
    int count();

    @Select("select * from user_info")
    List<PersonInfoDO> selectAll();}

Demo1(应用 @MyDbUnitcontent 指定导入数据内容,格局Closure)。

class Demo1Spec extends MyBaseSpec {

    /**
     * 间接获取待测试的 mapper
     */
    def personInfoMapper = MapperUtil.getMapper(PersonInfoMapper.class)

    /**
     * 测试数据筹备,通常为 sql 表构造创立用的 ddl,反对多个文件以逗号分隔。*/
    def setup() {executeSqlScriptFile("com/xxx/xxx/xxx/......../schema.sql")
    }
    /**
     * 数据表革除,通常待 drop 的数据表
     */
    def cleanup() {dropTables("person_info")
    }

    /**
     * 间接结构数据库中的数据表, 此办法实用于数据量较小的 mapper sql 测试
     */
    @MyDbUnit(
            content = {person_info(id: 1, name: "abc", age: 21)
                person_info(id: 2, name: "bcd", age: 22)
                person_info(id: 3, name: "cde", age: 23)
            }
    )
    def "demo1_01"() {
        when:
        int beforeCount = personInfoMapper.count()
        // groovy sql 用于疾速执行 sql,不仅能验证数据后果,也可向数据中增加数据。def result = new Sql(dataSource).firstRow("select * from `person_info`") 
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 3
        result.name == "abc"
        deleteCount == 1
        afterCount == 2
    }

    /**
     * 间接结构数据库中的数据表, 此办法实用于数据量较小的 mapper sql 测试
     */
    @MyDbUnit(content = {person_info(id: 1, name: 'a', age: 21)
    })
    def "demo1_02"() {
        when:
        int beforeCount = personInfoMapper.count()
        def result = new Sql(dataSource).firstRow("select * from `person_info`")
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 1
        result.name == "a"
        deleteCount == 1
        afterCount == 0
    }
}

setup() 阶段,把数据库表中的 Schema 创立好,而后通过上面的 @MyDbUnit 注解的 content 属性,把数据导入到数据库中。person_info是表名,idnameage是数据。

通过 MapperUtil.getMapper() 办法获取 mapper 实例。

当测试数据量较大时,能够编写相应的数据文件,通过 @MyDbUnitxmlLocationcsvLocation 加载文件(别离反对 csv 和 xml 格局)。

如通过 csv 加载文件,csvLocation指向 csv 文件所在文件夹。

 @MyDbUnit(csvLocation = "com/xxx/........./data01")
    def "demo2_01"() {
        when:
        int beforeCount = personInfoMapper.count()
        def result = new Sql(dataSource).firstRow("select * from `person_info`")
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 3
        result.name == "abc"
        deleteCount == 1
        afterCount == 2
    }

通过 xml 加载文件,xmlLocation指向 xml 文件所在门路。

@MyDbUnit(xmlLocation = "com/xxxx/........./demo3_02.xml")
    def "demo3_02"() {
        when:
        int beforeCount = personInfoMapper.count()
        def result = new Sql(dataSource).firstRow("select * from `person_info`")
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 1
        result.name == "a"
        deleteCount == 1
        afterCount == 0
    }

还能够不通过 @MyDbUnit 而应用 API 间接加载测试数据文件。

class Demo4Spec extends MyBaseSpec {def personInfoMapper = MapperUtil.getMapper(PersonInfoMapper.class)

    /**
     * 数据表革除,通常待 drop 的数据表
     */
    def cleanup() {dropTables("person_info")
    }
    def "demo4_01"() {
        given:
        executeSqlScriptFile("com/xxxx/.........../schema.sql")
        IDataSet dataSet = MyDbUnitUtil.loadCsv("com/xxxx/.........../data01");
        DatabaseOperation.CLEAN_INSERT.execute(MyIDatabaseConnection.getInstance().getConnection(), dataSet);

        when:
        int beforeCount = personInfoMapper.count()
        def result = new Sql(dataSource).firstRow("select * from `person_info`")
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 3
        result.name == "abc"
        deleteCount == 1
        afterCount == 2
    }

    def "demo4_02"() {
        given:
        executeSqlScriptFile("com/xxxx/.........../schema.sq")
        IDataSet dataSet = MyDbUnitUtil.loadXml("com/xxxx/.........../demo3_02.xml");
        DatabaseOperation.CLEAN_INSERT.execute(MyIDatabaseConnection.getInstance().getConnection(), dataSet);

        when:
        int beforeCount = personInfoMapper.count()
        def result = new Sql(dataSource).firstRow("select * from `person_info`")
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 1
        result.name == "a"
        deleteCount == 1
        afterCount == 0
    }
}

最初为大家梳理了一些文档,供大家参考。

  • Spock Framework Reference Documentation
  • 老 K 的 Java 博客

作者简介

建华,美团优选事业部工程师。

浏览美团技术团队更多技术文章合集

前端 | 算法 | 后端 | 数据 | 平安 | 运维 | iOS | Android | 测试

| 在公众号菜单栏对话框回复【2020 年货】、【2019 年货】、【2018 年货】、【2017 年货】等关键词,可查看美团技术团队历年技术文章合集。

| 本文系美团技术团队出品,著作权归属美团。欢送出于分享和交换等非商业目标转载或应用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者应用。任何商用行为,请发送邮件至 tech@meituan.com 申请受权。

正文完
 0