在这篇文章中,我们介绍了一种功能测试方法,该方法不需要任何手动设置,并且可以像单元测试一样在本地或在持续集成 (CI) 管道中运行。具体来说,该方法执行以下操作: 

  • 有助于在本地开发过程中捕获和重现更多的错误,并大大减少调试时间,以及早期错误检测和发布的信心。

  • 通过测试 API 合约来加速内部重构,而无需涉及内部实现细节。

  • 提供比传统单元测试更大的代码覆盖率。

  • 用作业务逻辑的简洁且人类可读的文档。

功能测试与单元测试

在深入讨论细节之前,让我们先介绍一下功能测试和单元测试之间的异同。

以下属性展示了功能测试和单元测试之间的一些相似之处:

  • 自动化:两者都可以在开发人员的本地计算机上运行,无需任何手动设置,并且都可以在 CI 管道中运行。

  • Hermetic:两者都避免对外部依赖项的网络调用。

  • 确定性:如果与正在测试的代码没有任何变化,那么两者的测试结果都不应改变。

  • 行为:两者都会对预期行为进行编码,并对代码行为的变化敏感。

  • 预测性:如果测试全部通过,则代码适合生产,因为两者都可以防止在进行代码更改时出现回归,并且可以在任何代码更改投入生产之前捕获错误。

  • 可调试:两者都将在调试时提供帮助,为开发人员提供一种以可重现状态在本地运行代码的快速方法。它们还为开发人员提供了通过添加断点、检查代码和控制执行在集成开发环境 (IDE) 中调试代码的机会。

以下属性展示了功能测试和单元测试之间的一些差异:

  1. 测试范围 

  2. 重构敏感性

  3. 自我记录

单元测试和功能测试的测试范围不同因为功能测试侧重于公共 API 端点,而单元测试侧重于实现细节。

功能测试只会执行公共 API 端点,而公共 API 端点又会执行所有代码层,包括对数据库和下游依赖项进行实际调用。单元测试将通过模拟短路直接依赖关系来测试系统的每一层。模拟是一个将依赖项替换为由测试控制的实现的过程。请注意,功能测试通过实例化兼容依赖项来代替真正的下游依赖项来避免模拟。兼容的依赖项可以是嵌入式数据库、嵌入式消息队列或侦听实际 HTTP 和 gRPC 请求的嵌入式服务。

这些类型的测试在重构敏感性方面也有所不同,因为在更改或重构内部类和方法时通常不需要更新功能测试。当内部类和方法发生更改时,单元测试及其模拟将需要重新编写,即使是微小的更改,这也可能非常乏味。仅当 API 或业务逻辑发生变化、下游依赖项的 API 发生变化以及数据库架构发生变化时,才需要更新功能测试。

这些类型的测试之间的最后一个区别在于自文档化,因为功能测试将类似于 API 契约,因为它们仅使用公共 API 端点,而单元测试则不然。理解单元测试需要了解正在测试的每个类和方法的内部知识,并且单元测试通常穿插着模拟逻辑。

以下总结了功能测试与单元测试的优缺点。

图片

一个好的经验法则是首先通过功能测试涵盖所有预期的 API 行为。然后为内部实用程序和库编写单元测试,这些实用程序和库大多是静态的并且需要最少的模拟。

如何实施功能测试

在DoorDash,服务是用Kotlin编写的,并使用Guice进行依赖注入。API 使用gRPC公开,数据库是Postgres、CockroachDB或Cassandra。测试是使用JUnit编写的。JUnit 是一个测试框架,允许开发人员在他们的机器上以及在 CI 中自动运行单元测试。

功能测试通常有以下步骤:

  • 测试设置:启动服务实例、清理数据库以及可能在测试之间携带状态的任何其他内容。

  • 准备Block:在数据库中设置任何所需的状态并存根下游服务以设置给定的场景。

  • 执行Block:向服务 API 发送网络请求。

  • 验证Block:对网络响应和数据库更改等副作用进行断言。

测试设置

