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

20|使用 LLM 按照测试策略生成代码

20|使用 LLM 按照测试策略生成代码-徐昊 · AI时代的软件工程-极客时间
下载APP

20|使用 LLM 按照测试策略生成代码

讲述:徐昊

时长03:10大小2.90M

你好,我是徐昊,今天我们来继续学习 AI 时代的软件工程。
上节课,我们讲解了如何利用架构划分功能上下文,以及如何为不同的架构组件,选择恰当的测试替身技术(Test Double),从而构造恰当的测试策略。
那么构建了测试策略之后,对于我们使用大语言模型(Large Language Model)生成代码有什么帮助呢?那么今天就让我们来看一看。

按照测试策略构造提示词模板

我们仍然使用上节课的例子,在上节课的讨论中,我们最后得到的测试策略是这样的:
也就是说,我们将架构中的三种不同的组件分别进行测试,其中 Persistent 层中的组件,使用假对象(Fake,内存数据库)作为测试替身。而 HTTP interface 和 Application Logic 层则通过存根(Stub)作为测试替身。最后,再通过功能测试,对整个系统进行验证。
接下来让我们按照测试策略,针对不同的组件构造提示词模板(Prompting template)。我们先从 Persistent 层开始:
架构描述
=======
当前系统技术栈为 Spring Boot,Jersey 和 MyBatis。
 
当前系统采用典型的三层架构设计,分布为:
- HTTP interface 层,负责提供 RESTful API,命名规则为 XXXAPI,比如 OrdersAPI;
– Application Logic 层,负责提供核心逻辑,命名规则为 XXXService,比如 OrderService;
– Persistent 层,负责与持久化数据交互,命名规则为 XXXDAO,比如 OrderDAO;
– DAO 通过 MyBatis 的 Mapper 实现
– 在对 DAO 进行测试时,使用 H2 数据库作为 fake implementation,也就是在测试中,初始化 H2 数据库,然后保证应用程序链接到这个数据库,并在测试结束后销毁这个数据库。
 
功能需求
=======
{functionalities}
 
任务
====
上面功能需求描述的场景,在 Persistent 层中需要哪些组件(目标组件);
列出需求描述的场景使用到目标组件的功能(目标功能);
列出目标功能需要测试的场景。描述场景,并给出相关的测试数据。
注意在这个提示词模板中,我们并没有直接要求 LLM 帮助我们生成代码,还是与之前一样,先让 LLM 给出场景和测试数据。ChatGPT 的答案如下:
在这个提示词模板中,使用了另一个我发明的小技巧,叫代词消除,可以在上下文中去除隐含的歧义。通过目标组件和目标功能的明确指示,我们可以确保 LLM 在后续交互中保持上下文连贯。
随后,我们就可以要求 LLM 帮助我们生成测试代码:
请按照指定技术栈,为目标功能场景提供测试代码
LLM 给出的结果是:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
import javax.sql.DataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
@SpringBootTest
@MybatisTest
@TestPropertySource(locations = "classpath:application-test.properties") // 使用测试配置文件
public class ProductDAOTest {
@Autowired
private DataSource dataSource;
@Autowired
private ProductDAO productDAO;
private Connection connection;
@BeforeEach
public void setUp() throws SQLException {
// 获取H2数据库连接
connection = dataSource.getConnection();
// 创建测试表
try (Statement statement = connection.createStatement()) {
statement.executeUpdate("CREATE TABLE Product (product_id INT PRIMARY KEY, name VARCHAR(255), description VARCHAR(255), price DECIMAL(10,2))");
// 插入测试数据
statement.executeUpdate("INSERT INTO Product (product_id, name, description, price) VALUES (1, 'Product 1', 'Description of Product 1', 19.99)");
statement.executeUpdate("INSERT INTO Product (product_id, name, description, price) VALUES (2, 'Product 2', 'Description of Product 2', 29.99)");
statement.executeUpdate("INSERT INTO Product (product_id, name, description, price) VALUES (3, 'Product 3', 'Description of Product 3', 39.99)");
}
}
@Test
public void testGetAllProducts() {
// 调用被测试的方法
List<Product> products = productDAO.getAllProducts();
// 验证结果
assertEquals(3, products.size());
assertTrue(products.stream().anyMatch(p -> p.getProductId() == 1 && p.getName().equals("Product 1")));
assertTrue(products.stream().anyMatch(p -> p.getProductId() == 2 && p.getName().equals("Product 2")));
assertTrue(products.stream().anyMatch(p -> p.getProductId() == 3 && p.getName().equals("Product 3")));
}
@AfterEach
public void tearDown() throws SQLException {
// 删除测试表并关闭连接
try (Statement statement = connection.createStatement()) {
statement.executeUpdate("DROP TABLE IF EXISTS Product");
}
if (connection != null) {
connection.close();
}
}
}
接下来,我们进一步要求 LLM 给出 application-test.properties 的具体配置,也可以让 LLM 帮助我们补全一些其他的细节。现在也可以直接让 LLM 帮助我们生成代码。
请给出 ProductDAO 的实现
对于现在这个简单的例子,这个代码也就足够了。接下来,我们可以补充提示词模板中关于 HTTP Interface 部分的架构描述,并更改任务描述:
架构描述
=======
当前系统技术栈为 Spring Boot,Jersey 和 MyBatis。
 
