RxJs is cool when you work with complex async operations. RxJS is designed for reactive programming using Observables. It converts your async operations to Observables. With observables we can "watch" the data stream, passively listening for an event.
React hooks supercharge your functional components in many ways. With hooks, we can abstract and decouple the logics with custom hooks. With the separation of logics makes your code testable and share between components.
This post helps explain how thou can test
useEffect
hook that uses RxJs inside to listen to mouse click and delay the click with RxJs's debounceTime
operator.Hooks that we are using here.
RxJs Operators We are using here.
Before we jump to write our test code, let see our example component.
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}
}
Above example, we have 2 files.
useSubscriber
and makeObservable
. functions.Button uses
useSubscriber
to delay the button clicks. Each clicks debounced with RxJs debounceTime
function.Clicks will be ignored while the user clicks within 400ms. Once the user has done clicks, it waits 400ms then fire the last event.
Simple!.🤓
Now lets test! 🧪.
Let's start with something simple. Test the `useState` hook.
// 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)
})
})
Now run `yarn test.`
Invariant Violation: Invalid hook call. Hooks can only be called
inside
of the body of a function component....
Not the result that we expected.
The error above means that calling hooks outside the functional component body is Invalid.
In this case, we can use react hooks testing utility library
@testing-library/react-hooks
.import { renderHook } from '@testing-library/react-hooks
With
renderHook
we can call the hooks outside of the body of a function component.let’s just replace
const result = useSubscriber(400)
with const {result} = renderHook(() => useSubscriber(400)
also,
const {updateCount} = result
with const {updateCount} = result.current
Then wrap your setState call with
act
otherwise your test throws an error.// 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)
})
})
Okay, now we good to go.
Again run
yarn test
.Voila!. As expected.
More tests
Now we test
makeObservable
function. Function makeObservable
take DOMElement
and event type as a string and should return Observable. It should return false if given an invalid argument(s).Lets test with no arguments, invalid arguments, and correct arguments.
import {render, fireEvent} from '@testing-library/react'
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)
})
})
Now the last test.
Testing useEffect and observable is the complicated part.
useEffect
and makes your component render asynchronous.To capture
useEffect
's side effect, we can wrap our test code with act
from react-dom/test-utils.
To run assertions inside the subscription, we can use `done().` Jest wait until the done callback is called before finishing the test.
// 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)
}
})
})
})
And button component test
// 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()
})
})
Now run
yarn test.
Now everything runs as expected, and you can see code coverage results and its more than 90%.
In this post, we've seen how to write tests for React Hooks that RxJS observable that's inside the custom hook with the react-testing-library.
If you have any questions or comments, you can share them below.