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

Add ComponentTestBed inject(..) method #26

Merged
merged 5 commits into from
Jan 16, 2024
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
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Component, EnvironmentProviders, ModuleWithProviders, Provider, Type } from '@angular/core';
import { Component, EnvironmentProviders, ModuleWithProviders, Provider, ProviderToken, Type } from '@angular/core';
import { ComponentFixture, TestBed, TestBedStatic, TestModuleMetadata } from '@angular/core/testing';
import { MaybeArray, Nullable } from '../../models/shared.model';
import { MaybeArray, Merge, NonEmptyString, Nullable } from '../../models/shared.model';
import { assertComponent } from './assert-component';
import { assertComponentFixture } from './assert-fixture';
import { getComponentAnnotation } from './component-annotation';
import { ComponentTestBed } from './models';
import { InjectionStore } from './store';

export class ComponentTestBedFactory<ComponentType> {
export class ComponentTestBedFactory<ComponentType, Injected extends InjectionStore = InjectionStore> {

public constructor(
private rootComponent: Type<ComponentType>,
Expand All @@ -18,49 +20,52 @@ export class ComponentTestBedFactory<ComponentType> {

private testBed: TestBedStatic = TestBed;
private fixture: ComponentFixture<ComponentType> = null!;
private injected: Map<ProviderToken<any>, string> = new Map();

/**
* Import one module or one standalone component / directive / pipe into the `ComponentTestBed`.
* @param importation
*/
public import(importation: Type<any> | ModuleWithProviders<any>): this
/**
* Import many modules or many standalone components / directives / pipes into the `ComponentTestBed`.
* @param imports
*/
public import(imports: (Type<any> | ModuleWithProviders<any>)[]): this
public import(oneOrManyImports: MaybeArray<Type<any> | ModuleWithProviders<any>>): this
public import(oneOrManyImports: MaybeArray<Type<any> | ModuleWithProviders<any>>): this {
return this.configure('imports', oneOrManyImports);
}

/**
* Add one provider into the `ComponentTestBed`.
* @param provider
*/
public provide(provider: Provider | EnvironmentProviders): this
/**
* Add many providers into the `ComponentTestBed`.
* @param providers
*/
public provide(providers: (Provider | EnvironmentProviders)[]): this
public provide(oneOrManyProviders: MaybeArray<Provider | EnvironmentProviders>): this
public provide(oneOrManyProviders: MaybeArray<Provider | EnvironmentProviders>): this {
return this.configure('providers', oneOrManyProviders);
}

/**
* Declare one non standalone component, directive or pipe into the `ComponentTestBed`.
* @param declaration
*/
public declare(declaration: Type<any>): this
/**
* Declare many non standalone components, directives and pipes into `ComponentTestBed`.
* @param declarations
*/
public declare(declarations: Type<any>[]): this
public declare(oneOrManyDeclarations: MaybeArray<Type<any>>): this
public declare(oneOrManyDeclarations: MaybeArray<Type<any>>): this {
return this.configure('declarations', oneOrManyDeclarations);
}

public inject<key extends string, T>(name: NonEmptyString<key>, token: ProviderToken<T>): ComponentTestBed<ComponentType, InjectionStore<Merge<Injected['injected'] & { [k in key]: T }>>> {
this.injected.set(token, name);
return this as any;
}

private configure(key: keyof TestModuleMetadata, itemS: MaybeArray<unknown>): this {
const defs: unknown[] = Array.isArray(itemS) ? itemS : [itemS];
this.testBed.configureTestingModule({ [key]: defs });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { ComponentExtraOptions } from './models';
import { ComponentActionTools } from './models/component-action-tools.model';
import { ComponentQueryTools } from './models/component-query-tools.model';
import { ComponentAssertion, ComponentTestBed } from './models/component-test-bed.models';
import { InjectionStore } from './store';
import { buildInjected } from './store/injected';

/**
* Creates a new `ComponentTestBed` to configure the test bed and wrap the assertion test.
Expand All @@ -16,7 +18,7 @@ import { ComponentAssertion, ComponentTestBed } from './models/component-test-be
export function componentTestBed<T>(rootComponent: Type<T>): ComponentTestBed<T> {
const factory = new ComponentTestBedFactory(rootComponent);

const tb: ComponentTestBed<T> = ((assertionCb: ComponentAssertion<T>, options: ComponentExtraOptions = {}) => {
const tb: ComponentTestBed<T> = ((assertionCb: ComponentAssertion<T, any>, options: ComponentExtraOptions = {}) => {
const { startDetectChanges = true } = options;

const expectationFn = (done: DoneFn = null!) => {
Expand All @@ -28,22 +30,41 @@ export function componentTestBed<T>(rootComponent: Type<T>): ComponentTestBed<T>

const query: ComponentQueryTools = buildComponentQueryTools(fixture);
const action: ComponentActionTools = buildComponentActionTools(fixture);
const injected: InjectionStore['injected'] = buildInjected(factory);

if (startDetectChanges) fixture.detectChanges();

return assertionCb({ fixture, component, injector, debug, query, action }, done);
return assertionCb({ fixture, component, injector, query, action, injected, debug }, done);
};

return (assertionCb.length > 1)
? (done: DoneFn) => expectationFn(done)
: () => expectationFn();
}) as ComponentTestBed<T>;

tb.import = factory.import.bind(factory) as any;
tb.provide = factory.provide.bind(factory) as any;
tb.declare = factory.declare.bind(factory) as any;
tb.compile = factory.compile.bind(factory);
return mergeFactoryToFn(factory, tb);
}

function mergeFactoryToFn<T>(factory: ComponentTestBedFactory<T, any>, tb: ComponentTestBed<T, any>): ComponentTestBed<T> {
tb.import = (imports) => {
factory.import(imports);
return tb;
};
tb.provide = (providers) => {
factory.provide(providers);
return tb;
};
tb.declare = (declarations) => {
factory.declare(declarations);
return tb;
};
tb.inject = (name, token) => {
factory.inject(name, token);
return tb;
};

tb.shouldCreate = factory.shouldCreate.bind(factory);
tb.compile = factory.compile.bind(factory);

return tb;
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './models';
export * from './store';
export { componentTestBed } from './component-test-bed';
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ComponentTestBedFactory } from '../component-test-bed-factory';
import { InjectionStore } from '../store';
import { ComponentExtraOptions } from './component-extra-options.model';
import { ComponentTools } from './component-tools.model';

export interface ComponentTestBed<T> extends ComponentTestBedFn<T>, ComponentTestBedFactory<T> {}
export interface ComponentTestBed<T, I extends InjectionStore = InjectionStore> extends ComponentTestBedFn<T, I>, ComponentTestBedFactory<T, I> {}

export type ComponentTestBedFn<T> = (assertion: ComponentAssertion<T>, options?: ComponentExtraOptions) => jasmine.ImplementationCallback
export type ComponentTestBedFn<T, I extends InjectionStore> = (assertion: ComponentAssertion<T, I['injected']>, options?: ComponentExtraOptions) => jasmine.ImplementationCallback

export type ComponentAssertion<T> = (tools: ComponentTools<T>, done: DoneFn) => ReturnType<jasmine.ImplementationCallback>
export type ComponentAssertion<T, I extends {}> = (tools: ComponentTools<T, I>, done: DoneFn) => ReturnType<jasmine.ImplementationCallback>
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { DebugElement, Injector } from '@angular/core';
import { ComponentFixture } from '@angular/core/testing';
import { InjectionStore } from '../store';
import { ComponentActionTools } from './component-action-tools.model';
import { ComponentQueryTools } from './component-query-tools.model';

export interface ComponentTools<T> {
export interface ComponentTools<T, I extends {}> extends InjectionStore<I> {
fixture: ComponentFixture<T>;
component: T;
injector: Injector;
query: ComponentQueryTools;
action: ComponentActionTools;
/**
* Will be removed in v3.
*
* Use `fixture.debugElement` instead.
* @deprecated
*/
debug: DebugElement;
query: ComponentQueryTools;
action: ComponentActionTools;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { InjectionStore } from './models/injected-store.model';
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { fromInjector } from '../../../injector';
import { ComponentTestBedFactory } from '../component-test-bed-factory';
import { InjectionStore } from './models/injected-store.model';

export function buildInjected(factory: ComponentTestBedFactory<unknown>): InjectionStore['injected'] {
const injected: InjectionStore<any>['injected'] = {};
for (const [key, value] of factory['injected'].entries()) {
injected[value] = fromInjector(factory['fixture'], key);
}
return injected;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type InjectionStore<T extends {} = {}> = {
injected: T;
}
7 changes: 7 additions & 0 deletions projects/ngx-testing-tools/src/lib/models/shared.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,10 @@ export type Nullable<T> =
export type MaybeArray<T> =
| T
| T[]

export type NonEmptyString<T extends string> = T extends '' ? never : T;

export type Merge<T> = {
[K in keyof T]: T[K];
} & {};

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { MyButtonDirective } from '../directives/my-button.directive';
import { AppService } from '../services/app.service';
import { InnerComponent } from './inner.component';

@Component({
Expand All @@ -14,10 +15,13 @@ import { InnerComponent } from './inner.component';
`,
standalone: true,
imports: [InnerComponent, MyButtonDirective],
providers: [AppService],
})
export class OuterComponent {

public extraInner: boolean = false;
public clicked: boolean = false;
public innerClicked: boolean = false;

public service = inject(AppService);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Injectable } from '@angular/core';

@Injectable()
export class AppService {

public info = true;
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,75 @@
import { Component } from '@angular/core';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { Component, inject } from '@angular/core';
import { componentTestBed } from '../../../../lib/components';
import { InnerComponent } from '../../../fixtures/components/inner.component';
import { OuterComponent } from '../../../fixtures/components/outer.component';
import { validateArray } from '../../../fixtures/helpers/validators/validate-array';
import { AppService } from '../../../fixtures/services/app.service';

describe('componentTestBed', () => {

describe('standalone', () => {

describe('standalone root component', () => {
const tb = componentTestBed(OuterComponent);
beforeEach(() => tb.compile());
tb.shouldCreate();
});

describe('non standalone root component', () => {
@Component({ template: `` })
class ClassicComponent {}

const tb = componentTestBed(ClassicComponent);
beforeEach(() => tb.compile());
tb.shouldCreate();
});

it('should click', tb(({ component, action }) => {
expect(component.clicked).toBeFalse();
action.click('#my-outer-button');
expect(component.clicked).toBeTrue();
describe('import', () => {
const tb = componentTestBed(OuterComponent);

beforeEach(() => tb.import(HttpClientTestingModule).compile());

it('should import', tb(({ injector }) => {
const httpc = injector.get(HttpTestingController);
expect(httpc).toBeTruthy();
}));
});

it('should emit InnerComponent output', tb(({ component, action }) => {
expect(component.innerClicked).toBeFalse();
action.emitOutput(InnerComponent, 'clicked', true);
expect(component.innerClicked).toBeTrue();
describe('provide', () => {
@Component({ standalone: true, template: `` })
class AppComponent {
service = inject(AppService);
}

const tb = componentTestBed(AppComponent);
beforeEach(() => tb.provide(AppService).compile());

it('should provide', tb(({ injector }) => {
const service = injector.get(AppService);
expect(service).toBeTruthy();
}));
});

describe('declare', () => {
@Component({
template: `
<app-b/>
`,
})
class AComponent {}

@Component({ selector: 'app-b', template: `` })
class BComponent {}

const tb = componentTestBed(AComponent);
beforeEach(() => tb.declare(BComponent).compile());

tb.shouldCreate();
});

describe('query', () => {
const tb = componentTestBed(OuterComponent);

beforeEach(() => tb.compile());

it('should find InnerComponent instance', tb(({ query }) => {
expect(query.findComponent(InnerComponent)).toBeTruthy();
Expand Down Expand Up @@ -54,20 +100,51 @@ describe('componentTestBed', () => {
fixture.detectChanges();
validateArray(query.findAllDebugElements(InnerComponent), { size: 2 });
}));
});

describe('action', () => {
const tb = componentTestBed(OuterComponent);

beforeEach(() => tb.compile());

it('should click', tb(({ component, action }) => {
expect(component.clicked).toBeFalse();
action.click('#my-outer-button');
expect(component.clicked).toBeTrue();
}));

it('should support jasmine DoneFn', tb(({}, done: DoneFn) => {
expect().nothing();
done();
it('should emit InnerComponent output', tb(({ component, action }) => {
expect(component.innerClicked).toBeFalse();
action.emitOutput(InnerComponent, 'clicked', true);
expect(component.innerClicked).toBeTrue();
}));
});

describe('classic', () => {
@Component({ template: `` })
class ClassicComponent {}
describe('inject method', () => {
const tb = componentTestBed(OuterComponent)
.inject('app', AppService);

const tb = componentTestBed(ClassicComponent);
beforeEach(() => tb.compile());

tb.shouldCreate();
it('should inject into test bed', tb(({ injected: { app } }) => {
expect(app).toBeTruthy();
expect(app.info).toBeTrue();
}));
});

describe('DoneFn and await/async support', () => {
const tb = componentTestBed(OuterComponent);

beforeEach(() => tb.compile());

it('should support jasmine DoneFn', tb(({}, done: DoneFn) => {
expect().nothing();
done();
}));

it('should support jasmine DoneFn', tb(async ({}) => {
await Promise.resolve();
expect().nothing();
}));
});
});