diff --git a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx index 3bb53c877..4aafb882a 100644 --- a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx @@ -1047,9 +1047,13 @@ describeWithDOM('mount', () => { describeHooks( { Wrap, Wrapper }, 'useCallback', + 'useContext', 'useEffect', 'useLayoutEffect', 'useMemo', + 'useReducer', + 'useState', + 'custom', ); describeIf(is('>= 16.6'), 'Suspense & lazy', () => { diff --git a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx index 780147f25..0541bb5eb 100644 --- a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx @@ -1228,9 +1228,13 @@ describe('shallow', () => { describeHooks( { Wrap, Wrapper }, 'useCallback', + 'useContext', 'useEffect', 'useLayoutEffect', 'useMemo', + 'useReducer', + 'useState', + 'custom', ); describe('.shallow()', () => { diff --git a/packages/enzyme-test-suite/test/shared/hooks/_hook.template b/packages/enzyme-test-suite/test/shared/hooks/_hook.template index 92484f389..dcf04751f 100644 --- a/packages/enzyme-test-suite/test/shared/hooks/_hook.template +++ b/packages/enzyme-test-suite/test/shared/hooks/_hook.template @@ -19,6 +19,7 @@ import { import { useCallback, + useContext, useEffect, useLayoutEffect, useMemo, diff --git a/packages/enzyme-test-suite/test/shared/hooks/custom.jsx b/packages/enzyme-test-suite/test/shared/hooks/custom.jsx new file mode 100644 index 000000000..8aad509d3 --- /dev/null +++ b/packages/enzyme-test-suite/test/shared/hooks/custom.jsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon-sandbox'; + +import { + describeIf, +} from '../../_helpers'; + +import { + useEffect, + useState, +} from '../../_helpers/react-compat'; + +export default function describeCustomHooks({ + hasHooks, + Wrap, + isShallow, +}) { + describeIf(hasHooks, 'hooks: custom', () => { + describe('custom hook : useCounter', () => { + function useCounter({ initialCount = 0, step = 1 } = {}) { + const [count, setCount] = useState(initialCount); + const increment = () => setCount(c => c + step); + const decrement = () => setCount(c => c - step); + return { count, increment, decrement }; + } + // testing custom hooks with renderProps + // may be we can think of adding in utils + // will be repeated + const Counter = ({ children, ...rest }) => children(useCounter(rest)); + + function setup(props) { + const returnVal = {}; + Wrap( + + {(val) => { + Object.assign(returnVal, val); + return null; + }} + , + ); + return returnVal; + } + + it('useCounter', () => { + const counterData = setup(); + counterData.increment(); + expect(counterData).to.have.property('count', 1); + counterData.decrement(); + expect(counterData).to.have.property('count', 0); + }); + + it('useCounter with initialCount', () => { + const counterData = setup({ initialCount: 2 }); + counterData.increment(); + expect(counterData).to.have.property('count', 3); + counterData.decrement(); + expect(counterData).to.have.property('count', 2); + }); + + it('useCounter with step', () => { + const counterData = setup({ step: 2 }); + counterData.increment(); + expect(counterData).to.have.property('count', 2); + counterData.decrement(); + expect(counterData).to.have.property('count', 0); + }); + + it('useCounter with step and initialCount', () => { + const counterData = setup({ step: 2, initialCount: 5 }); + counterData.increment(); + expect(counterData).to.have.property('count', 7); + counterData.decrement(); + expect(counterData).to.have.property('count', 5); + }); + }); + + // todo: enable shallow when useEffect works in the shallow renderer. see https://github.com/facebook/react/issues/15275 + describeIf(!isShallow, 'custom hook: formInput invoke props', () => { + function useFormInput(initialValue = '') { + const [value, setValue] = useState(initialValue); + + return { + value, + onChange(e) { + setValue(e.target.value); + }, + }; + } + + function Input(props) { + return ( +
+ +
+ ); + } + + function ControlledInputWithEnhancedInput({ searchSomething }) { + const search = useFormInput(); + + useEffect( + () => { + searchSomething(search.value); + }, + [search.value], + ); + + return ; + } + + function ControlledInputWithNativeInput({ searchSomething }) { + const search = useFormInput(); + + useEffect( + () => { + searchSomething(search.value); + }, + [search.value], + ); + + return ; + } + + it('work with native input', () => { + const spy = sinon.spy(); + const wrapper = Wrap(); + wrapper.find('input').invoke('onChange')({ target: { value: 'foo' } }); + + expect(spy.withArgs('foo')).to.have.property('callCount', 1); + }); + + it('work with custom wrapped Input', () => { + const spy = sinon.spy(); + const wrapper = Wrap(); + const input = wrapper.find('Input'); + input.invoke('onChange')({ target: { value: 'foo' } }); + expect(spy.withArgs('foo')).to.have.property('callCount', 1); + }); + + it('work with custom wrapped input', () => { + const spy = sinon.spy(); + const wrapper = Wrap(); + const input = wrapper.find('input'); + input.invoke('onChange')({ target: { value: 'foo' } }); + expect(spy.withArgs('foo')).to.have.property('callCount', 1); + }); + }); + }); +} diff --git a/packages/enzyme-test-suite/test/shared/hooks/useContext.jsx b/packages/enzyme-test-suite/test/shared/hooks/useContext.jsx new file mode 100644 index 000000000..1d7f13e50 --- /dev/null +++ b/packages/enzyme-test-suite/test/shared/hooks/useContext.jsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { expect } from 'chai'; + +import { + describeIf, + itIf, +} from '../../_helpers'; + +import { + useContext, + useState, + createContext, +} from '../../_helpers/react-compat'; + +export default function describeUseContext({ + hasHooks, + Wrap, + isShallow, +}) { + describeIf(hasHooks, 'hooks: useContext', () => { + describe('simple example', () => { + const initialTitle = 'initialTitle'; + const TitleContext = createContext && createContext(initialTitle); + + function UiComponent() { + const title = useContext(TitleContext); + return ( +
+ {title} +
+ ); + } + + const customTitle = 'CustomTitle'; + + function App() { + return ( + + + + ); + } + + it('render ui component with initial context value', () => { + const wrapper = Wrap(); + expect(wrapper.text()).to.equal(initialTitle); + }); + + // TODO: useContext: enable when shallow dive supports createContext + itIf(!isShallow, 'render ui component with value from outer provider', () => { + const wrapper = Wrap(); + const subWrapper = isShallow ? wrapper.dive().dive() : wrapper; + expect(subWrapper.text()).to.equal(customTitle); + }); + }); + + // TODO: useContext: enable when shallow dive supports createContext + describeIf(!isShallow, 'useContext: with Setting', () => { + const initialState = 10; + const context = createContext && createContext(null); + + function MyGrandChild() { + const myContextVal = useContext(context); + + const increment = () => { + myContextVal.setState(myContextVal.state + 1); + }; + + return ( +
+ + + {myContextVal.state} + +
+ ); + } + + function MyChild() { + return ( +
+ +
+ ); + } + + function App() { + const [state, setState] = useState(initialState); + + return ( + +
+ +
+
+ ); + } + + it('test render, get and set context value ', () => { + const wrapper = Wrap(); + + function getChild() { + const child = wrapper.find(MyChild); + return isShallow ? child.dive() : child; + } + function getGrandChild() { + const grandchild = getChild().find(MyGrandChild); + return isShallow ? grandchild.dive() : grandchild; + } + expect(getGrandChild().find('.grandChildState').debug()).to.equal(` + ${String(initialState)} +`); + + getGrandChild().find('button').props().onClick(); + wrapper.update(); + expect(getGrandChild().find('.grandChildState').debug()).to.equal(` + ${String(initialState + 1)} +`); + }); + }); + }); +} diff --git a/packages/enzyme-test-suite/test/shared/hooks/useEffect.jsx b/packages/enzyme-test-suite/test/shared/hooks/useEffect.jsx index 66776695e..9f49d818d 100644 --- a/packages/enzyme-test-suite/test/shared/hooks/useEffect.jsx +++ b/packages/enzyme-test-suite/test/shared/hooks/useEffect.jsx @@ -1,14 +1,18 @@ import React from 'react'; import { expect } from 'chai'; +import sinon from 'sinon-sandbox'; import { describeIf, itIf, } from '../../_helpers'; - +import { + is, +} from '../../_helpers/version'; import { useEffect, useState, + Fragment, } from '../../_helpers/react-compat'; export default function describeUseEffect({ @@ -16,7 +20,8 @@ export default function describeUseEffect({ Wrap, isShallow, }) { - describeIf(hasHooks, 'hooks: useEffect', () => { + // TODO: enable when the shallow renderer fixes its bug, see https://github.com/facebook/react/issues/15275. + describeIf(hasHooks && !isShallow, 'hooks: useEffect', () => { const timeout = 100; function ComponentUsingEffectHook() { const [ctr, setCtr] = useState(0); @@ -33,8 +38,7 @@ export default function describeUseEffect({ ); } - // TODO: enable when the shallow renderer fixes its bug - itIf(!isShallow, 'works with `useEffect`', (done) => { + it('works', (done) => { const wrapper = Wrap(); expect(wrapper.debug()).to.equal( @@ -65,5 +69,185 @@ export default function describeUseEffect({ done(); }, timeout + 1); }); + + describe('with mount effect', () => { + const didMountCount = 9; + + function FooCounterWithMountEffect({ initialCount = 0 }) { + const [count, setCount] = useState(+initialCount); + + useEffect(() => { + setCount(didMountCount); + }, []); + return ( + + + {count} + + + ); + } + + it('initial render after did mount effect', () => { + const wrapper = Wrap(); + expect(wrapper.find('.counter').text()).to.equal(String(didMountCount)); + }); + }); + + describe('with async effect', () => { + it('works with `useEffect`', (done) => { + const wrapper = Wrap(); + + expect(wrapper.debug()).to.equal( + isShallow + ? `
+ 1 +
` + : ` +
+ 1 +
+
`, + ); + + setTimeout(() => { + wrapper.update(); + expect(wrapper.debug()).to.equal( + isShallow + ? `
+ 2 +
` + : ` +
+ 2 +
+
`, + ); + done(); + }, timeout + 1); + }); + }); + + describe('on componentDidUpdate & componentDidMount', () => { + const expectedCountString = x => `You clicked ${x} times`; + + let setDocumentTitle; + function ClickCounterPage() { + const [count, setCount] = useState(0); + + useEffect(() => { + setDocumentTitle(expectedCountString(count)); + }, [count]); + + return ( +
+

You clicked {count} times

+ +
+ ); + } + + beforeEach(() => { + setDocumentTitle = sinon.stub(); + }); + + it('on mount initial render', () => { + const wrapper = Wrap(); + + expect(wrapper.find('p').text()).to.eq(expectedCountString(0)); + expect(setDocumentTitle).to.have.property('callCount', 1); + expect(setDocumentTitle.args).to.deep.equal([[expectedCountString(0)]]); + }); + + it('on didupdate', () => { + const wrapper = Wrap(); + + expect(setDocumentTitle).to.have.property('callCount', 1); + const [firstCall] = setDocumentTitle.args; + expect(firstCall).to.deep.equal([expectedCountString(0)]); + expect(wrapper.find('p').text()).to.equal(expectedCountString(0)); + + wrapper.find('button').invoke('onClick')(); + + expect(setDocumentTitle).to.have.property('callCount', 2); + const [, secondCall] = setDocumentTitle.args; + expect(secondCall).to.deep.equal([expectedCountString(1)]); + expect(wrapper.find('p').text()).to.equal(expectedCountString(1)); + + wrapper.find('button').invoke('onClick')(); + wrapper.find('button').invoke('onClick')(); + + expect(setDocumentTitle).to.have.property('callCount', 4); + const [,,, fourthCall] = setDocumentTitle.args; + expect(fourthCall).to.deep.equal([expectedCountString(3)]); + expect(wrapper.find('p').text()).to.equal(expectedCountString(3)); + }); + }); + + describe('with cleanup Effect', () => { + let ChatAPI; + + beforeEach(() => { + ChatAPI = { + subscribeToFriendStatus: sinon.stub(), + unsubscribeFromFriendStatus: sinon.stub(), + }; + }); + + function FriendStatus({ friend = {} }) { + const [isOnline, setIsOnline] = useState(null); + + function handleStatusChange(status) { + setIsOnline(status.isOnline); + } + + useEffect(() => { + ChatAPI.subscribeToFriendStatus(friend.id, handleStatusChange); + return function cleanup() { + ChatAPI.unsubscribeFromFriendStatus(friend.id, handleStatusChange); + }; + }, [isOnline]); + + if (isOnline === null) { + return 'Loading...'; + } + return isOnline ? 'Online' : 'Offline'; + } + + const friend = { id: 'enzyme' }; + + it('on initial mount', () => { + const wrapper = Wrap(); + expect(wrapper.debug()).to.equal( + ` + Loading... +`, + ); + expect(wrapper.html()).to.eql('Loading...'); + expect(ChatAPI.subscribeToFriendStatus.calledOnceWith(friend.id)).to.equal(true); + }); + + it('simulate status Change', () => { + const wrapper = Wrap(); + const [[, simulateChange]] = ChatAPI.subscribeToFriendStatus.args; + + simulateChange({ isOnline: true }); + + wrapper.update(); + expect(wrapper.html()).to.eql('Online'); + }); + + itIf(is('> 16.8.3'), 'cleanup on unmount', () => { + const wrapper = Wrap(); + + wrapper.unmount(); + + expect(ChatAPI.unsubscribeFromFriendStatus).to.have.property('callCount', 1); + const [[firstArg]] = ChatAPI.unsubscribeFromFriendStatus.args; + expect(firstArg).to.equal(friend.id); + }); + }); }); } diff --git a/packages/enzyme-test-suite/test/shared/hooks/useReducer.jsx b/packages/enzyme-test-suite/test/shared/hooks/useReducer.jsx new file mode 100644 index 000000000..6e0670858 --- /dev/null +++ b/packages/enzyme-test-suite/test/shared/hooks/useReducer.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { expect } from 'chai'; + +import { describeIf } from '../../_helpers'; + +import { useReducer } from '../../_helpers/react-compat'; + +export default function describeUseReducer({ + hasHooks, + Wrap, +}) { + describeIf(hasHooks, 'hooks: useReducer', () => { + describe('with custom dispatch', () => { + const initialState = []; + + function Child({ dispatch, text }) { + function fire() { + dispatch({ + type: 'ADD_TEXT', + payload: text, + }); + } + + return ; + } + + function reducer(state, action) { + switch (action.type) { + case 'ADD_TEXT': + return [...state, action.payload]; + default: + throw new Error(); + } + } + + function FooBarTextList() { + const [state, dispatch] = useReducer(reducer, initialState); + + return ( +
+ + + {state.map(text => ( +

{text}

+ ))} +
+ ); + } + + it('render with initial state from useReducer', () => { + const wrapper = Wrap(); + expect(wrapper.find('p')).to.have.lengthOf(0); + }); + + it('Test with Add Foo & Bar tex', () => { + const wrapper = Wrap(); + expect(wrapper.find('p')).to.have.lengthOf(0); + wrapper.find('Child').at(0).props().dispatch({ + type: 'ADD_TEXT', + payload: 'foo', + }); + wrapper.update(); + + expect(wrapper.find('p')).to.have.lengthOf(1); + expect(wrapper.find('p').at(0).text()).to.equal('foo'); + + wrapper.find('Child').at(1).props().dispatch({ + type: 'ADD_TEXT', + payload: 'bar', + }); + wrapper.update(); + expect(wrapper.find('p')).to.have.length(2); + expect(wrapper.find('p').at(0).text()).to.equal('foo'); + expect(wrapper.find('p').at(1).text()).to.equal('bar'); + }); + }); + }); +} diff --git a/packages/enzyme-test-suite/test/shared/hooks/useState.jsx b/packages/enzyme-test-suite/test/shared/hooks/useState.jsx new file mode 100644 index 000000000..30569d6cb --- /dev/null +++ b/packages/enzyme-test-suite/test/shared/hooks/useState.jsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { expect } from 'chai'; + +import { + describeIf, + itIf, +} from '../../_helpers'; + +import { + useState, + useEffect, + Fragment, +} from '../../_helpers/react-compat'; + +export default function describeUseState({ + hasHooks, + Wrap, + isShallow, +}) { + describeIf(hasHooks, 'hooks: useState', () => { + function FooCounter({ initialCount: initial = 0 }) { + const [count, setCount] = useState(+initial); + + return ( + + + + {count} + + + + ); + } + + const initialCount = 5; + + it('initial render', () => { + const wrapper = Wrap(); + expect(wrapper.find('.counter').text()).to.equal(String(initialCount)); + }); + + it('lets increment', () => { + const wrapper = Wrap(); + + wrapper.find('.increment').props().onClick(); + + expect(wrapper.find('.counter').text()).to.equal(String(initialCount + 1)); + }); + + it('now decrement', () => { + const wrapper = Wrap(); + + wrapper.find('.decrement').props().onClick(); + + expect(wrapper.find('.counter').text()).to.equal(String(initialCount - 1)); + }); + + it('handles useState', () => { + function ComponentUsingStateHook() { + const [count] = useState(0); + return
{count}
; + } + + const wrapper = Wrap(); + + expect(wrapper.find('div').length).to.equal(1); + expect(wrapper.find('div').text()).to.equal('0'); + }); + + it('handles setState returned from useState', () => { + function ComponentUsingStateHook() { + const [count, setCount] = useState(0); + return
setCount(count + 1)}>{count}
; + } + + const wrapper = Wrap(); + const div = wrapper.find('div'); + const setCount = div.prop('onClick'); + setCount(); + wrapper.update(); + + expect(wrapper.find('div').text()).to.equal('1'); + }); + + describe('useState with willReceive prop effect / simulate getDerivedStateFromProp', () => { + const newPropCount = 10; + + function FooCounterWithEffect({ initialCount: initial = 0 }) { + const [count, setCount] = useState(+initial); + + useEffect(() => { + setCount(initial); + }, [initial]); + + return ( + + + {count} + + + ); + } + + // TODO: fixme when useEffect works in the shallow renderer, see https://github.com/facebook/react/issues/15275 + itIf(!isShallow, 'initial render & new Props', () => { + const wrapper = Wrap(); + expect(wrapper.find('.counter').text()).to.equal(String(initialCount)); + + wrapper.setProps({ initialCount: newPropCount }); + expect(wrapper.find('.counter').text()).to.equal(String(newPropCount)); + }); + }); + }); +}