编写功能测试的大部分工作在于其设置。由于功能测试不会模拟内部类和方法,因此我们需要弄清楚如何通过启动服务及其所有依赖项的兼容实现来在 JUnit 中编写和执行功能测试。这些是我们在测试设置中遵循的策略。

使用我们可以自由擦除的真实数据库

我们使用Testcontainers来启动我们想要的任何数据库。  docker-compose也可用于启动数据库,但需要在运行测试之前手动设置。另一方面,测试容器可以由 JUnit 以编程方式设置和拆除。

存根网络响应而不是模拟类和方法

DoorDash 的服务使用gRPC或REST与其他服务交互,我们希望尽可能地测试这些交互。我们使用gRPCMock来处理与 gRPC 服务的交互,而不是模拟对 gRPC 客户端的调用。同样,我们使用WireMock或MockServer来处理与 REST 服务的交互,而不是模拟对 HTTP 客户端的调用。所有这些库都会启动真实的服务器,并允许测试设置对请求的响应。这样,我们不仅测试服务的代码,还测试与之交互的 gRPC/HTTP 客户端代码,从而覆盖比单元测试更多的代码。

启动该服务的实时版本

最后一步,我们创建了一个 gRPC 服务的实时实例,并用它来测试我们的 API。大多数服务使用 Guice 进行依赖注入,我们在测试中使用相同的 Guice 注入器,并进行一些覆盖以允许其在本地运行。根据测试的不同,覆盖可以是简单的东西,例如将本地配置注入到我们的一些依赖项中,也可以是更复杂的东西,例如允许每个测试覆盖的自定义功能标记实现。虽然 Guice 覆盖很方便,但我们尽量谨慎使用它们,因为它们会增加本地环境和生产环境之间的差异。除了 Guice 之外,我们的许多服务也将其配置公开为环境变量,我们使用系统规则来设置它们。

创建 gRPC 客户端以连接到服务

为了测试我们的 gRPC 服务,我们需要能够对其进行调用。我们创建了一个 gRPC 客户端并指向上一步中启动的服务。

我们上面使用的测试设置策略确保了服务的测试设置尽可能接近生产环境,因此我们对其在生产中的运行方式充满信心。

准备Block

测试设置过程完成后,我们在数据库中设置状态,并为正在测试的场景设置来自下游服务的存根响应。

执行Block

为了测试场景或 API,我们使用在测试设置中创建的 gRPC 客户端对服务进行 API 调用。

验证Block

我们对 API 调用的响应进行断言并验证其是否符合预期。如有必要,我们还查询数据库状态并验证它是否符合预期,并从下游存根验证是否使用正确的输入对下游服务进行了调用。

功能测试示例

让我们考虑 DoorDash 订阅服务的简化版本,该服务具有用于为新用户订阅订阅计划的 API。作为输入,API 接受订阅用户的 ID 和正在订阅的计划的 ID。作为输出,如果用户没有资格订阅该计划,API 将返回错误,或者返回成功结果,并向用户显示自定义文本。

为了确定订阅资格,订阅服务需要:

  • 查询位置服务是一项内部 gRPC 服务,用于检查该计划在用户位置是否可用。

  • 查询订阅服务Postgres数据库以查看用户是否已有活动订阅。一次只能有一个有效订阅!

图片

订阅API检查用户的资格,验证用户尚未订阅,并为用户保留新的订阅

与 DoorDash 中的许多其他服务一样,订阅服务是用Kotlin编写的,通过 gRPC 公开其 API,并使用 Guice 进行依赖项注入。我们将在此示例中使用简化的伪 Kotlin 代码。

我们的服务有一个函数,它是实例化 Guice 注入器并启动 gRPC 服务器的入口点。



fun instantiateAndStartServer(guiceOverrides: Module): Injector {


val guiceInjector = Guice.createInjector(


Modules.override(SubscriptionGrpcServiceModule()).with(guiceOverrides)


)


val server = guiceInjector.getInstance(SubscriptionGrpcServer::class.java)


server.start()


return guiceInjector


}

使用基类测试设置