当前系统采用典型的三层架构设计,分布为:
- HTTP interface 层,负责提供 RESTful API,命名规则为 XXXAPI,比如 OrdersAPI;
- API 通过 JAX-RS 的 Resource 实现
- 在对 API 进行测试时,首先要 stub 掉 Application Logic 层中的 Services,然后再使用 JerseyTest 对 API 进行测试
 
- Application Logic 层,负责提供核心逻辑,命名规则为 XXXService,比如 OrderService;
- Persistent 层,负责与持久化数据交互,命名规则为 XXXDAO,比如 OrderDAO;
- DAO 通过 MyBatis 的 Mapper 实现
- 在对 DAO 进行测试时,使用 H2 数据库作为 fake implementation,也就是在测试中,初始化 H2 数据库,然后保证应用程序链接到这个数据库,并在测试结束后销毁这个数据库。
 
功能需求
=======
{functionalities}
 
任务
====
上面功能需求描述的场景,在 HTTP Interface 层中需要哪些组件(目标组件);
列出需求描述的场景使用到目标组件的功能(目标功能);
列出目标功能需要测试的场景。描述场景,并给出相关的测试数据。
ChatGPT 的答案如下:
请按照指定技术栈,为目标功能场景提供测试代码
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import java.util.List;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.Invocation;
import javax.ws.rs.core.Application;
import javax.ws.rs.core.GenericType;
import javax.ws.rs.core.Response;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.test.JerseyTest;
import org.junit.Test;
public class OrdersAPITest extends JerseyTest {
@Override
protected Application configure() {
return new ResourceConfig(OrdersAPI.class);
}
@Test
public void testGetAllProducts() {
// 发送GET请求到/products端点
Response response = target("/products").request().get();
// 确保响应状态码为200
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
// 从响应中获取产品目录列表
List<Product> productList = response.readEntity(new GenericType<List<Product>>() {});
// 确保产品目录列表不为空
assertNotNull(productList);
// 检查第一个产品的信息
Product firstProduct = productList.get(0);
assertEquals(1, firstProduct.getId());
assertEquals("Product 1", firstProduct.getName());
assertEquals("Description of Product 1", firstProduct.getDescription());
assertEquals(10.99, firstProduct.getPrice(), 0.001);
// 可以继续检查其他产品信息...
}
}
还剩下一个 Application Logic 层,做法大同小异,我给你留作课后作业自行练习。

使用 LLM 构造功能测试

