I have ditched the
enzyme solution and fully embraced
@testing-library/react-native , which makes testing hooks and triggering events much easier. The same testing as shown in the original article can be written in just a few lines and without the need to mock.
Recently, I need to write tests for a functional component that uses
useEffect to toggle an internal state based on whether the virtual keyboard shows up. A simplified version of the functional component is displayed below.
The basic functionality is that a piece of text is shown by default. However, if the virtual keyboard is pulled up, the text disappears.
Tricky Part I
Although the component itself is not complex, testing it proves to be quite tricky using
enzyme. The tricky part is how to trigger the
"keyboardDidHide" events. If an event is associated with a component, such as the
"click" event in a button, one can use the
simulate API in
enzyme to trigger it directly (e.g. this article or the keyup keydown event trigger discussed here). However, the
"keyboardDidHide" events are not associated with any component, thus the
simulate API won’t work. This SO answer provides an alternative solution, but unfortunately it does not work for me.
Another method I have come across suggests using react-hooks-testing-library. Yet the documentation specifically mentions that this library is not suitable for testing hooks defined inside a component. So it is not the way to go.
Workaround for Tricky Part I
A workaround is finally found in this article. Very cleverly, the author uses a
map to mock the event listener registry. Once all the callbacks are registered to their corresponding event triggers, the event-to-callback mapping is available in the
map. Thus, instead of triggering an event and have the event listener call the callback, we can directly call the callback as if the event had been triggered. Let’s take a look at the testing code to have a better understanding of this method.
Line 10 sets up the map
keyboardCallbackMap. Line 9 puts a spy on
Keyboard.addListener so that we can mock its implementation later. Line 32 mocks the implementation, and we can see that the event-to-callback mapping is recorded in
keyboardCallbackMap. To better illustrate this point, we can log the content of
MyComponent is mounted, and this is the output we will get:
keyboardDidShow: [Function: _keyboardDidShow],
keyboardDidHide: [Function: _keyboardDidHide]
We have access to the two callbacks defined inside
MyComponent. Line 37 calls
_keyboardDidShow callback directly from
keyboardCallbackMap, as if the
"keyboardDidShow" event had been triggered. Similarly, line 39 calls
_keyboardDidHide as if the
"keyboardDidHide" event had been triggered.
Tricky Part II
Now that we have resolved the issue of “triggering” the
"keyboardDidHide" events, how do we test their effects? Note that the effects of these two events’ callbacks are associated with the internal state. Therefore, the effect of the events can be tested through the state change of the component. However, testing state change in a functional component is tricky, because the state itself is not accessible (functional component is stateless). We will have to test some side-effect associated with the state change. Unfortunately, there is no easy-to-test side-effect in
MyComponent. The only side-effect is that the entire component appears or disappears upon keyboard hiding or showing, which is rather difficult to test in
enzyme (see Other Tests for more details).
Workaround for Tricky Part II
The workaround is to mock
useState. Since the
_keyboardDidHide callbacks both call
setShowText with different arguments, we can mock
setShowText; this way the effect of the keyboard showing or hiding can be examined on the calling status of
setShowText. Referring back to the testing code, line 7 and line 8 set up the mock
mockSetShowText. Line 35 mocks the implementation for
mockUseState. After the component is mounted and the
"keyboardDidHide" events “triggered”, the keyboard event callbacks will call
mockSetShowState. Line 38 and Line 40 test the calling status of
mockSetShowText in response to the event triggering.
The discussion above covers the third test (Line 31 to 40). What about the other two tests? What do they do?
The first test from Line 18 to 23 tests the default behavior of the component. Without any manipulation, the component should display the text. This is a straightforward test with no trick involved.
The second test from Line 25 to 29 tests whether the text disappears if
showText is set to false. On Line 26, we use a trick to set the default value of
showText to false, thus ensuring that no text will be displayed. This apparently is a hack, because the ideal testing should be triggering the
"keyboardDidHide" events, and then testing whether the text disappears or shows up, respectively. Although we already have a way to “trigger” these events, the mounted
MyComponent cannot be updated to reflect the change in
showText. I guess this has something to do with
MyComponent being stateless. Since no state is stored in the mounted component, change of the internal state also cannot be manifested. Hence, the only way I can test the component’s behavior in response to change in
showText is to initialize it with different values, which is exactly what line 26 does.
Finally, in the third test, there is an additional line 41, which unmounts the component. Its purpose is to trigger the event listener remover. If any test is needed on the remover, one can write it after line 41. In my case, I don’t need to test the remover, so nothing follows that line.
In this article, we see how the
"keyboardDidHide" events can be “triggered” using their callbacks that are recorded in an external map. We also see how
useState can be mocked to test the effect of event callbacks when they are connected to the internal state change. Finally, we discuss a trick to examine the effect of internal state change by initialization the state with different value, instead of changing the state after the component is mounted.