50万奖金+官方证书,深圳国际金融科技大赛正式启动,点击报名 了解详情
写点什么

拒绝质量波动:使用生成式测试寻找隐藏错误

作者:Mourjo Sen

  • 2025-11-07
    北京
  • 本文字数:11948 字

    阅读完需:约 39 分钟

大小:3.74M时长:21:46
拒绝质量波动:使用生成式测试寻找隐藏错误
  • 传统测试依赖程序员编写的枚举示例(测试用例)——测试用例会遗漏一类缺陷,即编写者未曾意识到的“未知的未知”。

  • 依赖预先设定的示例会导致质量波动——基于示例的测试侧重于已知缺陷的可复现性,而生成式测试则侧重于未知缺陷的可发现性。

  • 生成式测试通过程序化地生成输入来发现缺陷,而不是依赖程序员列出示例,从而实现更大的覆盖范围并更好地检测出未预见的缺陷。

  • 生成式测试将失败的输入精简到最少,从而为程序员提供至关重要的反馈,帮助他们进行根因分析并最终修复缺陷。

 

生成式测试为我们评估软件质量提供了一种更好的思维模型。它鼓励我们更深入地思考系统的基本属性,而不是人为地挑选示例。自动化测试是现代软件开发的基石。它们确保每次我们构建新功能时,不会破坏用户依赖的现有特性。

 

传统上,我们通过基于示例的测试来解决这个问题。我们列出一些特定的场景(或测试用例)来验证预期的行为。在银行应用中,我们可能会编写一个测试来断言将 100 美元转移到朋友的银行账户,将他们的余额从 180 美元改变为 280 美元。

 

然而,基于示例的测试有一个关键缺陷。我们软件的质量取决于我们测试套件中的示例。这排除了测试作者没有预见到的场景类别——“未知的未知”。

 

生成式测试是测试软件的一种更健壮的方法。它将我们的重点从枚举示例转移到验证我们系统的基本原理属性。

不变量:系统的不变属性

传统的基于示例的测试依赖测试用例的穷尽性,而生成式(也称为基于属性的)测试从定义必须始终成立的重要系统属性开始。这些属性也被称为不变量。

 

每个系统都有不变量。以下是不同系统中的一些不变量:

 

  • 在 API 端点中,我们永远不想在响应中发送堆栈跟踪

  • 在银行应用中,账户转账不能改变银行中的总金额

  • 在会议应用中,一个人不能同时参加两个会议

  • 在排序算法中,排序后的数组中的每个元素应该小于或等于下一个元素

 

这些属性被定义后,生成式测试会尝试用随机输入破坏属性。目标是确保系统不变量在各种输入下都不会被违反。本质上,这是一个三步过程:

 

  • 给定一个属性(即不变量)

  • 生成不同的输入

  • 找到使属性不成立的最小输入

 

与传统的测试用例不同,触发错误的输入不是写在测试中的——它们是由测试引擎发现的。这很关键,因为我们编写的代码的反例既不容易找到,找出来也不准确。有些错误就隐藏在明处——即使是在基本的算术运算中,如加法。错误隐藏在明处——即使是在基本的算术中

假设我们有一个用于增加一些金额的方法。实现相当简单:

public static double add(double x, double y) {   return x + y;}
复制代码

通常,测试会涉及如下的案例列表:

@Testvoid exampleBasedAdditionTest() {   assertEquals(1.5, add(1.5, 0));   assertEquals(2.1, add(1.1, 1));   assertEquals(5.2, add(2.1, 3.1));   assertEquals(0, add(-1, 1));   assertEquals(-101.8, add(-100, -1.8));   assertEquals(-101, add(-1, -100));   assertEquals(230.5, add(130.5, 100));   assertEquals(231, add(100.6, 130.4));}
复制代码

所有这些案例都通过了,没有错误的迹象。然而,这种“没有证据”的状态并不能证明错误不存在。我们在这里验证的是,加法只在我们传递给它的参数上正确工作——即,我们只是展示了 add 方法在我们为测试手工挑选的八个示例上能正确运行。

 

与此相比,生成式测试从定义加法的基本属性开始:

 

  • 加零是无操作(恒等):a + 0 = a

  • 正负绝对值相加不变:a + (-a) = 0

  • 加法是交换的:a + b = b + a

  • 加法是结合的:a + (b + c) = (a + b) + c

 

使用Jqwik库,我们可以为加法编写一个生成式测试

