0

I'm wondering if anyone has any guidance on how to test that IoC wiring is as intended. For my use case, I'm using InversifyJS with Typescript, and Jest for testing.

I have used Inversify ContainerModules to allow my application to be split up, and the container itself is then constructed by loading the modules into it.

I can see a couple of approaches:

  1. Build the production container, then use get/resolve to ensure that dependencies are returned, and are instances of/have an interface that matches.

  2. Inject a mock container and check that the correct calls are made for each dependency. This might allow me to decouple the container tests from the instantiation of underlying services which might have complicated external dependencies.

The problem with mocking is that the fluent interface used by Inversify means that mocks are chained together.

I also didn't find a good way to test process of loading a container module without wrapping the construction of the module in a function so that I can get access to the bind/unbind/... methods passed to the constructor.

My container module setup currently looks like:

export const FooModule = new ContainerModule((bind) => {
    bind<Controller>(TYPES.Controller).to(ConcreteController).inSingletonScope().whenTargetNamed(NAMES.FooController)
    bind<Controller>(TYPES.Controller).to(ConcreteController).inSingletonScope().whenTargetNamed(NAMES.BarController)
})

My concern overall is that the wiring is a bit tricky and I want to ensure that everything is in place, but also wired correctly, especially for things like singletons. It seems easy to introduce an edit that changes the wiring unintentionally and is hard to spot, hence wanting it to be included in test coverage.

It's entirely possible that this is actually too hard to test, especially given that some dependencies might be constant/dynamic and can only be constructed if the external dependency can be mocked. Seems like it becomes IoC on IoC. Isolating dependencies into a single place seems like at least a step in the right direction to limit the scope of changes.

Dave Meehan
  • 3,133
  • 1
  • 17
  • 24

1 Answers1

0

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
        }
    }
}
Dave Meehan
  • 3,133
  • 1
  • 17
  • 24