极客时间已完结课程限时免费阅读

19|测试策略(二):功能上下文划分

19|测试策略(二):功能上下文划分-徐昊 · AI时代的软件工程-极客时间
下载APP

19|测试策略(二):功能上下文划分

讲述:徐昊

时长07:15大小6.64M

你好,我是徐昊,今天我们来继续学习 AI 时代的软件工程。
上节课,我们讲解了如何通过测试四象限(Agile Testing Quadrants)构造有效的测试策略(Test Strategy),以及为何构造支持团队的测试(Q1 和 Q2 象限)是测试策略中重要的一环。而在构造 Q1 和 Q2 象限的时候,重点在于建立 Q1 象限和 Q2 象限的直接关联。这时候,功能上下文就起到了非常重要的作用。
那么今天我们就来看一看如何有效地划分功能上下文。

利用架构划分功能上下文

划分功能上下文最简单的方法就是软件架构。软件架构是软件系统的基本结构和组织方式,主要包括系统由哪些组件构成,以及各个组件之间的关系。在软件架构中,组件是指软件系统中具有明确功能定义和责任的模块。
因而,通过组件负责的功能单元就可以很容易地获得功能上下文。让我们看一个非常简单的例子:
这是一个非常常见的后端分层架构模式,核心的业务逻辑处在业务逻辑层中,业务逻辑层通过持久化接口访问数据,所有的逻辑通过 HTTP 接口暴露对外访问的 API。现在,我们有一个业务场景需要使用这个架构模式实现。业务场景是“作为一个用户,我希望获取所有的在售商品,从而我可以选择我想要购买的商品”。
那么我们就可以按照架构模式的指引,为这个业务场景引入对应的组件,操作如下图所示:
在我们引入的组件中,按照架构模式,它们作用分别为:
ProductsAPI:通过 HTTP 协议将 ProductService 暴露为 API;
ProductService:封装围绕产品目录相关的业务逻辑,通过 ProductDAO 访问持久化的数据;
ProductDAO:封装对于持久化数据访问的相关逻辑。
按照我们寻找到的功能上下文,就可以很容易地构造相互关联的 Q1 与 Q2 测试:
这样划分之后,我们可以明显看到,对于“获取产品目录”的功能测试(Q2 测试),测试了多个架构中的组件(HTTP interface,application logic 以及 persistent)。而与之相对的,另外三个测试则仅仅是测试了对应的组件。同时由于我们按照架构分解了不同的功能上下文,那么当属于 Q2 的功能测试失败时,至少会有一个处于 Q1 的测试失败。失败的测试就指明了发生问题的组件。
需要注意的是,到底是 Q1 还是 Q2 的测试,并不是由使用的测试方式决定的。比如,前面这个例子中的 ProductDAO 测试。通常对于 DAO 测试,我们需要使用内存数据库(in-memory database)或是连接专门用于测试的数据库实例。
从测试技术的角度来看,这种使用数据库的测试通常会被归类为集成测试,而 Q1 测试通常是单元测试或者组件测试,那么这类测试往往被划归为 Q2 测试。
但是,按照我们上节课所讲的,Q1 测试与 Q2 测试的划分,是因为其受众与目的不同。就算采用了内存数据库,或是专门用于测试的数据库实例,它的受众仍然是技术导向。因而,只能是 Q1 象限的测试。

在测试中引入测试替身