@Propertyvoid propertyBasedAdditionTest(   @ForAll   double a,



@ForAll double b) { assertEquals(a, add(a, 0), "Additive Identity"); assertEquals(0, add(a, -a), "Additive Inverse"); assertEquals(add(a, b), add(b, a), "Commutativity"); assertEquals(add(1, add(a, b)), add(add(1, a), b), "Associativity");}
复制代码

与第一个测试相比,我们没有枚举示例输入——系统通过“@ForAll”注解为我们做了——这实际上翻译为“对于所有双精度浮点数 a 的值”。使用不同的输入组合,基于属性的测试扫描问题空间并找到定义加法属性不成立的示例。 

实际上,这个测试失败了。在 151 个输入后,生成式测试找到了加法结合性属性不成立的示例

                              |-------------------jqwik-------------------tries = 151                   | # of calls to propertychecks = 151                  | # of not rejected callsgeneration = RANDOMIZED       | parameters are randomly generatedafter-failure = RANDOM_SEED   | use a new random seedwhen-fixed-seed = ALLOW       | fixing the random seed is allowededge-cases#mode = MIXIN       | edge cases are mixed inedge-cases#total = 49         | # of all combined edge casesedge-cases#tried = 8          | # of edge cases tried in current runseed = 5966123421694918588    | random seed to reproduce generated values

Shrunk Sample (40 steps)------------------------ a: 0.02 b: 0.11

Original Sample--------------- a: -1.2356852729401996E16 b: 149.5

Original Error -------------- org.opentest4j.AssertionFailedError: expected: <-1.2356852729401844E16> but was: <-1.2356852729401846E16>
复制代码

这个 bug 的根本原因在于浮点数在二进制中的表示方式。有些数字,比如 0.11,在二进制形式中无法精确表示,因此会被四舍五入。根据四舍五入发生的位置,即括号的位置,结合律的性质会失败:

 

  • 1 + (0.02 + 0.11) = 1.13

  • (1 + 0.02) + 0.11 = 1.1300000000000001

 

这是不使用浮点数表示货币数据的一个好理由。除非测试用例的作者已经意识到双精度算术的问题,否则传统的测试套件不太可能包含这个例子。但如果他们已经意识到这个问题,他们可能已经解决了。

精选测试用例导致质量波动

生成式测试在没有事先知道其存在的情况下发现了上述 bug。基于示例的测试侧重于断言某些测试用例,而生成式测试能均匀地探索问题空间,发现不明显的 bug。不难想象,当这些 bug,无论多么不合直觉,被交付给用户时,会损害我们产品的声誉。

 

通过基于示例的测试,我们验证我们的代码对于特定预定义的输入条件集是正确的。这就是我们所说的质量波动——我们软件的质量取决于其测试用例的选择。作为源代码的作者或专门的测试工程师,我们只为我们能想到的场景编写测试。对个人知识的依赖突出了基于示例测试的谬误——它不探索未发现的 bug 空间,或者未知的未知



基于示例的测试依赖于手工挑选的测试用例,导致质量波动。未发现的 bug 被交付到生产环境,因为测试用例的作者不知道它的存在。搜索而不是仅仅测试 bug

与测试源代码针对一系列预定义的测试用例不同,生成式测试系统地搜索问题空间。它通过生成越来越多的多样化的随机输入开始探索。

 

与传统测试使用固定的例子列表不同,生成式测试通过创建和演变广泛的随机输入来探索问题空间——本质上是在搜索隐藏的 bug。

 

一旦它找到了一个属性不成立的输入,生成式测试就会缩小随机输入,提供最小的输入,该输入的属性也不成立。这是向程序员提供关键反馈以确定根本原因。

 

考虑原始和缩小的样本,其中加法的结合律属性不成立——原始样本比缩小的样本更难理解:

 

  • 原始样本:-1.2356852729401996E16, 149.5

  • 缩小样本:0.02, 0.11

现实世界中的生成式测试:一个会议 API

到目前为止,我们已经看了生成式测试的概念。现在让我们将生成式测试应用于一个设计用于安排会议的真实应用程序。这是一个典型的微服务,有三个 API 端点:

 

  • 创建会议

  • 邀请他人参加会议

  • 接受或拒绝会议邀请

 

该应用程序旨在确保一个人不会被要求同时参加两个会议。在下面的图片中,如果有一个黄色的现有会议,那么没有任何红色会议可以被安排。应用程序及其测试的源代码可以在这里找到。