按照我们之前讨论的测试策略,我们已经使用 LLM 完成了三个 Q1 象限的测试。在测试策略中,除去 Q1 象限之外,我们还有 Q2 象限的功能测试。现在来构造提示词模板,完成这个 Q2 的测试。需要注意的是,为了测试的可重复性,我们会在功能测试中,完成测试数据的灌入。
首先给出任务描述:
架构描述
=======
当前系统采用典型的三层架构设计,分布为:
HTTP interface 层,负责提供 RESTful API,命名规则为 XXXAPI,比如 OrdersAPI;
Application Logic 层,负责提供核心逻辑,命名规则为 XXXService,比如 OrderService;
Persistent 层,负责与持久化数据交互,命名规则为 XXXDAO,比如 OrderDAO;
 
当前系统技术栈为 Spring Boot、Jersery 和 MyBatis
 
当为当前系统生成功能测试时,我们使用 Http Client 通过访问 API 完成真正的测试。但是为了保证测试的可重复性,我们在 setup 测试的时候,链接数据库,完成测试数据的准备。测试时,使用 MySQL 数据库。
 
功能需求
=======
目前为一个电子商务系统。需要列出所有产品的目录
 
任务
====
上面功能需求描述的场景,需要用到哪些 API(目标 API);
列出需求描述的场景使用到目标 API 的功能(目标功能);
列出目标功能需要测试的场景。描述场景,并给出相关的测试数据。
接着要求 GPT 提供测试,返回结果如下。
请为目标功能测试场景提供测试
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProductAPITest {
@LocalServerPort
private int port;
@Autowired
private RestTemplate restTemplate;
private Connection connection;
@BeforeEach
public void setUp() throws SQLException {
// 连接测试用MySQL数据库,准备测试数据
connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/test_db", "username", "password");
Statement statement = connection.createStatement();
statement.executeUpdate("INSERT INTO products (product_id, product_name, category) VALUES (1, 'Laptop', 'Electronics')");
statement.executeUpdate("INSERT INTO products (product_id, product_name, category) VALUES (2, 'Smartphone', 'Electronics')");
statement.executeUpdate("INSERT INTO products (product_id, product_name, category) VALUES (3, 'Bookshelf', 'Furniture')");
statement.executeUpdate("INSERT INTO products (product_id, product_name, category) VALUES (4, 'Office Chair', 'Furniture')");
statement.executeUpdate("INSERT INTO products (product_id, product_name, category) VALUES (5, 'T-shirt', 'Clothing')");
}
@AfterEach
public void tearDown() throws SQLException {
// 清除测试数据库中的数据
Statement statement = connection.createStatement();
statement.executeUpdate("DELETE FROM products");
connection.close();
}
@Test
public void testListAllProducts() {
// 发送GET请求到API端点
ResponseEntity<String> responseEntity = restTemplate.getForEntity("http://localhost:" + port + "/products", String.class);
// 验证响应状态码为200
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
// 验证响应内容是否符合预期
String expectedResponseBody = "[{\"productId\":1,\"productName\":\"Laptop\",\"category\":\"Electronics\"}," +
"{\"productId\":2,\"productName\":\"Smartphone\",\"category\":\"Electronics\"}," +
"{\"productId\":3,\"productName\":\"Bookshelf\",\"category\":\"Furniture\"}," +
"{\"productId\":4,\"productName\":\"Office Chair\",\"category\":\"Furniture\"}," +
"{\"productId\":5,\"productName\":\"T-shirt\",\"category\":\"Clothing\"}]";
assertEquals(expectedResponseBody, responseEntity.getBody());
}
}

小结

至此,我们按照测试策略的指导,遵循前面讲过的测试驱动开发的节奏,完成了一个简单的功能。那么这么做有什么用处呢?最显而易见的用处是,针对存量系统,我们可以由测试策略切入,让 LLM 生成符合我们要求的代码
所谓存量系统,就是指不是全部由 LLM/AI 主导生成的代码库。在这样的代码库中,组织结构已经存在,LLM 生成的代码需要符合既定的结构,才能被引入代码库中。
那么通过这节课我们所介绍的方法,我们就可以在任何代码库中,随时引入 LLM 的辅助了。

