diff --git a/README.md b/README.md index 10bcc0a3..0c94e0b4 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Become a bronze sponsor and get your logo on our README on GitHub. - [Additional Options](#additional-options) - [Testing Pipes](#testing-pipes) - [Using Custom Host Component](#using-custom-host-component) +- [Testing DI Functions](#testing-di-functions) - [Mocking Providers](#mocking-providers) - [Mocking OnInit Dependencies](#mocking-oninit-dependencies) - [Mocking Constructor Dependencies](#mocking-constructor-dependencies) @@ -238,6 +239,7 @@ The `createComponent()` method returns an instance of `Spectator` which exposes - `debugElement` - The tested fixture's debug element - `flushEffects()` - Provides a wrapper for `TestBed.flushEffects()` +- `runInInjectionContext()` - Provides a wrapper for `TestBed.runInInjectionContext()` - `inject()` - Provides a wrapper for `TestBed.inject()`: ```ts const service = spectator.inject(QueryService); @@ -926,6 +928,8 @@ describe('AuthService', () => { The `createService()` function returns `SpectatorService` with the following properties: - `service` - Get an instance of the service - `inject()` - A proxy for Angular `TestBed.inject()` +- `flushEffects()` - A proxy for Angular `TestBed.flushEffects()` +- `runInInjectionContext()` - A proxy for Angular `TestBed.runInInjectionContext()` ### Additional Options @@ -1020,6 +1024,8 @@ The `createPipe()` function returns `SpectatorPipe` with the following propertie - `element` - The native element of the host component - `detectChanges()` - A proxy for Angular `TestBed.fixture.detectChanges()` - `inject()` - A proxy for Angular `TestBed.inject()` +- `flushEffects()` - A proxy for Angular `TestBed.flushEffects()` +- `runInInjectionContext()` - A proxy for Angular `TestBed.runInInjectionContext()` Setting inputs directly on a pipe using `setInput` or `props` is not possible. Inputs should be set through `hostProps` or `setHostInput` instead, and passed through to your pipe in the template. @@ -1075,6 +1081,52 @@ describe('AveragePipe', () => { }); ``` +## Testing DI Functions + +Every Spectator instance supports testing DI Function by passing them to `runInInjectionContext()` function. There is a dedicated test factory that simplifies such testing by eliminating the need to pass some arbitrary Angular class amongst other factory options. Let's say we have a following function that uses the http module to fetch users: + +```ts +import { HttpClient } from '@angular/common/http'; +import { inject } from '@angular/core'; + +function getUsers() { + return inject(HttpClient).get('users'); +} +``` + +Let's see how we can test DI Function easily with Spectator: + +```ts +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { createInjectionContextFactory, SpectatorInjectionContext } from '@ngneat/spectator'; + +function getUsers() { + return inject(HttpClient).get('users'); +} + +describe('Users', () => { + let spectator: SpectatorInjectionContext; + const createContext = createInjectionContextFactory({ providers: [provideHttpClientTesting()] }); + + it('should fetch users', () => { + spectator = createContext(); + + const controller = spectator.inject(HttpTestingController); + + spectator.runInInjectionContext(getUsers).subscribe((users) => { + expect(users.length).toBe(1); + }); + + controller.expectOne('users').flush([{ id: 1 }]); + }); +}); +``` + +The `createContext()` function returns `SpectatorInjectionContext` with the following properties: +- `inject()` - A proxy for Angular `TestBed.inject()` +- `flushEffects()` - A proxy for Angular `TestBed.flushEffects()` +- `runInInjectionContext()` - A proxy for Angular `TestBed.runInInjectionContext()` + ## Mocking Providers For every Spectator factory, we can easily mock any provider. @@ -1321,6 +1373,8 @@ We need to create an HTTP factory by using the `createHttpFactory()` function, p - `httpClient` - A proxy for Angular `HttpClient` - `service` - The service instance - `inject()` - A proxy for Angular `TestBed.inject()` +- `flushEffects()` - A proxy for Angular `TestBed.flushEffects()` +- `runInInjectionContext()` - A proxy for Angular `TestBed.runInInjectionContext()` - `expectOne()` - Expect that a single request was made which matches the given URL and it's method, and return its mock request diff --git a/projects/spectator/jest/src/lib/spectator-injection-context.ts b/projects/spectator/jest/src/lib/spectator-injection-context.ts new file mode 100644 index 00000000..dc5a9ede --- /dev/null +++ b/projects/spectator/jest/src/lib/spectator-injection-context.ts @@ -0,0 +1,30 @@ +import { AbstractType, InjectionToken, Type } from '@angular/core'; +import { + createInjectionContextFactory as baseInjectionContextFactory, + SpectatorInjectionContextOverrides, + SpectatorInjectionContextOptions, + SpectatorInjectionContext as BaseSpectatorInjectionContext, +} from '@ngneat/spectator'; +import { mockProvider, SpyObject } from './mock'; + +/** + * @publicApi + */ +export interface SpectatorInjectionContext extends BaseSpectatorInjectionContext { + inject(token: Type | InjectionToken | AbstractType): SpyObject; +} + +/** + * @publicApi + */ +export type SpectatorInjectionContextFactory = (overrides?: SpectatorInjectionContextOverrides) => SpectatorInjectionContext; + +/** + * @publicApi + */ +export function createInjectionContextFactory(options: SpectatorInjectionContextOptions): SpectatorInjectionContextFactory { + return baseInjectionContextFactory({ + mockProvider, + ...options, + }) as SpectatorInjectionContextFactory; +} diff --git a/projects/spectator/jest/src/public_api.ts b/projects/spectator/jest/src/public_api.ts index 6e41f549..8e87ef56 100644 --- a/projects/spectator/jest/src/public_api.ts +++ b/projects/spectator/jest/src/public_api.ts @@ -9,3 +9,4 @@ export * from './lib/spectator-service'; export * from './lib/spectator-host'; export * from './lib/spectator-routing'; export * from './lib/spectator-pipe'; +export * from './lib/spectator-injection-context'; diff --git a/projects/spectator/jest/test/run-in-injection-context.spec.ts b/projects/spectator/jest/test/run-in-injection-context.spec.ts new file mode 100644 index 00000000..c7f0dd23 --- /dev/null +++ b/projects/spectator/jest/test/run-in-injection-context.spec.ts @@ -0,0 +1,39 @@ +import { inject, Injectable, InjectionToken, NgModule } from '@angular/core'; +import { createInjectionContextFactory, SpectatorInjectionContext } from '@ngneat/spectator/jest'; + +const TEST_TOKEN = new InjectionToken('simple-token'); + +@Injectable() +export class TestService { + flag = false; +} + +@NgModule({ + providers: [TestService], +}) +export class TestModule {} + +const testFn = (arg: any) => { + const token = inject(TEST_TOKEN); + const { flag } = inject(TestService); + + return { token, flag, arg }; +}; + +describe('Run in injection context', () => { + describe('with Spectator', () => { + const createContext = createInjectionContextFactory({ imports: [TestModule], providers: [{ provide: TEST_TOKEN, useValue: 'abcd' }] }); + + let spectator: SpectatorInjectionContext; + + beforeEach(() => (spectator = createContext())); + + it('should execute fn in injection context', () => { + const service = spectator.inject(TestService); + service.flag = true; + + const result = spectator.runInInjectionContext(() => testFn(2)); + expect(result).toEqual({ token: 'abcd', flag: true, arg: 2 }); + }); + }); +}); diff --git a/projects/spectator/src/lib/base/base-spectator.ts b/projects/spectator/src/lib/base/base-spectator.ts index 73559438..8609e19b 100644 --- a/projects/spectator/src/lib/base/base-spectator.ts +++ b/projects/spectator/src/lib/base/base-spectator.ts @@ -17,4 +17,8 @@ export abstract class BaseSpectator { public flushEffects(): void { TestBed.flushEffects(); } + + public runInInjectionContext(fn: () => T): T { + return TestBed.runInInjectionContext(fn); + } } diff --git a/projects/spectator/src/lib/spectator-injection-context/create-factory.ts b/projects/spectator/src/lib/spectator-injection-context/create-factory.ts new file mode 100644 index 00000000..dc5721de --- /dev/null +++ b/projects/spectator/src/lib/spectator-injection-context/create-factory.ts @@ -0,0 +1,44 @@ +import { TestBed } from '@angular/core/testing'; +import { initialInjectionContextModule as initialInjectionContextModule } from './initial-module'; +import { getDefaultFunctionOptions, SpectatorInjectionContextOptions } from './options'; +import { overrideModules } from '../spectator/create-factory'; +import { BaseSpectatorOverrides } from '../base/options'; +import { SpectatorInjectionContext } from './spectator-injection-context'; +import { Provider } from '@angular/core'; + +/** + * @publicApi + */ +export type SpectatorInjectionContextFactory = (overrides?: SpectatorInjectionContextOverrides) => SpectatorInjectionContext; + +/** + * @publicApi + */ +export interface SpectatorInjectionContextOverrides extends BaseSpectatorOverrides {} + +/** + * @publicApi + */ +export function createInjectionContextFactory(options: SpectatorInjectionContextOptions): SpectatorInjectionContextFactory { + const fullOptions = getDefaultFunctionOptions(options); + + const moduleMetadata = initialInjectionContextModule(fullOptions); + + beforeEach(() => { + TestBed.configureTestingModule(moduleMetadata); + overrideModules(fullOptions); + }); + + return (overrides?: SpectatorInjectionContextOverrides) => { + const defaults: SpectatorInjectionContextOverrides = { providers: [] }; + const { providers } = { ...defaults, ...overrides }; + + if (providers && providers.length) { + providers.forEach((provider: Provider) => { + TestBed.overrideProvider((provider as any).provide, provider as any); + }); + } + + return new SpectatorInjectionContext(); + }; +} diff --git a/projects/spectator/src/lib/spectator-injection-context/initial-module.ts b/projects/spectator/src/lib/spectator-injection-context/initial-module.ts new file mode 100644 index 00000000..3d9c2571 --- /dev/null +++ b/projects/spectator/src/lib/spectator-injection-context/initial-module.ts @@ -0,0 +1,11 @@ +import { initialModule, ModuleMetadata } from '../base/initial-module'; +import { FullInjectionContextOptions } from './options'; + +/** + * @internal + */ +export function initialInjectionContextModule(options: FullInjectionContextOptions): ModuleMetadata { + const moduleMetadata = initialModule(options); + + return moduleMetadata; +} diff --git a/projects/spectator/src/lib/spectator-injection-context/options.ts b/projects/spectator/src/lib/spectator-injection-context/options.ts new file mode 100644 index 00000000..254b65d2 --- /dev/null +++ b/projects/spectator/src/lib/spectator-injection-context/options.ts @@ -0,0 +1,23 @@ +import { BaseSpectatorOptions, getDefaultBaseOptions } from '../base/options'; +import { merge } from '../internals/merge'; +import { AtLeastOneRequired, OptionalsRequired } from '../types'; + +export type SpectatorInjectionContextOptions = AtLeastOneRequired< + Pick +>; + +const defaultFunctionOptions: OptionalsRequired = { + ...getDefaultBaseOptions(), +}; + +/** + * @internal + */ +export type FullInjectionContextOptions = Required & Required; + +/** + * @internal + */ +export function getDefaultFunctionOptions(overrides: SpectatorInjectionContextOptions): FullInjectionContextOptions { + return merge(defaultFunctionOptions, overrides) as FullInjectionContextOptions; +} diff --git a/projects/spectator/src/lib/spectator-injection-context/spectator-injection-context.ts b/projects/spectator/src/lib/spectator-injection-context/spectator-injection-context.ts new file mode 100644 index 00000000..93c3ce25 --- /dev/null +++ b/projects/spectator/src/lib/spectator-injection-context/spectator-injection-context.ts @@ -0,0 +1,10 @@ +import { BaseSpectator } from '../base/base-spectator'; + +/** + * @publicApi + */ +export class SpectatorInjectionContext extends BaseSpectator { + constructor() { + super(); + } +} diff --git a/projects/spectator/src/lib/types.ts b/projects/spectator/src/lib/types.ts index 79d50ee6..36201542 100644 --- a/projects/spectator/src/lib/types.ts +++ b/projects/spectator/src/lib/types.ts @@ -15,6 +15,10 @@ export type InferInputSignals = { export type OptionalsRequired = Required> & Partial; +export type AtLeastOneRequired = { + [K in keyof T]: Required> & Partial>; +}[keyof T]; + export type SpectatorElement = string | Element | DebugElement | ElementRef | Window | Document | DOMSelector; export type QueryType = Type | DOMSelector | string; diff --git a/projects/spectator/src/public_api.ts b/projects/spectator/src/public_api.ts index 93ec589f..c23f9a47 100644 --- a/projects/spectator/src/public_api.ts +++ b/projects/spectator/src/public_api.ts @@ -33,6 +33,15 @@ export { SpectatorPipeOptions } from './lib/spectator-pipe/options'; export { createPipeFactory, SpectatorPipeFactory, SpectatorPipeOverrides } from './lib/spectator-pipe/create-factory'; export { initialSpectatorPipeModule } from './lib/spectator-pipe/initial-module'; +export { SpectatorInjectionContext } from './lib/spectator-injection-context/spectator-injection-context'; +export { SpectatorInjectionContextOptions } from './lib/spectator-injection-context/options'; +export { + createInjectionContextFactory, + SpectatorInjectionContextFactory, + SpectatorInjectionContextOverrides, +} from './lib/spectator-injection-context/create-factory'; +export { initialInjectionContextModule } from './lib/spectator-injection-context/initial-module'; + export * from './lib/dom-selectors'; export * from './lib/matchers'; export * from './lib/mock'; diff --git a/projects/spectator/test/run-in-injection-context.spec.ts b/projects/spectator/test/run-in-injection-context.spec.ts new file mode 100644 index 00000000..bb96ed50 --- /dev/null +++ b/projects/spectator/test/run-in-injection-context.spec.ts @@ -0,0 +1,39 @@ +import { inject, Injectable, InjectionToken, NgModule } from '@angular/core'; +import { createInjectionContextFactory, SpectatorInjectionContext } from '@ngneat/spectator'; + +const TEST_TOKEN = new InjectionToken('simple-token'); + +@Injectable() +export class TestService { + flag = false; +} + +@NgModule({ + providers: [TestService], +}) +export class TestModule {} + +const testFn = (arg: any) => { + const token = inject(TEST_TOKEN); + const { flag } = inject(TestService); + + return { token, flag, arg }; +}; + +describe('Run in injection context', () => { + describe('with Spectator', () => { + const createContext = createInjectionContextFactory({ imports: [TestModule], providers: [{ provide: TEST_TOKEN, useValue: 'abcd' }] }); + + let spectator: SpectatorInjectionContext; + + beforeEach(() => (spectator = createContext())); + + it('should execute fn in injection context', () => { + const service = spectator.inject(TestService); + service.flag = true; + + const result = spectator.runInInjectionContext(() => testFn(2)); + expect(result).toEqual({ token: 'abcd', flag: true, arg: 2 }); + }); + }); +}); diff --git a/projects/spectator/vitest/src/lib/spectator-injection-context.ts b/projects/spectator/vitest/src/lib/spectator-injection-context.ts new file mode 100644 index 00000000..dc5a9ede --- /dev/null +++ b/projects/spectator/vitest/src/lib/spectator-injection-context.ts @@ -0,0 +1,30 @@ +import { AbstractType, InjectionToken, Type } from '@angular/core'; +import { + createInjectionContextFactory as baseInjectionContextFactory, + SpectatorInjectionContextOverrides, + SpectatorInjectionContextOptions, + SpectatorInjectionContext as BaseSpectatorInjectionContext, +} from '@ngneat/spectator'; +import { mockProvider, SpyObject } from './mock'; + +/** + * @publicApi + */ +export interface SpectatorInjectionContext extends BaseSpectatorInjectionContext { + inject(token: Type | InjectionToken | AbstractType): SpyObject; +} + +/** + * @publicApi + */ +export type SpectatorInjectionContextFactory = (overrides?: SpectatorInjectionContextOverrides) => SpectatorInjectionContext; + +/** + * @publicApi + */ +export function createInjectionContextFactory(options: SpectatorInjectionContextOptions): SpectatorInjectionContextFactory { + return baseInjectionContextFactory({ + mockProvider, + ...options, + }) as SpectatorInjectionContextFactory; +} diff --git a/projects/spectator/vitest/src/public_api.ts b/projects/spectator/vitest/src/public_api.ts index 4db5a40c..c3f511a4 100644 --- a/projects/spectator/vitest/src/public_api.ts +++ b/projects/spectator/vitest/src/public_api.ts @@ -9,3 +9,4 @@ export * from './lib/spectator-service'; export * from './lib/spectator-host'; export * from './lib/spectator-routing'; export * from './lib/spectator-pipe'; +export * from './lib/spectator-injection-context'; diff --git a/projects/spectator/vitest/test/run-in-injection-context.spec.ts b/projects/spectator/vitest/test/run-in-injection-context.spec.ts new file mode 100644 index 00000000..d4eab021 --- /dev/null +++ b/projects/spectator/vitest/test/run-in-injection-context.spec.ts @@ -0,0 +1,39 @@ +import { inject, Injectable, InjectionToken, NgModule } from '@angular/core'; +import { createInjectionContextFactory, SpectatorInjectionContext } from '@ngneat/spectator/vitest'; + +const TEST_TOKEN = new InjectionToken('simple-token'); + +@Injectable() +export class TestService { + flag = false; +} + +@NgModule({ + providers: [TestService], +}) +export class TestModule {} + +const testFn = (arg: any) => { + const token = inject(TEST_TOKEN); + const { flag } = inject(TestService); + + return { token, flag, arg }; +}; + +describe('Run in injection context', () => { + describe('with Spectator', () => { + const createContext = createInjectionContextFactory({ imports: [TestModule], providers: [{ provide: TEST_TOKEN, useValue: 'abcd' }] }); + + let spectator: SpectatorInjectionContext; + + beforeEach(() => (spectator = createContext())); + + it('should execute fn in injection context', () => { + const service = spectator.inject(TestService); + service.flag = true; + + const result = spectator.runInInjectionContext(() => testFn(2)); + expect(result).toEqual({ token: 'abcd', flag: true, arg: 2 }); + }); + }); +});