一个人不能同时参加两个会议。我们的示例应用程序只允许创建不与任何现有会议重叠的新会议。所有红色会议都被禁止,因为它们与现有会议重叠。

修复错误比发现错误更容易

即使像这样的简单系统,用户也可能遇到像 NullPointerException 这样的意外错误。如下所示,包含错误的样本响应没有解释为什么会出现这个错误。因此,用户无法解决问题。



即使是简单的应用程序也有多个故障点——在这个例子中,用户忘记了传递持续时间参数,但响应是一个 5xx 错误,没有解释缺少什么东西。相反,“内部服务器错误”消息似乎表明应用程序有一个内部问题。

 

虽然从 API 响应中移除 NullPointerException 来修复错误很容易,但发现像这样的问题并不简单。在 OSDI 2014 年的论文《简单测试可以防止大多数关键故障》中,作者列出了两个重要发现:

 

  • 大多数生产故障可以通过测试复现

  • 大多数故障需要一系列用户操作才能显现

 

作者研究了 HBase、Cassandra 和 Redis 等工业标准系统,并确定这些可以预防的 bug 实际上真被交付到生产环境中了。这些发现提出了两个重要问题:

 

  • 我们知道测试可以防止 bug,但我们怎么知道我们已经涵盖了所有案例

  • 如果需要多个用户操作,我们必须测试多少可能的组合以确保正确性

 

对于这两个问题,瓶颈来自于程序员无法枚举所有可能的测试用例——它变成了一个组合搜索问题。生成式测试以以下方式解决这两个问题:

 

  • 识别系统必须不违反的属性或不变量(例如,我们永远不应该向用户返回堆栈跟踪)

  • 生成任意复杂的输入组合以系统性搜索违反这些属性的输入

 

在接下来的部分中,我们将探讨生成式测试如何帮助构建更健壮的微服务。像典型的微服务一样,在“快速会议”示例应用程序中有三个层次:表示层、业务逻辑和数据库层。我们现在将生成式测试应用于这些层次。



在表示层中寻找错误

在表示层中,我们希望确保应用程序永远不会返回用户不期望或不理解的内容。

 

这个仓库分支引入了一个生成式测试,用于以下属性:

 

  • 服务器永远不会返回 5xx 错误

  • 响应始终是有效的 JSON

 

我们希望这两个属性对我们应用程序中的所有五个端点都成立:



演示应用程序中有五个简单的端点,用于创建用户、会议、邀请以及接受或拒绝收到的邀请。

 

下面的测试通过生成不同的 HTTP 方法、URL 路径、头部和主体的不同组合,然后验证上述两个属性来实现这一点:

@Propertyvoid responsesAreAlwaysValidJson(   @ForAll("methods")    String method,      @ForAll("paths")    String path,      @ForAll("contentTypes")    String contentTypeHeader,      @ForAll("contentTypes")    String acceptHeader,      @ForAll("bodies")    String body) {



var response = httpRequest(method, path, acceptHeader, contentTypeHeader, body); var responseBody = response.getBody(); int status = response.getStatusCode().value();



var failureMsg = "Status: %s, Body: %s".formatted(status, responseBody); assertThat(responseBody).withFailMessage(failureMsg).isNotBlank(); assertThatValidJson(responseBody, failureMsg); assertThat(status).withFailMessage(failureMsg).isLessThan(500);}
复制代码

错误 1:无效的 JSON 响应

上述测试因 JSONParseException 而失败,因为服务器返回了一个 HTML 响应:

Original Error  --------------  com.fasterxml.jackson.core.JsonParseException:    Unexpected character ('<' (code 60)): expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false')     at [Source: (String)"<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Sat May 24 14:11:11 CEST 2025</div><div>There was an unexpected error (type=Method Not Allowed, status=405).</div></body></html> ; line: 1, column: 1]
复制代码

以下是生成式测试找到的缩减样本,服务器返回 HTML:

Shrunk Sample (1 steps)-----------------------  method: "GET"  path: "/user"  contentTypeHeader: "text/html"  acceptHeader: "text/html"  body:    "{      "userId": 1,      "name": "A",      "duration": {        "from": {          "date": "2025-06-09",          "time": "12:40"        },        "to": {          "date": "2025-06-09",          "time": "12:40"        }      },      "timezone": "Asia/Kolkata"    }    "
复制代码

