+Testing React
+I have been developing in React for years. Since the transition to
+hooks I’ve noticed that React has gotten… messy. I am sure if you have
+been working with React for any amount of time you have seen this too.
+Functional components that are 1000 lines long. Test that are 99% mocks
+and windy logic that is impossible to follow. So what is a developer to
+do in this situation?
+All of these problems are related. Your components are too big. Your
+tests are too complex. I intend to address that problem in this
+post.
+Example
+Let’s start with a simple example. Form components are great for this
+conversation. They are simple, but the React code required to wire one
+up is complex.
+Here we have a relatively simple form that depends on an imaginary
+external data source. You can look at this and think, hey this isn’t so
+bad but there is a lot of hidden complexity here.
+my-form-component.js
+import React, { useState, useEffect } from 'react'
+import { gql, useQuery } from '@apollo/client'
+import { toast } from 'react-toastify'
+
+const GetCountries = gql`
+ query GetCountries {
+ countries {
+ id
+ name
+ }
+ }
+`
+
+const MyForm = (props) => {
+ const [name, setName] = useState('')
+ const [phone, setPhone] = useState('')
+ const [email, setEmail] = useState('')
+ const [country, setCountry] = useState('')
+ const { data, loading, error } = useQuery(GetCountries)
+ const countries = data?.countries || []
+ const onSubmit = (e) => {
+ e.preventDefault()
+ props.onSubmit({name, phone, email})
+ }
+ useEffect(() => {
+ if (error) {
+ toast.error(error)
+ }
+ }, [error])
+
+ return (
+ <form onSubmit={onSubmit}>
+ <label htmlFor="name">Name</label>
+ <input type="text" id="name" value={name} onChange={e => setName(e.target.value)} />
+ <label htmlFor="phone">Phone</label>
+ <input type="text" id="phone" value={phone} onChange={e => setPhone(e.target.value)} />
+ <label htmlFor="email">Email</label>
+ <input type="text" id="email" value={email} onChange={e => setEmail(e.target.value)} />
+ <label htmlFor="country">Country</label>
+ {loading
+ ? null
+ : (
+ <select id="country" onChange={e => setCountry(e.target.value)}>
+ {countries.map(country => <option key={country.id} value={country.id}>{country.name}</option>)}
+ </select>
+ )
+ }
+ <submit>Submit</submit>
+ </form>
+ )
+}
+For starters, there is a query that is being made to an external data
+source. This would become hard to write a test for. Additionally, there
+is a component that conditionally renders based on the loading state.
+There is also an effect hook that only is triggered when an Apollo error
+exists. This component is small too. This is not the 1000-line
+monolithic component, but there is a lot of complexity.
+So to keep things simple let’s break this component down into smaller
+pieces. Then we’ll write the test easily.
+Breaking it down
+The first thing we can do is break the business logic out of the
+component. We can do this by building a custom hook specific to contain
+this logic.
+use-my-form-component.js
+import { useState, useEffect } from 'react'
+import { gql, useQuery } from '@apollo/client'
+import { toast } from 'react-toastify'
+
+const GetCountries = gql`
+ query GetCountries {
+ countries {
+ id
+ name
+ }
+ }
+`
+
+export default function useMyFormComponent({ onSubmit }) {
+ const [name, setName] = useState('')
+ const [phone, setPhone] = useState('')
+ const [email, setEmail] = useState('')
+ const { data, loading, error } = useQuery(GetCountries)
+ const countries = data?.countries || []
+ const onSubmit = (e) => {
+ e.preventDefault()
+ props.onSubmit({name, phone, email})
+ }
+ const onNameChange = (e) => {
+ setName(e.target.value)
+ }
+ const onPhoneChange = (e) => {
+ setPhone(e.target.value)
+ }
+ const onEmailChange = (e) => {
+ setEmail(e.target.value)
+ }
+ useEffect(() => {
+ if (error) {
+ toast.error(error)
+ }
+ }, [error])
+
+ return {
+ countries,
+ email,
+ loading,
+ name,
+ onEmailChange,
+ onNameChange,
+ onPhoneChange,
+ onSubmit
+ phone,
+ }
+}
+Let’s replace the original component code with the custom hook.
+my-form.js
+import React from 'react'
+import useMyFormComponent from './hooks/use-my-form-component'
+
+
+const MyForm = (props) => {
+ const {
+ countries,
+ email,
+ loading,
+ name,
+ onEmailChange,
+ onNameChange,
+ onPhoneChange,
+ onSubmit
+ phone,
+ } = useMyFormComponent(props)
+
+ return (
+ <form onSubmit={onSubmit}>
+ <label htmlFor="name">Name</label>
+ <input type="text" id="name" value={name} onChange={onNameChange} />
+ <label htmlFor="phone">Phone</label>
+ <input type="text" id="phone" value={phone} onChange={onPhoneChange} />
+ <label htmlFor="email">Email</label>
+ <input type="text" id="email" value={email} onChange={onEmailChange} />
+ <label htmlFor="country">Country</label>
+ {loading
+ ? null
+ : (
+ <select id="country" onChange={onCountryChange}>
+ {countries.map(country => <option key={country.id} value={country.id}>{country.name}</option>)}
+ </select>
+ )
+ }
+ <submit>Submit</submit>
+ </form>
+ )
+}
+This is a good start, but we can make it better.
+Breaking it down further
+The next thing we can do is break another custom hook out of the
+custom hook we just wrote. You’ll notice the query hook is a good
+candidate to be broken down further.
+use-get-countries.js
+import { gql, useQuery } from '@apollo/client'
+
+const GetCountries = gql`
+ query GetCountries {
+ countries {
+ id
+ name
+ }
+ }
+`
+
+export default function useGetCountries() {
+ return useQuery(GetCountries)
+}
+use-my-form-component.js
+import { useState, useEffect } from 'react'
+import { toast } from 'react-toastify'
+import useGetCountries from './use-get-countries'
+
+export default function useMyFormComponent({ onSubmit }) {
+ const [name, setName] = useState('')
+ const [phone, setPhone] = useState('')
+ const [email, setEmail] = useState('')
+ const { data, loading, error } = useGetCountries()
+ const countries = data?.countries || []
+ const onSubmit = (e) => {
+ e.preventDefault()
+ props.onSubmit({name, phone, email})
+ }
+ useEffect(() => {
+ if (error) {
+ toast.error(error)
+ }
+ }, [error])
+
+ return {
+ countries,
+ email,
+ loading,
+ name,
+ onSubmit
+ phone,
+ setEmail,
+ setName,
+ setPhone,
+ }
+}
+There are certainly other ways to break this down smaller too, but
+we’ll avoid that for now. This is a good start. Let’s write some
+tests.
+Testing
+Let’s test the custom hooks first. Now that these are both small
+functions this should be easy.
+use-get-countries.test.js
+import { renderHook } from '@testing-library/react-hooks'
+import { useQuery } from '@apollo/client'
+import useGetCountries from './use-get-countries'
+
+jest.mock('@apollo/client', () => ({
+ __esModule: true,
+ ...jest.requireAction('@apollo/client'),
+ useQuery: jest.fn(),
+}))
+
+test('useGetCountries should return data, loading, and error', () => {
+ jest.mocked(useQuery).mockReturnValue({
+ data: { countries: [{ id: 1, name: 'USA' }] },
+ loading: false,
+ error: null,
+ })
+ const { result } = renderHook(() => useGetCountries())
+ expect(result.current.data).toBeDefined()
+ expect(result.current.loading).toBeFalse()
+ expect(result.current.error).toBeNull()
+})
+This test is incredibly short but it ensures that our contract is
+being met. We can now build a manual mock of the useGetCountries hook so
+that we can use it elsewhere since it is being tested.
+__mocks__/use-get-countries.js
+export default function useGetCountries() {
+ return {
+ data: { countries: [{ id: 1, name: 'USA' }] },
+ loading: false,
+ error: null,
+ }
+}
+This manual mock allows us to mock this hook in other tests. This is
+a great way to isolate the tests.
+use-my-form-component.test.js
+import { renderHook } from '@testing-library/react-hooks'
+import useMyFormComponent from './use-my-form-component'
+import useGetCountries from './use-get-countries'
+
+// Automatically uses the manual mock from earlier.
+jest.mock('./use-get-countries')
+
+describe('useMyFormComponent', () => {
+ test('should return countries, email, loading, name, onSubmit, phone, setEmail, setName, and setPhone.', () => {
+ const onSubmit = jest.fn()
+ const { result } = renderHook(() => useMyFormComponent({ onSubmit }))
+ expect(result.current.countries).toBeDefined()
+ expect(result.current.email).toBe('')
+ expect(result.current.loading).toBeTrue()
+ expect(result.current.name).toBe('')
+ expect(result.current.onSubmit).toBeFunction()
+ expect(result.current.phone).toBe('')
+ expect(result.current.setEmail).toBeFunction()
+ expect(result.current.setName).toBeFunction()
+ expect(result.current.setPhone).toBeFunction()
+ })
+
+ test('onSubmit should call the onSubmit prop.', () => {
+ const onSubmit = jest.fn()
+ const { result } = renderHook(() => useMyFormComponent({ onSubmit }))
+ result.current.onSubmit()
+ expect(onSubmit).toHaveBeenCalled()
+ })
+
+ test('should call toast.error when error is defined.', () => {
+ jest.mocked(useGetCountries).mockReturnValue({
+ data: null,
+ loading: false,
+ error: 'An error occurred.',
+ })
+ const { result } = renderHook(() => useMyFormComponent({ onSubmit: jest.fn() }))
+ expect(toast.error).toHaveBeenCalledWith('An error occurred.')
+ })
+
+ test('should not call toast.error when error is undefined.', () => {
+ jest.mocked(useGetCountries).mockReturnValue({
+ data: null,
+ loading: false,
+ error: null,
+ })
+ const { result } = renderHook(() => useMyFormComponent({ onSubmit: jest.fn() }))
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ test('should call setName when onNameChange is called.', () => {
+ const { result } = renderHook(() => useMyFormComponent({ onSubmit: jest.fn() }))
+ result.current.onNameChange({ target: { value: 'John' } })
+ expect(result.current.name).toBe('John')
+ })
+
+ test('should call setPhone when onPhoneChange is called.', () => {
+ const { result } = renderHook(() => useMyFormComponent({ onSubmit: jest.fn() }))
+ result.current.onPhoneChange({ target: { value: '555-5555' } })
+ expect(result.current.phone).toBe('555-5555')
+ })
+
+ test('should call setEmail when onEmailChange is called.', () => {
+ const { result } = renderHook(() => useMyFormComponent({ onSubmit: jest.fn() }))
+ result.current.onEmailChange({ target: { value: 'test@email.com' } })
+ expect(result.current.email).toBe('test@email.com')
+ })
+})
+__mocks__/use-my-form-component.js
+export default function useMyFormComponent({ onSubmit }) {
+ return {
+ countries: [],
+ email: '',
+ loading: false,
+ name: '',
+ onEmailChange: jest.fn(),
+ onNameChange: jest.fn(),
+ onPhoneChange: jest.fn(),
+ onSubmit,
+ phone: '',
+ }
+Great now we have the custom hooks tested. We can now test the
+component.
+my-form.test.js
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import MyForm from './my-form'
+
+jest.mock('./hooks/use-my-form-component')
+
+const onSubmit = jest.fn()
+test('should render the form.', () => {
+ render(<MyForm onSubmit={onSubmit} />)
+ expect(screen.getByLabelText('Name')).toBeInTheDocument()
+ expect(screen.getByLabelText('Phone')).toBeInTheDocument()
+ expect(screen.getByLabelText('Email')).toBeInTheDocument()
+ expect(screen.getByLabelText('Country')).toBeInTheDocument()
+ expect(screen.getByText('Submit')).toBeInTheDocument()
+})
+
+test('should not show Country if loading is true.', () => {
+ jest.mocked(useMyFormComponent).mockReturnValue({
+ countries: [],
+ email: '',
+ loading: true,
+ name: '',
+ onEmailChange: jest.fn(),
+ onNameChange: jest.fn(),
+ onPhoneChange: jest.fn(),
+ onSubmit: jest.fn(),
+ phone: '',
+ })
+ render(<MyForm onSubmit={onSubmit} />)
+ expect(screen.queryByLabelText('Country')).not.toBeInTheDocument()
+})
+This is a great start. We now have a component that is easy to test
+because the business logic is separate.
+One quick note here is my opinion differs from the broader React
+community in one way. I don’t believe that you need to directly modify
+the rendered DOM by modifying fields or clicking buttons. We’ve already
+tested that as part of the hook test. If inputs and buttons don’t work
+in React we have much bigger problems. In my opinion, those tests are
+much better to test in an end-to-end test.
+Conclusion
+If you break the pieces of your React components into smaller pieces
+you will have a much easier time maintaining them and writing tests for
+them. In addition, these tests are easy to maintain should something
+need to change and you don’t have to build a Rube Goldberg machine of
+mocks and set them up just so to have test coverage.
+
+
+
diff --git a/scripts/list b/scripts/list
index 0708d5b..db9e0fc 100755
--- a/scripts/list
+++ b/scripts/list
@@ -1,5 +1,5 @@
#!/bin/bash
echo "Listing all blog posts."
-sqlite3 blog.db "SELECT * FROM blog;"
+sqlite3 blog.db "SELECT id, title, date, published FROM blog;"
echo "Listing all blog posts done."
diff --git a/scripts/write b/scripts/write
index cf0ae2b..5300836 100755
--- a/scripts/write
+++ b/scripts/write
@@ -3,11 +3,14 @@
set -e
# Handle flags
-while getopts "f:" opt; do
+while getopts "f:i:" opt; do
case $opt in
f)
file=$OPTARG
;;
+ i)
+ ID=$OPTARG
+ ;;
\?)
echo "Invalid option: -$OPTARG" >&2
exit 1
@@ -22,5 +25,10 @@ DATE=$(date +%Y-%m-%d\ %H:%M:%S)
CONTENT=$(pandoc -f markdown -t html "$file")
TITLE=$(basename "$file" .md | tr '-' ' ')
# Write to database
+if [ -n "$ID" ]; then
+ sqlite3 blog.db "UPDATE blog SET title = '$TITLE', content = '$CONTENT', date = '$DATE' WHERE id = $ID;"
+ echo "Updating file in database done."
+ exit 0
+fi
sqlite3 blog.db "INSERT INTO blog (title, content, date, published) VALUES ('$TITLE', '$CONTENT', '$DATE', 0);"
echo "Writing file to database done."