Java 测试 01:JUnit 5 动态测试(DynamicTests 生成测试用例)
本文介绍了JUnit 5动态测试的核心概念与实战应用。动态测试通过@TestFactory注解和DynamicTest类,允许在运行时生成测试用例,相比传统静态测试具有更高灵活性。文章首先解释了动态测试的基本原理,包括其核心API(DynamicTest和DynamicContainer)以及适用场景(参数化测试、处理外部数据源等)。随后通过两个实战案例演示了具体实现:第一个案例展示了基础动态测试

👋 大家好,欢迎来到我的技术博客!
💻 作为一名热爱 Java 与软件开发的程序员,我始终相信:清晰的逻辑 + 持续的积累 = 稳健的成长。
📚 在这里,我会分享学习笔记、实战经验与技术思考,力求用简单的方式讲清楚复杂的问题。
🎯 本文将围绕一个常见的开发话题展开,希望能为你带来一些启发或实用的参考。
🌱 无论你是刚入门的新手,还是正在进阶的开发者,希望你都能有所收获!
Java 测试 01:JUnit 5 动态测试(DynamicTests 生成测试用例) 🧪
大家好!今天我们要深入探讨一个在 Java 单元测试领域非常强大且灵活的特性——JUnit 5 的动态测试(Dynamic Tests)。你是否厌倦了为每个测试场景手动编写静态的 @Test 方法?想象一下,能够根据运行时的数据或配置,自动地、程序化地生成和执行测试用例,是不是感觉瞬间提升了测试的效率和可维护性?这就是动态测试的魅力所在!🚀
什么是 JUnit 5 动态测试? 🤔
在我们深入代码之前,先来理解一下动态测试的核心概念。在传统的 JUnit 5 测试中,我们通过使用 @Test 注解来标记具体的测试方法。这些方法在编译时就已经确定,并且在运行时会按照固定的顺序执行。
而 动态测试 则提供了一种不同的方式来定义和执行测试。它允许我们在测试执行期间(即运行时),通过编程的方式来创建和注册测试用例。这意味着你可以根据外部数据源、配置文件、甚至复杂的业务逻辑来决定要运行哪些测试以及如何运行它们。
简单来说,动态测试就是让测试本身变得“聪明”起来,能够根据条件动态地生成测试案例,而不是事先写死在代码里。这对于需要大量相似但略有不同的测试用例的场景尤其有用,比如参数化测试,或者需要根据不同输入或状态生成测试的场景。🧠
JUnit 5 中的动态测试 API 🛠️
JUnit 5 提供了强大的 API 来创建动态测试。核心在于 org.junit.jupiter.api.DynamicTest 和 org.junit.jupiter.api.DynamicContainer 类,以及 org.junit.jupiter.api.TestFactory 注解。
核心概念
@TestFactory: 这是定义动态测试工厂的关键注解。被此注解标记的方法必须返回以下类型之一:Stream<DynamicTest>Iterable<DynamicTest>Collection<DynamicTest>DynamicNode[](包括DynamicTest和DynamicContainer)DynamicTest(单个测试)Stream<DynamicNode>(包含DynamicTest和DynamicContainer)Iterable<DynamicNode>Collection<DynamicNode>DynamicNode[]
DynamicTest: 表示一个单独的动态测试用例。你需要为它指定一个名称和一个Executable(通常是一个Runnable或ThrowingConsumer)。DynamicContainer: 可以将多个动态测试或动态容器组织在一起,形成一个测试组。这有助于更好地组织和管理复杂的动态测试结构。
为什么使用动态测试? ✅
动态测试的优势主要体现在以下几个方面:
- 灵活性: 能够根据运行时条件生成测试,处理那些无法在编译时预知的测试场景。
- 减少重复代码: 当你有一大堆相似的测试用例时,动态测试可以让你用少量代码生成大量测试。
- 处理不确定数量的测试: 如果你的测试依赖于外部数据源(如数据库记录、文件列表等),动态测试可以根据实际数据量生成相应数量的测试。
- 增强测试覆盖率: 结合外部数据,可以更全面地覆盖各种边界情况和异常场景。
- 支持复杂逻辑: 允许在测试执行前进行复杂的计算或数据准备。
实战演练:从基础到进阶 🚀
让我们通过一系列的例子来学习如何在实践中使用 JUnit 5 的动态测试。
1. 最简单的动态测试 📝
首先,我们来看一个最基础的例子,创建一个简单的动态测试。
import org.junit.jupiter.api.*;
import java.util.stream.Stream;
public class SimpleDynamicTest {
@TestFactory
Stream<DynamicTest> dynamicTests() {
// 使用 Stream.of 创建一个包含两个动态测试的流
return Stream.of(
DynamicTest.dynamicTest("测试用例 1", () -> {
Assertions.assertTrue(true);
System.out.println("执行了动态测试 1");
}),
DynamicTest.dynamicTest("测试用例 2", () -> {
Assertions.assertEquals(2, 1 + 1);
System.out.println("执行了动态测试 2");
})
);
}
}
这个例子展示了 @TestFactory 的基本用法。我们返回了一个 Stream<DynamicTest>,其中包含了两个通过 DynamicTest.dynamicTest() 方法创建的测试用例。每个测试用例都有自己的名称和执行逻辑。当你运行这个测试类时,JUnit 会发现并执行这两个动态生成的测试。
2. 参数化测试的动态实现 💡
这是动态测试最常见的应用场景之一。我们可以利用动态测试来替代或补充传统的 @ParameterizedTest。假设我们有一个简单的加法函数,想要测试多种输入组合。
import org.junit.jupiter.api.*;
import java.util.stream.Stream;
import java.util.Arrays;
public class ParameterizedDynamicTest {
public static int add(int a, int b) {
return a + b;
}
// 基于数据集合的动态测试
@TestFactory
Stream<DynamicTest> addTests() {
// 定义测试数据
int[][] testCases = {
{1, 2, 3},
{5, 3, 8},
{-1, 1, 0},
{0, 0, 0},
{100, -50, 50}
};
// 使用 Stream.map 将测试数据转换为 DynamicTest
return Arrays.stream(testCases).map(tc -> {
int a = tc[0];
int b = tc[1];
int expected = tc[2];
String testName = "add(" + a + ", " + b + ") = " + expected;
// 每个 DynamicTest 都有自己的名称和断言逻辑
return DynamicTest.dynamicTest(testName, () -> {
int result = add(a, b);
Assertions.assertEquals(expected, result, "加法运算失败: " + testName);
System.out.println("测试通过: " + testName);
});
});
}
// 更复杂的动态测试 - 生成范围内的所有可能值
@TestFactory
Stream<DynamicTest> rangeAddTests() {
int start = -5;
int end = 5;
int step = 1; // 可以调整步长
// 使用 IntStream.rangeClosed 生成数字序列
return java.util.stream.IntStream.rangeClosed(start, end)
.boxed()
.flatMap(i -> java.util.stream.IntStream.rangeClosed(start, end)
.filter(j -> i >= j) // 可选:只测试特定条件下的组合
.mapToObj(j -> new int[]{i, j, i + j}))
.map(tc -> {
int a = tc[0];
int b = tc[1];
int expected = tc[2];
String testName = "add(" + a + ", " + b + ") = " + expected;
return DynamicTest.dynamicTest(testName, () -> {
int result = add(a, b);
Assertions.assertEquals(expected, result, "加法运算失败: " + testName);
System.out.println("测试通过: " + testName);
});
});
}
}
在这个例子中,我们展示了两种动态参数化的方式:
- 基于预定义数组: 我们定义了一个二维数组
testCases,每个子数组包含三个元素[a, b, expected]。然后使用Arrays.stream和map方法将这些数据转换成DynamicTest。 - 基于范围生成: 使用
IntStream.rangeClosed生成一系列整数,并通过flatMap和mapToObj构造出所有满足条件的测试用例组合。
这种方式比传统参数化测试更灵活,因为你可以在这里加入更复杂的逻辑来决定哪些测试用例应该被生成。
3. 基于外部数据源的动态测试 🌐
动态测试的强大之处在于它可以轻松地与外部数据源结合。例如,从文件读取测试数据,或者调用服务获取测试用例。
import org.junit.jupiter.api.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class ExternalDataSourceDynamicTest {
// 假设我们有一个名为 "test_data.txt" 的文件,每行包含一个测试用例的描述和预期结果
// 格式: "输入描述: 输入A, 输入B, 预期结果"
// 示例:
// "正数相加: 3, 5, 8"
// "负数相加: -2, -3, -5"
// "混合相加: 10, -5, 5"
private static final String DATA_FILE_PATH = "src/test/resources/test_data.txt";
// 模拟从外部文件读取数据
private static List<String> readTestDataFromFile() throws IOException {
try (Stream<String> lines = Files.lines(Paths.get(DATA_FILE_PATH))) {
return lines.collect(Collectors.toList());
}
}
// 从文件数据动态生成测试
@TestFactory
Stream<DynamicTest> externalDataTests() {
try {
List<String> lines = readTestDataFromFile();
return lines.stream().map(line -> {
// 解析每一行数据
String[] parts = line.split(": ");
if (parts.length != 2) {
throw new IllegalArgumentException("无效的数据格式: " + line);
}
String description = parts[0];
String[] values = parts[1].split(", ");
if (values.length < 3) {
throw new IllegalArgumentException("数据项不足: " + line);
}
int inputA = Integer.parseInt(values[0].trim());
int inputB = Integer.parseInt(values[1].trim());
int expected = Integer.parseInt(values[2].trim());
// 生成测试名称
String testName = "外部数据测试 - " + description;
// 返回动态测试
return DynamicTest.dynamicTest(testName, () -> {
int actual = inputA + inputB; // 假设是加法操作
Assertions.assertEquals(expected, actual, "外部数据测试失败: " + testName);
System.out.println("外部数据测试通过: " + testName);
});
});
} catch (IOException e) {
// 如果读取文件失败,也可以抛出一个失败的测试用例
return Stream.of(DynamicTest.dynamicTest("文件读取失败", () -> {
Assertions.fail("无法读取测试数据文件: " + e.getMessage());
}));
} catch (Exception e) {
// 处理解析错误
return Stream.of(DynamicTest.dynamicTest("数据解析失败", () -> {
Assertions.fail("解析测试数据时发生错误: " + e.getMessage());
}));
}
}
}
这个例子展示了如何将外部数据集成到动态测试中。虽然我们模拟了文件读取,但在实际应用中,你可以连接数据库、API 服务或其他任何数据源来驱动测试。
4. 使用 DynamicContainer 组织测试 📦
当你的动态测试变得复杂时,使用 DynamicContainer 可以帮助你更好地组织和分组测试。
import org.junit.jupiter.api.*;
import java.util.stream.Stream;
public class ContainerDynamicTest {
// 模拟一些数据
private static final String[] categories = {"数学", "字符串", "日期"};
private static final int[] numbers = {1, 2, 3};
// 使用 DynamicContainer 分组测试
@TestFactory
Stream<DynamicNode> categorizedTests() {
return Stream.of(
DynamicContainer.dynamicContainer("数学测试", Stream.of(
DynamicTest.dynamicTest("数学 - 加法测试", () -> {
Assertions.assertEquals(5, 2 + 3);
System.out.println("数学加法测试通过");
}),
DynamicTest.dynamicTest("数学 - 减法测试", () -> {
Assertions.assertEquals(1, 3 - 2);
System.out.println("数学减法测试通过");
})
)),
DynamicContainer.dynamicContainer("字符串测试", Stream.of(
DynamicTest.dynamicTest("字符串 - 长度测试", () -> {
Assertions.assertEquals(5, "Hello".length());
System.out.println("字符串长度测试通过");
}),
DynamicTest.dynamicTest("字符串 - 连接测试", () -> {
Assertions.assertEquals("Hello World", "Hello" + " " + "World");
System.out.println("字符串连接测试通过");
})
)),
DynamicContainer.dynamicContainer("日期测试", Stream.of(
DynamicTest.dynamicTest("日期 - 当前时间测试", () -> {
Assertions.assertNotNull(java.time.LocalDateTime.now());
System.out.println("日期测试通过");
})
))
);
}
// 更复杂的嵌套容器和动态测试
@TestFactory
Stream<DynamicNode> nestedCategorizedTests() {
return Stream.of(
DynamicContainer.dynamicContainer("数值运算测试", Stream.of(
DynamicContainer.dynamicContainer("正数运算", Stream.of(
DynamicTest.dynamicTest("正数加法", () -> {
Assertions.assertEquals(10, 5 + 5);
}),
DynamicTest.dynamicTest("正数乘法", () -> {
Assertions.assertEquals(25, 5 * 5);
})
)),
DynamicContainer.dynamicContainer("负数运算", Stream.of(
DynamicTest.dynamicTest("负数加法", () -> {
Assertions.assertEquals(-10, -5 + (-5));
}),
DynamicTest.dynamicTest("负数乘法", () -> {
Assertions.assertEquals(25, -5 * -5);
})
)),
DynamicContainer.dynamicContainer("混合运算", Stream.of(
DynamicTest.dynamicTest("混合加法", () -> {
Assertions.assertEquals(0, 5 + (-5));
})
))
)),
DynamicContainer.dynamicContainer("字符串处理测试", Stream.of(
DynamicTest.dynamicTest("空字符串测试", () -> {
Assertions.assertTrue("".isEmpty());
}),
DynamicTest.dynamicTest("非空字符串测试", () -> {
Assertions.assertFalse("Hello".isEmpty());
})
))
);
}
}
DynamicContainer 允许你将相关的动态测试组织成树状结构。这不仅有助于清晰地看到测试的分类,而且在测试报告中也会体现出来,便于调试和分析。嵌套的 DynamicContainer 可以构建更复杂的层次结构。
5. 异常处理的动态测试 🧨
动态测试也适用于需要验证异常行为的场景。你可以动态地生成测试用例来检查代码在遇到特定输入时是否会抛出预期的异常。
import org.junit.jupiter.api.*;
import java.util.stream.Stream;
public class ExceptionHandlingDynamicTest {
// 模拟一个可能抛出异常的方法
public static int divide(int dividend, int divisor) {
if (divisor == 0) {
throw new ArithmeticException("除数不能为零");
}
return dividend / divisor;
}
// 动态生成异常测试用例
@TestFactory
Stream<DynamicTest> exceptionTests() {
return Stream.of(
DynamicTest.dynamicTest("正常除法测试", () -> {
int result = divide(10, 2);
Assertions.assertEquals(5, result);
System.out.println("正常除法测试通过");
}),
DynamicTest.dynamicTest("除零异常测试", () -> {
// 使用 Assertions.assertThrows 来捕获并验证异常
ArithmeticException exception = Assertions.assertThrows(
ArithmeticException.class,
() -> divide(10, 0),
"除法操作应该抛出 ArithmeticException"
);
Assertions.assertEquals("除数不能为零", exception.getMessage());
System.out.println("除零异常测试通过");
}),
DynamicTest.dynamicTest("负数除法测试", () -> {
int result = divide(-10, 2);
Assertions.assertEquals(-5, result);
System.out.println("负数除法测试通过");
})
);
}
}
Assertions.assertThrows 是一个非常有用的工具,它允许你验证特定代码块是否抛出了期望类型的异常。结合动态测试,你可以轻松地为各种异常场景生成测试。
6. 结合 @BeforeEach, @AfterEach 的动态测试 🔄
动态测试同样可以与生命周期回调方法配合使用。在 @BeforeEach 和 @AfterEach 中,你可以执行一些初始化或清理工作,这些工作对于每个动态生成的测试都是通用的。
import org.junit.jupiter.api.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public class LifecycleDynamicTest {
private List<String> testLog; // 用于记录测试执行情况
@BeforeEach
void setUp() {
testLog = new ArrayList<>();
System.out.println("=== 开始执行动态测试套件 ===");
}
@AfterEach
void tearDown() {
System.out.println("=== 执行完毕,测试日志: " + testLog + " ===");
}
// 动态测试,同时记录执行日志
@TestFactory
Stream<DynamicTest> lifecycleAwareTests() {
// 定义测试数据
int[] testNumbers = {1, 2, 3, 4, 5};
return Stream.of(testNumbers).map(num -> {
String testName = "生命周期测试 - 数字 " + num;
return DynamicTest.dynamicTest(testName, () -> {
// 在这里可以访问 setUp 中初始化的 testLog
testLog.add("执行了测试: " + testName);
// 一些简单的测试逻辑
Assertions.assertTrue(num > 0);
System.out.println("测试 " + testName + " 通过");
});
});
}
}
在这个例子中,@BeforeEach 和 @AfterEach 为整个动态测试套件提供了统一的设置和清理环境。每个动态测试都可以访问 testLog,并在执行后记录相关信息。
与传统参数化测试的对比 🆚
虽然动态测试提供了更大的灵活性,但有时传统的 @ParameterizedTest 也能满足需求。让我们看看它们之间的区别和选择依据。
传统参数化测试 (@ParameterizedTest)
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
public class TraditionalParameterizedTest {
// 使用 @ParameterizedTest 和 @ValueSource
@ParameterizedTest
@ValueSource(strings = {"hello", "world", "JUnit"})
void testWithStrings(String input) {
Assertions.assertNotNull(input);
Assertions.assertTrue(input.length() > 0);
System.out.println("传统参数化测试 - 输入: " + input);
}
// 使用 @ParameterizedTest 和 @CsvSource
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"5, 3, 8",
"-1, 1, 0"
})
void testAddition(int a, int b, int expected) {
Assertions.assertEquals(expected, a + b);
System.out.println("传统参数化测试 - 加法: " + a + " + " + b + " = " + expected);
}
}
动态测试 vs 传统参数化测试
| 特性 | 传统参数化测试 (@ParameterizedTest) |
动态测试 (@TestFactory) |
|---|---|---|
| 定义方式 | 使用 @ParameterizedTest 注解和 @ValueSource, @CsvSource 等提供者 |
使用 @TestFactory 注解和 DynamicTest/DynamicContainer |
| 灵活性 | 有限,主要受限于提供的参数化注解 | 高,完全由 Java 代码控制测试生成逻辑 |
| 复杂逻辑 | 难以嵌入复杂的业务逻辑 | 易于嵌入复杂的计算、条件判断 |
| 性能 | 通常更快,因为参数化机制是内建优化的 | 可能稍慢,因为涉及更多的运行时计算 |
| 调试 | 直接显示每个参数值 | 通过 DynamicTest 名称标识,调试信息更丰富 |
| 数据源 | 主要支持 CSV、数组、方法等内置来源 | 支持任意外部数据源(文件、数据库、API 等) |
| 代码量 | 对于简单场景,代码更简洁 | 对于复杂场景,可能需要更多代码 |
选择建议:
- 简单、明确的参数化场景: 优先考虑
@ParameterizedTest,因为它简洁明了,易于理解和维护。 - 需要复杂逻辑或外部数据驱动: 使用动态测试。例如,根据配置文件生成测试,或者根据数据库中的记录生成测试用例。
- 需要动态改变测试结构: 如果你需要在运行时决定测试的组织方式(如分组、嵌套),动态测试是更好的选择。
高级技巧和最佳实践 🎯
1. 使用 DynamicTest 的 displayName 和 dynamicTest 重载
DynamicTest.dynamicTest 有多个重载版本,可以为你提供更详细的测试信息。
import org.junit.jupiter.api.*;
import java.util.stream.Stream;
public class AdvancedDynamicTest {
@TestFactory
Stream<DynamicTest> advancedTests() {
return Stream.of(
// 基本形式
DynamicTest.dynamicTest("基础测试名称", () -> {
Assertions.assertTrue(true);
}),
// 带有自定义 DisplayName 的形式
DynamicTest.dynamicTest("自定义显示名称", () -> {
Assertions.assertEquals(10, 5 + 5);
}).withDisplayName("【高级】加法测试"),
// 从方法引用创建
DynamicTest.dynamicTest("方法引用测试", this::someTestMethod)
);
}
private void someTestMethod() {
Assertions.assertTrue(false); // 会失败
}
}
2. 错误处理和失败测试
在动态测试中,如果某个测试用例的执行失败,JUnit 会将其视为一个失败的测试。你也可以主动地生成一个失败的测试用例。
import org.junit.jupiter.api.*;
import java.util.stream.Stream;
public class FailureDynamicTest {
@TestFactory
Stream<DynamicTest> failureTests() {
return Stream.of(
DynamicTest.dynamicTest("成功测试", () -> {
Assertions.assertTrue(true);
}),
// 主动失败的测试
DynamicTest.dynamicTest("主动失败测试", () -> {
Assertions.fail("这是一个故意失败的测试");
}),
// 有条件地失败的测试
DynamicTest.dynamicTest("条件失败测试", () -> {
boolean shouldFail = true; // 可以从配置或数据中读取
if (shouldFail) {
Assertions.fail("根据条件失败");
}
Assertions.assertTrue(true);
})
);
}
}
3. 性能考量
动态测试的生成逻辑会在每次测试运行时执行。如果生成逻辑非常复杂或涉及大量 I/O 操作,可能会对整体测试运行时间产生影响。因此,尽量将耗时的操作放在 @BeforeEach 或预先计算好,而不是在 @TestFactory 方法内部。
4. 测试报告友好性
动态测试的名称对于测试报告非常重要。确保为每个 DynamicTest 提供清晰、有意义的名称,以便于在测试运行后快速定位问题。
5. 结合其他 JUnit 5 特性
动态测试可以与 JUnit 5 的许多其他特性良好地结合:
@Disabled: 可以为动态生成的测试添加@Disabled注解来临时禁用某些测试。@Timeout: 可以为单个动态测试设置超时。@Tag: 可以为动态测试打标签,方便筛选和运行特定类型的测试。
import org.junit.jupiter.api.*;
import java.util.stream.Stream;
public class TaggedDynamicTest {
@TestFactory
Stream<DynamicTest> taggedTests() {
return Stream.of(
DynamicTest.dynamicTest("标记测试 1", () -> {
Assertions.assertTrue(true);
}).withTags("fast", "unit"),
DynamicTest.dynamicTest("标记测试 2", () -> {
Assertions.assertEquals(2, 1 + 1);
}).withTags("slow", "integration")
);
}
}
实际应用场景 🏗️
动态测试的应用场景非常广泛,下面是一些常见的实际例子:
- API 接口测试: 根据 API 文档或契约测试,动态生成针对不同请求体、响应体、状态码的测试用例。
- 配置文件验证: 读取配置文件,为每个配置项生成相应的验证测试。
- 数据库记录测试: 从数据库查询记录,为每条记录生成对应的测试用例。
- 文件系统测试: 遍历文件夹,为每个文件生成对应的处理或校验测试。
- 随机测试: 生成随机数据集进行压力测试或边界值测试。
总结与展望 📈
JUnit 5 的动态测试为 Java 测试带来了前所未有的灵活性和表达力。它打破了传统静态测试的束缚,让测试代码变得更加智能和强大。通过 @TestFactory、DynamicTest 和 DynamicContainer,我们可以根据运行时数据、外部资源甚至复杂的业务逻辑来生成和执行测试。
在实际项目中,合理运用动态测试可以显著提升测试的自动化程度和覆盖率,尤其是在面对大量相似但细节不同的测试场景时。然而,它也要求开发者具备更强的编程能力,需要仔细考虑性能和可维护性。
随着软件开发的复杂性不断增加,动态测试无疑将成为现代测试框架不可或缺的一部分。掌握这项技术,将使你在构建可靠、高质量的软件时拥有更强大的武器。🚀
未来,我们期待看到更多基于动态测试的创新实践和工具,进一步简化测试编写过程,提高测试效率和质量。
希望这篇博客能帮助你深入理解并有效利用 JUnit 5 的动态测试功能!如果你有任何疑问或想要探讨更多关于动态测试的话题,欢迎留言交流。💬
📚 参考资料
- JUnit 5 User Guide - Dynamic Tests
- JUnit 5 API Documentation for DynamicTest
- JUnit 5 API Documentation for TestFactory
- JUnit 5 API Documentation for DynamicContainer
📈 Mermaid 图表
动态测试核心组件关系图
动态测试与传统参数化测试对比
动态测试执行流程
🙌 感谢你读到这里!
🔍 技术之路没有捷径,但每一次阅读、思考和实践,都在悄悄拉近你与目标的距离。
💡 如果本文对你有帮助,不妨 👍 点赞、📌 收藏、📤 分享 给更多需要的朋友!
💬 欢迎在评论区留下你的想法、疑问或建议,我会一一回复,我们一起交流、共同成长 🌿
🔔 关注我,不错过下一篇干货!我们下期再见!✨
更多推荐


所有评论(0)