这个失败的根因很容易错过。请求包含一个"text/html"作为接受头部。在 Spring 中,如果我们在控制器中编写一个端点,它将始终返回我们的代码产生的数据。然而,当没有为特定路径/方法定义控制器时,Spring 将根据接受头部生成一个错误响应,在这个例子中是 HTML,违反了我们的属性,即 API 端点始终返回一个有效的 JSON。

 

尽管在这篇文章中我们更关注错误检测而不是它们的解决方法,但这个特定的错误可以通过添加一个 servlet 配置来覆盖请求的接受头部来修复

错误 2:未处理异常的 5xx 响应

API 永远不应该在其响应中故意返回 5xx 错误状态——特别是如果错误可以通过用户修复。然而,同一个生成式测试发现了一个不同的示例,违反了这个期望:

    Status: 500, Body: {"timestamp":"2025-08-15T05:00:51.642+00:00","status":500,"error":"Internal Server Error","path":"/meeting"}
复制代码

像以前一样,测试引擎找到了一个缩减样本,这个失败发生了:

Shrunk Sample (6 steps)-----------------------  method: "POST"  path: "/meeting"  contentTypeHeader: "application/json"  acceptHeader: "text/html"  body: "{   "meetingId": 1,   "invitees": [] } "
复制代码

服务器日志包含以下堆栈跟踪信息,表明输入中需要一个名为“duration”的参数,但该参数未传递。服务器没有返回包含解释信息的 400 错误,而是返回了一个包含通用有效负载的 500 错误——错误地表明问题出在服务端。

Cannot invoke "me.mourjo.quickmeetings.web.dto.MeetingDuration.from()" because the return value of "me.mourjo.quickmeetings.web.dto.MeetingCreationRequest.duration()" is null
复制代码

要解决这个问题,我们需要添加全局异常处理器来构建适当的错误消息。

错误 3:会议在夏令时间隙中开始

演示层的最后一次测试是确保 API 端点接受有效的日期作为参数。以下测试确保对于有效的参数,响应状态是 2xx。

@Propertyvoid validMeetingRangeShouldReturn2xx(   @ForAll("meetingArgs")    MeetingArgs meetingArgs) {   createMeetingAndExpectSuccess(       meetingArgs.fromDate(),       meetingArgs.fromTime(),       meetingArgs.toDate(),       meetingArgs.toTime(),       meetingArgs.timezone()   );}
复制代码

然而,这个测试也失败了。失败是在夏令时转换期间触发的——当会议开始于夏令时的“间隙”时。

 

在一些时区,由于夏令时存在“间隙”。例如,在 2025 年 3 月 30 日,荷兰的时钟在凌晨 2 点向前调整了 1 小时到 3 点。凌晨 2 点到 3 点之间的小时不存在,被称为“间隙”。

 

在下面生成式测试找到的例子中,会议在 2025 年 3 月 30 日凌晨 2:30 开始,3:00 结束。然而,由于 2:30 落在这个“间隙”中,Java日期时间计算的默认行为是将时钟向前移动,正如文档中所述:“结果的带时区日期时间将通过间隙的长度向前移动”。

 

尽管会议创建参数是有效的,但请求的开始时间 2:30 落在间隙中,并被 Java 标准库认为是 3:30,这在会议的结束时间之后,触发了 bug:

Shrunk Sample (6 steps)-----------------------  meetingArgs:    MeetingArgs[fromDate=2025-03-30, fromTime=02:30:00, toDate=2025-03-30, toTime=03:00:00, timezone=Europe/Amsterdam]
复制代码

这些特殊案例几乎不可能在测试中手动枚举。这种特定情况只会在会议开始于夏令时间隙但结束于间隙之后,并且重新计算的开始时间在结束时间之后时发生。

 

有趣的是,这从技术上讲并不是一个 bug,因为夏令时间隙没有明显的技术修复方法。我们必须决定我们的应用程序如何处理这种情况。一个选项可能是不允许在这些间隙中创建会议。或者,我们可以显示一个警告,考虑到时钟偏移的有效会议开始时间。

 

通过找出这些边缘案例,生成式测试为更好的软件铺平了道路。发现这些可能发生的情况,然后我们为其设计对应的体验,是构建更可靠和可预测软件的第一步。生成式测试有助于发现这些情况,否则很容易被忽视。

在数据库层中寻找错误

