After a bit of playing around I managed to work out a solution, at least on a partial test case that I had been working on.
I've wrapped it up in a class and provided a Jest custom matcher to make the expectations fluent and consistent with normal Jest expectations.
As much as anything, this was an interesting exploration of how to mock fluent interfaces, something I didn't really find a lot of discussion on, other than for Builder patterns that always return the same object. Inverisfy is slightly different at the return from each chained call narrow the options to prevent duplication and contradictions.
The implementation of the solution is presented below, but first the caveats, and an example:
Caveats
- This is a partial implementation, the mock supplied to bind is incomplete for many of the chained calls that one can make on bind.
- This only works with
ContainerModule
, which takes a list of functions used to manipulate the container the module is being loaded into. You can still use it with a Container
if you split your container into at least one ContainerModule
and load that. That gives you an easy way to inject the mock in your tests. Splitting Containers into modules seems like a sensible way of using Inversify.
- This does not provide an implementation for anything other than bind, merely supplying mocks for unbind, isBound, etc. That's left as an exercise for the reader. If I find use cases to expand I may come back here and update, but I think the scope of a full implementation is beyond posting here, and it might be good, ultimately, to create a package. That's beyond the scope of what I need or can do right now.
- Return values for dynamic values are not validated. You could use a Jest Asymmetric Matcher to do this if valid for your use case.
- To support the other registry callbacks, the expectation handling would need refactoring to cope, at present its simplistic due to only actually supporting bind in a limited way.
Usage & Example
There is a class InversifyMock
which you can instantiate in your tests, and this will provide mocks for use with the ContainerModule
under test, and track their usage.
After the ContainerModule
has been loaded, you can then validate that the correct chain of calls were made. The code will cope with multiple calls with the same serviceIdentifier
(symbol, string or class as per Inversify - the first argument to bind()
), and the custom matcher will look for at least one match.
Note that its possible to use Jest Asymmetric Matchers when you specify the call arguments, which can be useful with things like toDynamicValue
which takes a function, so we can validate that it is a function even if the return value may not be correct.
Important This solution doesn't call the dynamic functions, as that might invoke external dependencies which we don't want to deal with. You could of course provide your own Asymmetric Matcher to do just that if its important to you.
Let's start with an example of a ContainerModule
:
export const MyContainerModule = new ContainerModule((bind) => {
bind<MySimpleService>(TYPES.MySimpleService).to(MySimpleServiceImpl)
bind<MySingletonService>(TYPES.MySingletonService).to(MySingletonServiceImpl)
bind<MyDynamicService>(TYPES.MyDynamicService).toDynamicValue(() => new MyDynamicService()).whenTargetNamed("dynamo")
})
And now some tests:
import { MyContainerModule } from 'my-container-module'
describe('MyContainerModule', () => {
const inversifyMock = new InversifyMock()
beforeEach(() => {
MyContainerModule.registry(...inversifyMock.registryHandlers)
})
afterEach(() => {
inversifyMock.clear() // reset for next test
jest.clearAllMocks() // maybe do this if you have other mocks in play
})
it('should bind MySimpleService', () => {
expect(inversifyMock).toHaveBeenBoundTo(TYPES.MySimpleService, [
{ to: [ MySimpleService ] }
])
})
it('should bind MySingletonService', () => {
expect(inversifyMock).toHaveBeenBoundTo(TYPES.MySingletonService, [
{ to: [ MySingletonService ] },
{ inSingletonScope: [] },
])
})
it('should bind MyDynamicService', () => {
expect(inversifyMock).toHaveBeenBoundTo(TYPES.MyDynamicService, [
{ toDynamicValue: [ expect.any(Function) ] },
{ whenTargetNamed: [ "dynamo" ] },
])
})
})
Hopefully its clear that with toHaveBeenBoundTo
we pass the serviceIdentifier
as the first argument, then an array of object where
each represents a call in the change. NB: Must match the call order. For each chained call, we get the name of the chained function and its arguments as an array. Note in the toDynamicValue
example how an Asymmetric Matcher is used just to validate that we got a function as the dynamic value.
NB: It's conceivable that the calls could all be in the same object, as I don't think Inversify supports multiple calls to the same chained function, but I've not looked into this. This seemed safe if a little verbose.
The Solution
import { MatcherFunction } from "expect"
import { interfaces } from "inversify"
export interface ExpectedCalls {
type: interfaces.ServiceIdentifier<any>
calls: Record<string, any[]>[]
}
type InversifyRegistryHandlers = [ interfaces.Bind, interfaces.Unbind, interfaces.IsBound, interfaces.Rebind, interfaces.UnbindAsync, interfaces.Container['onActivation'], interfaces.Container['onDeactivation'] ]
/**
* Provides an interface for mocking Inversify ContainerModules
*/
export class InversifyMock {
private bindCalls = new Map<interfaces.ServiceIdentifier<any>, Record<string, any>[][]>()
private bind: jest.Mock = jest.fn(this.handleBind.bind(this))
private unbind: jest.Mock = jest.fn()
private isBound: jest.Mock = jest.fn()
private rebind: jest.Mock = jest.fn()
private unbindAsync: jest.Mock = jest.fn()
private onActivation: jest.Mock = jest.fn()
private onDeactivation: jest.Mock = jest.fn()
get registryHandlers(): InversifyRegistryHandlers {
return [ this.bind, this.unbind, this.isBound, this.rebind, this.unbindAsync, this.onActivation, this.onDeactivation ]
}
expect(expected: ExpectedCalls): void {
const actual = this.bindCalls.get(expected.type)
expect(actual).toContainEqual(expected.calls)
}
clear(): void {
this.bindCalls.clear()
this.bind.mockClear()
this.unbind.mockClear()
this.isBound.mockClear()
this.rebind.mockClear()
this.unbindAsync.mockClear()
this.onActivation.mockClear()
this.onDeactivation.mockClear()
}
callCount(): number {
return [...this.bindCalls].reduce((acc, [_, calls]) => acc + calls.length, 0)
}
private handleBind(identifier: interfaces.ServiceIdentifier<any>) {
const callChain: any[] = []
const existingCallChain = this.bindCalls.get(identifier) || []
existingCallChain.push(callChain)
this.bindCalls.set(identifier, existingCallChain)
return {
to: (...args: any[]) => {
callChain.push({ to: args })
return {
inSingletonScope: () => {
callChain.push({ inSingletonScope: [] })
return {
whenTargetNamed: (...args: any[]) => {
callChain.push({ whenTargetNamed: args })
},
whenInjectedInto: (...args: any[]) => {
callChain.push({ whenInjectedInto: args })
},
}
},
}
},
toConstantValue: (...args: any[]) => {
callChain.push({ toConstantValue: args })
return {
whenTargetNamed: (...args: any[]) => {
callChain.push({ whenTargetNamed: args })
},
}
},
toDynamicValue: (...args: any[]) => {
callChain.push({ toDynamicValue: args })
return {
inSingletonScope: () => {
callChain.push({ inSingletonScope: [] })
},
}
},
}
}
}
/**
* Define a custom matcher for use with Jest
*/
const toHaveBeenBoundTo: MatcherFunction<[ type: interfaces.ServiceIdentifier<any>, calls: Record<string, any>[]]> = (actual, type, calls) => {
if (!(actual instanceof InversifyMock)) {
throw new Error('Actual must be an instance of InversifyBindMock')
}
try {
(actual as InversifyMock).expect({ type, calls })
return {
message: () => 'Did not expect to have been called with chain',
pass: true,
}
} catch (e: any) {
return {
message: () => e.message,
pass: false,
}
}
}
/**
* Extend Jest with the custom matcher
*/
expect.extend({
toHaveBeenBoundTo,
})
/**
* Add the custom matcher to the Jest namespace so that we can use it
* with expect, as in expect(mock).toHaveBeenBoundTo(identitifer, calls)
* NB: This is for Typescript only
*/
declare global {
namespace jest {
interface Matchers<R> {
toHaveBeenBoundTo(type: interfaces.ServiceIdentifier<any>, calls: Record<string, any>[]): R
}
}
}