DEV Community

Cover image for A quick guide to Testing React hooks that use RxJS
Kamaal Aboothalib
Kamaal Aboothalib

Posted on

A quick guide to Testing React hooks that use RxJS

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.

  • useState: Enhance functional component with the state.
  • useEffect: We can perform DOM manipulation and select.

RxJs Operators we are using here.

  • map: returns Observable value from the provided function using emitted by the source.
  • debouonceTime: Emits a value from the source Observable only after a particular time has passed without another source emission.

Before we jump to write our test code, let see our example component.

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

Above example, we have 2 files.

  • 1 Button.tsx: is an simple button component.
  • 2 useClick.ts: contains the custom hook useClick and makeObservable. functions.

Button uses useClick 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)
    })
})
Enter fullscreen mode Exit fullscreen mode

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

With renderHook we can call the hooks outside of the body of a function component.

let’s just replace const result = useClick(400) with
const {result} = renderHook(() => useClick(400)

also, const {updateCount} = result with
const {updateCount} = result.current

Then wrap your setState call with act otherwise your test will throw 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)
    })
})
Enter fullscreen mode Exit fullscreen mode

Okay, now we good to go.

Again run yarn test.

Test result v1

Voila!. Test passing.

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 makeObservable function.

// 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

Test Subscriber and useEffect.

Testing useEffect and observable is the complicated part.

  1. Because useEffect and makes your component render asynchronous.

  2. Assertions that inside the subscribers never run so the tests are always passing.

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

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

Now run yarn test again.

All tests

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.

GitHub logo kamaal- / react-hook-rxjs-test

Test react hook & RxJs.

Test react hook & RxJs

Build Status






Top comments (2)

Collapse
 
chrisachard profile image
Chris Achard

Huh, for some reason it never occurred to me that you could use rxjs inside of useEffect - but yeah, I guess that'd be how you'd do it!

I also like the picture you chose for this post - a rats nest of wires on a breadboard... very fitting :)

Collapse
 
cubiclebuddha profile image
Cubicle Buddha

Great article. But I have this strange feeling that the code would have been slightly easier to read if it didn’t use hooks. I say that because there’s nothing about the code that tells me that your button is stateful. And it is in fact stateful because it literally relies on side-effects (or in React parlance “useEffect”). However we can’t tell that because (a) button is a function component which makes me think it’s stateless and (b) the function that uses useEffect abstracts that away inside of useClick.

tl;dr: if this was a class component then it would be clear that the component is stateful.

That being said, bravo on the testing code! :)