在现代软件开发中,有效的测试对于确保应用程序的可靠性和稳定性起着关键的作用。
本文提供了编写集成测试的实用建议,展示了如何专注于与外部服务交互的规范,使测试更具可读性和更易于维护。这种方法不仅可以提高测试效率,还可以促进对应用程序内集成过程的更好理解。通过具体示例的视角,我们将探讨各种策略和工具(例如 DSL 包装器、JsonAssert 和 Pact),为读者提供提高集成测试质量和可见性的全面指南。
本文介绍了使用 Groovy 中的 Spock 框架执行集成测试的示例,用于测试 Spring 应用程序中的 HTTP 交互。同时,所提出的主要技术和方法可有效应用于 HTTP 以外的各种类型的交互。
文章《 在 Spring 中编写有效的集成测试:HTTP 请求模拟的组织测试策略》描述了一种编写测试的方法,该方法将测试明确划分为不同的阶段,每个阶段都执行其特定角色。让我们根据这些建议描述一个测试示例,但模拟的不是一个请求,而是两个请求。为简洁起见,将省略 Act 阶段(执行)(可以在项目存储库中找到完整的测试示例)。
所呈现的代码按条件分为:“支持代码”(灰色)和“外部交互规范”(蓝色)。支持代码包括用于测试的机制和实用程序,包括拦截请求和模拟响应。外部交互规范描述了系统在测试期间应与之交互的外部服务的具体数据,包括预期的请求和响应。支持代码为测试奠定了基础,而规范直接与我们要测试的系统的业务逻辑和主要功能相关。
规范只占代码的一小部分,但对于理解测试却具有重要价值,而支持代码则占了很大一部分,但价值较低,并且对于每个模拟声明都是重复的。该代码旨在与 MockRestServiceServer 一起使用。参考WireMock 上的示例,我们可以看到相同的模式:规范几乎相同,而支持代码则有所不同。
本文的目的是提供编写测试的实用建议,以便将重点放在规范上,而将支持代码放在次要位置。
对于我们的测试场景,我提出了一个假设的 Telegram 机器人,它将请求转发给 OpenAI API 并将响应发送回用户。
与服务交互的契约以简化的方式描述,以突出操作的主要逻辑。下面是演示应用程序架构的序列图。我理解从系统架构的角度来看,这种设计可能会引起问题,但请理解这一点——这里的主要目标是展示一种增强测试可见性的方法。
本文讨论了编写测试的以下实用建议:
使用 DSL 包装器可以隐藏样板模拟代码并提供一个简单的接口来处理规范。需要强调的是,所提出的不是特定的 DSL,而是它实现的通用方法。下面提供了一个使用 DSL 的更正测试示例( 完整测试文本)。
setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess("{...}")) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1
其中以方法restExpectation.openai.completions
为例,其描述如下:
public interface OpenaiMock { /** * This method configures the mock request to the following URL: {@code https://api.openai.com/v1/chat/completions} */ RequestCaptor completions(DefaultResponseCreator responseCreator); }
对方法进行注释后,当将鼠标悬停在代码编辑器中的方法名称上时,可以获得帮助,包括查看将被模拟的 URL。
在建议的实现中,使用ResponseCreator
实例来声明来自模拟的响应,允许自定义实例,例如:
public static ResponseCreator withResourceAccessException() { return (request) -> { throw new ResourceAccessException("Error"); }; }
下面显示了指定一组响应的不成功场景的示例测试:
import static org.springframework.http.HttpStatus.FORBIDDEN setup: def openaiRequestCaptor = restExpectation.openai.completions(openaiResponse) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 0 where: openaiResponse | _ withResourceAccessException() | _ withStatus(FORBIDDEN) | _
对于 WireMock,一切都是相同的,只是响应形成略有不同( 测试代码、 响应工厂类代码)。
在实现 DSL 时,可以使用@Language("JSON")
注释方法参数,以启用 IntelliJ IDEA 中特定代码片段的语言功能支持。例如,使用 JSON,编辑器会将字符串参数视为 JSON 代码,从而启用语法突出显示、自动完成、错误检查、导航和结构搜索等功能。以下是注释用法的示例:
public static DefaultResponseCreator withSuccess(@Language("JSON") String body) { return MockRestResponseCreators.withSuccess(body, APPLICATION_JSON); }
它在编辑器中的样子如下:
JSONAssert 库旨在简化 JSON 结构的测试。它使开发人员能够轻松比较预期的 JSON 字符串和实际的 JSON 字符串,并且具有高度的灵活性,支持各种比较模式。
这允许从这样的验证描述移动
openaiRequestCaptor.body.model == "gpt-3.5-turbo" openaiRequestCaptor.body.messages.size() == 1 openaiRequestCaptor.body.messages[0].role == "user" openaiRequestCaptor.body.messages[0].content == "Hello!"
像这样
assertEquals("""{ "model": "gpt-3.5-turbo", "messages": [{ "role": "user", "content": "Hello!" }] }""", openaiRequestCaptor.bodyString, false)
我认为,第二种方法的主要优点是它确保了各种上下文(文档、日志和测试)中数据表示的一致性。这大大简化了测试过程,提供了比较的灵活性和错误诊断的准确性。因此,我们不仅节省了编写和维护测试的时间,而且还提高了测试的可读性和信息量。
在 Spring Boot 中工作时,至少从版本 2 开始,不需要额外的依赖项即可使用该库,因为org.springframework.boot:spring-boot-starter-test
已经包含对org.skyscreamer:jsonassert
的依赖。
我们可以得出的一个结论是,JSON 字符串占据了测试的很大一部分。它们应该被隐藏吗?是也不是。重要的是要了解什么会带来更多好处。隐藏它们可以使测试更紧凑,并简化乍一看就掌握测试本质的过程。另一方面,为了进行彻底的分析,有关外部交互规范的部分关键信息将被隐藏,需要跨文件进行额外的跳转。决定取决于便利性:做对你来说更舒服的事情。
如果您选择将 JSON 字符串存储在文件中,一个简单的选项是将响应和请求分别保存在 JSON 文件中。下面是一个测试代码( 完整版),演示了一个实现选项:
setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess(fromFile("json/openai/response.json"))) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1
fromFile
方法只是从src/test/resources
目录中的文件中读取一个字符串,并没有任何革命性的想法,但仍可在项目存储库中参考。
对于字符串的变量部分,建议使用org.apache.commons.text.StringSubstitutor进行替换,并在描述模拟时传递一组值,例如:
setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess(fromFile("json/openai/response.json", [content: "Hello! How can I assist you today?"])))
JSON 文件中替换的部分如下所示:
... "message": { "role": "assistant", "content": "${content:-Hello there, how may I assist you today?}" }, ...
采用文件存储方法时,开发人员面临的唯一挑战是制定适当的文件放置方案和命名方案。很容易犯错误,从而降低使用这些文件的体验。解决此问题的一个方法是使用规范,例如 Pact 中的规范,稍后将对此进行讨论。
在用 Groovy 编写的测试中使用所述方法时,您可能会遇到不便:IntelliJ IDEA 不支持从代码导航到文件,但预计将来会添加对该功能的支持。在用 Java 编写的测试中,这种方法效果很好。
让我们从术语开始。
契约测试是一种测试集成点的方法,其中每个应用程序都单独进行测试,以确认其发送或接收的消息符合“契约”中记录的相互理解。这种方法可确保系统不同部分之间的交互符合预期。
契约测试中的契约是一份文档或规范,记录了应用程序之间交换的消息(请求和响应)的格式和结构的协议。它作为验证每个应用程序是否能够正确处理集成中其他应用程序发送和接收的数据的基础。
合同建立在消费者(例如,想要检索某些数据的客户端)和提供者(例如,提供客户端所需数据的服务器上的 API)之间。
消费者驱动测试是一种契约测试方法,消费者在自动测试运行期间生成契约。这些契约被传递给提供商,然后提供商运行他们的一组自动测试。契约文件中包含的每个请求都会发送给提供商,并将收到的响应与契约文件中指定的预期响应进行比较。如果两个响应都匹配,则意味着消费者和服务提供商是兼容的。
最后是 Pact。Pact 是一个实现消费者驱动契约测试思想的工具。它支持测试 HTTP 集成和基于消息的集成,专注于代码优先的测试开发。
正如我之前提到的,我们可以使用 Pact 的合约规范和工具来完成我们的任务。实现可能如下所示( 完整测试代码):
setup: def openaiRequestCaptor = restExpectation.openai.completions(fromContract("openai/SuccessfulCompletion-Hello.json")) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1
合同文件可供审阅。
使用契约文件的优势在于,它们不仅包含请求和响应主体,还包含外部交互规范的其他元素 - 请求路径、标头和 HTTP 响应状态,从而允许基于这样的契约来完整描述模拟。
需要注意的是,在这种情况下,我们只进行契约测试,不进行消费者驱动测试。不过,有人可能想进一步探索 Pact。
本文回顾了在使用 Spring Framework 进行开发时增强集成测试可见性和效率的实用建议。我的目标是专注于明确定义外部交互规范和尽量减少样板代码的重要性。为了实现这一目标,我建议使用 DSL 包装器和 JsonAssert、将规范存储在 JSON 文件中以及通过 Pact 使用契约。本文描述的方法旨在简化编写和维护测试的过程,提高测试的可读性,最重要的是,通过准确反映系统组件之间的交互来提高测试本身的质量。
链接到演示测试的项目存储库 - sandbox/bot 。
感谢您对本文的关注,祝您在编写有效、可见的测试过程中好运!