你在测试金字塔的哪一层?(下)

2024-01-22 09:08:28
韩笑
  • 访问次数: 2
  • 注册日期: 2016-08-04
  • 最后登录: 2016-08-04
  • 我的积分: 69
  • 门派等级: 玄清 等级1 居士

《你在测试金字塔的哪一层?(上)》中介绍了自动化测试的重要性以及测试金字塔。测试金字塔分为单元测试、服务测试、UI测试,它们分别是什么呢?本期文章让我们一起详细看看测试金字塔的不同层次。

一、 单元测试

单元测试是指对程序模块(软件设计的最小单位)进行正确性检验的测试工作,能够提高代码质量和可维护性。


但对“一个单元”的感念是没有标准答案,每个人可以根据自身所处的编程范式和语言环境确定。在函数式语言中,一个函数可以被视为一个单元,其单元测试涉及使用不同的参数调用该函数,并断言其返回了期待的结果。而在面向对象语言里,下至一个方法,上至一个类都有可能视为一个单元。


单元测试的一个重要好处在于我们可以为所有的产品代码类写单元测试,不需要在意它们的功能或者它们在内部结构中所处的层次。我们可以对controller进行单元测试,也可以用同样的方式对repository、领域类或文件读写类进行单元测试。一个良好的开端始于坚持一个实现类对应一个测试类的原则。


一个好的单元测试类至少应该测试该类的公共接口,因为私有方法无法直接进行测试。受保护的和包私有的方法可以被测试类直接调用(如果测试类和生产代码类的包结构相同),但是测试这些方法可能会过于以来实现细节。


编写单元测试有一条准则:测试应该覆盖代码的所有路径,包括正常路径和边缘路径,同时不与代码的实现有过于紧密的耦合。如果测试与产品代码耦合太紧密,这可能失去单元测试作为代码变更保护网的好处,这会导致每次重构测试的失败,给测试人员增加额外的工作量。因此,我们应该测试可观察的行为,而不是过于依赖实现的内部结构。


在编写单元测试时,我们需要思考:


如果我得输入是X和Y,输出会是Z吗?

而不是这样:

如果我的输入是x和y,那么这个方法会先调用A类,然后调用B类,接着输出A类和B类返回值相加的结果吗?


私有方法应该被视为实现细节。有人认为,单元测试是毫无意义的工作,为了获得高测试覆盖率就必须测试所有方法,包括getter、setter等琐碎的代码。

但这个观点是错误的。我们确实需要测试公共接口,但重要的是不要测试微不足道的代码。这些代码不会带来任何价值,应该节省时间开始其他有意义的工作。


如果你发现自己陷入测试私有方法的困境中,先问问自己为什么需要测试私有方法。很可能是一个设计问题,而不仅仅是方法可见性的问题。可能是因为方法过于复杂,如果通过公共接口来测试它,需要准备大量的数据和环境。在这种情况下,可以考虑将原来的类拆分成两个类,按照职责进行拆分。将原来急于测试的私有方法移到新的类中,然后让旧类调用新类上的方法。这样,原来难以测试的私有方法就变成了公共方法,可以轻松添加测试。同时,这种重构还改善了代码结构,符合单一职责原则。


一个好的测试结构是这样的:

  • 准备测试数据
  • 调用被测方法
  • 断言返回的是你期待的结果


有一个口诀可以帮你记住这种结构:“Arrange、Act、Assert”。另一个口诀则是从BDD获取的灵感given、when、then”,即given是准备数据,when是调用方法,then是断言。


这种模式不仅适用于单元测试,还可以应用于其他更高层次的测试。在任何情况下,这种测试结构都能让测试保持一致,且易于阅读。此外,使用这种结构写出来的测试往往更加简短、更具表达力。


在明确了要测试什么以及如何组织单元测试后,我们可以看一个简化版的ExampleController类:

@RestController
public class ExampleController {
 
    private final PersonRepository personRepo;
 
    @Autowired
    public ExampleController(final PersonRepository personRepo) {
        this.personRepo = personRepo;
    }
 
    @GetMapping("/hello/{lastName}")
    public String hello(@PathVariable final String lastName) {
        Optional foundPerson = personRepo.findByLastName(lastName);
 
        return foundPerson
                .map(person -> String.format("Hello %s %s!",
                        person.getFirstName(),
                        person.getLastName()))
                .orElse(String.format("Who is this '%s' you're talking about?",
                        lastName));
    }
}

一个针对hello(lastname)方法的单元测试可能是这样的:


public class ExampleControllerTest {
 
    private ExampleController subject;
 
    @Mock
    private PersonRepository personRepo;
 
    @Before
    public void setUp() throws Exception {
        initMocks(this);
        subject = new ExampleController(personRepo);
    }
 
