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

你好,我是徐昊,今天我们来继续学习 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)按照某个特定架构风格生成代码了。这将是我们下节课的内容。

思考题

除了架构之外,还有什么划分功能上下文的办法?

欢迎在留言区分享你的想法,我会让编辑置顶一些优质回答供大家学习讨论。