什么是质量金字塔?如何实现?

“质量金字塔”是一个通常与软件测试和质量保证相关的概念。它表示一个层次结构,根据范围和抽象级别说明各种类型测试的分布。金字塔通常由三个主要层组成:底部的单元测试,中间的集成测试,最后是顶部的端到端(E2E)测试。这个想法是强调单元测试的坚实基础,并逐渐减少更高级别的测试。

以下是质量金字塔中每一层的细分:

  1. 单元测试:
    • 范围:单个组件或功能。
    • 用途:验证各个代码单元(例如函数、方法或类)是否按预期独立工作。
    • 特点:执行速度快、粒度细、与外部依赖隔离。
  • 集成测试:
    • 范围:组件或模块之间的交互。
    • 用途:验证不同的单元或组件在集成时是否可以正常工作。
    • 特点:比单元测试慢,注重单元之间的交互和协作。
  • 端到端 (E2E) 测试:
    • 范围:整个应用程序或系统。
    • 用途:模拟真实的用户场景并端到端验证系统的行为。
    • 特点:执行最慢,涉及整个系统,对系统进行整体测试。

    质量金字塔是实现平衡且高效的测试策略的指南。金字塔背后的基本原理是促进更多数量的快速且集中的单元测试,从而在开发过程中提供快速反馈。随着金字塔向上移动,测试数量会减少,但范围和覆盖范围会增加。

    金字塔模型与“冰淇淋甜筒”模型形成对比。或“倒金字塔”方法,过度强调高水平的端到端测试,可能导致反馈周期变慢并增加维护工作。

    如何实现?
    假设我们在某家 X 公司工作,并且正在构建一个简单的 REST API。我们暴露了一些终点。我们有服务层来处理更复杂的流程。此外,我们还有一个 Repository 用于连接到某些数据库。我们大多数人在公司中都会遇到的标准内容。当前的问题是如何在本例中实现质量金字塔、应该存在哪些层以及应该使用哪些工具。

    单元测试层
    单元测试的编写方式必须小、执行快,并且仅测试一小部分,即一个单元,应用程序的。它们不应该依赖于任何其他东西,或者调用任何其他系统,或者需要特殊的设置才能运行。这个想法是让它们在开发阶段一直运行,而且也在每个代码审查/合并请求阶段运行。如果它们运行很长时间,人们就不会经常运行它们,这样我们就会失去它们的好处。

    当谈到 Java 世界中的单元测试时,我的建议是使用JUnit5。如果您由于某种原因无法使用它,JUnit4 以及一些额外的库也可以解决问题。
    在我们的用例中,一个简单的单元测试可能如下所示。

    @SpringBootTest
    class PostServiceTest {
     
        @Mock
        PostRepository postRepository;
     
        @InjectMocks
        PostService postService = new PostService();
     
        @BeforeEach
        void setUp() {
            List list = new ArrayList<>();
     
            Post post = new Post();
            post.setTitle("title 1");
            list.add(post);
     
            Mockito.when(postRepository.findAll()).thenReturn(list);
        }
     
        @Test
        void getAllPosts() {
            List list = postService.getAllPosts();
     
            Assertions.assertEquals(1,list.size());
            Assertions.assertEquals(
    "title 1", list.get(0).getTitle());
        }
    }

    在本代码示例中,我们使用 Mockito 对 Repository 进行了模拟。我们没有使用原来的版本库,也没有进行任何数据库调用。

    经验法则是,对于我们正在编写单元测试的代码可能具有的任何依赖关系,始终要进行模拟或使用复制。我们这样做是为了确保不会出现误报,即依赖关系中存在错误或问题,导致单元测试失败,而我们的代码却没有错误。

    此外,通过使用模拟,我们可以缩短执行时间,从而使单元测试尽可能小、尽可能快。

    组件层
    在我看来,下一层是可选的,它就是组件层。组件测试背后的理念是测试系统中更大的部分,即组件。

    在实践中,我们可以使用 JUnit 轻松创建组件测试,并在测试的组件边界上利用模拟依赖关系。这样,我们就能轻松决定组件测试的大小。由于组件测试的规模较大,我们可以用较少的测试次数覆盖整个系统。

    虽然我理解它们背后的逻辑和推理,但在现实生活中却很少遇到。要让它们发挥价值,我们需要一些非常复杂的系统和复杂的组件。即使在这些用例中,组件测试的投资回报率也要比单元测试和后面几层的其他测试高出一个大问号。

    功能层
    我认为下一层应该是功能测试层。在这里,我们需要从用户角度出发,涵盖与系统的所有交互,并验证在这些情况下,一切都能按预期执行。有些人可能会说,这应该叫做系统测试。

    我个人倾向于使用功能测试这个术语,因为它更容易让人们(包括非技术人员)理解测试的内容。此外,多年来,我看到人们在各种情况下使用系统测试,其含义也不尽相同。

    功能测试需要满足一些不同于单元和组件测试的要求。功能测试需要能够独立运行,因为其目的是针对 "工作系统 "执行测试,通常是先针对本地版本,然后针对不同环境中的应用程序版本,如开发、测试、暂存,也许还有生产环境。因此,在不同的环境中运行应用程序时,应该有一种方法来指示不同的基本 URL 或其他东西,这一点也不足为奇。

    同样重要的是,不同的环境(开发、测试、暂存和生产)要尽可能相同。这样,我们就能很容易地在所有环境下运行相同的功能测试,并确保运行功能测试时出现的任何错误都是真正的错误,而不是特定环境的错误。多年来,我曾看到过一些非常不同的设置,其结果是,实际上,不同的测试在不同的环境中运行。这导致了测试质量的下降,而测试质量的下降又不可避免地导致了产品质量的下降。

    随着时间的推移,我注意到 BDD(行为驱动开发)是创建功能测试的最佳方法。我最常用的工具是 Cucumber。

    在我们的用例中,一个功能测试的 BDD 可能是这样的

     Scenario: load data
        Given base url 'http://localhost:8080'
        When user hit end point 'posts'
        Then I expect list of data

    代码如下:

    public class StepDefinitions {
     
        HttpClient client;
        String baseUrl;
        private HttpResponse data;
     
        public StepDefinitions() {
            client = HttpClient.newBuilder()
                    .version(HttpClient.Version.HTTP_1_1)
                    .followRedirects(HttpClient.Redirect.NORMAL)
                    .connectTimeout(Duration.ofSeconds(20))
                    .build();
        }
     
        private HttpResponse hitURL(String urlPath) throws IOException, InterruptedException {
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(urlPath))
                    .build();
     
            return client.send(request, HttpResponse.BodyHandlers.ofString());
        }
     
        @Given("base url {string}")
        public void base_url(String url) {
            baseUrl = url;
        }
        @When(
    "user hit end point {string}")
        public void user_hit_end_point(String endPoint) {
            try {
                data = hitURL(baseUrl +
    "/" + endPoint) ;
            } catch (IOException e) {
                data = null;
                e.printStackTrace();
            } catch (InterruptedException e) {
                data = null;
                e.printStackTrace();
            }
        }
        @Then(
    "I expect list of data")
        public void i_expect_list_of_data() {
            if(data == null)
              throw new io.cucumber.java.PendingException();
        }
     
    }

    在我们的用例中,我们正在测试与简单 REST API 的交互,因此我们利用 Java HTTP Client 进行 REST 调用,并用 Cucumber 验证结果。如果我们的用户要与更复杂的系统进行交互,我们可能会使用 Selenium 而不是 HTTPClient。

    我个人认为,BDD 非常适合功能测试的生态系统,因为它易于阅读,而且非技术人员也可以轻松地为其做出贡献和补充。

    端到端层
    金字塔的下一层是端到端测试。这里的逻辑是真正测试整个链条。某物或某人与我们的系统交互,触发我们的系统与其他系统交互,以此类推。在某一点上,响应开始通过这个链条返回。这些响应需要验证,这样我们才能知道一切都按预期运行。编写这样的测试比较困难。它们需要在尽可能接近生产环境的环境中运行。不言而喻,稳定的环境是成功的关键。所有股东都需要支持这种类型的测试。因为它们很复杂,编写它们需要时间,而且如果由于测试环境不稳定而导致误报,人们就会停止运行和编写它们。

    这些测试的好处在于,它们只需要覆盖系统中与其他系统交互的部分。

    我们在编写端到端测试时应使用的工具和库,通常与功能测试时使用的工具和库相同。
    端到端测试需要独立,原因与功能测试相同。它们不应与任何特定环境绑定,我们应在应用程序的多个环境中运行它们。在暂存环境中运行测试是必须的,而在之前的任何环境中运行测试都是很好的补充。

    性能层
    下一层经常被忽视:性能层。在大多数情况下,前面所有的层都是测试一个用户一次点击的情况,而我们都知道,这并不是真实用户与我们系统交互的方式。这就是为什么我们需要测试系统在真实场景中的性能。这就是负载测试(也称为性能测试)的作用所在。

    我的首选武器是加特林。它能完美地完成任务,配置简单,用途广泛。在我们的使用案例中,负载测试可能是这样的

    public class LoadTestSimulation extends Simulation {
     
        ChainBuilder query = exec(
                http("get posts").get("/posts"))
                        .pause(1);
     
        HttpProtocolBuilder httpProtocol =
                http.baseUrl(
    "http://localhost:8080")
                        .acceptHeader(
    "application/json")
                        .acceptLanguageHeader(
    "en-US,en;q=0.5")
                        .acceptEncodingHeader(
    "gzip, deflate")
                        .userAgentHeader(
                               
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:16.0) Gecko/20100101 Firefox/16.0"
                        );
     
        ScenarioBuilder users = scenario(
    "Users").exec(query);
     
        {
            setUp(
                    users.injectOpen(rampUsers(10).during(10))
            ).protocols(httpProtocol);
        }
    }

    要运行它,我们只需执行以下命令:

    $ mvn gatling:test

    经验法则是,在将代码推送到生产环境之前,必须在暂存环境中运行性能测试。正如我们在研究端到端测试时所讨论的,暂存环境需要尽可能接近生产环境并保持稳定。性能测试不应与任何环境绑定。

    我们金字塔中的最后一层是额外层,原因很简单,大多数人都没有这一层,或者不把它当作测试层。平心而论,这其实与测试无关,更多的是为了在生产过程中出现问题时提供保护。

    我强烈建议大家将开发的所有功能都置于功能标志之后。

    功能标志的概念很简单:如果标志为 "开",则功能处于活动状态。停用后,用户将看不到该功能。从本质上讲,功能标志就像一个开关,我们可以打开它,激活或停用某个功能。因此,如果我们在生产过程中遇到问题,只需翻转功能标志,禁用有问题的功能,确保一切恢复正常,而无需打补丁和向生产中推送新代码。

    功能标志是一个强大的概念,但却经常被忽视。正如我之前所说,它并不是一个真正的测试层,而是所有应用程序都应具备的强大保护层。

    资源: