快速指南:如何使用 RxJS 测试 React Hooks
测试 React Hook 和 RxJS
当你处理复杂的异步操作时,RxJS 非常实用。RxJS 专为使用 Observables 进行响应式编程而设计。它将你的异步操作转换为 Observables。借助 Observables,我们可以“观察”数据流,被动地监听事件。
React Hooks 可以从多方面增强你的函数式组件。借助 Hooks,我们可以使用自定义 Hooks 来抽象和解耦逻辑。逻辑分离使你的代码更易于测试,并且可以在组件之间共享。
这篇文章解释了如何测试useEffect使用 RxJs 监听鼠标点击并使用 RxJs 的debounceTime操作符延迟点击的 hook。
我们在这里使用的钩子。
- useState:使用状态增强功能组件。
- useEffect:我们可以执行 DOM 操作和选择。
这里我们使用的 RxJs 操作符。
- map:使用源发出的值,从提供的函数返回 Observable 值。
- debounceTime:仅在经过特定时间而没有其他源发出值后,才从源 Observable 发出值。
在开始编写测试代码之前,让我们先看一下示例组件。
Button.tsx
//Button.tsx
import React, { SFC} from 'react'
import {useClick} from './useClick'
type Props = {
interval?: number;
label?:string;
}
const Button:SFC<Props> = (props:Props) => {
const {ref, count} = useClick(props.interval)
return <button data-testid="btn" ref={ref}>Hello {count}</button>
}
export default Button
useClick.ts
// useClick.ts
import React, { useRef, useEffect, useCallback, useState, RefObject, Dispatch} from 'react'
import {fromEvent, Observable, Subscribable, Unsubscribable} from 'rxjs'
import {map, debounceTime} from 'rxjs/operators'
type NullableObservarbel = Observable<any> | null;
type NUllabe = HTMLButtonElement | null;
type NullableSubscribable = Subscribable<any> | null
type NullableUnsubscribable = Unsubscribable | null
export type Result = {
ref: RefObject<HTMLButtonElement>;
count:number;
updateCount:Dispatch<React.SetStateAction<number>>;
}
export const isString = (input:any):Boolean => (typeof input === "string" && input !== "")
export const makeObservable = (el:NUllabe, eventType:string):NullableObservarbel => el instanceof HTMLElement && isString(eventType) ? fromEvent(el, eventType) : null
export const useClick = (time:number = 500):Result => {
const button: RefObject<HTMLButtonElement> = useRef(null)
const [count, updateCount] = useState<number>(0)
const fireAfterSubscribe = useCallback((c) => {updateCount(c)}, [])
useEffect(():()=>void => {
const el = button.current
const observerble = makeObservable(el, 'click')
let _count = count
let subscribable:NullableSubscribable = null
let subscribe:NullableUnsubscribable = null
if(observerble){
subscribable = observerble.pipe(
map(e => _count++),
debounceTime(time)
)
subscribe = subscribable.subscribe(fireAfterSubscribe)
}
return () => subscribe && subscribe.unsubscribe() // cleanup subscription
// eslint-disable-next-line
}, [])
return {ref:button, count, updateCount:fireAfterSubscribe}
}
以上示例中,我们有两个文件。
- 1. Button.tsx:是一个简单的按钮组件。
- 2 useClick.ts:包含自定义钩子
useClick和makeObservable. 函数。
按钮用于useClick延迟按钮点击事件。每次点击都使用 RxJSdebounceTime函数进行防抖处理。
用户在400毫秒内点击后,之前的点击将被忽略。用户完成点击操作后,系统会等待400毫秒,然后触发最后一个事件。
很简单!🤓
现在让我们来测试一下!🧪
我们先从简单的开始。测试一下useState钩子函数。
// useClick.test.tsx - v1
import React from 'react'
import {useClick} from './useClick'
describe('useState', () => {
it('should update count using useState', () => {
const result = useClick(400) // test will break due to invarient violation
const {updateCount} = result
updateCount(8)
expect(result.current.count).toBe(8)
})
})
现在运行 yarn test.
Invariant Violation: Invalid hook call. Hooks can only be called inside of the body of a function component....
结果并非我们所预期的。
上述错误表示在函数组件主体之外调用 hooks 是无效的。
在这种情况下,我们可以使用 react hooks 测试实用库@testing-library/react-hooks。
import { renderHook } from '@testing-library/react-hooks
这样renderHook我们就可以在函数组件的主体之外调用钩子函数。
我们直接替换const result = useClick(400)成const {result} = renderHook(() => useClick(400)
此外,const {updateCount} = result还有const {updateCount} = result.current
然后,setState用 `if` 语句包裹你的调用,act否则你的测试会抛出错误。
// useClick.test.tsx -v2
import React from 'react'
import { useClick } from './useClick'
import { renderHook, act as hookAct } from '@testing-library/react-hooks'
describe('useState', () => {
it('should update count using useState', () => {
const {result} = renderHook(() => useClick(400))
const {updateCount} = result.current
hookAct(() => {
updateCount(8)
})
expect(result.current.count).toBe(8)
})
})
好了,现在我们可以出发了。
再次运行yarn test。
恭喜!测试通过。
更多测试
现在我们测试makeObservable这个函数。该函数makeObservable接收 DOM 元素和事件类型(字符串形式)作为参数,并应返回一个 Observable 对象。如果传入无效参数,则应返回 false。
让我们测试一下makeObservable这个函数。
// useClick.test.tsx
import React from 'react'
import { makeObservable, useClick } from './useClick'
import {Observable} from 'rxjs'
import Button from './Button'
import { render } from '@testing-library/react'
import { renderHook, act as hookAct } from '@testing-library/react-hooks'
describe('useState', () => {
it('should update count using useState', () => {
const {result} = renderHook(() => useClick(400))
const {updateCount} = result.current
hookAct(() => {
updateCount(8)
})
expect(result.current.count).toBe(8)
})
})
describe('makeObservable', () => {
it('should return false for non HTMLElement', () => {
const observable = makeObservable({}, 'click')
expect(observable instanceof Observable).toBe(false)
})
it('should return false for non non string event', () => {
const {getByTestId} = render(<Button/>)
const el = getByTestId('btn') as HTMLButtonElement
const observable = makeObservable(el, 20)
expect(observable instanceof Observable).toBe(false)
})
it('should return false for null', () => {
const observable = makeObservable(null, 'click')
expect(observable instanceof Observable).toBe(false)
})
it('should create observable', () => {
const {getByTestId} = render(<Button/>)
const el = getByTestId('btn') as HTMLButtonElement
const observable = makeObservable(el, 'click')
expect(observable instanceof Observable).toBe(true)
})
})
测试订阅者和 useEffect。
测试 useEffect 和 observable 是其中最复杂的部分。
-
因为
useEffect这样会使你的组件异步渲染。 -
订阅者内部的断言永远不会运行,因此测试总是通过。
为了捕获 useEffect 的副作用,我们可以用actreact-dom/test-utils 中的 useEffect 来包装我们的测试代码。
要在订阅内运行断言,我们可以使用done().Jest,在完成测试之前等待 done 回调被调用。
// useClick.test.tsx
import React from 'react'
import {isString, makeObservable, useClick } from './useClick'
import {Observable} from 'rxjs'
import {map, debounceTime} from 'rxjs/operators'
import Button from './Button'
import { render, fireEvent, waitForElement } from '@testing-library/react'
import {act} from 'react-dom/test-utils'
import { renderHook, act as hookAct } from '@testing-library/react-hooks'
describe('useState', () => {
it('should update count using useState', () => {
const {result} = renderHook(() => useClick(400))
const {updateCount} = result.current
hookAct(() => {
updateCount(8)
})
expect(result.current.count).toBe(8)
})
})
describe('makeObservable', () => {
it('should return false for non HTMLElement', () => {
const observable = makeObservable({}, 'click')
expect(observable instanceof Observable).toBe(false)
})
it('should return false for non non string event', () => {
const {getByTestId} = render(<Button/>)
const el = getByTestId('btn') as HTMLButtonElement
const observable = makeObservable(el, 20)
expect(observable instanceof Observable).toBe(false)
})
it('should return false for null', () => {
const observable = makeObservable(null, 'click')
expect(observable instanceof Observable).toBe(false)
})
it('should create observable', () => {
const {getByTestId} = render(<Button/>)
const el = getByTestId('btn') as HTMLButtonElement
const observable = makeObservable(el, 'click')
expect(observable instanceof Observable).toBe(true)
})
})
describe('isString', () => {
it('is a string "click"', () => {
expect(isString('click')).toEqual(true)
})
it('is not a string: object', () => {
expect(isString({})).toEqual(false)
})
it('is not a string: 9', () => {
expect(isString(9)).toEqual(false)
})
it('is not a string: nothing', () => {
expect(isString(null)).toEqual(false)
})
})
describe('Observable', () => {
it('Should subscribe observable', async (done) => {
await act( async () => {
const {getByTestId} = render(<Button/>)
const el = await waitForElement(() => getByTestId('btn')) as HTMLButtonElement
const observerble = makeObservable(el, 'click');
if(observerble){
let count = 1
observerble
.pipe(
map(e => count++),
debounceTime(400)
)
.subscribe(s => {
expect(s).toEqual(6)
done()
})
fireEvent.click(el)
fireEvent.click(el)
fireEvent.click(el)
fireEvent.click(el)
fireEvent.click(el)
fireEvent.click(el)
}
})
})
})
按钮组件测试
// Button.test.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import Button from './Button'
import { render, fireEvent, waitForElement, waitForDomChange } from '@testing-library/react'
describe('Button component', () => {
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<Button />, div);
ReactDOM.unmountComponentAtNode(div);
});
})
describe('Dom updates', () => {
it('should update button label to "Hello 2"', async (done) => {
const {getByTestId} = render(<Button interval={500}/>)
const el = await waitForElement(() => getByTestId('btn')) as HTMLButtonElement
fireEvent.click(el)
fireEvent.click(el)
fireEvent.click(el)
const t = await waitForDomChange({container: el})
expect(el.textContent).toEqual('Hello 2')
done()
})
})
现在再跑yarn test一次。
现在一切运行正常,您可以看到代码覆盖率结果,超过 90%。
在这篇文章中,我们已经了解了如何使用 react-testing-library 为自定义 hook 中的 RxJS observable 编写 React Hooks 测试。
如果您有任何问题或意见,欢迎在下方留言。
文章来源:https://dev.to/kamaal/a-quick-guide-to-testing-react-hooks-that-use-rxjs-4lpa