在数据库交互层,我们希望确保我们编写的查询将适用于数据库中所有可能的数据组合。作为一个会议应用程序,系统不允许一个人同时参加两个会议。因为我们在数据库中存储会议,我们需要为这个验证编写一个 SQL 查询。

错误 4:SQL 查询错误计算会议重叠

以下查询检查正在创建的新会议(:from 和:to)是否与现有会议重叠。但它有一个 bug——没有事先了解 bug,很难一眼就发现它,更不用说编写测试了。

SELECT ...FROM ...WHERE (  (:from >= existing_meeting.from_ts AND :from <= existing_meeting.to_ts)  OR  (:to >= existing_meeting.from_ts AND :to <= existing_meeting.to_ts))
复制代码

这个查询背后的逻辑推理是,如果新会议在现有会议之间开始或结束,那么新会议就是重叠的会议。然而,它并没有涵盖所有可能性。上述 SQL 查询中的 bug 只有在新会议在现有会议之前开始并在现有会议之后结束时才会发生——即,完全重叠:



新会议与现有会议重叠有四种情况。有 bug 的 SQL 查询错过了第四种情况。

 

通过上面的图表,很容易注意到存在第四种情况。但是,当查询最初编写时,这种清晰的图景可能并不在作者的脑海中,从而引入了 bug。查询在上述四种场景中的三种中正确工作。所以虽然它不是一个完全错误的查询,但它只是部分正确,展示了解决组合问题的困难性——查询中的参数越多,确保所有情况都被覆盖就越难。

 

以下生成式测试发现了查询中的 bug。对于所有会议开始时间和持续时间,它打算通过程序验证数据库的结果正确地检测重叠。