    @Test
    public void shouldReturnFullNameOfAPerson() throws Exception {
        Person peter = new Person("Peter", "Pan");
        given(personRepo.findByLastName("Pan"))
            .willReturn(Optional.of(peter));
 
        String greeting = subject.hello("Pan");
 
        assertThat(greeting, is("Hello Peter Pan!"));
    }
 
    @Test
    public void shouldTellIfPersonIsUnknown() throws Exception {
        given(personRepo.findByLastName(anyString()))
            .willReturn(Optional.empty());
 
        String greeting = subject.hello("Pan");
 
        assertThat(greeting, is("Who is this 'Pan' you're talking about?"));
    }
}


二、 集成测试

常见的应用通常需要与外部环境进行集成,如数据库,文件系统等。为了更好地隔离测试并提高运行速度,我们通常在写单元测试时不涉及这些外部依赖。不过,这些交互始终是存在的,需要进行测试覆盖。这正是集成测试的用途,是应用与所有外部依赖的集成。


对于自动化测试来说,不仅需要运行应用本身,还需要运行与之集成的组件。如果要测试与数据库的集成,就需要在与运行测试时启动数据库。如果要测试从硬盘里读取文件的功能,就需要先在集成测试种保存一个文件到硬盘上,然后进行读取测试。


前面我提到过「单元测试」是一个模糊的术语,集成测试也是如此。我对集成测试更加狭义:每次只测试一个集成点。在进行测试时,我们使用测试替身来代替其他的外部服务、数据库等。同时,使用契约测试来覆盖测试替身和真实实现之间的约定。这样进行的集成测试更快、更独立、更易理解和调试。


狭义的集成测试主要测试是服务的边界。从概念上来说,这种测试总是在触发应用与外部依赖(如文件系统、数据库、其他服务等)进行集成的行为。例如,一个数据库集成测试可能按照以下步骤进行:

  • 启动数据库
  • 连接应用到数据库
  • 调用被测函数,该函数会往数据库写数据
  • 读取数据库,查看期望的数据是不是被写到了数据库里

另一个例子是通过REST API和外部服务集成的测试,可能会这样写:

  • 启动应用
  • 启动一个被测外部服务的实例(或者一个具有相同接口的测试替身)
  • 调用被测函数,该函数会从外部服务的API读取数据
  • 检查应用是否能正确解析返回结果


集成测试同样可以写得很白盒。一些框架在应用启动后,仍然支持对应用的某些部分进行mock,我们可以验证正确的交互是否发生。


代码中所有涉及数据序列化和反序列化的地方都要写集成测试,保证了对外部系统的数据读写操作的正常行。这些场景可能比你想象得更多,比如说:

  • 调用自身服务的 REST API
  • 读写数据库
  • 调用外部服务的 API
  • 读写队列
  • 写入文件系统


编写狭义的集成测试时,我们应尽可能在本地运行外部依赖,如启动本地的MySQL数据库、针对本地的ext4文件系统进行测试等。如果是与外部服务集成,可以在本地运行该服务的实例,或构建一个在本地运行的模拟真实服务的假服务。


对于无法在本地运行实例的某些第三方服务,可以考虑运行一个专用实例,并在集成测试中指向该实例。这能避免在自动化测试种集成真实的生产环境的服务。在生产环境种生成大量的测试请求可能会干扰日志记录,最坏的情况可能是对该服务产生DoS攻击。通过网络与服务集成是广义集成测试的一大特征,这会导致测试更慢、更难编写。


在测试金字塔中,集成测试的层级比单元测试更高。与隔离了外部依赖的单元测试相比,集成测试通常需要更长的时间来处理缓慢的外部依赖(如文件系统或数据库等)。这可能更难写,因为我们需要确保外部依赖在测试中正常运行,但它们的优势在于建立对应用正确访问外部依赖的信心,这是纯粹的单元测试无法做到的。


PersonRepository是代码里唯一的数据库类。它依赖于Spring Data,我们并没有实际实现它。只需要继承CrudRepository接口并声明一个方法名,剩下的就是Spring魔法了,Spring会帮我们实现其他所有的东西。

public interface PersonRepository extends CrudRepository {
    Optional findByLastName(String lastName);
}

Spring Boot提供了完整的CRUD方法,例如findOne,findAll,save,update和delete。我们自定义的方法(findByLastName())继承了这些基础功能并实现了根据last name获取Persons对象的功能。Spring Data会解析方法的返回类型,按照命名规范解析方法名,从而决定如何实现这些方法。


尽管Spring Data已经实现了与数据库的交互功能,但我认为需要写一个数据库集成测试。首先,它测试了我们自定义的findByLastName方法是否按预期工作。其次,它证明了我们的数据库类正确地使用了Spring的装配特性,并且能够正确地连接到数据库。


