Skip to content

feat: testing directives #47

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

Merged
merged 2 commits into from
Sep 18, 2019
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
1 change: 1 addition & 0 deletions jest.base.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module.exports = {
transform: {
'^.+\\.(ts|js|html)$': 'ts-jest',
},
transformIgnorePatterns: ['node_modules/(?!@ngrx)'],
snapshotSerializers: [
'jest-preset-angular/AngularSnapshotSerializer.js',
'jest-preset-angular/HTMLCommentSerializer.js',
Expand Down
68 changes: 49 additions & 19 deletions projects/testing-library/src/lib/models.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { Type } from '@angular/core';
import { Type, DebugElement } from '@angular/core';
import { ComponentFixture } from '@angular/core/testing';
import { Routes } from '@angular/router';
import { BoundFunction, FireObject, Queries, queries } from '@testing-library/dom';
import { UserEvents } from './user-events';

export type RenderResultQueries<Q extends Queries = typeof queries> = { [P in keyof Q]: BoundFunction<Q[P]> };

export interface RenderResult extends RenderResultQueries, FireObject, UserEvents {
export interface RenderResult<ComponentType, WrapperType = ComponentType>
extends RenderResultQueries,
FireObject,
UserEvents {
/**
* @description
* The containing DOM node of your rendered Angular Component.
Expand All @@ -31,11 +34,19 @@ export interface RenderResult extends RenderResultQueries, FireObject, UserEvent
detectChanges: () => void;
/**
* @description
* The Angular `ComponentFixture` of the component.
* The Angular `ComponentFixture` of the component or the wrapper.
* If a template is provided, it will be the fixture of the wrapper.
*
* For more info see https://angular.io/api/core/testing/ComponentFixture
*/
fixture: ComponentFixture<any>;
fixture: ComponentFixture<WrapperType>;
/**
* @description
* The Angular `DebugElement` of the component.
*
* For more info see https://angular.io/api/core/DebugElement
*/
debugElement: DebugElement;
/**
* @description
* Navigates to the href of the element or to the path.
Expand All @@ -44,7 +55,7 @@ export interface RenderResult extends RenderResultQueries, FireObject, UserEvent
navigate: (elementOrPath: Element | string, basePath?: string) => Promise<boolean>;
}

export interface RenderOptions<C, Q extends Queries = typeof queries> {
export interface RenderComponentOptions<ComponentType, Q extends Queries = typeof queries> {
/**
* @description
* Will call detectChanges when the component is compiled
Expand Down Expand Up @@ -146,7 +157,7 @@ export interface RenderOptions<C, Q extends Queries = typeof queries> {
* }
* })
*/
componentProperties?: Partial<C>;
componentProperties?: Partial<ComponentType>;
/**
* @description
* A collection of providers to inject dependencies of the component.
Expand Down Expand Up @@ -180,19 +191,6 @@ export interface RenderOptions<C, Q extends Queries = typeof queries> {
* })
*/
queries?: Q;
/**
* @description
* An Angular component to wrap the component in.
*
* @default
* `WrapperComponent`, an empty component that strips the `ng-version` attribute
*
* @example
* const component = await render(AppComponent, {
* wrapper: CustomWrapperComponent
* })
*/
wrapper?: Type<any>;
/**
* @description
* Exclude the component to be automatically be added as a declaration.
Expand All @@ -208,6 +206,7 @@ export interface RenderOptions<C, Q extends Queries = typeof queries> {
* })
*/
excludeComponentDeclaration?: boolean;

/**
* @description
* The route configuration to set up the router service via `RouterTestingModule.withRoutes`.
Expand All @@ -231,3 +230,34 @@ export interface RenderOptions<C, Q extends Queries = typeof queries> {
*/
routes?: Routes;
}

export interface RenderDirectiveOptions<DirectiveType, WrapperType, Q extends Queries = typeof queries>
extends RenderComponentOptions<DirectiveType, Q> {
/**
* @description
* The template to render the directive.
* This template will override the template from the WrapperComponent.
*
* @example
* const component = await render(SpoilerDirective, {
* template: `<div spoiler message='SPOILER'></div>`
* })
*/
template: string;
/**
* @description
* An Angular component to wrap the component in.
* The template will be overridden with the `template` option.
*
* @default
* `WrapperComponent`, an empty component that strips the `ng-version` attribute
*
* @example
* const component = await render(SpoilerDirective, {
* template: `<div spoiler message='SPOILER'></div>`
* wrapper: CustomWrapperComponent
* })
*/
wrapper?: Type<WrapperType>;
componentProperties?: Partial<any>;
}
151 changes: 61 additions & 90 deletions projects/testing-library/src/lib/testing-library.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,53 @@
import { Component, DebugElement, ElementRef, OnInit, Type, NgZone } from '@angular/core';
import { Component, ElementRef, OnInit, Type, NgZone } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { fireEvent, FireFunction, FireObject, getQueriesForElement, prettyDOM } from '@testing-library/dom';
import { RenderOptions, RenderResult } from './models';
import { RenderComponentOptions, RenderDirectiveOptions, RenderResult } from './models';
import { createSelectOptions, createType } from './user-events';

@Component({ selector: 'wrapper-component', template: '' })
class WrapperComponent implements OnInit {
constructor(private elemtRef: ElementRef) {}
constructor(private elementRef: ElementRef) {}

ngOnInit() {
this.elemtRef.nativeElement.removeAttribute('ng-version');
this.elementRef.nativeElement.removeAttribute('ng-version');
}
}

export async function render<T>(template: string, renderOptions: RenderOptions<T>): Promise<RenderResult>;
export async function render<T>(component: Type<T>, renderOptions?: RenderOptions<T>): Promise<RenderResult>;
export async function render<T>(
templateOrComponent: string | Type<T>,
renderOptions: RenderOptions<T> = {},
): Promise<RenderResult> {
export async function render<ComponentType>(
component: Type<ComponentType>,
renderOptions?: RenderComponentOptions<ComponentType>,
): Promise<RenderResult<ComponentType, ComponentType>>;
export async function render<DirectiveType, WrapperType = WrapperComponent>(
component: Type<DirectiveType>,
renderOptions?: RenderDirectiveOptions<DirectiveType, WrapperType>,
): Promise<RenderResult<DirectiveType, WrapperType>>;

export async function render<SutType, WrapperType = SutType>(
sut: Type<SutType>,
renderOptions: RenderComponentOptions<SutType> | RenderDirectiveOptions<SutType, WrapperType> = {},
): Promise<RenderResult<SutType>> {
const {
detectChanges = true,
declarations = [],
imports = [],
providers = [],
schemas = [],
queries,
template,
wrapper = WrapperComponent,
componentProperties = {},
componentProviders = [],
excludeComponentDeclaration = false,
routes,
} = renderOptions;

const isTemplate = typeof templateOrComponent === 'string';
const componentDeclarations = declareComponents({
templateOrComponent,
wrapper,
isTemplate,
excludeComponentDeclaration,
});
routes
} = renderOptions as RenderDirectiveOptions<SutType, WrapperType>;

TestBed.configureTestingModule({
declarations: [...declarations, ...componentDeclarations],
imports: addAutoImports({ imports, routes }),
declarations: addAutoDeclarations(sut, { declarations, excludeComponentDeclaration, template, wrapper }),
imports: addAutoImports({imports, routes}),
providers: [...providers],
schemas: [...schemas],
});
Expand All @@ -61,9 +61,8 @@ export async function render<T>(
});
}

const fixture = isTemplate
? createWrapperComponentFixture(templateOrComponent as string, { wrapper, componentProperties })
: createComponentFixture(templateOrComponent as Type<T>, { componentProperties });
const fixture = createComponentFixture(sut, { template, wrapper });
setComponentProperties(fixture, { componentProperties });

await TestBed.compileComponents();

Expand Down Expand Up @@ -93,12 +92,16 @@ export async function render<T>(

const href = typeof elementOrPath === 'string' ? elementOrPath : elementOrPath.getAttribute('href');

await zone.run(() => router.navigate([basePath + href]));
let result;
await zone.run(() => result = router.navigate([basePath + href]));
fixture.detectChanges();
return result;
}
const debugElement = fixture.debugElement.query(By.directive(sut));

return {
fixture,
debugElement,
container: fixture.nativeElement,
debug: (element = fixture.nativeElement) => console.log(prettyDOM(element)),
detectChanges: () => fixture.detectChanges(),
Expand All @@ -107,86 +110,54 @@ export async function render<T>(
type: createType(eventsWithDetectChanges),
selectOptions: createSelectOptions(eventsWithDetectChanges),
navigate,
} as any;
};
}

/**
* Creates the wrapper component and sets its the template to the to-be-tested component
*/
function createWrapperComponentFixture<T>(
template: string,
{
wrapper,
componentProperties,
}: {
wrapper: RenderOptions<T>['wrapper'];
componentProperties: RenderOptions<T>['componentProperties'];
},
): ComponentFixture<any> {
TestBed.overrideComponent(wrapper, {
set: {
template: template,
},
});

const fixture = TestBed.createComponent(wrapper);
// get the component selector, e.g. <foo color="green"> and <foo> results in foo
const componentSelector = template.match(/\<(.*?)\ /) || template.match(/\<(.*?)\>/);
if (!componentSelector) {
throw Error(`Template ${template} is not valid.`);
function createComponentFixture<SutType>(
component: Type<SutType>,
{ template, wrapper }: Pick<RenderDirectiveOptions<SutType, any>, 'template' | 'wrapper'>,
): ComponentFixture<SutType> {
if (template) {
TestBed.overrideTemplate(wrapper, template);
return TestBed.createComponent(wrapper);
}

const sut = fixture.debugElement.query(By.css(componentSelector[1]));
setComponentProperties(sut, { componentProperties });
return fixture;
return TestBed.createComponent(component);
}

/**
* Creates the components and sets its properties
*/
function createComponentFixture<T>(
component: Type<T>,
{
componentProperties = {},
}: {
componentProperties: RenderOptions<T>['componentProperties'];
},
): ComponentFixture<T> {
const fixture = TestBed.createComponent(component);
setComponentProperties(fixture, { componentProperties });
return fixture;
}

/**
* Set the component properties
*/
function setComponentProperties<T>(
fixture: ComponentFixture<T> | DebugElement,
{
componentProperties = {},
}: {
componentProperties: RenderOptions<T>['componentProperties'];
},
function setComponentProperties<SutType>(
fixture: ComponentFixture<SutType>,
{ componentProperties = {} }: Pick<RenderDirectiveOptions<SutType, any>, 'componentProperties'>,
) {
for (const key of Object.keys(componentProperties)) {
fixture.componentInstance[key] = componentProperties[key];
}
return fixture;
}

function declareComponents({ isTemplate, wrapper, excludeComponentDeclaration, templateOrComponent }) {
if (isTemplate) {
return [wrapper];
}
function addAutoDeclarations<SutType>(
component: Type<SutType>,
{
declarations,
excludeComponentDeclaration,
template,
wrapper,
}: Pick<
RenderDirectiveOptions<SutType, any>,
'declarations' | 'excludeComponentDeclaration' | 'template' | 'wrapper'
>,
) {
const wrappers = () => {
return template ? [wrapper] : [];
};

if (excludeComponentDeclaration) {
return [];
}
const components = () => {
return excludeComponentDeclaration ? [] : [component];
};

return [templateOrComponent];
return [...declarations, ...wrappers(), ...components()];
}

function addAutoImports({ imports, routes }: Pick<RenderOptions<any>, 'imports' | 'routes'>) {
function addAutoImports({ imports, routes }: Pick<RenderComponentOptions<any>, 'imports' | 'routes'>) {
const animations = () => {
const animationIsDefined =
imports.indexOf(NoopAnimationsModule) > -1 || imports.indexOf(BrowserAnimationsModule) > -1;
Expand Down
8 changes: 2 additions & 6 deletions projects/testing-library/tests/debug.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ class FixtureComponent {}

test('debug', async () => {
jest.spyOn(console, 'log').mockImplementation(() => {});
const { debug } = await render('<fixture></fixture>', {
declarations: [FixtureComponent],
});
const { debug } = await render(FixtureComponent);

debug();

Expand All @@ -24,9 +22,7 @@ test('debug', async () => {

test('debug allows to be called with an element', async () => {
jest.spyOn(console, 'log').mockImplementation(() => {});
const { debug, getByTestId } = await render('<fixture></fixture>', {
declarations: [FixtureComponent],
});
const { debug, getByTestId } = await render(FixtureComponent);
const btn = getByTestId('btn');

debug(btn);
Expand Down
Loading