import 'reflect-metadata'; import { Container, injectable, interfaces, unmanaged } from 'inversify'; import * as React from 'react'; import { useState } from 'react'; import { assert, IsExact } from 'conditional-type-checks'; import { render } from '@testing-library/react'; import * as hooksModule from '../src/hooks'; // for jest.spyOn import { Provider, useAllInjections, useContainer, useInjection, useOptionalInjection, useNamedInjection, useTaggedInjection, } from '../src'; // We want to test types around hooks with signature overloads (as it's more complex), // but don't actually execute them, // so we wrap test code into a dummy function just for TypeScript compiler function staticTypecheckOnly(_fn: () => void) { return () => {}; } function throwErr(msg: string): never { throw new Error(msg); } @injectable() class Foo { readonly name = 'foo'; } @injectable() class Bar { readonly name: string; constructor(@unmanaged() tag: string) { this.name = 'bar-' + tag; } } const aName = 'a-name'; const bName = 'b-name'; const rootTag = 'tag'; const aTag = 'a-tag'; const bTag = 'b-tag'; const multiId = Symbol('multi-id'); class OptionalService { readonly label = 'OptionalService' as const; } interface RootComponentProps { children?: React.ReactNode; } const RootComponent: React.FC = ({ children }) => { const [container] = useState(() => { const c = new Container(); c.bind(Foo).toSelf(); c.bind(Bar).toDynamicValue(() => new Bar('aNamed')).whenTargetNamed(aName); c.bind(Bar).toDynamicValue(() => new Bar('bNamed')).whenTargetNamed(bName); c.bind(Bar).toDynamicValue(() => new Bar('aTagged')).whenTargetTagged(rootTag, aTag); c.bind(Bar).toDynamicValue(() => new Bar('bTagged')).whenTargetTagged(rootTag, bTag); c.bind(multiId).toConstantValue('x'); c.bind(multiId).toConstantValue('y'); c.bind(multiId).toConstantValue('z'); return c; }); return (
{children}
); }; describe('useContainer hook', () => { const hookSpy = jest.spyOn(hooksModule, 'useContainer'); const ChildComponent = () => { const resolvedContainer = useContainer(); return
{resolvedContainer.id}
; }; afterEach(() => { hookSpy.mockClear(); }); // hook with overloads, so we test types test('types', staticTypecheckOnly(() => { const container = useContainer(); assert>(true); const valueResolvedFromContainer = useContainer(c => { assert>(true); return c.resolve(Foo); }); assert>(true); })); test('resolves container from context', () => { const container = new Container(); const tree = render( ); const fragment = tree.asFragment(); expect(hookSpy).toHaveBeenCalledTimes(1); expect(hookSpy).toHaveLastReturnedWith(container); expect(fragment.children[0].nodeName).toBe('DIV'); expect(fragment.children[0].textContent).toEqual(`${container.id}`); }); test('throws when no context found (missing Provider)', () => { expect(() => { render(); }).toThrow('Cannot find Inversify container on React Context. `Provider` component is missing in component tree.'); // unfortunately currently it produces console.error, but it's only question of aesthetics // @see https://github.com/facebook/react/issues/15520 expect(hookSpy).toHaveBeenCalled(); // looks like React v17 actually calls it 2 times, so we can't expect specific amount expect(hookSpy).toHaveReturnedTimes(0); }); }); describe('useInjection hook', () => { test('resolves using service identifier (newable)', () => { const ChildComponent = () => { const foo = useInjection(Foo); return
{foo.name}
; }; const tree = render( ); const fragment = tree.asFragment(); expect(fragment.children[0].nodeName).toBe('DIV'); expect(fragment.children[0].children[0].nodeName).toBe('DIV'); expect(fragment.children[0].children[0].textContent).toEqual('foo'); }); test('resolves using service identifier (string)', () => { const container = new Container(); container.bind('FooFoo').to(Foo); const ChildComponent = () => { const foo = useInjection('FooFoo'); return
{foo.name}
; }; const tree = render( ); const fragment = tree.asFragment(); expect(fragment.children[0].nodeName).toBe('DIV'); expect(fragment.children[0].textContent).toEqual('foo'); }); test('resolves using service identifier (symbol)', () => { // NB! declaring symbol as explicit ServiceIdentifier of specific type, // which gives extra safety through type inference (both when binding and resolving) const identifier = Symbol('Foo') as interfaces.ServiceIdentifier; const container = new Container(); container.bind(identifier).to(Foo); const ChildComponent = () => { const foo = useInjection(identifier); return
{foo.name}
; }; const tree = render( ); const fragment = tree.asFragment(); expect(fragment.children[0].nodeName).toBe('DIV'); expect(fragment.children[0].textContent).toEqual('foo'); }); }); describe('useNamedInjection hook', () => { test('resolves using service identifier and name constraint', () => { const ChildComponent = () => { const aBar = useNamedInjection(Bar, aName); const bBar = useNamedInjection(Bar, bName); return
{aBar.name},{bBar.name}
; }; const tree = render( ); const fragment = tree.asFragment(); expect(fragment.children[0].nodeName).toBe('DIV'); expect(fragment.children[0].children[0].nodeName).toBe('DIV'); expect(fragment.children[0].children[0].textContent).toEqual("bar-aNamed,bar-bNamed"); }); }); describe('useTaggedInjection hook', () => { test('resolves using service identifier and tag constraint', () => { const ChildComponent = () => { const aBar = useTaggedInjection(Bar, rootTag, aTag); const bBar = useTaggedInjection(Bar, rootTag, bTag); return
{aBar.name},{bBar.name}
; }; const tree = render( ); const fragment = tree.asFragment(); expect(fragment.children[0].nodeName).toBe('DIV'); expect(fragment.children[0].children[0].nodeName).toBe('DIV'); expect(fragment.children[0].children[0].textContent).toEqual("bar-aTagged,bar-bTagged"); }); }); describe('useOptionalInjection hook', () => { const hookSpy = jest.spyOn(hooksModule, 'useOptionalInjection'); afterEach(() => { hookSpy.mockClear(); }); // hook with overloads, so we test types test('types', staticTypecheckOnly(() => { const opt = useOptionalInjection(Foo); assert>(true); const optWithDefault = useOptionalInjection(Foo, () => 'default' as const); assert>(true); })); test('returns undefined for missing injection/binding', () => { const ChildComponent = () => { const optionalThing = useOptionalInjection(OptionalService); return ( <> {optionalThing === undefined ? 'missing' : throwErr('unexpected')} ); }; const tree = render( ); const fragment = tree.asFragment(); expect(hookSpy).toHaveBeenCalledTimes(1); expect(hookSpy).toHaveReturnedWith(undefined); expect(fragment.children[0].textContent).toEqual('missing'); }); test('resolves using fallback to default value', () => { const defaultThing = { label: 'myDefault', isMyDefault: true, } as const; const ChildComponent = () => { const defaultFromOptional = useOptionalInjection(OptionalService, () => defaultThing); if (defaultFromOptional instanceof OptionalService) { throwErr('unexpected'); } else { assert>(true); expect(defaultFromOptional).toBe(defaultThing); } return ( <> {defaultFromOptional.label} ); }; const tree = render( ); const fragment = tree.asFragment(); expect(hookSpy).toHaveBeenCalledTimes(1); expect(hookSpy).toHaveReturnedWith(defaultThing); expect(fragment.children[0].textContent).toEqual(defaultThing.label); }); test('resolves if injection/binding exists', () => { const ChildComponent = () => { const foo = useOptionalInjection(Foo); return ( <> {foo !== undefined ? foo.name : throwErr('Cannot resolve injection for Foo')} ); }; const tree = render( ); const fragment = tree.asFragment(); expect(hookSpy).toHaveBeenCalledTimes(1); expect(fragment.children[0].textContent).toEqual('foo'); }); }); describe('useAllInjections hook', () => { const hookSpy = jest.spyOn(hooksModule, 'useAllInjections'); afterEach(() => { hookSpy.mockClear(); }); test('resolves all injections', () => { const ChildComponent = () => { const stuff = useAllInjections(multiId); return ( <> {stuff.join(',')} ); }; const tree = render( ); const fragment = tree.asFragment(); expect(hookSpy).toHaveBeenCalledTimes(1); expect(fragment.children[0].textContent).toEqual('x,y,z'); }); });