Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve support for mocking dependencies in DI #32

Merged
merged 4 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,51 @@ const result1 = awilixManager.getWithTags(diContainer, ['queue'])
// This will return only dependency2
const result2 = awilixManager.getWithTags(diContainer, ['queue', 'low-priority'])
```

## Type-safe resolver definition

You can use `ResolvedDependencies` for defining your DI configuration as an object:

```ts
type DiContainerType = {
testClass: TestClass
}
const diConfiguration: ResolvedDependencies<DiContainerType> = {
testClass: asClass(TestClass),
}

const diContainer = createContainer<DiContainerType>({
injectionMode: 'PROXY',
})

for (const [dependencyKey, dependencyValue] of Object.entries(diConfiguration)) {
diContainer.register(dependencyKey, dependencyValue as Resolver<unknown>)
}
```

## Mocking dependencies

Sometimes you may want to intentionally inject objects that do not fully conform to the type definition of an original class. For that you can use `asMockClass` resolver:

```ts
type DiContainerType = {
realClass: RealClass
realClass2: RealClass
}
const diConfiguration: ResolvedDependencies<DiContainerType> = {
realClass: asClass(RealClass),
realClass2: asMockClass(FakeClass),
}

const diContainer = createContainer<DiContainerType>({
injectionMode: 'PROXY',
})

for (const [dependencyKey, dependencyValue] of Object.entries(diConfiguration)) {
diContainer.register(dependencyKey, dependencyValue as Resolver<unknown>)
}

const { realClass, realClass2 } = diContainer.cradle
expect(realClass).toBeInstanceOf(RealClass)
expect(realClass2).toBeInstanceOf(FakeClass)
```
3 changes: 2 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
export {
AwilixManager,
asMockClass,
eagerInject,
asyncInit,
asyncDispose,
getWithTags,
} from './lib/awilixManager'
export type { AwilixManagerConfig } from './lib/awilixManager'
export type { AwilixManagerConfig, ResolvedDependencies } from './lib/awilixManager'
31 changes: 23 additions & 8 deletions lib/awilixManager.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import type { AwilixContainer } from 'awilix'
import {
type AwilixContainer,
type BuildResolver,
type BuildResolverOptions,
type Constructor,
type DisposableResolver,
asClass,
} from 'awilix'
import type { Resolver } from 'awilix/lib/resolvers'

declare module 'awilix' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ResolverOptions<T> {
asyncInit?: boolean | string
asyncInitPriority?: number // lower means it gets initted earlier
asyncDispose?: boolean | string | ((instance: T) => Promise<unknown>)
asyncDispose?: boolean | string | (<U extends T>(instance: U) => Promise<unknown>)
asyncDisposePriority?: number // lower means it gets disposed earlier
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this change it wasn't possible to pass a class that would extend the original class as a resolver override.

E. g. something like this:

consumerErrorResolver: asClass(FakeConsumerErrorResolver, SINGLETON_CONFIG),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I was just writing up a repro for this! https://codesandbox.io/p/devbox/awilix-ts-interface-j5sll2

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wahahaha, awesome!

eagerInject?: boolean | string
tags?: string[]
Expand All @@ -21,6 +29,17 @@ export type AwilixManagerConfig = {
strictBooleanEnforced?: boolean
}

export type ResolvedDependencies<TDependencies> = {
[Key in keyof TDependencies]: Resolver<TDependencies[Key]>
}

export function asMockClass<T = object>(
Type: unknown,
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for cases when we know that passed class does not fully implement the original class contract, and we are fine with that.

opts?: BuildResolverOptions<T>,
): BuildResolver<T> & DisposableResolver<T> {
return asClass(Type as Constructor<T>, opts)
}

export class AwilixManager {
public readonly config: AwilixManagerConfig

Expand Down Expand Up @@ -149,11 +168,7 @@ export async function asyncDispose(diContainer: AwilixContainer) {
await resolvedValue.asyncDispose()
continue
}

// assume it's a string
{
// @ts-ignore
await resolvedValue[asyncDispose]()
}
// @ts-ignore
await resolvedValue[asyncDispose]()
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"build:release": "del-cli dist && del-cli coverage && npm run lint && npm run build",
"test": "vitest",
"test:coverage": "npm test -- --coverage",
"lint": "biome lint index.ts lib test biome.json",
"lint": "biome lint index.ts lib test biome.json && tsc --project tsconfig.lint.json --noEmit",
"lint:fix": "biome check --apply index.ts lib test biome.json",
"prepublishOnly": "npm run build:release"
},
Expand Down
28 changes: 28 additions & 0 deletions test/awilixManager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { asClass, createContainer } from 'awilix'
import { describe, expect, it } from 'vitest'

import type { Resolver } from 'awilix/lib/resolvers'
import {
AwilixManager,
type ResolvedDependencies,
asMockClass,
asyncDispose,
asyncInit,
eagerInject,
Expand Down Expand Up @@ -82,6 +85,31 @@ class InitSetClass {
}
}

describe('asMockClass', () => {
it('Supports passing a mock instance that does not fully implement the real class', () => {
type DiContainerType = {
asyncInitClass: AsyncInitClass
asyncInitClass2: AsyncInitClass
}
const diConfiguration: ResolvedDependencies<DiContainerType> = {
asyncInitClass: asClass(AsyncInitClass),
asyncInitClass2: asMockClass(AsyncDisposeClass),
}

const diContainer = createContainer<DiContainerType>({
injectionMode: 'PROXY',
})

for (const [dependencyKey, dependencyValue] of Object.entries(diConfiguration)) {
diContainer.register(dependencyKey, dependencyValue as Resolver<unknown>)
}

const { asyncInitClass, asyncInitClass2 } = diContainer.cradle
expect(asyncInitClass).toBeInstanceOf(AsyncInitClass)
expect(asyncInitClass2).toBeInstanceOf(AsyncDisposeClass)
})
})

describe('awilixManager', () => {
describe('constructor', () => {
it('throws an error if strictBooleanEnforced is set and undefined is passed', () => {
Expand Down
10 changes: 6 additions & 4 deletions vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ export default defineConfig({
exclude: ['lib/**/*.spec.ts'],
reporter: ['text', 'lcov'],
all: true,
statements: 100,
branches: 100,
functions: 100,
lines: 100,
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
}
},
},
})
Loading