测试策略中另一个重要的问题,就是对于不同的测试使用何种测试替身(Test Double)。如果没有测试替身,那么我们将无法独立测试架构模式中指定的组件。比如还是上面的例子,ProductsAPI 可能是这样实现的(使用 Java Jersey 作为 RESTful API 的框架):
@Path("/products")
public class ProductsAPI {
private ProductService productService = new ProductServiceImpl();
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<Product> getAllProducts() {
return productService.getAllProducts();
}
}
其中 ProductServiceImpl 是生产环境中使用的代码,也就是要使用 ProductsDAO 去访问持久化数据的实现方式。当我们要测试这个 API 层的组件时,可能会用这样的方式:
public class ProductsAPITest extends JerseyTest {
@Override
protected Application configure() {
return new ResourceConfig(ProductsAPI.class);
}
@Test
public void testGetAllProducts() {
Response response = target("/products").request().get();
// 验证响应状态码是否为200
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
// 获取响应中的产品列表
List<Product> products = response.readEntity(new GenericType<List<Product>>() {});
// 验证返回的产品列表是否包含了预期的产品
assertEquals(3, products.size()); // 假设我们期望有3个产品s
}
}
这里的问题是,如果按照现在的代码去写,那么这个测试就会是一个功能测试。因为无论我们怎么构造这个测试,它都会使用 ProductServiceImpl 去执行测试。而 ProductServiceImpl 则又会使用 ProductsDAO。这个测试就变成了使用所有架构组件的测试。
所以这里我们必须引入测试替身,才能解决这个问题。我们可以做一个简单的修改,首先,改为依赖注入(Dependency Injection)
@Path("/products")
public class ProductsAPI {
@Inject
private ProductService productService;
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<Product> getAllProducts() {
return productService.getAllProducts();
}
}
然后在测试中注入测试替身,而不是真实的实现:
public class ProductResourceTest extends JerseyTest {
private ProductService productService;
@Override
protected Application configure() {
// 创建 ProductService 的替身
productService = new ProductServiceDouble();
// 创建 ResourceConfig 实例并注册资源类及依赖
return new ResourceConfig()
.register(ProductsAPI.class)
.register(new AbstractBinder() {
@Override
protected void configure() {
bind(productService).to(ProductService.class);
}
});
}
@Test
public void testGetAllProducts() {
// 发送GET请求到"/products"端点
Response response = target("/products").request().get();
// 验证响应状态码是否为200
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
// 获取响应中的产品列表
List<Product> products = response.readEntity(new GenericType<List<Product>>() {});
// 验证返回的产品列表是否包含了预期的产品
assertEquals(3, products.size()); // 假设我们期望有3个产品
}
}
这样,我们在测试中将使用 ProductServiceDouble 而不是 ProductServiceImpl 去进行测试。这个测试只会覆盖架构模式里,HTTP interface 层中 ProdcutsAPI 这个组件。我们也就可以针对不同的功能上下文去进行独立的测试了。
如果不能在测试中引入测试替身,那么我们几乎无法在 Q2 测试与 Q1 测试之间直接建立关联,甚至都无法构造有效的 Q1 测试。因而,测试替身是测试策略能够落地的关键。
在确认能够引入测试替身之后,针对不同组件选择哪种测试替身,就是另一个重要的课题。测试替身有多种不同的形态,目前我们广泛使用的测试替身有这么几种:
哑对象(Dummy Object):哑对象不会被实际使用,通常它们只是用来填充参数列表。它们可以是简单的占位符,只是为了满足方法签名的需求;
假实现(Fake Object):假实现是一种实际可以使用的特定实现,但跟真实实现相比会简化很多。一般不会用于生产环境。比如,内存数据库就是一个很好的例子,对于绝大多数场景,它提供的功能已经足够了,但是不会被用于生产环境;
存根对象(Stub):为测试过程中调用的方法提供预先准备好的答案,但通常不会对测试调用之外的方法有任何响应。与假实现不同,存根只满足于特定的场景,而无法看作是一个可用的实现;
间谍对象(Spy):间谍对象是一种特殊的存根对象。除了响应某些具体方法之外,它还会根据测试的调用,记录一些信息。比如,使用间谍对象进行测试,它可能会记录某个方法一共被调用了多少次等等;
模拟对象(Mock):模拟对象对于将要进行的调用存在明确的预期。它会根据预先编排好的答案响应所有的调用。如果接收的调用不满足预期,它们会抛出异常。通常使用模拟对象时,会在最终的验证过程中进行检查,以确保它们接收到符合预期的所有调用。
比如在前面的例子里,ProductDAO 的测试就使用了假实现(Fake)。ProductService 和 ProdcutsAPI 的测试则会使用存根对象(Stub)。
为功能上下文选择合适的测试替身策略,是测试策略中非常容易被忽略的一环。而选择恰当的替身策略,则能保证测试的有效性,并控制测试成本。那么对应到我们的例子中,我们可以这样来总结:
需要注意的是,除了五种测试替身之外,我们还有一个选择,就是使用真实的对象。当我们使用真实对象的时候,我们实际上在合并不同的功能上下文。比如,对于之前的例子,我们可以选择让 ProductsAPI 直接使用 ProductService,但并不使用真正的 DAO 对象。那么,测试可能就是这个样子:
public class ProductResourceTest extends JerseyTest {
private ProductService productService;
@Override
protected Application configure() {
// 创建 ProductService 的替身
productService = new ProductServiceImpl(new ProductDAOStub());
// 创建 ResourceConfig 实例并注册资源类及依赖
return new ResourceConfig()
.register(ProductsAPI.class)
.register(new AbstractBinder() {
@Override
protected void configure() {
bind(productService).to(ProductService.class);
}
});
}
@Test
public void testGetAllProducts() {
// 发送GET请求到"/products"端点
Response response = target("/products").request().get();
// 验证响应状态码是否为200
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
// 获取响应中的产品列表
List<Product> products = response.readEntity(new GenericType<List<Product>>() {});
// 验证返回的产品列表是否包含了预期的产品
assertEquals(3, products.size()); // 假设我们期望有3个产品
}
}
那么实际上,我们表达的测试策略是这样的:
也就是说,虽然从架构模式上看,系统中存在两个不同的功能上下文,但是作为测试策略,我们选择将这两个功能上下文看作一个整体进行测试。
比如在上面的例子中,我们这个做法是非常有道理的。因为 HTTP interface 这一层,只有很少的逻辑,绝大部分逻辑都是将 application logic 层中的返回值进行格式转换。因而我们可以将这两个功能上下文合并,以减少低价值的测试。
当然,我们也可以选择把 Application Logic 层与 Persistent 层合并,虽然在目前这个例子里,这么做并没有带来什么好处。

