> For the complete documentation index, see [llms.txt](https://docs.decentraland.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.decentraland.org/contributor/contributor-zh/gong-xian-zhe-zhi-nan/testing-standards/testing-uis.md).

# 测试 UI

本节描述开发者将如何测试与 UI 相关的代码库。

## 测试栈

我们所有的 UI 测试都必须使用 [Jest](https://jestjs.io/) 作为主要测试框架，并使用 [redux-saga-test-plan](https://github.com/jfairbank/redux-saga-test-plan) 作为 saga 的测试工具，并且 [react testing library](https://testing-library.com/docs/react-testing-library/intro/) 作为 UI 组件测试工具。测试代码必须使用 [TypeScript](https://www.typescriptlang.org/) 并使用 [ts-jest](https://github.com/kulshekhar/ts-jest) 以获得类型检查支持。

## 测试内容

* UI 组件
* Saga
* Reducer
* 选择器
* Action creator
* 工具函数

## 测试 saga

Saga 让我们能够通过创建处理 redux action 的生成器来处理副作用。Saga 的主要优势之一是它的可测试性，因为 saga 中广泛使用的 effect 都具有很高的可测试性。

这些 handler 必须使用 [redux-saga-test-plan](https://github.com/jfairbank/redux-saga-test-plan)进行测试，并且测试必须覆盖完整执行流程，从整个模块的 handler（其主 handler 或 saga）到所有会对我们即将 dispatch 的 action 做出响应的 handler 的全部执行过程。这种方式使 saga 测试更容易构建和维护，同时也让测试更有价值，因为它们尽可能覆盖了完整流程。

handler 的测试必须通过它们与 effect 的交互来进行测试，主要是与 `put` 这一项，因为它们的目的就是处理 action 并生成新的 action。大多数测试肯定会包含一个 `provide` call，用于模拟 effect，一个或多个 `put` call，以验证 handler 按预期 dispatch 这些 action，以及一个单独的 `dispatch` 后接一个 `run` call，以执行整个 saga 直到完成。

对被测模块之外资源的模拟应该使用 redux-saga-test-plan providers 来完成，并充分利用 redux-sagas 提供的不同 effect。像 call、apply 或其他 effect，只要在测试中调用函数时可行，就应该尽量使用它们，以便轻松进行模拟。Jest 不应被用于模拟模块或函数。

describe 及其内容中的文本必须遵循以下准则：

* 一个 saga 或主 handler 的主 describe 必须以 *when handling*开头，这表示将要测试的是一个 handler。紧接着 *when handling* 这个短语之后，必须写出要处理的 action（或该 action 的意图）的描述。例如： *处理表示成功获取的 action。*
* 内部 describe 用于区分执行上下文，它们遵循与以下部分中描述的相同规则： [描述并构建上下文](/contributor/contributor-zh/gong-xian-zhe-zhi-nan/testing-standards/writing-tests.md#describing-and-building-context) 部分。
* 该 **it**s 应描述测试的预期结果，以一个 *should put* 以及在执行 handler 时应该被 put 的 action 描述。

### Saga 示例

```tsx
...
export function* tiersSaga(builder: BuilderAPI) {
	function* handleBuyThirdPartyItemTierRequest(action: BuyThirdPartyItemTiersRequestAction) {
    const { thirdParty, tier } = action.payload
    try {
			// 获取 chain id 可能会失败，必须编写测试以确保这些情况被覆盖
      const maticChainId: ChainId = yield call(getChainIdByNetwork, Network.MATIC)
      const thirdPartyContract = yield call(getContract, ContractName.ThirdPartyRegistry, maticChainId)
			// 发送交易可能成功也可能失败，测试中必须覆盖这两种情况
      const txHash: string = yield call(sendTransaction, thirdPartyContract, instantiatedThirdPartyContractContract =>
        instantiatedThirdPartyContractContract.buyItemSlots(thirdParty.id, tier.id, tier.price)
      )
      yield put(buyThirdPartyItemTiersSuccess(txHash, maticChainId, thirdParty, tier))
    } catch (error) {
      yield put(buyThirdPartyItemTiersFailure(error.message, thirdParty.id, tier))
    }
  }
}
```

### Saga 测试示例

```tsx
// 主 describe 以 'when handling...' 开头
describe('处理购买第三方物品层级请求', () => {
	// 覆盖所有情况，包括正向和负向
  describe('并且无法获取 chain id', () => {
    it('应该 dispatch 表示购买 item slots 层级失败的 action，包含该层级和第三方 id', () => {
      return expectSaga(tiersSaga, mockedBuilderApi)
				// provide 调用会模拟不同的 effect，以构建不同场景
        .provide([[call(getChainIdByNetwork, Network.MATIC), Promise.reject(new Error(defaultError))]])
				// put 调用用于验证作为最终结果的预期是否发生
        .put(buyThirdPartyItemTiersFailure(defaultError, thirdParty.id, thirdPartyItemTier))
				// 每个测试只做一次 dispatch，以覆盖我们正在测试的 action 的处理
        .dispatch(buyThirdPartyItemTiersRequest(thirdParty, thirdPartyItemTier))
        .run({ silenceTimeout: true })
    })
  })

  describe('并且发送交易失败', () => {
    it('应该 dispatch 表示购买 item slots 层级失败的 action，包含该层级和第三方 id', () => {
      return expectSaga(tiersSaga, mockedBuilderApi)
        .provide([
					// call 会精确匹配 call effect，并将参数考虑在内
					// 这能确保在 put 之前执行的代码按我们的预期运行
          [call(getChainIdByNetwork, Network.MATIC), ChainId.MATIC_MUMBAI],
          [call(getContract, ContractName.ThirdPartyRegistry, ChainId.MATIC_MUMBAI), contract],
					// matchers 仅用于匹配函数本身，而不匹配参数
          [matchers.call.fn(sendTransaction), Promise.reject(new Error(defaultError))]
        ])
        .put(buyThirdPartyItemTiersFailure(defaultError, thirdParty.id, thirdPartyItemTier))
        .dispatch(buyThirdPartyItemTiersRequest(thirdParty, thirdPartyItemTier))
        .run({ silenceTimeout: true })
    })
  })

  describe('并且发送交易成功', () => {
    let contract: any
    beforeEach(() => {
      contract = { buyItemSlots: jest.fn() }
    })

    it('应该 dispatch 表示成功购买 item slots 层级的 action，包含该层级、第三方以及交易详情', () => {
      return expectSaga(tiersSaga, mockedBuilderApi)
        .provide([
          [call(getChainIdByNetwork, Network.MATIC), ChainId.MATIC_MUMBAI],
          [call(getContract, ContractName.ThirdPartyRegistry, ChainId.MATIC_MUMBAI), contract],
          [matchers.call.fn(sendTransaction), Promise.resolve(txHash)]
        ])
        .put(buyThirdPartyItemTiersSuccess(txHash, ChainId.MATIC_MUMBAI, thirdParty, thirdPartyItemTier))
        .dispatch(buyThirdPartyItemTiersRequest(thirdParty, thirdPartyItemTier))
        .run({ silenceTimeout: true })
    })
  })
})
```

## 测试 reducer

Reducer 是接收一个 state 和一个 action 并返回新 state 的函数。这个新 state 可以包含针对给定 action 预先编排的变化。

如有需要，测试应变化初始状态和被测试 action 的不同参数。断言必须针对返回的整个 state 进行，因为一个 action 可能修改给定初始 state 的任何部分。

在 reducing 一个 action 时，我们应使用 action creator，从而让测试更易于维护。

为了标准化我们的编写方式 **describe**和 **it**：

* 主 describe 应通过使用短语来描述将要被 reducer 处理的 action *当 reducing 该 action* 随后接上 action 的描述。我们不应使用 action type 来描述要测试的 action，因为 type 可能会改变。
* 该 **it**s 应清晰描述返回状态是如何变化的。例如： *它应该返回一个状态，其中 error 被清空，fruits 已设置*.

### Reducer 示例

```tsx
const INITIAL_STATE: FruitsState = {
  data: {
    fruits: []
  },
  loading: []
}

export function fruitsReducer(state = INITIAL_STATE, action: any): FruitsState {
  switch (action.type) {
    case FETCH_FRUIT_REQUEST: {
      return {
        ...state,
        loading: loadingReducer(state.loading, action),
				error: null
      }
    }
		case FETCH_FUIT_ERROR: {
			const { error } = action
      return {
        ...state,
				error,
        loading: loadingReducer(state.loading, action)
      }
		}
		case FETCH_FRUIT_SUCCESS: {
			const { fruits } = action
      return {
        ...state,
				fruits,
        loading: loadingReducer(state.loading, action)
      }
		}
}
```

### Reducer 测试示例

```tsx
let state: FruitsState

beforeEach(() => {
	state = {
	  data: {
	    fruits: []
	  },
	  loading: [],
		error: null
	}
})

describe('当 reducing 表示获取 fruits 的 action 时', () => {
	beforeEach(() => {
		// 先设置一个 error，以确保在 reducing 该 action 时它会被清除。
		state.error = 'anError'
	})

	it('应该返回一个状态，其中 error 被清除，且 loading 状态被设为 loading', () => {
		// 使用 action creator 来测试 reducer，以便更容易维护。
		const action = fetchFruitsRequest()

		expect(fruitsReducer(state, action)).toEqual({
			...state,
			error: null,
			/* 由于 loading reducer 是另一个 reducer 的一部分（且无需测试），
				 我们可以选择原样使用它，也可以使用一个模拟它的辅助函数。
			*/
			loading: loadingReducer(state.loading, action),
		})
	})
})

describe('当 reducing 表示获取 fruits 失败的 action 时', () => {
	const error = 'anError'

	it('应该返回一个状态，其中 error 已设置，且 loading 状态已从 fruits 获取 action 中清除', () => {
		const action = fetchFruitsFailure(error)

		expect(fruitsReducer(state, action)).toEqual({
			...state,
			error,
			loading: loadingReducer(state.loading, action),
		})
	})
})

describe('当 reducing 表示获取 fruits 成功的 action 时', () => {
	let fruits: string[]
	beforeEach(() => {
		fruits = ['apples', 'pears']
	})

	it('应该返回一个状态，其中 fruits 已设置，且 loading reducer 已从 fruits 获取 action 中清除', () => {
		const action = fetchFruitsSuccess(fruits)

		expect(fruitsReducer(state, action)).toEqual({
			...state,
			fruits,
			loading: loadingReducer(state.loading, action),
		})
	})
})
```

## 测试 selector

Selector 是接收一个 state 并返回该 state 的某个部分或经过转换后的 state 部分的方法。

selector 有两种类型：记忆化 selector 和普通 selector。普通 selector 必须像任何其他单元测试函数一样进行测试，而记忆化 selector 应使用 `resultFunc` 或使用接收状态中记忆化部分并返回所需值的函数。

**描述**和 **it**s 必须按照以下内容所述的方式编写： [描述并构建上下文](/contributor/contributor-zh/gong-xian-zhe-zhi-nan/testing-standards/writing-tests.md#describing-and-building-context) 部分。

### Selector 示例

```tsx
// 普通 selector
export const collectionExists = (state: RootState, id: string): boolean => {
	return state.collections.data.some((collection) => collection.id === id)
}

// 使用 reselect 的记忆化 selector，返回增强后的 collection 列表
export const getEnhancedCollections = createSelector(
	getCollections,
	getExtraData,
	(collections, extraData) => {
		return collections.map((collection) =>
			({ ...collection, ...extraData[collection[id]] }))
	}
)
```

### Selector 测试示例

```tsx
let state: RootState
let collection: { id: string }
beforeEach(() => {
	collection = {
		id: 'anId'
	}
	state = {
		collections: {
			data: []
		}
	}
})

beforeEach(() => {
	state.collections.data = [collection]
})

// 普通 selector 作为普通函数进行单元测试
describe('当检查某个 collection 是否存在时', () => {
	describe('并且不存在具有给定 id 的 collection', () => {
		it('应该返回 false', () => {
			expect(collectionExists(state, "anotherId")).toBe(false)
		})
	})

	describe('并且存在具有给定 id 的 collection', () => {
		it('应该返回 true', () => {
			expect(collectionExists(state, collection.id)).toBe(true)
		})
	})
})

describe('当获取增强后的 collections 时', () => {
	let enhacements: Record<string, Enhacement>

	describe('并且没有用于增强它们的数据', () => {
		beforeEach(() => {
			enhacements = {}
		})

		// 记忆化 selector 应使用 resultFunc 进行单元测试
		it('应该获取所有未增强的 collection', () => {
			expect(getEnhancedCollections.resultFunc(collections, enhacements)).toEqual([
				collection
			])
		})
	})

	describe('并且有用于增强它们的数据', () => {
		beforeEach(() => {
			enhacements = { [collection.id]: { aNewProperty: true } }
		})

		it('应该获取所有增强后的 collection', () => {
			expect(getEnhancedCollections.resultFunc(collections, enhacements)).toEqual([
				{ ...collection, ...enhacements[collection.id] }
			])
		})
	})
})
```

## 测试 action creator

Action creator 负责创建稍后会在 reducer 中处理的 action。虽然 action creator 通常很简单，但它们仍必须经过测试，以确保它们按我们的预期工作。

**描述**和 **it**s 必须按照以下内容所述的方式编写： [描述并构建上下文](/contributor/contributor-zh/gong-xian-zhe-zhi-nan/testing-standards/writing-tests.md#describing-and-building-context) 部分。

### Action creator 示例

```tsx
export const BUY_THIRD_PARTY_ITEM_TIERS_REQUEST = '[Request] 购买第三方物品层级'
export const BUY_THIRD_PARTY_ITEM_TIERS_SUCCESS = '[Success] 购买第三方物品层级'
export const BUY_THIRD_PARTY_ITEM_TIERS_FAILURE = '[Failure] 购买第三方物品层级'

export const buyThirdPartyItemTiersRequest = (thirdParty: ThirdParty, tier: ThirdPartyItemTier) =>
  action(BUY_THIRD_PARTY_ITEM_TIERS_REQUEST, { thirdParty, tier })
export const buyThirdPartyItemTiersSuccess = (txHash: string, chainId: ChainId, thirdParty: ThirdParty, tier: ThirdPartyItemTier) =>
  action(BUY_THIRD_PARTY_ITEM_TIERS_SUCCESS, { thirdParty, tier, ...buildTransactionPayload(chainId, txHash, { tier, thirdParty }) })
export const buyThirdPartyItemTiersFailure = (error: string, thirdPartyId: string, tier: ThirdPartyItemTier) =>
  action(BUY_THIRD_PARTY_ITEM_TIERS_FAILURE, { error, thirdPartyId, tier })
```

### Action creator 测试示例

```tsx
describe('当创建表示 tiers 获取请求开始的 action 时', () => {
  it('应该返回一个表示 tiers 获取开始的 action', () => {
    expect(fetchThirdPartyItemTiersRequest()).toEqual({ type: FETCH_THIRD_PARTY_ITEM_TIERS_REQUEST })
  })
})

describe('当创建表示 tiers 成功获取的 action 时', () => {
  it('应该返回一个表示 tiers 获取成功的 action', () => {
    expect(fetchThirdPartyItemTiersSuccess([thirdPartyItemTier])).toEqual({
      type: FETCH_THIRD_PARTY_ITEM_TIERS_SUCCESS,
      payload: { tiers: [thirdPartyItemTier], error: undefined, meta: undefined }
    })
  })
})

describe('当创建表示 tiers 获取失败的 action 时', () => {
  it('应该返回一个表示 tiers 获取失败的 action', () => {
    expect(fetchThirdPartyItemTiersFailure(defaultError)).toEqual({
      type: FETCH_THIRD_PARTY_ITEM_TIERS_FAILURE,
      payload: { error: defaultError }
    })
  })
})
```

## 测试 UI 组件

在测试 UI 组件时，我们使用 [React Testing Library](https://testing-library.com/docs)。我们使用这个库而不是 enzyme 等其他方案，因为它让我们在测试组件时更多考虑用户将如何与其交互，而不是组件的实现本身。我们不会与 react 组件实例交互，而是按照它们的渲染方式与 DOM 元素交互。

### 测试内容

这个 UI 组件测试的目的是测试组件的行为以及用户如何与其交互。在许多情况下，可能会有组件连接到 redux store，以获取某些属性并调用某些 action。我们通常通过单独创建一个 `container` 文件来与 store 交互。这些文件不在此类测试的范围内。我们测试的是组件在不同 props 值下的表现，我们不应关心这些值的来源（redux store、context、父组件），而应关注它们如何影响最终渲染。

### 渲染

每个测试都应该从调用 render 函数开始。这不仅会渲染我们作为参数传入的组件，还会渲染所有子组件。我们应该避免在测试 setup 中这样做（`beforeEach`），因为这可能会导致问题，让 DOM 树中包含比我们需要更多的组件。我们应该将结果保存到一个名为 **screen**的属性中。这样我们就能在测试中获取所需的所有属性，而无需不断更改解构。

```jsx
it('应该渲染 hello world 组件', () => {
	const screen = render(<HelloWorld />)
})
```

如果文档中会有多个测试，而组件有多个 prop，我们可以在文件顶部创建一个函数来负责设置默认 prop 并渲染组件。

```jsx
function renderSelectedFilters(props: Partial<Props> = {}) {
  return render(
    <SelectedFilters
      isLandSection={false}
      category={undefined}
      browseOptions={{}}
      onBrowse={jest.fn()}
      {...props}
    />
  )
}
```

### 查询

查询是 Testing Library 提供给你的，用于在页面上查找元素的方法。它们有不同的 [查询类型](https://testing-library.com/docs/queries/about/#priority) 可供我们使用来访问元素。我们应该尽量始终使用 `对所有人都可访问` 的查询，因为它们反映了所有用户（视觉/鼠标用户以及使用辅助技术的用户）的体验。

这些查询包括 `getByRole`, `getByLabelText`, `getByPlaceholderText`, `getByText`, `getByDisplayValue`。如果我们可以使用这些查询访问元素，那通常意味着该组件不可访问。

### 测试用户事件

我们无法访问组件内部的函数，因此应该模拟导致这些函数被调用的用户行为。为此我们使用 `userEvent` 库。

```jsx
const screen = renderSelectedFilters({
      browseOptions: raritiesOptions,
      onBrowse: onBrowseMock
    })
const commonPill = screen.getByTestId('pill-common')
await userEvent.click(within(commonPill).getByRole('button'))
```

### 调试

要调试测试以及在某个特定时刻 DOM 的当前状态，我们可以使用 `debug` 函数，它是调用 render 时返回的属性之一

```jsx
const { debug } = render(<HelloWorld />)

debug()
```

### UI 组件测试完整示例

```jsx
function renderSelectedFilters(props: Partial<Props> = {}) {
  return render(
    <SelectedFilters
      isLandSection={false}
      category={undefined}
      browseOptions={{}}
      onBrowse={jest.fn()}
      {...props}
    />
  )
}

describe('稀有度筛选器', () => {
  it('应该渲染稀有度', () => {
    const raritiesOptions = { rarities: [Rarity.COMMON, Rarity.EPIC] }
    const screen = renderSelectedFilters({
      browseOptions: raritiesOptions
    })
    expect(screen.getByText(Rarity.COMMON)).toBeInTheDocument()
    expect(screen.getByText(Rarity.EPIC)).toBeInTheDocument()
  })

  it('应该在删除稀有度后调用 onBrowse', async () => {
    const raritiesOptions = { rarities: [Rarity.COMMON, Rarity.EPIC] }
    const onBrowseMock = jest.fn()

    const screen = renderSelectedFilters({
      browseOptions: raritiesOptions,
      onBrowse: onBrowseMock
    })
    const commonPill = screen.getByTestId('pill-common')
    await userEvent.click(within(commonPill).getByRole('button'))
    expect(onBrowseMock).toHaveBeenCalledWith({ rarities: [Rarity.EPIC] })
  })
})
```

## 目录结构

测试必须放在它们将要执行的文件或模块旁边，名称相同，但扩展名为 `spec.ts` 而不是 `ts`.

文件结构必须如下所示：

```
actions.ts
action.spec.ts
reducer.ts
reducer.spec.ts
sagas.ts
sagas.spec.ts
selectors.ts
selectors.spec.ts
utils.ts
utils.spec.ts
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.decentraland.org/contributor/contributor-zh/gong-xian-zhe-zhi-nan/testing-standards/testing-uis.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
