This is an automated email from the ASF dual-hosted git repository.
kirs pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/dolphinscheduler-website.git
The following commit(s) were added to refs/heads/master by this push:
new 2a1d791 Add ut template (#438)
2a1d791 is described below
commit 2a1d79140e34611e525d8e6f20dc70b8c7401600
Author: 深刻 <[email protected]>
AuthorDate: Sun Sep 26 18:34:04 2021 +0800
Add ut template (#438)
* Add ut template
* update ut-template
---
blog/zh-cn/ut-template.md | 408 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 408 insertions(+)
diff --git a/blog/zh-cn/ut-template.md b/blog/zh-cn/ut-template.md
new file mode 100644
index 0000000..4cd359d
--- /dev/null
+++ b/blog/zh-cn/ut-template.md
@@ -0,0 +1,408 @@
+# DolphinScheduler 单元测试模版
+
+## 1. 单元测试原则
+
+1. **3A 原则**
+
+ 3A
原则简单易懂,它为套件中的所有测试提供了统一的结构,这种统一的结构是其最大的优势之一:一旦习惯了这种模式,就可以更轻松地阅读和理解测试,这反过来又降低了整个测试套件的维护成本。
+
+ - Arrange:初始化测试数据。
+ - Act:调用被测方法,传入依赖参数并获取返回值。
+ - Assert:断言,对返回值做出断言。
+
+ 示例:
+
+ ```java
+ public class Calculator {
+ public long sum(long a, long b) {
+ return a + b;
+ }
+ }
+ ```
+
+ ```java
+ public class CalculatorTest {
+ @Test
+ public void sum() {
+ // Arrange
+ long a = 1L, b = 2L;
+ Calculator calculator = new Calculator();
+ // Act
+ long actual = calculator.sum(a, b);
+ // Assert
+ long expected = 3L;
+ assertEquals(expected, actual);
+ }
+ }
+ ```
+
+2. **AIR 原则**
+ - Automatic:测试过程应当是完全自动的、非交互的。
+ - Independent:为了保证单元测试稳定可靠且便于维护,单元测试用例之间不允许互相调用,也不能依赖执行的先后次序。
+ - Repeatable:单元测试是可以重复执行的,不能受到外界环境的影响。
+
+除此以外,还应遵循以下原则:
+
+1. **隔离性与单一性**
+
+一个测试类应该只对应于一个被测试类,并且对被测试类行为的测试环境应该是隔离的。
+
+一个测试用例应该精确到方法级别,并应该能够单独执行该测试用例,同时关注点也始终在该方法上。
+
+如果方法过于复杂,开发阶段就应该将其再次进行拆分,对于测试用例来讲,最佳做法是一个用例只关注一个分支(判断)。当对其进行修改后,也仅仅影响一个测试用例的成功与否。这会极大方便我们在开发阶段验证问题和解决问题,但与此同时,也对我们覆盖率提出了极大的挑战。
+
+2. **可重复性**
+
+在任何环境、任何时间,多次执行后的结果一致,且可以重复执行。
+
+3. **轻量型**
+
+测试应当是秒级甚至是毫秒级的,不应占用过多时间。
+
+> 由于 Spring Boot 启动花费时间较长,因此当待测试对象不依赖 Spring Bean 或 Spring 容器时,应当避免使用 Spring
Test 进行单元测试,可以通过直接创建目标类对象的方式实现。
+
+4. **可测性**
+
+为了保证每个软件组件的正确性,我们希望将代码的质量保障提前,使每个软件组件在开发阶段就能够测试,因此在设计和开发过程中,需要保证每个模块是可以进行测试的。
+
+为了保证可测性,设计和编写业务逻辑代码时需注意以下几点:
+
+-
依赖隔离。在编写业务逻辑代码时,应遵循依赖倒置原则——高层次的模块不应该依赖于低层次的模块,它们都应该依赖于抽象,抽象不应该依赖于具体实现,具体实现应该依赖于抽象。通过依赖倒置,可以减少类与类之间的耦合性,提高系统的稳定性,提高代码的可读性和可维护性,并且能够降低修改程序所造成的风险。
+-
避免使用静态变量。静态变量是全局性的、有状态的,在多线程中处理复杂。单元测试应该彼此独立、隔离,不应该依赖于执行顺序,甚至应该允许并发同时执行所有单元测试进而使测试更快速。使用静态变量会破坏单元测试的可重复执行性,并且可能干扰到其他的单元测试方法。
+- 避免使用静态方法。一般建议只在一些工具类提供静态方法,这种情况下也不需要
mock,直接使用真实类即可。如果被依赖类不是工具类,可以将静态方法重构为实例方法。这样更加符合面向对象的设计理念。
+
+5. **完备性**
+
+测试覆盖率通常被用来衡量测试的充分性和完整性,核心流程期望达到 90% 的覆盖率,非核心流程期望达到 60% 以上的覆盖率。
+
+覆盖率足够高的情况下可以减少 bug 出现的概率,同时也减少了回归测试的成本。
+
+> DolphinScheduler 使用 Sonar
存储和管理覆盖率等各项指标:https://sonarcloud.io/code?id=apache-dolphinscheduler
+
+6. **拒绝无效断言**
+
+无效断言让测试本身变得毫无意义,它和你的代码正确与否几乎没什么关系,且有可能会给你造成一种成功的假象,这种假象有可能持续到你的代码部署到生产环境。
+
+无效断言类型:
+
+- 不同类型的比较。
+- 判断一个具有默认值的对象或者变量不为空。
+
+断言尽可能采用肯定断言而非否定断言,断言尽可能在一个预知结果范围内,或者是准确的数值(否则有可能会导致一些不符合你的实际预期但是通过了断言)除非你的代码只关心他是否为空。
+
+7. **异常**
+
+对异常的验证是单元测试中一个很重要的环节,在编写单元测试时,除了正常的输入输出,还需要特别针对可能导致异常的情况进行测试。
+
+需注意以下几点:
+
+1. 程序在测试过程中抛出异常,并不一定是 bug;程序应当抛出异常的地方却没有抛出异常,一定是 bug。
+2. 在编写单元测试代码时,需要全面了解设计文档或业务代码,明确在什么情况下会抛出哪些异常,尽可能使单元测试能够覆盖更多的场景。
+3. 在测试失败的代码块中通过 `Assert.fail(String message)` 声明测试失败情况。
+4. 使用 `@Test(expected = RuntimeException.class)` 表明方法抛出的 `RuntimeException`
是合法的,若抛出其他异常或不抛出异常都会使测试失败。
+
+## 2. 单元测试编写建议
+
+1. 应当保证单元测试的测试粒度足够小,有助于精确定位问题。单元测试粒度至多是类级别,一般是方法级别。
+
+ > 只有测试粒度足够小,才能在出错时尽快定位到出错位置。除此以外,单元测试不负责检查跨类或者跨系统的交互逻辑(这是集成测试的领域)。
+
+2. 单元测试代码应当放在 `src/test/java` 路径下。
+
+3. 同一个单元测试方法中只能涵盖对同一类测试用例的测试,并且建议使用 `test_MethodName_CaseDescription` 测试方法命名方式。
+
+4. 编写单元测试代码应遵守 BCDE 原则,以保证被测试模块的交付质量。
+
+ - Border:边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。
+ - Correct:正确的输入,并得到预期的结果。
+ - Design:与设计文档相结合,来编写单元测试。
+ - Error:强制错误信息输入(如:非法数据、异常流程、业务允许外等),并得到预期的结果。
+
+5. 对于数据库相关的增删改查等操作,不能假设数据库里的数据是存在的,或者直接向数据库中插入数据,应当采用程序插入或者导入数据的方式准备数据。
+
+ >
例如,在删除某一行数据的单元测试中,若事先手动向数据库中增加一行数据作为待删除目标,但由于该行新增数据并不一定符合业务插入规范,因此有可能导致测试结果异常。
+
+6. 数据库相关的单元测试可以设定自动回滚机制,避免数据库中存在脏数据。或者对单元测试产生的数据有明确的前后缀标识。
+
+7. 为了更方便地进行单元测试,业务代码应避免出现以下情况:
+
+ 1. 构造方法中做的事情过多。
+
+ 2. 存在过多的全局变量和静态方法。
+
+ 3. 存在过多的外部依赖。
+
+ 4. 存在过多的条件语句。
+
+ > 多层条件语句可以使用卫语句、策略模式、状态模式等方式重构。
+
+## 3. Mock
+
+### 3.1 为什么需要 mock?
+
+单元测试要求在不涉及依赖关系的情况下测试代码,即可迁移性。
+
+模拟对象(Mock
Object)可以取代真实对象的位置,用于测试一些与真实对象进行交互或依赖于真实对象的功能。模拟真实对象的目的就是创建一个轻量级的、可以控制的对象来代替测试中需要的真实对象,模拟真实对象的行为和功能。
+
+> 例如,service 调用 dao,即 service 依赖 dao,这时候可以用 mock 对象来模拟真实的 dao 调用,从而达到不依赖 dao
具体实现逻辑的情况下测试 service 的目的,以减少模块间耦合。
+
+**mock对象使用范畴:**
+
+1. 真实对象具有不可确定的行为,产生不可预测的效果。
+2. 真实对象很难被创建的。
+3. 真实对象的某些行为很难被触发。
+4. 真实对象实际上并不存在的。
+
+> 因此,发送邮件、依赖 DAO 的上层操作(如 Service)、Controller 层 HTTP 请求等场景建议使用 mock 测试。
+
+### 3.2 为什么应当避免使用 mock?
+
+1. 需要额外编写 mock 测试代码。
+2. 掩盖实际代码的执行情况,可能无法完全覆盖测试场景。
+3. 测试代码的可复用性差。
+
+## 4. Controller 层测试
+
+### 4.1 编写建议
+
+1. 为了使测试更加快速,测试时应尽量避免构建 Spring Context。
+
+2. 通过构造函数配置依赖项。
+
+ > 优点:
+ >
+ > 1. 允许将字段声明为 final。final 关键字会有助于性能提升,并且由于 final 变量是只读的,因此在多线程环境下无需额外的同步开销。
+ > 2. 避免通过 Spring 配置依赖项,使测试运行更快。
+
+3. 建议使用 `MockMvc` 对象模拟 HTTP 请求实现 Controller 层测试。
+
+ >优点:
+ >
+ >1. `MockMvc` 可以在不启动 Web 服务器和构建 Spring Context 的情况下实现 Controller 测试。
+ >2. `MockMvc` 提供了许多有用的、用于执行请求和断言结果的方法。
+
+### 4.2 编写示例
+
+Controller UT 架构如下:
+
+
+
+对于新增的 controller,其测试代码编写步骤如下:
+
+1. 继承 `RestControllerTest` 或 `NormalControllerTest`
+2. 重写 `getTestedController()` 方法,返回新增的待测试 controller 对象
+3. 基于 `MockMvc` 编写测试方法,模拟 HTTP 请求
+
+待测试方法:
+
+```java
+@RestController
+public class UserController extends BaseController {
+
+ private final UserService service;
+
+ public UserController(UserService service) {
+ this.service = service;
+ }
+
+ @RequestMapping("/login")
+ @ResponseStatus(HttpStatus.OK)
+ public Result login(@RequestParam(value = "userName", required = false)
String userName, @RequestParam(value = "userPassword", required = false) String
userPassword) {
+
+ Map<String, Object> result = service.login(userName, userPassword);
+ return returnDataList(result);
+ }
+
+}
+```
+
+测试代码:
+
+```java
+public class RestUserControllerTest extends AbstractRestControllerTest {
+
+ private static final Logger logger =
LoggerFactory.getLogger(RestUserControllerTest.class);
+
+ @Test
+ public void testLogin() throws Exception {
+ // (2) Act
+ MvcResult mvcResult = mockMvc.perform(post("/login"))
+ .andExpect(status().isOk()) // (3) Assert
+ .andReturn();
+
+ MockHttpServletResponse response = mvcResult.getResponse();
+ String content = response.getContentAsString();
+
+ Result result = JSONUtils.parseObject(content, Result.class);
+ // (3) Assert
+ Assert.assertEquals(Status.SUCCESS.getCode(),
result.getCode().intValue());
+ logger.info(content);
+ }
+
+ @Override
+ protected Object getTestedController() {
+ // (1) Arrange
+ UserService service = new UserService();
+ return new UserController(service);
+ }
+
+}
+```
+
+## 5. DAO 层测试
+
+### 5.1 编写建议
+
+DAO 层测试有三种方案:
+
+1. 使用 mock 对象模拟数据库操作
+2. 使用内存数据库进行测试
+3. 使用真实数据库环境进行测试
+
+三者对比如下:
+
+- 使用模拟对象有利于提高可测试性,避免环境对单元测试的限制。但是这种方案是建立在数据库操作都正确的假想下,因此并不能完全覆盖所有场景(如对 SQL
语句执行结果的测试)。
+- 使用内存数据库进行测试同样避免了环境对单元测试的限制,同时使测试更可靠、更快速。
+- 使用真实数据库环境进行测试更具有可靠性,但是也对测试环境有了限制,降低了测试的可迁移性,同时数据库操作可能会使测试时间延长,并产生许多脏数据。
+
+综上,在涉及数据库的单元测试场景中,推荐使用前两种方案。
+
+### 5.2 编写示例
+
+待测试代码:
+
+```java
+@Service
+public class UsersServiceImpl extends BaseServiceImpl implements UsersService {
+
+ /**
+ * query user
+ *
+ * @param name name
+ * @param password password
+ * @return user info
+ */
+ @Override
+ public User queryUser(String name, String password) {
+ String md5 = EncryptionUtils.getMd5(password);
+ return userMapper.queryUserByNamePassword(name, md5);
+ }
+
+}
+```
+
+##### 1. Mock
+
+> 模拟 DAO 层数据库操作,实现 Service 层测试
+
+测试代码:
+
+```java
+@RunWith(MockitoJUnitRunner.class)
+public class UsersServiceTest {
+
+ private static final Logger logger =
LoggerFactory.getLogger(UsersServiceTest.class);
+
+ @InjectMocks
+ private UsersServiceImpl usersService;
+
+ @Mock
+ private UserMapper userMapper;
+
+ @Test
+ public void testQueryUser() {
+ String userName = "userTest0001";
+ String userPassword = "userTest0001";
+ when(userMapper.queryUserByNamePassword(userName,
EncryptionUtils.getMd5(userPassword))).thenReturn(getGeneralUser());
+ User queryUser = usersService.queryUser(userName, userPassword);
+ logger.info(queryUser.toString());
+ Assert.assertTrue(queryUser != null);
+ }
+
+ /**
+ * get user
+ */
+ private User getGeneralUser() {
+ User user = new User();
+ user.setUserType(UserType.GENERAL_USER);
+ user.setUserName("userTest0001");
+ user.setUserPassword("userTest0001");
+ return user;
+ }
+
+}
+```
+
+##### 2. H2 内存数据库
+
+1. pom 依赖
+
+ ```xml
+ <dependency>
+ <groupId>com.h2database</groupId>
+ <artifactId>h2</artifactId>
+ <scope>test</scope>
+ </dependency>
+ ```
+
+2. 数据库初始化
+
+ > 由于 h2 是内存数据库,不会持久化表结构,因此在每次测试前都要先初始化表结构。
+
+ 在 `test/resources` 目录下新建 `application.yml` 文件
+
+ ```yaml
+ spring:
+ database:
+ driver-class-name: org.h2.Driver
+ url: jdbc:h2:mem:test # test 为数据库名称
+ initialization-mode: always # always: 每次启动时进行初始化
+ schema: classpath:sql/schema.sql # 用于初始化表结构的 sql 文件的路径
+ data: classpath:sql/data.sql # 用于初始化表数据的 sql 文件的路径
+
+ # 打印 sql debug 日志
+ logging:
+ level:
+ org.apache.dolphinscheduler.dao.mapper: debug
+ ```
+
+3. 测试
+
+ > 基于数据库环境进行测试,无需编写 mock 代码
+
+ 测试代码:
+
+ ```java
+ @RunWith(MockitoJUnitRunner.class)
+ public class UsersServiceTest {
+
+ private static final Logger logger =
LoggerFactory.getLogger(UsersServiceTest.class);
+
+ @InjectMocks
+ private UsersServiceImpl usersService;
+
+ @Mock
+ private UserMapper userMapper;
+
+ @Before
+ public void createUser() {
+ String userName = "userTest0001";
+ String userPassword = "userTest0001";
+ User user = new User();
+ user.setUserName(userName);
+ user.setUserPassword(userPassword);
+ userMapper.insert(user);
+ }
+
+ @Test
+ public void testQueryUser() {
+ String userName = "userTest0001";
+ String userPassword = "userTest0001";
+ User queryUser = usersService.queryUser(userName, userPassword);
+ logger.info(queryUser.toString());
+ Assert.assertTrue(queryUser != null);
+ }
+
+ }
+ ```
+