小结

这节课我们主要讲解了利用架构划分功能上下文,以及为不同的功能上下文选择对应的测试替身。
我们刻意回避了存根与模拟对象这一老生常谈的话题,感兴趣的同学,可以参考我 TDD 专栏中,关于行为验证状态验证的章节,或是 Martin Folwer 在 07 年写下的经典文章 Mocks Ain’t Stubs.
当我们获得了测试策略之后,我们就能准确地要求大语言模型(Large Language Model)按照某个特定架构风格生成代码了。这将是我们下节课的内容。

思考题

除了架构之外,还有什么划分功能上下文的办法?
欢迎在留言区分享你的想法,我会让编辑置顶一些优质回答供大家学习讨论。

1. 构造有效的测试策略是测试中的重要环节,通过测试四象限(Agile Testing Quadrants)来实现。 2. 划分功能上下文的重要性,可以通过软件架构来实现,有助于构造相互关联的Q1与Q2测试。 3. 在测试中引入测试替身是测试策略中另一个重要的问题,选择合适的测试替身策略能保证测试的有效性,并控制测试成本。 4. 测试替身有多种不同的形态,包括哑对象、假实现、存根对象、间谍对象和模拟对象,选择恰当的替身策略对于功能上下文的独立测试至关重要。 5. 除了架构之外,还有其他办法可以划分功能上下文,这是一个需要思考和讨论的问题。

分享给需要的人,Ta购买本课程,你将得29
生成海报并分享
2024-04-19

赞 1

提建议

上一篇
18|测试策略(一):如何构造有效的测试策略?
下一篇
20|使用 LLM 按照测试策略生成代码
unpreview
 写留言

全部留言(5)

  • 最新
  • 精选
  • hakunamatata
    2024-05-17 来自四川
    在买点咖啡的场景的中,可以按照消费者,咖啡馆服务员根据角色来划分上下文。
    1
  • Gojustforfun
    2024-05-10 来自北京
    最后代码中注释有问题 ——// 创建 ProductService 的替身 应该是创建ProductDAO的替身,创建ProductService的真是对象
  • 咖啡
    2024-04-29 来自广东
    在DDD架构下通过参与者的角色或者领域/子域划分功能上下文感觉也行
  • 术子米德
    2024-04-20 来自浙江
    🤔☕️🤔☕️🤔 【Q】除了架构之外,还有什么划分功能上下文的办法? 【A】功能,它是钉子,它要技术的锤子,才能有用。可是,钉子死在田里,锤子窝在家里,两不相见,没用。 功能,它是穿线引针,技术,它是面料、它是里子、它是边角饰件。一件好衣服,需要好的料子、加上好引针、再加好穿线。穿过的每个点,都会发生事件(Event),连续的事件线串起来的技术,做成我们期待的系统。 因此,所谓的事件风暴法,我喜欢叫做穿针引线法,也是划分功能上下文的好法子。 — by 术子米德@2024年4月20日
    展开
  • 术子米德
    2024-04-19 来自浙江
    🤔☕️🤔☕️🤔 【R】划分功能上下文 简单法 软件架构。 Q1的可行需要依赖注入(Dependency Inject)和测试替身(Test Double)的加持。 哑(Dummy)/有但不用,假(Fake)/用随意值,存根(Stub)/用特定值,间谍(Spy)/记点东西,模拟(Mock)/交互验证。 【.I.】单元测试,要Mock,这个太麻烦,这个引入很多工作量,这个没必要搞这么复杂,这个不靠谱,这个…,这么稀奇古怪的理由的潜台词,有两个:1)很明显,我根本不想单元测试;2)很隐晦,我根本没明白问题点在哪里。 我工作的单位是模块,一个有边界的模块,一个定义清楚接口和操作、其对象有状态切换的模块,那么我交付的模块,就是这边界定义清晰的模块,且,我能验证交付时模块具备的功能。 也就是说,在我出手交付前,我要对我的模块做验证,它是我的工作单元,于是我把这种模块级的验证,叫做单元测试。 【Q】单元测试,要进行哪些方面,或者说哪些维度的设计,是否有推荐的文章或书籍? — by 术子米德@2024年4月19日
    展开