# Endless 用例编写风格指南

虽然本文档标题是「Endless 用例编写风格指南」，然而它的重要性却不亚于任何一份文档（甚至不亚于 README.md）。在本文档中我们将介绍我们对 Endless 的期望，这些期望是如何在仍在持续演进的 Endless
中得到实现，以及常见的误解和推荐的用例编写方法。

## 设计原则

Endless 的意义并不仅仅在于加快测试编写。同时，我们希望使用 Endless 编写的测试能够达到以下效果：

- **风格统一。**
  我们观察到的一个现象是，编写用例的各个小组和个人对用例自动化中的各个实体有不同的理解，甚至对于「用例」到底是一个多大粒度的单位亦未达成一致。这导致不同小组、不同同学编写的用例风格差别较大。我们希望通过提供一组风格一致的
  DSL（和本文档）来强制制定并统一测试的风格。
- **受管理。** Endless 是测试的*集合*，并不负责测试的编排和调度。使用 Endless 编写的用例是受管理的，需要包含能全局唯一的 ID。
- **高效。** 我们希望通过提供一组通用、语义化的 `GomegaMatcher`，能够将常见的测试模式进行抽象，从而提高编写用例的效率。

## 最佳实践

本节列出了为达到在在上一节中给出的设计原则，应以何种形式组织您的测试用例。_请务必仔细阅读并遵循之。_

### 1. 遵循 BDD 原则组织测试。

