Skip to content

Added error handling in hook callback #21

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 31, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ Renders a test component that will call the provided `callback`, including any h

- `result` (`object`)
- `current` (`any`) - the return value of the `callback` function
- `error` (`Error`) - the error that was thrown if the `callback` function threw an error during rendering
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically you can throw anything (not just an Error instance), so I'm not sure if this should be any as well?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not knowledgeable enough with typescript, but I think even if someone would throw, it would be an instance of Error as they would extend Error to create a custom error in case they wanted to. So, I think this should be fine unless someone with knowledge of typescript differs in opinion.

- `waitForNextUpdate` (`function`) - returns a `Promise` that resolves the next time the hook renders, commonly when state is updated as the result of a asynchronous action.
- `rerender` (`function([newProps])`) - function to rerender the test component including any hooks called in the `callback` function. If `newProps` are passed, the will replace the `initialProps` passed the the `callback` function for future renders.
- `unmount` (`function()`) - function to unmount the test component, commonly used to trigger cleanup effects for `useEffect` hooks.
Expand Down
54 changes: 40 additions & 14 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,64 @@ import React from 'react'
import { render, cleanup, act } from 'react-testing-library'

function TestHook({ callback, hookProps, children }) {
children(callback(hookProps))
try {
children(callback(hookProps))
} catch (e) {
children(undefined, e)
}
return null
}

function resultContainer() {
let value = null
let error = null
const resolvers = []

const result = {
get current() {
if (error) {
throw error
}
return value
},
get error() {
return error
}
}

return {
result,
addResolver: (resolver) => {
resolvers.push(resolver)
},
updateResult: (val, err) => {
value = val
error = err
resolvers.splice(0, resolvers.length).forEach((resolve) => resolve())
}
}
}

function renderHook(callback, { initialProps, ...options } = {}) {
const result = { current: null }
const { result, updateResult, addResolver } = resultContainer()
const hookProps = { current: initialProps }
const resolvers = []
const waitForNextUpdate = () =>
new Promise((resolve) => {
resolvers.push(resolve)
})

const toRender = () => (
<TestHook callback={callback} hookProps={hookProps.current}>
{(res) => {
result.current = res
resolvers.splice(0, resolvers.length).forEach((resolve) => resolve())
}}
{updateResult}
</TestHook>
)

const { unmount, rerender: rerenderComponent } = render(toRender(), options)

return {
result,
waitForNextUpdate,
unmount,
waitForNextUpdate: () => new Promise((resolve) => addResolver(resolve)),
rerender: (newProps = hookProps.current) => {
hookProps.current = newProps
rerenderComponent(toRender())
}
},
unmount
}
}

Expand Down
108 changes: 108 additions & 0 deletions test/errorHook.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useState, useEffect } from 'react'
import { renderHook } from 'src'

describe('error hook tests', () => {
function useError(throwError) {
if (throwError) {
throw new Error('expected')
}
return true
}

const somePromise = () => Promise.resolve()

function useAsyncError(throwError) {
const [value, setValue] = useState()
useEffect(() => {
somePromise().then(() => {
setValue(throwError)
})
}, [throwError])
return useError(value)
}

describe('synchronous', () => {
test('should raise error', () => {
const { result } = renderHook(() => useError(true))

expect(() => {
expect(result.current).not.toBe(undefined)
}).toThrow(Error('expected'))
})

test('should capture error', () => {
const { result } = renderHook(() => useError(true))

expect(result.error).toEqual(Error('expected'))
})

test('should not capture error', () => {
const { result } = renderHook(() => useError(false))

expect(result.current).not.toBe(undefined)
expect(result.error).toBe(undefined)
})

test('should reset error', () => {
const { result, rerender } = renderHook((throwError) => useError(throwError), {
initialProps: true
})

expect(result.error).not.toBe(undefined)

rerender(false)

expect(result.current).not.toBe(undefined)
expect(result.error).toBe(undefined)
})
})

describe('asynchronous', () => {
test('should raise async error', async () => {
const { result, waitForNextUpdate } = renderHook(() => useAsyncError(true))

await waitForNextUpdate()

expect(() => {
expect(result.current).not.toBe(undefined)
}).toThrow(Error('expected'))
})

test('should capture async error', async () => {
const { result, waitForNextUpdate } = renderHook(() => useAsyncError(true))

await waitForNextUpdate()

expect(result.error).toEqual(Error('expected'))
})

test('should not capture async error', async () => {
const { result, waitForNextUpdate } = renderHook(() => useAsyncError(false))

await waitForNextUpdate()

expect(result.current).not.toBe(undefined)
expect(result.error).toBe(undefined)
})

test('should reset async error', async () => {
const { result, waitForNextUpdate, rerender } = renderHook(
(throwError) => useAsyncError(throwError),
{
initialProps: true
}
)

await waitForNextUpdate()

expect(result.error).not.toBe(undefined)

rerender(false)

await waitForNextUpdate()

expect(result.current).not.toBe(undefined)
expect(result.error).toBe(undefined)
})
})
Copy link
Member Author

@mpeyper mpeyper Mar 26, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any other test cases or usage patterns we'd want to to see being tested?

})
9 changes: 9 additions & 0 deletions test/typescript/renderHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ function checkTypesWhenHookReturnsVoid() {
const _rerender: () => void = rerender
}

function checkTypesWithError() {
const { result } = renderHook(() => useCounter())

// check types
const _result: {
error: Error
} = result
}

async function checkTypesForWaitForNextUpdate() {
const { waitForNextUpdate } = renderHook(() => {})

Expand Down
3 changes: 2 additions & 1 deletion typings/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export function renderHook<P, R>(
} & RenderOptions
): {
readonly result: {
current: R
readonly current: R,
readonly error: Error
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, technically you can throw anything, so maybe this should be a bit looser?

Also, typescript isn't really my thing, so not sure if readonly usage is correct here and the fact that either current or error will be undefined depending on whether the hook threw or not (I think types are nullable/undefinedable by default in Typescript?)?

}
readonly waitForNextUpdate: () => Promise<void>
readonly unmount: RenderResult['unmount']
Expand Down