思考题

请完成 Application Layer 的架构说明,并使用 LLM 完成代码生成。
欢迎在留言区分享你的想法,我会让编辑置顶一些优质回答供大家学习讨论。

1. 构建测试策略,选择恰当的测试替身技术,如假对象和存根,以及通过功能测试验证整个系统。 2. 使用提示词模板按照测试策略,针对不同的组件构造场景和测试数据,确保LLM在后续交互中保持上下文连贯。 3. 利用大语言模型(LLM)生成测试代码,如为目标功能场景提供测试代码和ProductDAO的实现。 4. 提供架构描述和任务描述,列出需求描述的场景使用到目标组件的功能,以及列出目标功能需要测试的场景,并描述场景并给出相关的测试数据. 5. 通过LLM生成HTTP Interface层的测试代码,包括发送GET请求到端点、获取响应并验证状态码、从响应中获取产品目录列表等. 6. 通过LLM生成Application Logic层的测试代码,包括配置和测试核心逻辑,使用存根(Stub)对服务进行测试等.

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

赞 1

提建议

上一篇
19|测试策略(二):功能上下文划分
下一篇
21|什么是测试工序?
unpreview
 写留言

全部留言(3)

  • 最新
  • 精选
  • 范飞扬
    2024-04-23 来自广东
    同学问:自己设计Q1的测试用例,有典型用法、边界值、对象状态、耗时性能、并发情况、误用情况、反复调用等方面的考虑,这些在目前的示例里没体现,是否有必要考虑,在哪个步骤做合适? === 我理解,这个问题其实是,How many tests should you write? 这是个好问题。 我之前有个 Kent Beck 的《TDD by example》的读书笔记,可以回答这个问题: How many tests should you write? For simple problem of triangle, Kent wrote six tests, Bob Binder wrote 65. Think about MTBF, if you want the MTBF to be 10 years, you should write more tests. (下面是原文了) TDD’s view of testing is pragmatic. In TDD, the tests are a means to have great confidence. If our knowledge of the implementation gives us confidence even without a test, then we will not write that test. Black box testing demonstrates adifferent value system. It’s an appropriate attitude to take in some circumstances, but that is different from TDD. 总结一下就是:“看情况”。测与不测,黑盒白盒,覆盖范围,都看情况。 怎么看情况? 如果很有 confidence,那就不测了。如果有 fear,那可以测测,就像 Kent Beck 说的:“Write tests until fear is transformed into boredom.” 这里 boredom 也可以理解成 Confidence
    展开
    2
  • 术子米德
    2024-04-23 来自浙江
    🤔☕️🤔☕️🤔 【R】Prompt4Q1: 架构描述:技术栈、架构风格、规则和示例; 功能需求:$FuncLists; 任务-Step1:描述目标组件、目标功能、测试场景描述、测试数据准备; 任务-Step2:根据指定技术栈,为目标场景提供测试代码; 【Q】最终的Clear是任务-Step2,这个步骤里,执行清晰明确的任务步骤即可,这之前的任务-Step1是Complicated,这个步骤里,要根据架构描述和功能需求,分析做出选择,并生成下一步的清晰任务列表,如此理解合理吗? 【Q】自己设计Q1的测试用例,有典型用法、边界值、对象状态、耗时性能、并发情况、误用情况、反复调用等方面的考虑,这些在目前的示例里没体现,是否有必要考虑,在哪个步骤做合适? — by 术子米德@2024年4月22日
    展开
    共 2 条评论
  • 范飞扬
    2024-04-22 来自广东
    代词消除真是妙, 1、感觉架构可以通过 mermaid class diagram 加上 半结构化自然语言 来表达吧,这样更凝炼一点? 2、功能需求也可以用验收测试或者用户故事表达