正如我们上面已经描述的,我们需要一种方法来使用 Testcontainers 启动数据库,使用 GrpcMock 存根网络响应,启动订阅服务的实时版本,并创建 gRPC 客户端来连接到正在运行的服务。为此,我们定义了一个基类,以便在测试之间更轻松地设置、配置和重用资源。



open class AbstractFunctionalTestBase {


companion object {


// All fields in companion object are static


// and can be reused between instances.



val postgresContainer =


PostgreSQLContainer(DockerImageName.parse(" postgres :15.0"))



val locationService = GrpcMockServer("location-service")



val guiceOverrides = Module {


// Use locally running Postgres instance.


it.bind(PostgresConfig::class.java).toInstance(


PostgresConfig(


host = postgresContainer.host,


port = postgresContainer.port,


user = "root",


password = ""


)


)



it.bind(LocationServiceClientConfig::class.java).toInstance(


LocationServiceClientConfig(


host = "localhost", port = locationService.port


)


)


}



val guiceInjector = instantiateAndStartServer(guiceOverrides)


}



val postgresClient: PostgresClient =


guiceInjector.getInstance(PostgresClient::class.java)


val subscriptionServiceGrpcClient =


SubscriptionServiceGrpcKt.SubscriptionServiceCoroutineStub(host = "localhost")



@BeforeEach


fun beforeEachTest() {


// Delete any rows written by previous tests.


postgresClient.flushAllTables()


}


}

准备、执行和验证区块

现在我们已经设置了基类,我们可以编写测试,使用相同的生产 postgres 客户端来写入数据并为我们对依赖项的请求定义响应存根。让我们从快乐路径( happy path)的测试开始。我们的测试将为美国用户创建一个测试订阅计划,方法是创建一个测试用户,让该用户订阅该计划,并确保在数据库中创建的订阅记录符合预期。路径测试可能如下所示:



class SubscriptionFunctionalTests : AbstractFunctionalTestBase() {


fun `eligible user should subscribe to monthly plan`() {


// Prepare: A DoorDash user that is eligible for a monthly plan.


val monthlyPlan =


postgresClient.writeNewPlan(type = "Monthly", area = "USA")


val user = postgresClient.writeNewUser(email = "user@doordash.com")


locationService.stubFor(


method = GetUserAreaRequest,


request = """


{"user_id": ${user.id}}


""",


response = """


{"area": "USA"}


"""


)



// Act: We call subscribe.


subscriptionServiceGrpcClient.subscribe(


"""


{"user_id": ${user.id}, "plan_id": ${monthlyPlan.id} }


"""


)



// Verify: User should be subscribed.


val subscription = postgresClient.getSubscription(userId = user.id)


assertEquals("active", subscription.status)


}


}

由于我们重用与生产中相同的控制流,因此该测试的代码覆盖范围将包括用于检索、写入和转换数据的任何内部组件,从而减少对更细粒度的单元测试的需求,同时提供可读的、高记录此端点的快乐路径业务逻辑的级别测试。

功能测试使我们能够快速重现并修复错误

功能测试方法使我们能够快速重现和修复错误,并确信我们可以添加新的功能测试来同时测试所有代码层。想象一下,DoorDash 正在美国推出一种每年更新的新型计划。然而,推出后,我们收到加拿大用户订阅该计划的报告,发现年度计划实施中的位置检查逻辑存在错误。

重现和修复错误的最快方法是编写一个重现问题的功能测试,并修改我们的代码,直到这个新的功能测试通过。由于我们可以快速重新编译代码并在开发机器中重新运行测试,因此我们可以快速推出修复程序!

新的功能测试如下所示:



