发布于 2026-01-06 3 阅读
0

快速指南:如何使用 RxJS 测试 React Hooks 测试 React Hook 和 RxJS

快速指南:如何使用 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

Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

以上示例中,我们有两个文件。

  • 1. Button.tsx:是一个简单的按钮组件。
  • 2 useClick.ts:包含自定义钩子useClickmakeObservable. 函数。

按钮用于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)
    })
})
Enter fullscreen mode Exit fullscreen mode

现在运行 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
Enter fullscreen mode Exit fullscreen mode

这样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)
    })
})
Enter fullscreen mode Exit fullscreen mode

好了,现在我们可以出发了。

再次运行yarn test

测试结果 v1

恭喜!测试通过。

更多测试

现在我们测试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)
    })
})
Enter fullscreen mode Exit fullscreen mode

测试订阅者和 useEffect。

测试 useEffect 和 observable 是其中最复杂的部分。

  1. 因为useEffect这样会使你的组件异步渲染。

  2. 订阅者内部的断言永远不会运行,因此测试总是通过。

为了捕获 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)
            }
        })
    })
})
Enter fullscreen mode Exit fullscreen mode

按钮组件测试

// 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()
    })
})
Enter fullscreen mode Exit fullscreen mode

现在再跑yarn test一次。

所有测试

现在一切运行正常,您可以看到代码覆盖率结果,超过 90%。

在这篇文章中,我们已经了解了如何使用 react-testing-library 为自定义 hook 中的 RxJS observable 编写 React Hooks 测试。 

如果您有任何问题或意见,欢迎在下方留言。

GitHub 标志 kamaal- / react-hook-rxjs-测试

测试 React Hook 和 RxJs。

测试 React Hook 和 RxJS

构建状态






文章来源:https://dev.to/kamaal/a-quick-guide-to-testing-react-hooks-that-use-rxjs-4lpa