Endless 是基于 [ginkgo](https://onsi.github.io/ginkgo/) 设计的测试框架，使用 Endless 编写的测试用例应遵循 BDD 提倡的模式。

#### 要点

- `Describe` 用于填写**待测对象**。
- `Context` 用于填写本用例**将待测对象置于何种场景下**，即**场景**。
- `It` / `TestCase` 用于填写**该待测对象在该场景下期望观测到的行为**，即**预期行为**。

#### 举例

- ❌ `Describe(AzDown) --> Context(TPC-C workload) --> It(should recover the QPS in 2 minutes after tikv is restarted)`
    - `Describe` 处填写的不是*待测对象*而是*场景*。
    - ✅ `Describe(TiKV) --> Context(when DownTiKVsInOneAZ) --> It(TPC-C QPS should recover in 2 minutes)`
- ❌ `Describe(Randgen) --> Context(randgen test diff tikv with tiflash) --> It(should be consistent)`
    - `Describe` 处填写的不是*待测对象*而是*场景*，*待测对象*被误填到了 `Context` 中。
    - ✅ `Describe(TiKVAndTiFlash) --> Context(when test diff with randgen) --> It(should be consistent)`
- ❌ `Describe(TiKV) --> Context(TiKV under heavy workload) --> It(should not OOM)`
    - `Describe` 中已经出现的待测对象不需要再在 `Context` 中重复出现。
    - ✅ `Describe(TiKV) --> Context(under heavy workload) --> It(should not OOM)`

**一个经验法则**（不一定必然如此）：`Describe` 中*只能出现「产品」及其组合*，比如 `TiDB`、`PD`、`TiCDC`、`TiKVAndTiFlash`；而对于每一个 `TestCase` / `It`
块，确保从父级 `Describe` 到 `Context` 再到当前 `TestCase` / `It` 能够形成*完整、没有重复*
的一句话（比如 `TiKV -> under heavy workload -> should not OOM` 就是完整且没有重复的一句话）。

### 2. 使用大驼峰命名法（`UpperCamelCase`）指定 `Describe` 和 `ParameterizedDescribe` 的名称。

`Describe` 和 `ParameterizedDescribe` 的第一个参数（即 "名称"）参数均应以大驼峰命名法（`UpperCamelCase`
）指定，不要包含空格和特殊符号。事实上 `ParameterizedDescribe` 确实会强制要求传入的名称不能包含空格。

#### 举例

- ❌：`Describe(oom)`, `Describe(somethingWentWrong)`, `Describe(TiKV & TiFlash)`
- ✅：`Describe(OOM)`, `Describe(SomethingWentWrong)`, `Describe(TiKVAndTiFlash)`

### 3. `TestCase` 是具有可执行逻辑的最小单位，它对应一个 `It`。

一个完整的可执行逻辑就是一个测试用例，它具有一个全局唯一的 ID。

#### 举例

比如，测试逻辑 `组件 X 在 TPC-C 负载下工作 900 秒` 和 `组件 X 在 sysbench 负载下工作 900 秒` 是两个逻辑，应被指定*两个*全局唯一的 ID，放在两个独立的 `It`
块中（它们可以共用一个 `Describe`，因为待测对象都是 `组件 X`，但是需要两个独立的 `Context` 和 `It`）。「组件 X 工作 900 秒」只是完整测试逻辑的一个片段，*不能*为这一片段指定
ID（因为它不是最小单位，亦不是完整的可执行逻辑）。

### 4. 使用 `TestCase` 替代 `It`。

`TestCase` 是在 `pkg/util/dsl.go` 中定义的 DSL，**旨在替代 `It`。**

#### 要点

`TestCase` 的用法和语义与 `It` 完全相同。唯一的区别是，它强制要求每一个用例都提供一个全局唯一的 ID，并且将这个 TestCase ID 拼接到 `It` 中。之所以鼓励使用 `TestCase` 替代 `It`
，主要是基于以下两点考虑：

1. **强调「测试用例」和 `It` 等同**，即「可执行逻辑的最小单位」。在 ginkgo 中只有 `It`、`BeforeEach` 和 `AfterEach` 等基本块是能放置逻辑的，在其它容器块（如 `Describe`
   、`Context`）中放置逻辑不仅是反模式，还会导致严重的测试间干扰：参见下文 `不要在 Describe、Context 和 When 这些容器块中放置资源获取和初始化逻辑`。
2. **每一个「测试用例」，即每一个 `It` 块，都应具有一个全局唯一的 ID。** 如前所述，大量文档对「测试用例」的定义不甚明确。我们在此强制规定了什么是测试用例，并将每一个测试用例都与一个全局唯一的 TestCase ID
   挂钩。这个 ID 可以由各组自行管理，也可能由 Endless 统一分配和登记。

   > TODO：相关设计仍在讨论中。我个人赞成由各组独立管理、但在 Endless 中统一登记的形式，通过 pull request 提交用例的时候需要一并修改 `docs/catalog.md`。

### 5. 使用全局唯一的 TestCase ID 指定 `-focus '#<TestCase ID>#'` 参数。

在调用 `TestCase` 时填写的全局唯一 TestCase ID 将作为本用例的唯一索引。

#### 要点

尽量使用 `TestCase` 来替代 `It`，填写的全局唯一 TestCase ID 将作为索引，通过语法 `ginkgo -focus '#<TestCase ID>#'`（注意，**不要漏掉** TestCase ID 两边的井号和引号）
来锁定。如果需要锁定多个用例，请指定多个 `-focus`，它们将以或的关系执行匹配（如 `-focus '#<TestCase ID 1>#' -focus '#<TestCase ID 2>#'`。

### 6. 不要在 `Describe`、`Context` 和 `When` 这些容器块中放置资源获取和初始化逻辑。

任何业务逻辑都应被放置在基本块（`TestCase`、`BeforeEach`、`AfterEach`、`BeforeSuite` 和 `AfterSuite`）中，包括资源获取和初始化。

#### 要点

ginkgo 执行测试分为两个阶段，按顺序执行：

1. 测试树构造阶段。
    1. 在本阶段中，首先执行本 suite 中所有的*顶层定义*，即本 suite 中所有 `_test.go` 文件的*顶层* `var _ = ...`。
    2. **在每一个顶层定义中，执行所有的容器块（`Describe`、`Context` 和 `When`）。**
2. 测试执行阶段。
    1. 对于每一个 `TestCase` / `It`，找到从顶层定义经过各级容器块、`BeforeEach` 等到达该  `TestCase` / `It` 的路径。
    2. 随机从这些路径中抽取一个，执行之。

问题在于，一个 suite 中的**所有** `Describe`、`Context` 和 `When` 这些容器块会首先**全部被一起执行**。这意味着如果一个 suite
中包含多个容器块，而容器块中又包含各自的资源获取和初始化逻辑，**不同测试需要的资源会被混淆，还可能发生「找不到资源」等令人困惑的运行时错误。**

**不要在容器块中放置资源获取和初始化逻辑。** 正确的方式是仅在容器块中包含*声明*，并在 `BeforeEach` 中初始化之，在 `AfterEach` 中回收之。

#### 举例

- ❌：
  ```go
  var _ = Describe("TiDB", func() {
      ctx := suiteTestCtx
      tc := ctx.Resource("tc").(resource.TiDBCluster)
      db := Try(sqlx.Open("mysql", dbDSN)).(*sqlx.DB)
      defer db.Close()
  
      Context("receive Alter Clause", func() {
          It("alter table attributes", func() {
              ...
          })
      })
  })
  ```

- ✅：
  ```go
  var _ = Describe("TiDB", func() {
  	ctx := suiteTestCtx
  	var (
  		tc resource.TiDBCluster
  		db *sqlx.DB
  	)
      
  	BeforeEach(func() {
  		tc := ctx.Resource("tc").(resource.TiDBCluster)
  		db = Try(sqlx.Open("mysql", dbDSN)).(*sqlx.DB)
  	})
  	AfterEach(func() {
  		db.Close()
  	})
  
  	Context("receive Alter Clause", func() {
  		It("alter table attributes", func() {
  			...
  		})
  	})
  })
  ```

### 7. 可以嵌套或省略 `Describe` 和 `Context`。

需要时，可以嵌套 `Describe` 和 `Context`。比如，当「测试场景」包含多条时，可以嵌套多级 `Context`，从而将测试组织得更好。但是，这样做的前提是多出来的级别确实有*多个用例*需要管理。需要避免 "
只包含一个 `Context` 的嵌套 `Context`"。

对于省略的情况（典型的是省略 `Context`，而采用 `Describe --> It` 的 "二段式" 结构），如果测试用例确实十分简单、并不需要 BDD 式的组织（比如一些单元测试），也可以采取这种形式。

### 8. `BeforeEach` 和 `AfterEach` 是有作用域的。

`BeforeEach` 和 `AfterEach` 遵循由 `Context`、`Describe` 和 `When` 划定的边界，内层的 `Before` & `After` 仅对内层 `TestCase` / `It`
有效，而外层的 `Before` & `After` 则对外层中所有（包括内层） `TestCase` / `It` 都有效。

#### 举例

```go
var _ = Describe("BeforeAfterTest", func() {
	Context("outer", func() {
		BeforeEach(func() {
			fmt.Println("outer before")
		})
		AfterEach(func() {
			fmt.Println("outer after")
		})

		Context("inner", func() {
			BeforeEach(func() {
				fmt.Println("inner before")
			})
			AfterEach(func() {
				fmt.Println("inner after")
			})

			It("it1", func() {
				fmt.Println("It inner!")
			})
		})

		It("it2", func() {
			fmt.Println("It outer!")
		})
	})
})
```

使用 ginkgo 执行这段测试，输出是：

```
outer before
inner before
It inner!
inner after
outer after
•outer before
It outer!
outer after
```

可以看到这是按照作用域的规则执行的。

综合上两点 `可以嵌套或省略 Describe 和 Context` 和 `BeforeEach 和 AfterEach 是有作用域的`，可以最大化抽取公用的资源回收逻辑，实现测试的良好组织。

### 9. dot-import `ginkgo`、`gomega` 和 `endless/matcher` 三个包。

这三个包几乎每个测试用例都会用到，而且反复出现在用例的各个地方。通过 dot-import 引入可以改善测试的可读性。当然，如果你没有用到这三个包中的某一个，你也不需要引入它。