fun `users in canada should not be able to subscribe to annual plan`() {


// Prepare: An annual plan which is available only in USA, and a user from Canada.


val annualPlan = postgresClient.writeNewPlan(type = "Annual", area = "USA")


val canadaUser =


postgresClient.writeNewUser(email = "canada_user@doordash.com")


locationService.stubFor(


method = GetUserAreaRequest,


request = """


{"user_id": ${canadaUser.id}}


""",


response = """


{"area": "CA"}


"""


)



// Act: A user from Canada tries to subscribe to the annual plan.


val response = subscriptionServiceGrpcClient.subscribe(


"""


{"user_id": ${canadaUser.id}, "plan_id": ${annualPlan.id} }


"""


)



// Verify: We should get an error response back and not create any subscription.


assertEquals("error", response.getError())


assertNull(postgresClient.getSubscription(userId = canadaUser.id))


}

面临的挑战以及如何克服这些挑战

启动我们的应用程序的真实实例、真实数据库以及存根 gRPC 服务器要比仅仅模拟它们花费更长的时间。为了帮助解决这一问题,我们进行了 JUnit 扩展,以确保我们仅启动一个资源实例,例如我们的应用程序、数据库和 gRPCMock,并且在运行下一个测试之前清除上一个测试留下的任何状态。这些扩展看起来与上面示例中的“AbstractFunctionalTestBase”类非常相似,但具有更多功能,以便于使用和清理应用程序状态。

另一个重大挑战是由于共享本地数据库和应用程序实例而导致测试不稳定。为了解决这个问题,可以采取以下措施:

  • 在启动应用程序之前清理应用程序的状态及其依赖项。

  • 如果测试的操作块产生异步作业,例如新线程、工作流等,则应在进入验证块之前将它们连接起来。我们还使用了诸如等待性之类的包来测试此类交互。

  • 测试需要按顺序运行并且不是线程安全的。我们正在研究如何安全地并行运行它们。

理想情况下,仅模拟网络响应,但有时如果没有大量额外工作,这是不切实际的。在这种情况下,我们可能会想使用模拟。相反,我们建议一个好的中间立场是实现一个假类。例如,我们发现伪造我们的内部实验库更容易,因此我们可以在测试之间无缝地更改功能标志。在很多情况下,注入假配置类也比设置单独的配置文件和/或设置环境变量更容易。然而,我们不鼓励覆盖和伪造,我们正在努力消除它们的必要性,但这需要更多时间。

最后,为了使团队和服务能够轻松采用这种测试方法,我们执行了以下操作:

  • 提供了 JUnit 扩展,可以轻松编写功能测试。

  • 提供了有关如何编写功能测试的大量文档。 

  • 构建了通用辅助函数来设置测试状态,包括存根 gRPC 调用和填充数据库表。

结语

我们的开发流程和开发人员的幸福感提高了很多,因为现在可以在本地快速设置测试场景,然后不断运行和重新运行测试,这在调试时非常有帮助。开发人员还喜欢使用 IDE 来端到端地调试和检查 API 执行路径。

代码覆盖率也显着上升,在某些服务中甚至上升了20%,仅仅是因为我们覆盖的代码比单元测试多得多。这也增强了我们将代码发布到生产环境的信心。

大多数新测试都采用函数式风格,并作为我们 API 合约的动态文档。很多情况下,我们也不必为内部实现细节编写额外的单元测试,只要功能测试覆盖所有可能的业务场景即可。

结论和未来工作

虽然最初需要付出相当大的努力,但我们发现一旦采用功能测试,开发人员就会报告更高的速度和幸福感。对于订阅服务,目前有600多个测试涵盖了无数的业务案例。

这种方法已被其他几个服务采用,我们计划继续开发工具和文档以鼓励进一步采用。我们还在研究并行运行这些测试并减少顺序运行它们所需的总时间的方法。

我们还致力于将filibuster集成到功能测试中。这将通过发现和编码我们的服务应该容忍的错误来提供进一步的价值。

最后作为一位过来人也是希望大家少走一些弯路,如果你不想再体验一次学习时找不到资料,没人解答问题,坚持几天便放弃的感受的话,在这里我给大家分享一些软件测试的学习资源,希望能给你前进的路上带来帮助。

视频文档获取方式:
这份文档和视频资料,对于想从事【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴我走过了最艰难的路程,希望也能帮助到你!以上均可以分享,点下方小卡片即可自行领取。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