Through some experimentation, I was able to figure out a solution for a partial test case I had been working on.
I encapsulated the solution in a class and created a Jest custom matcher to ensure the expectations are smooth and aligned with regular Jest expectations.
This exploration offered insights into mocking fluent interfaces, a topic not widely discussed except for Builder patterns that always return the same object. Inversify differs slightly as each chained call narrows down the options to avoid duplication and conflicts.
The actual implementation of the solution is outlined below, but first, let's look at the limitations and an example:
Limitations
- This is a partial implementation; the provided mock for bind is inadequate for many possible chained calls on bind.
- It only functions with
ContainerModule
, which requires a list of functions to modify the container being loaded into. You can still use it with a Container
by dividing your container into at least one ContainerModule
and loading that. This offers a simple way to inject the mock into your tests. Organizing containers into modules seems like a practical approach with Inversify.
- This does not cover implementations other than bind, merely offering mocks for unbind, isBound, etc. Expanding this will be up to the user. If there are use cases warranting expansion, I may revisit and update here in the future. However, creating a full implementation might require a separate package. This goes beyond my immediate needs or capabilities currently.
- Return values for dynamic inputs are not validated. A Jest Asymmetric Matcher could be employed if necessary for your scenario.
- To support other registry callbacks, the expectation handling would need restructuring to accommodate them. Currently, it is simplistic since it only supports bind in a limited manner.
Usage & Example
A class called InversifyMock
can be initialized in your tests to provide mocks for usage with the ContainerModule
under testing while tracking their usage.
Upon loading the ContainerModule
, you can verify if the correct chain of calls was made. The code handles multiple calls with the same serviceIdentifier
(symbol, string, or class as per Inversify - the first argument to bind()
), and the custom matcher looks for at least one match.
Note that using Jest Asymmetric Matchers when specifying call arguments can be helpful, especially with functions like toDynamicValue
that expect a function. This enables validation that a function is used even if the return value may be incorrect.
Important: This solution avoids calling dynamic functions to prevent invoking external dependencies, which we aim to avoid dealing with. If needed, you could create your own Asymmetric Matcher specifically for this purpose.
Let's begin 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")
})
Now, onto 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() // clear other mocks if present
})
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" ] },
])
})
})
Clearly, with toHaveBeenBoundTo
, we pass the serviceIdentifier
as the first argument, followed by an array of objects where each represents a call in the chain. It's essential to maintain the order of calls accurate. For each chained call, we receive the name of the chained function and its arguments within an array. Note how an Asymmetric Matcher is utilized in the toDynamicValue
instance just to ensure that a function is received as the dynamic value.
It's plausible that all the calls could be unified within the same object, considering Inversify possibly doesn't support multiple calls to the same chained function. However, further investigation is required in this area. This method seemed reliable albeit a bit lengthy.
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'] ]
/**
* 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()
…
(expect continues...)