> 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 组件
* Sagas
* Reducers
* Selectors
* Action creators
* 工具函数

## 测试 sagas

Sagas 让我们通过创建处理 redux action 的生成器来处理副作用。sagas 的主要优点之一是它们具有很强的可测试性，因为 saga 中广泛使用的 effect 非常容易测试。

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

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

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

describe 中的文本必须遵循以下准则：

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

### Sagas 示例

```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))
    }
  }
}
```

### Sagas 测试示例

```tsx
// 主 describe 以 'when handling...' 开头
describe('when handling the request to buy a third party item tier', () => {
	// 所有情况，包括负向和正向情况，都要覆盖
  describe('and the chain id couldn\'t be retrieved', () => {
    it('should put the action signaling the failure of the purchase of an item slots tier with the tier and the third party id', () => {
      return expectSaga(tiersSaga, mockedBuilderApi)
				// 通过 provide 调用来 mock 不同的 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('and sending the transaction fails', () => {
    it('should put the action signaling the failure of the purchase of an item slots tier with the tier and the third party 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],
					// matcher 只用于匹配函数，而不匹配参数
          [matchers.call.fn(sendTransaction), Promise.reject(new Error(defaultError))]
        ])
        .put(buyThirdPartyItemTiersFailure(defaultError, thirdParty.id, thirdPartyItemTier))
        .dispatch(buyThirdPartyItemTiersRequest(thirdParty, thirdPartyItemTier))
        .run({ silenceTimeout: true })
    })
  })

  describe('and sending the transaction succeeds', () => {
    let contract: any
    beforeEach(() => {
      contract = { buyItemSlots: jest.fn() }
    })

    it('should put the action signaling the successful purchase of an item slots tier with the tier, the third party and the transaction details', () => {
      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 })
    })
  })
})
```

## 测试 reducers

Reducer 是接收 state 和 action 并返回新 state 的函数。这个新 state 可能包含针对给定 action 的预设变更。

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

在处理 action 时，我们应该使用 action creator，以便让测试更容易维护。

为了统一我们的编写方式 **describe**和 **它**：

* 主 describe 应该使用短语 *when reducing the action* 开头，来描述将要被 reducer 处理的是哪些 action。我们不应该使用 action type 来描述要测试的 action，因为 type 可能会改变。
* 该 **它**应清晰地描述返回的 state 是如何变化的。例如： *应该返回一个 error 被清空且 fruits 已设置的 state*.

### 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)
      }
		}
}
```

### Reducers 测试示例

```tsx
let state: FruitsState

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

describe('when reducing the action that signals the fetching of fruits', () => {
	beforeEach(() => {
		// 设置一个 error，以确保在 reducing 该 action 时它会被清除。
		state.error = 'anError'
	})

	it('should return a state where the error is cleared and the loading state is set to loading', () => {
		// 使用 action creator 来测试 reducer，以便更易于维护。
		const action = fetchFruitsRequest()

		expect(fruitsReducer(state, action)).toEqual({
			...state,
			error: null,
			/* 由于 loading reducer 是另一个 reducer 的一部分（并不需要测试），
				 我们可以选择原样使用它，或者使用一个模拟它的 helper。
			*/
			loading: loadingReducer(state.loading, action),
		})
	})
})

describe('when reducing the action that signals the failure of fetching fruits', () => {
	const error = 'anError'

	it('should return a state where the error is set and the loading state is cleared from the fruits fetch action', () => {
		const action = fetchFruitsFailure(error)

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

describe('when reducing the action that signals the success of fetching fruits', () => {
	let fruits: string[]
	beforeEach(() => {
		fruits = ['apples', 'pears']
	})

	it('should return a state where the fruits are set and the loading reducer is cleared from the fruits fetch action', () => {
		const action = fetchFruitsSuccess(fruits)

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

## 测试 selectors

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

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

**Describe**和 **它**s 必须按照 [描述和构建上下文](/contributor/contributor-zh/gong-xian-zhe-zhi-nan/testing-standards/writing-tests.md#describing-and-building-context) 部分中所描述的相同规则。

### Selectors 示例

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

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

### Selectors 测试示例

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

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

// 普通 selector 按照常规函数进行单元测试
describe('when checking if a collection exists', () => {
	describe('and there\'s no collection with the given id', () => {
		it('should return false', () => {
			expect(collectionExists(state, "anotherId")).toBe(false)
		})
	})

	describe('and there\'s a collection with the given id', () => {
		it('should return true', () => {
			expect(collectionExists(state, collection.id)).toBe(true)
		})
	})
})

describe('when getting the enhanced collections', () => {
	let enhacements: Record<string, Enhacement>

	describe('and there\'s no data to enhance them', () => {
		beforeEach(() => {
			enhacements = {}
		})

		// memoized selector 应使用 resultFunc 进行单元测试
		it('should retrieve all the collections without enhancing them', () => {
			expect(getEnhancedCollections.resultFunc(collections, enhacements)).toEqual([
				collection
			])
		})
	})

	describe('and there\'s data to enhance them', () => {
		beforeEach(() => {
			enhacements = { [collection.id]: { aNewProperty: true } }
		})

		it('should retrieve all the enhanced collections', () => {
			expect(getEnhancedCollections.resultFunc(collections, enhacements)).toEqual([
				{ ...collection, ...enhacements[collection.id] }
			])
		})
	})
})
```

## 测试 action creators

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

**Describe**和 **它**s 必须按照 [描述和构建上下文](/contributor/contributor-zh/gong-xian-zhe-zhi-nan/testing-standards/writing-tests.md#describing-and-building-context) 部分中所描述的相同规则。

### Action creators 示例

```tsx
export const BUY_THIRD_PARTY_ITEM_TIERS_REQUEST = '[Request] Buy a third party item tier'
export const BUY_THIRD_PARTY_ITEM_TIERS_SUCCESS = '[Success] Buy a third party item tier'
export const BUY_THIRD_PARTY_ITEM_TIERS_FAILURE = '[Failure] Buy a third party item tier'

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('when creating the action that signals the start of a tiers fetch request', () => {
  it('should return an action signaling the start of the tiers fetch', () => {
    expect(fetchThirdPartyItemTiersRequest()).toEqual({ type: FETCH_THIRD_PARTY_ITEM_TIERS_REQUEST })
  })
})

describe('when creating the action that signals the successful fetch of tiers', () => {
  it('should return an action signaling the success of the tiers fetch', () => {
    expect(fetchThirdPartyItemTiersSuccess([thirdPartyItemTier])).toEqual({
      type: FETCH_THIRD_PARTY_ITEM_TIERS_SUCCESS,
      payload: { tiers: [thirdPartyItemTier], error: undefined, meta: undefined }
    })
  })
})

describe('when creating the action that signals the unsuccessful fetch of tiers', () => {
  it('should return an action signaling the failure of the tiers fetch', () => {
    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 函数开始。它不仅会渲染我们作为参数传入的组件，还会渲染所有子组件。我们应避免在测试初始化（`beforeEach`）中这样做，因为这可能会导致 DOM 树包含比我们需要更多的组件。我们应将结果保存到名为 **screen**的属性中。这样我们就可以在测试中获取所需的所有属性，而无需不断进行解构。

```jsx
it('should render hello world component', () => {
	const screen = render(<HelloWorld />)
})
```

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

```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('rarities filter', () => {
  it('should render rarities', () => {
    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('should call onBrowse without deleted rarity', 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 by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

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

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

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.
