Refactor DI for simplicity and type safety
This commit improves the dependency injection mechanism by introducing a custom `injectKey` function. Key improvements are: - Enforced type consistency during dependency registration and instantiation. - Simplified injection process, abstracting away the complexity with a uniform API, regardless of the dependency's lifetime. - Eliminated the possibility of `undefined` returns during dependency injection, promoting fail-fast behavior. - Removed the necessity for type casting to `symbol` for injection keys in unit tests by using existing types. - Consalidated imports, combining keys and injection functions in one `import` statement.
This commit is contained in:
53
tests/integration/composite/DependencyResolution.spec.ts
Normal file
53
tests/integration/composite/DependencyResolution.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { it, describe, expect } from 'vitest';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { defineComponent, inject } from 'vue';
|
||||
import { InjectionKeySelector, InjectionKeys, injectKey } from '@/presentation/injectionSymbols';
|
||||
import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider';
|
||||
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
||||
import { IApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
|
||||
describe('DependencyResolution', () => {
|
||||
describe('all dependencies can be injected', async () => {
|
||||
// arrange
|
||||
const context = await buildContext();
|
||||
const dependencies = collectProvidedKeys(context);
|
||||
Object.values(InjectionKeys).forEach((key) => {
|
||||
it(`"${key.key.description}"`, () => {
|
||||
// act
|
||||
const resolvedDependency = resolve(() => key, dependencies);
|
||||
// assert
|
||||
expect(resolvedDependency).to.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type ProvidedKeys = Record<symbol, unknown>;
|
||||
|
||||
function collectProvidedKeys(context: IApplicationContext): ProvidedKeys {
|
||||
const providedKeys: ProvidedKeys = {};
|
||||
provideDependencies(context, {
|
||||
inject,
|
||||
provide: (key, value) => {
|
||||
providedKeys[key as symbol] = value;
|
||||
},
|
||||
});
|
||||
return providedKeys;
|
||||
}
|
||||
|
||||
function resolve<T>(
|
||||
selector: InjectionKeySelector<T>,
|
||||
providedKeys: ProvidedKeys,
|
||||
): T | undefined {
|
||||
let injectedDependency: T | undefined;
|
||||
shallowMount(defineComponent({
|
||||
setup() {
|
||||
injectedDependency = injectKey(selector);
|
||||
},
|
||||
}), {
|
||||
global: {
|
||||
provide: providedKeys,
|
||||
},
|
||||
});
|
||||
return injectedDependency;
|
||||
}
|
||||
3
tests/integration/composite/README.md
Normal file
3
tests/integration/composite/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Composite Tests
|
||||
|
||||
The `composite` directory contains integration tests that validate the cooperative functionality of multiple components within the application, going beyond unit tests to ensure interconnected parts perform as expected.
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe } from 'vitest';
|
||||
import { createApp } from 'vue';
|
||||
import { ApplicationBootstrapper } from '@/presentation/bootstrapping/ApplicationBootstrapper';
|
||||
import { expectDoesNotThrowAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
|
||||
describe('ApplicationBootstrapper', () => {
|
||||
it('can bootstrap without errors', async () => {
|
||||
// arrange
|
||||
const sut = new ApplicationBootstrapper();
|
||||
const vueApp = createApp({});
|
||||
// act
|
||||
const act = () => sut.bootstrap(vueApp);
|
||||
// assert
|
||||
await expectDoesNotThrowAsync(act);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { IApplication } from '@/domain/IApplication';
|
||||
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
|
||||
import { ApplicationStub } from '@tests/unit/shared/Stubs/ApplicationStub';
|
||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
|
||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
|
||||
describe('ApplicationContextFactory', () => {
|
||||
describe('buildContext', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
||||
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
|
||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystemOpsStub';
|
||||
import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BootstrapperStub } from '@tests/unit/shared/Stubs/BootstrapperStub';
|
||||
import { ApplicationBootstrapper } from '@/presentation/bootstrapping/ApplicationBootstrapper';
|
||||
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
|
||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
import type { App } from 'vue';
|
||||
|
||||
describe('ApplicationBootstrapper', () => {
|
||||
|
||||
@@ -18,8 +18,9 @@ describe('DependencyProvider', () => {
|
||||
useCurrentCode: createTransientTests(),
|
||||
};
|
||||
Object.entries(testCases).forEach(([key, runTests]) => {
|
||||
describe(`Key: "${key}"`, () => {
|
||||
runTests(InjectionKeys[key]);
|
||||
const registeredKey = InjectionKeys[key].key;
|
||||
describe(`Key: "${registeredKey.toString()}"`, () => {
|
||||
runTests(registeredKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions';
|
||||
import { RuntimeSanityValidator } from '@/presentation/bootstrapping/Modules/RuntimeSanityValidator';
|
||||
import { expectDoesNotThrowAsync, expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
|
||||
import { expectDoesNotThrowAsync, expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
|
||||
describe('RuntimeSanityValidator', () => {
|
||||
it('calls validator with correct options upon bootstrap', async () => {
|
||||
|
||||
@@ -39,12 +39,12 @@ function mountComponent(options?: {
|
||||
return shallowMount(CodeCopyButton, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useClipboard as symbol]: () => (
|
||||
[InjectionKeys.useClipboard.key]: () => (
|
||||
options?.clipboard
|
||||
? new UseClipboardStub(options.clipboard)
|
||||
: new UseClipboardStub()
|
||||
).get(),
|
||||
[InjectionKeys.useCurrentCode as symbol]: () => (
|
||||
[InjectionKeys.useCurrentCode.key]: () => (
|
||||
options.currentCode === undefined
|
||||
? new UseCurrentCodeStub()
|
||||
: new UseCurrentCodeStub().withCurrentCode(options.currentCode)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import CodeInstruction from '@/presentation/components/Code/CodeButtons/Save/Instructions/CodeInstruction.vue';
|
||||
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
|
||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
|
||||
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
|
||||
@@ -74,7 +74,7 @@ function mountComponent(options?: {
|
||||
return shallowMount(CodeInstruction, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useClipboard as symbol]:
|
||||
[InjectionKeys.useClipboard.key]:
|
||||
() => {
|
||||
if (options?.clipboard) {
|
||||
return new UseClipboardStub(options.clipboard).get();
|
||||
|
||||
@@ -406,11 +406,11 @@ function mountComponent(options?: {
|
||||
return shallowMount(TheScriptsView, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]:
|
||||
[InjectionKeys.useCollectionState.key]:
|
||||
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
|
||||
[InjectionKeys.useApplication as symbol]:
|
||||
[InjectionKeys.useApplication.key]:
|
||||
new UseApplicationStub().get(),
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
[InjectionKeys.useAutoUnsubscribedEvents.key]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -79,7 +79,7 @@ function mountWrapperComponent(nodeWatcher: WatchSource<ReadOnlyTreeNode | undef
|
||||
{
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
[InjectionKeys.useAutoUnsubscribedEvents.key]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -70,7 +70,7 @@ function mountWrapperComponent(treeWatcher: WatchSource<TreeRoot | undefined>) {
|
||||
{
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
[InjectionKeys.useAutoUnsubscribedEvents.key]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -336,7 +336,7 @@ class UseNodeStateChangeAggregatorBuilder {
|
||||
{
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
[InjectionKeys.useAutoUnsubscribedEvents.key]:
|
||||
() => this.events.get(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -172,7 +172,7 @@ function mountWrapperComponent() {
|
||||
}, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(),
|
||||
[InjectionKeys.useCollectionState.key]: () => useStateStub.get(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -239,9 +239,9 @@ function mountWrapperComponent(scenario?: {
|
||||
}, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]:
|
||||
[InjectionKeys.useCollectionState.key]:
|
||||
() => useStateStub.get(),
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
[InjectionKeys.useAutoUnsubscribedEvents.key]:
|
||||
() => (scenario?.useAutoUnsubscribedEvents ?? new UseAutoUnsubscribedEventsStub()).get(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -148,9 +148,9 @@ function mountWrapperComponent(options?: {
|
||||
}, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]:
|
||||
[InjectionKeys.useCollectionState.key]:
|
||||
() => useStateStub.get(),
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
[InjectionKeys.useAutoUnsubscribedEvents.key]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -106,7 +106,7 @@ function mountWrapperComponent(categoryIdWatcher: WatchSource<number | undefined
|
||||
}, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(),
|
||||
[InjectionKeys.useCollectionState.key]: () => useStateStub.get(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BrowserClipboard, NavigatorClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/BrowserClipboard';
|
||||
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
|
||||
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
|
||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
|
||||
describe('BrowserClipboard', () => {
|
||||
describe('writeText', () => {
|
||||
|
||||
@@ -1,23 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { InjectionKeySelector, InjectionKeys, injectKey } from '@/presentation/injectionSymbols';
|
||||
import { getAbsentObjectTestCases } from '../shared/TestCases/AbsentTests';
|
||||
|
||||
describe('injectionSymbols', () => {
|
||||
Object.entries(InjectionKeys).forEach(([key, symbol]) => {
|
||||
describe(`symbol for ${key}`, () => {
|
||||
it('should be a symbol type', () => {
|
||||
// arrange
|
||||
const expectedType = Symbol;
|
||||
// act
|
||||
// assert
|
||||
expect(symbol).to.be.instanceOf(expectedType);
|
||||
describe('InjectionKeys', () => {
|
||||
Object.entries(InjectionKeys).forEach(([key, injectionKey]) => {
|
||||
describe(`symbol for ${injectionKey.key.toString()}`, () => {
|
||||
it('should be a symbol type', () => {
|
||||
// arrange
|
||||
const actualKey = injectionKey.key;
|
||||
const expectedType = Symbol;
|
||||
// act
|
||||
// assert
|
||||
expect(actualKey).to.be.instanceOf(expectedType);
|
||||
});
|
||||
it(`should have a description matching the key "${injectionKey.key.toString()}"`, () => {
|
||||
// arrange
|
||||
const actualKey = injectionKey.key;
|
||||
const expected = key;
|
||||
// act
|
||||
const actual = actualKey.description;
|
||||
// assert
|
||||
expect(expected).to.equal(actual);
|
||||
});
|
||||
});
|
||||
it(`should have a description matching the key "${key}"`, () => {
|
||||
// arrange
|
||||
const expected = key;
|
||||
// act
|
||||
const actual = symbol.description;
|
||||
// assert
|
||||
expect(expected).to.equal(actual);
|
||||
});
|
||||
});
|
||||
describe('injectKey', () => {
|
||||
it('returns the correct value for singleton keys', () => {
|
||||
// arrange
|
||||
const mockValue = { someProperty: 'someValue' };
|
||||
// act
|
||||
const mockInject = () => mockValue;
|
||||
const result = injectKey((keys) => keys.useApplication, mockInject);
|
||||
// assert
|
||||
expect(result).to.equal(mockValue);
|
||||
});
|
||||
it('invokes and returns result from factory function for transient keys', () => {
|
||||
// arrange
|
||||
const mockValue = { anotherProperty: 'anotherValue' };
|
||||
const mockFactory = () => mockValue;
|
||||
const mockInject = () => mockFactory;
|
||||
// act
|
||||
const result = injectKey((keys) => keys.useCollectionState, mockInject);
|
||||
// assert
|
||||
expect(result).to.equal(mockValue);
|
||||
});
|
||||
describe('throws error when no value is provided for a key', () => {
|
||||
// arrange
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly name: string,
|
||||
readonly act: (selector: InjectionKeySelector<unknown>) => void,
|
||||
}> = getAbsentObjectTestCases().flatMap((absentTest) => [
|
||||
{
|
||||
name: `singleton factory: ${absentTest.absentValue}`,
|
||||
act: (selector) => injectKey(selector, () => (() => absentTest.absentValue)),
|
||||
},
|
||||
{
|
||||
name: `transient: ${absentTest.absentValue}`,
|
||||
act: (selector) => injectKey(selector, () => absentTest.absentValue),
|
||||
},
|
||||
]);
|
||||
testScenarios.forEach((testCase) => {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const key = InjectionKeys.useRuntimeEnvironment;
|
||||
const expectedError = `Failed to inject value for key: ${key.key.description}`;
|
||||
const selector: InjectionKeySelector<typeof key> = () => key;
|
||||
// act
|
||||
const act = () => testCase.act(selector);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user