@Property(afterFailure = AfterFailureMode.RANDOM_SEED)void overlappingMeetingsCannotBeCreated(



@ForAll @DateTimeRange(min = "2025-02-12T10:00:00", max = "2025-02-12T11:59:59") LocalDateTime meeting1Start,



@ForAll @IntRange(min = 1, max = 60) int meeting1DurationMins,



@ForAll @DateTimeRange(min = "2025-02-12T10:00:00", max = "2025-02-12T11:59:59") LocalDateTime meeting2Start,



@ForAll @IntRange(min = 1, max = 60) int meeting2DurationMins



) { var debbie = userService.createUser("debbie"); var meeting1End = meeting1Start.plusMinutes(meeting1DurationMins); var meeting2End = meeting2Start.plusMinutes(meeting2DurationMins);



// Create the first meeting createMeeting("Debbie's meeting", meeting1Start, debbie, meeting1End);



// Ask the repository if the second meeting has any overlaps var overlappingMeetingsDb = findOverlaps(meeting2Start, debbie, meeting2End);



// Verify programmatically if there is an overlap - check the query result matches if (doIntervalsOverlap(meeting1Start, meeting1End, meeting2Start, meeting2End)) { assertThat(overlappingMeetingsDb.size()).isEqualTo(1); } else { assertThat(overlappingMeetingsDb).isEmpty(); }}
复制代码

这个测试依赖于doIntervalsOverlap方法来检查查询结果是否正确。它需要两对会议的开始和结束时间,以编程方式检查一个会议是否在另一个会议进行中时开始:

 

  • 会议 1 在会议 2 进行中时开始

  • 会议 2 在会议 1 进行中时开始

 

将这个逻辑写成 SQL 查询,考虑到会议 1 和会议 2 的所有开始和结束时间场景,比命令式地验证给定的一对会议是否重叠要困难。

 

测试失败,并提供了以下缩减样本。会议 1 是一个已存在的会议,尽管会议 2 与会议 1 完全重叠,但允许创建会议 2:

Shrunk Sample (9 steps)-----------------------  meeting1Start: 2025-02-12T10:00:01  meeting1DurationMins: 1  meeting2Start: 2025-02-12T10:00  meeting2DurationMins: 2
复制代码

修复方法是更改 SQL 查询中用于检测重叠的最后一个 AND 子句(:from 和:to 是即将创建的新会议的开始和结束时间):

可以说,尽管原始子句不正确,但它比正确的修复子句更直观。原始子句的意图是通过检查会议是否在现有会议期间开始或结束来检测重叠会议。乍一看这似乎是有效的,因此甚至可能在代码审查中被遗漏。我们人类的倾向通常是更喜欢一个可理解的解决方案,而不是一个更正确的解决方案。由于生成式测试不需要手动列出测试用例,生成式测试绕过了程序员的认知偏见。

多个用户执行多个操作的组合问题

通过基于示例的测试最难捕捉的错误是那些可能性非常多的错误——比如涉及多个用户和操作的场景。

错误 5:接受会议创建了重叠

我们首先模拟用户与系统的交互,就像他们在现实世界中那样。我们的应用程序有四个操作:创建、邀请、接受和拒绝。系统中可以存在许多用户,使用这些操作中的任何一个。

 

在 Jqwik 的动作链的帮助下,我们从一个初始的空数据库开始,并要求系统代表其用户组合不同的操作。这个建模的细节可以在项目仓库中找到。

@ProvideArbitrary<ActionChain<MeetingState>> meetingActions() {   return ActionChain.startWith(this::init)       .withAction(new CreateAction(users))       .withAction(new InviteAction(users))       .withAction(new AcceptInviteAction(users))       .withAction(new RejectInviteAction(users));}
复制代码

通过上述动作链,Jqwik 生成不同的用户和操作的排列组合。然后我们验证没有任何由不同用户和操作组成的序列违反了系统的不变性,即永远不会有任何重叠的会议

@Propertyvoid noOperationCausesAnOverlap(@ForAll("meetingActions") ActionChain<MeetingState> chain) {   chain       .withInvariant(MeetingState::assertNoUserHasOverlappingMeetings)       .run();}
复制代码

测试失败——它找到了一个用户操作序列,导致重叠不变性失败:

Shrunk Sample (26 steps)------------------------  chain: ActionChain[NOT_RUN]: 4 max actions

Invariant failed after the following actions: [ Inputs{action=CREATE, user=alice, from=2025-06-09T10:21Z, to=2025-06-09T10:22Z} Inputs{action=INVITE, user=bob, meetingIdx=0} Inputs{action=CREATE, user=bob, from=2025-06-09T10:21Z, to=2025-06-09T10:22Z} Inputs{action=ACCEPT, user=bob, meetingIdx=0} ]
复制代码

上述输出找出了一个触发错误的最小跨用户操作集

 

  • Alice 创建了会议 0

  • Bob 被邀请参加 Alice 的会议 0

  • Bob 创建了一个与 Alice 的会议 0 重叠的会议 1(到目前为止,这是可以的,因为 Bob 还没有确认他将参加 Alice 的会议 0)

  • Bob 接受了 Alice 的会议 0 的邀请(现在这是一个问题,因为系统允许 Bob 同时参加两个会议——即,Alice 的会议 0 和 Bob 自己的会议 1)

许多输入可以触发相同的错误

还有其他更微妙的情况会触发相同的错误。例如,以下操作涉及三个用户,而不是上述示例中的两个。相同的无重叠不变性在不同的输入条件下被违反,被单一测试捕获:

Invariant failed after the following actions: [    Inputs{action=CREATE, user=alice, from=2025-06-09T10:21Z, to=2025-06-09T10:22Z}    Inputs{action=CREATE, user=bob,   from=2025-06-09T10:21Z, to=2025-06-09T10:22Z}    Inputs{action=INVITE, user=charlie, meetingIdx=1}    Inputs{action=INVITE, user=charlie, meetingIdx=0}    Inputs{action=ACCEPT, user=charlie, meetingIdx=1}    Inputs{action=ACCEPT, user=charlie, meetingIdx=0}  ]
复制代码
  • Alice 创建了一个会议 0

  • Bob 在 Alice 的会议 0 的同时创建了会议 1

  • Charlie 被邀请参加 Bob 的会议 1

  • Charlie 被邀请参加 Alice 的会议 0

  • Charlie 接受了 Bob 的会议 1 的邀请

  • Charlie 接受了 Alice 的会议 1 的邀请(这是一个问题,因为现在 Charlie 已经确认他将在同一时间参加两个会议)

以较低的信噪比更快地调试

请注意,错误只发生在系统条件的一个子集中。上述缩减的样本足够简单,可以在本文中阅读和解释,因为系统在检测到故障后去除了随机生成输入中的噪声。

 

在缩减发生之前,测试发现了其第一个故障,涉及更多的操作,并且更难调试。注意它涉及 REJECT 操作,这在我们之前看到的缩减样本中都没有出现——这正是因为 REJECT 操作不会导致会议重叠。

Invariant failed after the following actions: [    Inputs{action=CREATE, user=bob, from=2025-06-09T10:35Z, to=2025-06-09T10:56Z}    Inputs{action=CREATE, user=bob, from=2025-06-09T11:00Z, to=2025-06-09T11:52Z}    Inputs{action=INVITE, user=alice, meetingIdx=0}    Inputs{action=REJECT, user=charlie, meetingIdx=1}    Inputs{action=ACCEPT, user=bob, meetingIdx=1}    Inputs{action=REJECT, user=charlie, meetingIdx=1}    Inputs{action=CREATE, user=bob, from=2025-06-09T10:38Z, to=2025-06-09T11:38Z}    Inputs{action=INVITE, user=charlie, meetingIdx=0}    Inputs{action=ACCEPT, user=bob, meetingIdx=0}    Inputs{action=REJECT, user=alice, meetingIdx=1}    Inputs{action=CREATE, user=charlie, from=2025-06-09T11:10Z, to=2025-06-09T11:15Z}    Inputs{action=INVITE, user=bob, meetingIdx=1}    Inputs{action=CREATE, user=alice, from=2025-06-09T10:21Z, to=2025-06-09T11:21Z}    Inputs{action=ACCEPT, user=bob, meetingIdx=2}    Inputs{action=REJECT, user=alice, meetingIdx=2}    Inputs{action=REJECT, user=charlie, meetingIdx=1}    Inputs{action=ACCEPT, user=alice, meetingIdx=0}  ]
复制代码

系统能够检测到这种噪声,并能够从我们之前看到的缩减示例中移除 REJECT 操作,为程序员节省宝贵的时间。

错误 6:扩展不变量——拒绝导致空会议

现在我们有了一种复用用户操作的方法,我们可以扩展我们的测试套件,增加新的不变量:我们不应该允许存在没有参与者的会议。请注意,这次我们只定义了一个新的不变量,复用了现有的操作链:

@Propertyvoid noOperationCausesEmptyMeetings(@ForAll("meetingActions") ActionChain<MeetingState> chain) {   chain       .withInvariant(MeetingState::assertEveryMeetingHasOneConfirmedAttendee)       .run();}
复制代码

这个测试失败,以下缩减的示例展示了可能导致空会议的最简单情况:

Invariant failed after the following actions: [    Inputs{action=CREATE, user=alice, from=2025-06-09T10:21Z, to=2025-06-09T10:22Z}    Inputs{action=REJECT, user=alice, meetingIdx=0}  ]
复制代码

基于不变量的测试使我们能够质疑我们构建的系统的基本性质,而不会迷失在测试用例及其变体的迷宫中。我们最初设定的目标是确保不能存在重叠的会议。实现这一点后,我们能够通过轻松扩展其不变量,使应用程序更加健壮,依赖于生成式测试。

基于示例测试的权衡

虽然生成式测试有助于发现未知的错误,但像软件工程中的任何事物一样,有一些需要注意的地方:

 

  • 长运行时间的成本:生成式测试比基于示例的测试需要更长的时间来运行,因为它需要进行穷举搜索。在 CI 构建中,如果这些测试在每次提交时都运行,运行时间的成本将减慢构建过程。

  • 不可重现性:基于随机种子生成的输入导致非确定性故障。有时,测试套件会检测到一个非常罕见的失败条件,但在不同的测试运行中无法重现。尽管像 Jqwik 这样的库会存储特定测试运行中使用的种子,以便在另一次运行中复现,但由于其检测结果并非确定性的,因此可能需要反复进行调查。

  • 学习曲线:属性的范围可以是“用户名必须唯一”,也可以是“银行账户余额即使在账户间转账后也不会改变”。前者可以通过数据库来保证,作为测试意义不大;而后者则非常适合生成式测试。有效地定义属性需要时间,并且通常需要一定的学习曲线。此外,诸如模拟多个用户与系统交互等高级功能也需要大量的学习和维护工作。

所见即全部:未知的未知无法用示例测试

尽管存在一些局限性,但生成式测试最大的优势在于能够对抗人类的认知偏差。当我们仅仅依赖基于示例的测试时,就会陷入一种被称为“所见即全部”(WYSIATI)的认知偏差。Daniel Kahenman推广了这一概念,它强调了人类倾向于仅根据我们能够立即获取的信息做出判断,而忽略甚至无视未知的未知的可能性。

 

基于示例的测试的根本缺陷在于对缺陷存在的先验知识或直觉。例如,来自印度(一个不实行夏令时的国家)的程序员可能从未想到要编写考虑夏令时差异的测试用例,从而导致系统容易受到一些开发者未曾预料到的缺陷的影响。要求在测试缺陷之前必须预先考虑到缺陷的存在,这本身就是一个悖论——如果我们事先知道缺陷的存在,我们一开始就不会引入这个缺陷

 

生成式测试克服了人类知识的局限性,它无需像传统测试用例那样以枚举测试用例为起点。它从系统规范(即系统属性或不变量)入手。由于测试用例由系统生成,因此可以避免人为编写测试用例时可能存在的思维偏差。生成式测试的真正优势在于帮助程序员发现系统中未知的未知因素,从而最终避免我们构建的系统出现偶然的质量问题。

作者介绍

Mourjo Sen 是 Booking.com 的高级软件工程师,拥有十年构建高弹性微服务的经验。他来自印度,目前居住在阿姆斯特丹。

 

原文链接:Beyond Accidental Quality: Finding Hidden Bugs with Generative Testing


2025-11-07 10:497

评论

发布
暂无评论

机器学习与AI|如何利用数据科学优化库存周转率?

Altair RapidMiner

人工智能 数据分析 altair RapidMiner

Pytest 内置插件 Hook 体系:深入了解与实践

测吧(北京)科技有限公司

测试

利用外部数据源 JSON 管理测试:灵活的数据驱动测试方法

测吧(北京)科技有限公司

测试

90后斩获多家名企offer的小哥哥,做对了什么?

霍格沃兹测试开发学社

全链路压力测试:确保系统在高负载下的稳定性与响应能力

测吧(北京)科技有限公司

测试

鸿蒙开发实战:智能日志定位与高效调试技巧

王二蛋和他的张大花

鸿蒙

深入理解 yield 用法:从生成器到高级测试场景的应用

测吧(北京)科技有限公司

测试

推理王者o1到底怎么落地?

脑极体

AI

探索微店API接口:如何高效获取商品详情数据

代码忍者

API 接口 pinduoduo API

鸿蒙开发实战:轻松配置多环境目录,实现高效应用部署

王二蛋和他的张大花

鸿蒙

鸿蒙开发实战:灵活定制编译选项,打造高效应用

王二蛋和他的张大花

鸿蒙

Robotaxi三国杀

脑洞汽车

AI

全面升级的“新清影”,给AI生成视频带来了哪些新玩法?

Alter

智源举办2024具身与世界模型专题峰会 产学研共促技术创新与产业应用

智源研究院

全国数据标准化技术委员会成立,企业该对数据”下狠手”了

用友BIP

为什么真全闪分布式存储离不开 RoCE/RDMA 流控技术?

XSKY星辰天合

#分布式存储 流控技术

利用外部数据源 CSV 管理测试:轻量化数据驱动测试方案

测吧(北京)科技有限公司

测试

深入理解 Fixture 作为参数使用的技巧:提升测试代码的灵活性和复用性

测吧(北京)科技有限公司

测试

C# 单例模式的多种实现

不在线第一只蜗牛

JavaScript C#

61支队伍入围!用友第六届企业数智化应用开发大赛决赛名单公布

新消费日报

鸿蒙开发实战:深度解析网络管理技巧与实战应用

王二蛋和他的张大花

鸿蒙

Pytest-ordering:自定义 Pytest 测试用例执行顺序的指南

测吧(北京)科技有限公司

测试

Pytest 并行与分布式运行测试用例的实现与优化

测吧(北京)科技有限公司

测试

和鲸社区地球科学轻科研交流局:在这个卷来卷去的时代,我们都想要找到一些答案

ModelWhale

数据科学 气象 地球科学 DDE 深时数字地球 大气

重磅发布 | 末等调整和不胜任退出数智化解决方案

用友BIP

TikTok矩阵怎么玩?

Ogcloud

云手机 tiktok云手机 tiktok运营 TikTok养号 tiktok矩阵

从消息中间件架构发展趋势,探讨物联网平台如何支持亿级设备推送?

华为云开发者联盟

IoT Apache Pulsar 消息中间件 华为云IoTDA

深入理解 fixture 的作用范围:优化测试环境的管理

测吧(北京)科技有限公司

测试

改变财务规划思维方式,迎接创新技术新时代

智达方通

技术创新 预算管理 财务规划

拒绝质量波动:使用生成式测试寻找隐藏错误_软件工程_InfoQ精选文章