我们在本地运行测试,无需真的安装PostgreSQL数据库,而是连接到一个内存H2数据库,这可以提供更简单的环境设置。我们build.gradle中已经将H2定义为测试依赖项。在测试目录下的application.properties文件中没有定义任何spring.datasource属性这会告诉Spring Data使用内存数据库,并在classpath中找到H2运行测试。


当我们真正启动应用时,可以使用int profile(如SPRING_PROFILES_ACTIVE=int设置int),它会连接到application-int.properties里定义的PostgreSQL数据库。


除此以外,使用内存数据库进行测试实际上是有风险的。毕竟,集成测试针对的数据库和我们生产用的数据库是不同。下面是一个集成测试的示例,它先将一个Person对象保存到数据库中,根据last name查找。

@RunWith(SpringRunner.class)
@DataJpaTest
public class PersonRepositoryIntegrationTest {
    @Autowired
    private PersonRepository subject;
 
    @After
    public void tearDown() throws Exception {
        subject.deleteAll();
    }
 
    @Test
    public void shouldSaveAndFetchPerson() throws Exception {
        Person peter = new Person("Peter", "Pan");
        subject.save(peter);
 
        Optional maybePeter = subject.findByLastName("Pan");
 
        assertThat(maybePeter, is(Optional.of(peter)));
    }
}


UI测试

大多数应用都有用户界面,特别是在web应用的上下文中,我们所谈的界面就是指网页界面。但人们常常忽视除了多彩的网页页面,还有许多的REST API界面、命令行界面等。


UI测试的目标是验证应用的用户界面是否按预期工作。例如,用户的输入要触发正确的动作、数据要能正确展示给用户、UI的状态要发生正确变化等。


大家有时候会将UI测试和端到端测试混为一谈。诚然,端到端测试通常包含了许多UI测试。但UI测试不必非得通过端到端的方式完成。根据技术栈不同,有时UI测试可以很简单,只需要为前端的JavaScript代码写一些单元测试,同时用桩(stub)将后端隔离开即可。


对于网页界面而言,UI可以围绕这些部分测试:行为、布局、可用性以及少数人认为需要测试的设计一致性。测试应用的布局是否前后一致确实则有些困难。由于应用类型和用户需求的不同,我们需要确保代码的更改不会意外破坏页面的布局。众所周知,计算机在判断某物「看起来是否不错」方面一直表现不佳。


当我们想测试可用性或一些「看起来对不对」的东西时,就已经超越了自动化测试的范畴。这属于探索性测试、可用性测试、走廊测试的领域。我们需要向用户展示产品,观察他们是否喜欢使用,是否有任何功能会让他们在使用时感到困惑。


通过用户界面测试一个已部署好的应用,这是一个典型的端到端测试(也被称为广域栈测试)。端到端测试会让我们更了解软件能否正常工作,然而它们通常比较脆弱,经常因为一些意料之外的问题而失败,并且错误信息通常不是真正的根本原因。浏览器差异、时间(时序)问题、元素渲染、意外的弹出框这些问题仅仅是冰山一角,但却需要花费大量时间进行调试。


在微服务的世界中,谁负责写这些测试是一个大问题。因为端到端测试覆盖到整个服务,这就导致写端到端测试并不是任何一个团队的责任。


如果有一个集中的质量保障团队来编写端到端测试,这似乎是个不错的选择。但是,拥有一个集中式的QA团队实际上是一种反模式,不符合DevOps的理念。您的团队应该是真正的跨职能团队。回答谁应该负责端到端测试的问题并不容易,这与您的组织具体情况相关。也许您的组织中有一些社区实践或质量协会等机构可以负责这方面的工作。合适的答案与您的组织有关。


此外,端到端测试需要大量的维护成本,且运行速度较慢。试想一下,除非只有几个微服务,否则根本没办法在本地运行端到端测试,因为这需要启动所有的服务。

由于维护成本高昂,我们应该尽量将端到端测试的数量减少到最低限度。考虑到应用中对用户而言具有高价值的交互,并定义产品核心价值的用户旅程,将这些旅程中最重要的步骤转化为自动化的端到端测试。


例如,如果您正在构建一个电子商务网站,最有价值的用户旅程可能是用户搜索商品、将其添加到购物车,然后进行付款。只要这个旅程正常工作,您就无需过多担心。您可以找出一两个重要的用户旅程,并使用端到端测试来覆盖它们。但是,不要过度测试,否则会带来痛苦。


写在最后

请记住,在测试金字塔中,还有许多更低层级的测试,它们已经全面测试了各种边缘情况和与其他系统的集成。不需要在高层级测试中重复测试。否则,高维护成本和大量虚假错误报告将降低开发速度,最终会让您对测试失去信心。