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

feat(spectator): add support for runInInjectionContext() #690

Merged
merged 2 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<User[]>('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<User[]>('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.
Expand Down Expand Up @@ -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


Expand Down
30 changes: 30 additions & 0 deletions projects/spectator/jest/src/lib/spectator-injection-context.ts
Original file line number Diff line number Diff line change
@@ -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<T>(token: Type<T> | InjectionToken<T> | AbstractType<T>): SpyObject<T>;
}

/**
* @publicApi
*/
export type SpectatorInjectionContextFactory = (overrides?: SpectatorInjectionContextOverrides) => SpectatorInjectionContext;

/**
* @publicApi
*/
export function createInjectionContextFactory(options: SpectatorInjectionContextOptions): SpectatorInjectionContextFactory {
return baseInjectionContextFactory({
mockProvider,
...options,
}) as SpectatorInjectionContextFactory;
}
1 change: 1 addition & 0 deletions projects/spectator/jest/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
39 changes: 39 additions & 0 deletions projects/spectator/jest/test/run-in-injection-context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { inject, Injectable, InjectionToken, NgModule } from '@angular/core';
import { createInjectionContextFactory, SpectatorInjectionContext } from '@ngneat/spectator/jest';

const TEST_TOKEN = new InjectionToken<string>('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 });
});
});
});
4 changes: 4 additions & 0 deletions projects/spectator/src/lib/base/base-spectator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ export abstract class BaseSpectator {
public flushEffects(): void {
TestBed.flushEffects();
}

public runInInjectionContext<T>(fn: () => T): T {
return TestBed.runInInjectionContext(fn);
}
}
Original file line number Diff line number Diff line change
@@ -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();
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { initialModule, ModuleMetadata } from '../base/initial-module';
import { FullInjectionContextOptions } from './options';

/**
* @internal
*/
export function initialInjectionContextModule<F>(options: FullInjectionContextOptions): ModuleMetadata {
const moduleMetadata = initialModule(options);

return moduleMetadata;
}
23 changes: 23 additions & 0 deletions projects/spectator/src/lib/spectator-injection-context/options.ts
Original file line number Diff line number Diff line change
@@ -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<BaseSpectatorOptions, 'imports' | 'mockProvider' | 'mocks' | 'providers'>
>;

const defaultFunctionOptions: OptionalsRequired<SpectatorInjectionContextOptions> = {
...getDefaultBaseOptions(),
};

/**
* @internal
*/
export type FullInjectionContextOptions = Required<SpectatorInjectionContextOptions> & Required<BaseSpectatorOptions>;

/**
* @internal
*/
export function getDefaultFunctionOptions(overrides: SpectatorInjectionContextOptions): FullInjectionContextOptions {
return merge(defaultFunctionOptions, overrides) as FullInjectionContextOptions;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { BaseSpectator } from '../base/base-spectator';

/**
* @publicApi
*/
export class SpectatorInjectionContext extends BaseSpectator {
constructor() {
super();
}
}
4 changes: 4 additions & 0 deletions projects/spectator/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export type InferInputSignals<C> = {

export type OptionalsRequired<T> = Required<OptionalProperties<T>> & Partial<T>;

export type AtLeastOneRequired<T> = {
[K in keyof T]: Required<Pick<T, K>> & Partial<Omit<T, K>>;
}[keyof T];

export type SpectatorElement = string | Element | DebugElement | ElementRef | Window | Document | DOMSelector;

export type QueryType = Type<any> | DOMSelector | string;
Expand Down
9 changes: 9 additions & 0 deletions projects/spectator/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
39 changes: 39 additions & 0 deletions projects/spectator/test/run-in-injection-context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { inject, Injectable, InjectionToken, NgModule } from '@angular/core';
import { createInjectionContextFactory, SpectatorInjectionContext } from '@ngneat/spectator';

const TEST_TOKEN = new InjectionToken<string>('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 });
});
});
});
30 changes: 30 additions & 0 deletions projects/spectator/vitest/src/lib/spectator-injection-context.ts
Original file line number Diff line number Diff line change
@@ -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<T>(token: Type<T> | InjectionToken<T> | AbstractType<T>): SpyObject<T>;
}

/**
* @publicApi
*/
export type SpectatorInjectionContextFactory = (overrides?: SpectatorInjectionContextOverrides) => SpectatorInjectionContext;

/**
* @publicApi
*/
export function createInjectionContextFactory(options: SpectatorInjectionContextOptions): SpectatorInjectionContextFactory {
return baseInjectionContextFactory({
mockProvider,
...options,
}) as SpectatorInjectionContextFactory;
}
1 change: 1 addition & 0 deletions projects/spectator/vitest/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading