diff --git a/jest.base.config.js b/jest.base.config.js index 4d9a2cc1..a2b039ad 100644 --- a/jest.base.config.js +++ b/jest.base.config.js @@ -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', diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 03a78808..789ec305 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,4 +1,4 @@ -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'; @@ -6,7 +6,10 @@ import { UserEvents } from './user-events'; export type RenderResultQueries = { [P in keyof Q]: BoundFunction }; -export interface RenderResult extends RenderResultQueries, FireObject, UserEvents { +export interface RenderResult + extends RenderResultQueries, + FireObject, + UserEvents { /** * @description * The containing DOM node of your rendered Angular Component. @@ -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; + fixture: ComponentFixture; + /** + * @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. @@ -44,7 +55,7 @@ export interface RenderResult extends RenderResultQueries, FireObject, UserEvent navigate: (elementOrPath: Element | string, basePath?: string) => Promise; } -export interface RenderOptions { +export interface RenderComponentOptions { /** * @description * Will call detectChanges when the component is compiled @@ -146,7 +157,7 @@ export interface RenderOptions { * } * }) */ - componentProperties?: Partial; + componentProperties?: Partial; /** * @description * A collection of providers to inject dependencies of the component. @@ -180,19 +191,6 @@ export interface RenderOptions { * }) */ 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; /** * @description * Exclude the component to be automatically be added as a declaration. @@ -208,6 +206,7 @@ export interface RenderOptions { * }) */ excludeComponentDeclaration?: boolean; + /** * @description * The route configuration to set up the router service via `RouterTestingModule.withRoutes`. @@ -231,3 +230,34 @@ export interface RenderOptions { */ routes?: Routes; } + +export interface RenderDirectiveOptions + extends RenderComponentOptions { + /** + * @description + * The template to render the directive. + * This template will override the template from the WrapperComponent. + * + * @example + * const component = await render(SpoilerDirective, { + * template: `
` + * }) + */ + 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: `
` + * wrapper: CustomWrapperComponent + * }) + */ + wrapper?: Type; + componentProperties?: Partial; +} diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 5b1f288e..1bc5a4e6 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -1,28 +1,35 @@ -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(template: string, renderOptions: RenderOptions): Promise; -export async function render(component: Type, renderOptions?: RenderOptions): Promise; -export async function render( - templateOrComponent: string | Type, - renderOptions: RenderOptions = {}, -): Promise { +export async function render( + component: Type, + renderOptions?: RenderComponentOptions, +): Promise>; +export async function render( + component: Type, + renderOptions?: RenderDirectiveOptions, +): Promise>; + +export async function render( + sut: Type, + renderOptions: RenderComponentOptions | RenderDirectiveOptions = {}, +): Promise> { const { detectChanges = true, declarations = [], @@ -30,24 +37,17 @@ export async function render( 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; TestBed.configureTestingModule({ - declarations: [...declarations, ...componentDeclarations], - imports: addAutoImports({ imports, routes }), + declarations: addAutoDeclarations(sut, { declarations, excludeComponentDeclaration, template, wrapper }), + imports: addAutoImports({imports, routes}), providers: [...providers], schemas: [...schemas], }); @@ -61,9 +61,8 @@ export async function render( }); } - const fixture = isTemplate - ? createWrapperComponentFixture(templateOrComponent as string, { wrapper, componentProperties }) - : createComponentFixture(templateOrComponent as Type, { componentProperties }); + const fixture = createComponentFixture(sut, { template, wrapper }); + setComponentProperties(fixture, { componentProperties }); await TestBed.compileComponents(); @@ -93,12 +92,16 @@ export async function render( 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(), @@ -107,66 +110,23 @@ export async function render( 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( - template: string, - { - wrapper, - componentProperties, - }: { - wrapper: RenderOptions['wrapper']; - componentProperties: RenderOptions['componentProperties']; - }, -): ComponentFixture { - TestBed.overrideComponent(wrapper, { - set: { - template: template, - }, - }); - - const fixture = TestBed.createComponent(wrapper); - // get the component selector, e.g. and results in foo - const componentSelector = template.match(/\<(.*?)\ /) || template.match(/\<(.*?)\>/); - if (!componentSelector) { - throw Error(`Template ${template} is not valid.`); +function createComponentFixture( + component: Type, + { template, wrapper }: Pick, 'template' | 'wrapper'>, +): ComponentFixture { + 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( - component: Type, - { - componentProperties = {}, - }: { - componentProperties: RenderOptions['componentProperties']; - }, -): ComponentFixture { - const fixture = TestBed.createComponent(component); - setComponentProperties(fixture, { componentProperties }); - return fixture; -} - -/** - * Set the component properties - */ -function setComponentProperties( - fixture: ComponentFixture | DebugElement, - { - componentProperties = {}, - }: { - componentProperties: RenderOptions['componentProperties']; - }, +function setComponentProperties( + fixture: ComponentFixture, + { componentProperties = {} }: Pick, 'componentProperties'>, ) { for (const key of Object.keys(componentProperties)) { fixture.componentInstance[key] = componentProperties[key]; @@ -174,19 +134,30 @@ function setComponentProperties( return fixture; } -function declareComponents({ isTemplate, wrapper, excludeComponentDeclaration, templateOrComponent }) { - if (isTemplate) { - return [wrapper]; - } +function addAutoDeclarations( + component: Type, + { + declarations, + excludeComponentDeclaration, + template, + wrapper, + }: Pick< + RenderDirectiveOptions, + '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, 'imports' | 'routes'>) { +function addAutoImports({ imports, routes }: Pick, 'imports' | 'routes'>) { const animations = () => { const animationIsDefined = imports.indexOf(NoopAnimationsModule) > -1 || imports.indexOf(BrowserAnimationsModule) > -1; diff --git a/projects/testing-library/tests/debug.spec.ts b/projects/testing-library/tests/debug.spec.ts index 67f6cfc3..cdbded94 100644 --- a/projects/testing-library/tests/debug.spec.ts +++ b/projects/testing-library/tests/debug.spec.ts @@ -12,9 +12,7 @@ class FixtureComponent {} test('debug', async () => { jest.spyOn(console, 'log').mockImplementation(() => {}); - const { debug } = await render('', { - declarations: [FixtureComponent], - }); + const { debug } = await render(FixtureComponent); debug(); @@ -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('', { - declarations: [FixtureComponent], - }); + const { debug, getByTestId } = await render(FixtureComponent); const btn = getByTestId('btn'); debug(btn); diff --git a/projects/testing-library/tests/directive.spec.ts b/projects/testing-library/tests/directive.spec.ts new file mode 100644 index 00000000..a76fda2e --- /dev/null +++ b/projects/testing-library/tests/directive.spec.ts @@ -0,0 +1,79 @@ +import { Directive, HostListener, ElementRef, Input, Output, EventEmitter, Component } from '@angular/core'; + +import { render } from '../src/public_api'; + +@Directive({ + selector: '[onOff]', +}) +export class OnOffDirective { + @Input() on = 'on'; + @Input() off = 'off'; + @Output() clicked = new EventEmitter(); + + constructor(private el: ElementRef) { + this.el.nativeElement.textContent = 'init'; + } + + @HostListener('click') onClick() { + this.el.nativeElement.textContent = this.el.nativeElement.textContent === this.on ? this.off : this.on; + this.clicked.emit(this.el.nativeElement.textContent); + } +} +test('the directive renders', async () => { + const component = await render(OnOffDirective, { + template: '
', + }); + + expect(component.container.querySelector('[onoff]')).toBeInTheDocument(); +}); + +test('uses the default props', async () => { + const component = await render(OnOffDirective, { + template: '
', + }); + + component.click(component.getByText('init')); + component.click(component.getByText('on')); + component.click(component.getByText('off')); +}); + +test('overrides input properties', async () => { + const component = await render(OnOffDirective, { + template: '
', + }); + + component.click(component.getByText('init')); + component.click(component.getByText('hello')); + component.click(component.getByText('off')); +}); + +test('overrides input properties via a wrapper', async () => { + // `bar` will be set as a property on the wrapper component, the property will be used to pass to the directive + const component = await render(OnOffDirective, { + template: '
', + componentProperties: { + bar: 'hello', + }, + }); + + component.click(component.getByText('init')); + component.click(component.getByText('hello')); + component.click(component.getByText('off')); +}); + +test('overrides output properties', async () => { + const clicked = jest.fn(); + + const component = await render(OnOffDirective, { + template: '
', + componentProperties: { + clicked, + }, + }); + + component.click(component.getByText('init')); + expect(clicked).toHaveBeenCalledWith('on'); + + component.click(component.getByText('on')); + expect(clicked).toHaveBeenCalledWith('off'); +}); diff --git a/projects/testing-library/tests/providers/component-provider.spec.ts b/projects/testing-library/tests/providers/component-provider.spec.ts index 4339ecf6..c91a25b0 100644 --- a/projects/testing-library/tests/providers/component-provider.spec.ts +++ b/projects/testing-library/tests/providers/component-provider.spec.ts @@ -9,14 +9,6 @@ test('shows the service value', async () => { getByText('foo'); }); -test('shows the service value with template syntax', async () => { - const { getByText } = await render('', { - declarations: [FixtureComponent], - }); - - getByText('foo'); -}); - test('shows the provided service value', async () => { const { getByText } = await render(FixtureComponent, { componentProviders: [ @@ -35,8 +27,7 @@ test('shows the provided service value', async () => { }); test('shows the provided service value with template syntax', async () => { - const { getByText } = await render('', { - declarations: [FixtureComponent], + const { getByText } = await render(FixtureComponent, { componentProviders: [ { provide: Service, diff --git a/projects/testing-library/tests/providers/module-provider.spec.ts b/projects/testing-library/tests/providers/module-provider.spec.ts index 48dcabc7..359f625f 100644 --- a/projects/testing-library/tests/providers/module-provider.spec.ts +++ b/projects/testing-library/tests/providers/module-provider.spec.ts @@ -12,8 +12,7 @@ test('shows the service value', async () => { }); test('shows the service value with template syntax', async () => { - const { getByText } = await render('', { - declarations: [FixtureComponent], + const { getByText } = await render(FixtureComponent, { providers: [Service], }); @@ -38,8 +37,7 @@ test('shows the provided service value', async () => { }); test('shows the provided service value with template syntax', async () => { - const { getByText } = await render('', { - declarations: [FixtureComponent], + const { getByText } = await render(FixtureComponent, { providers: [ { provide: Service, diff --git a/projects/testing-library/tests/user-events/selectOptions.spec.ts b/projects/testing-library/tests/user-events/selectOptions.spec.ts index fb5c35bb..c6d288c1 100644 --- a/projects/testing-library/tests/user-events/selectOptions.spec.ts +++ b/projects/testing-library/tests/user-events/selectOptions.spec.ts @@ -99,7 +99,7 @@ describe('selectOption: single', () => { assertSelectOptions(component, () => component.fixture.componentInstance.value.nativeElement.value); }); - function assertSelectOptions(component: RenderResult, value: () => string) { + function assertSelectOptions(component: RenderResult, value: () => string) { const inputControl = component.getByTestId('select') as HTMLSelectElement; component.selectOptions(inputControl, /apples/i); component.selectOptions(inputControl, 'Oranges'); @@ -129,7 +129,7 @@ describe('selectOption: multiple', () => { `, }) class FixtureComponent { - value: string; + value: string[]; } const component = await render(FixtureComponent, { @@ -222,7 +222,7 @@ describe('selectOption: multiple', () => { expect((component.getByTestId('lemons') as HTMLOptionElement).selected).toBe(true); }); - function assertSelectOptions(component: RenderResult, value: () => string) { + function assertSelectOptions(component: RenderResult, value: () => string[]) { const inputControl = component.getByTestId('select') as HTMLSelectElement; component.selectOptions(inputControl, /apples/i); component.selectOptions(inputControl, ['Oranges', 'Lemons']); diff --git a/projects/testing-library/tests/user-events/type.spec.ts b/projects/testing-library/tests/user-events/type.spec.ts index 97127acc..4fa4e769 100644 --- a/projects/testing-library/tests/user-events/type.spec.ts +++ b/projects/testing-library/tests/user-events/type.spec.ts @@ -80,7 +80,7 @@ describe('updates the value', () => { assertType(component, () => component.fixture.componentInstance.value.nativeElement.value); }); - function assertType(component: RenderResult, value: () => string) { + function assertType(component: RenderResult, value: () => string) { const input = '@testing-library/angular'; const inputControl = component.getByTestId('input') as HTMLInputElement; component.type(inputControl, input); diff --git a/projects/testing-library/tests/wrapper.spec.ts b/projects/testing-library/tests/wrapper.spec.ts deleted file mode 100644 index 63813764..00000000 --- a/projects/testing-library/tests/wrapper.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component, ElementRef, OnInit } from '@angular/core'; -import { render } from '../src/public_api'; - -@Component({ - selector: 'fixture', - template: ` -

rawr

- `, -}) -class FixtureComponent {} - -@Component({ selector: 'wrapper-component', template: '' }) -class WrapperComponent implements OnInit { - constructor(private elemtRef: ElementRef) {} - - ngOnInit() { - const textnode = document.createTextNode('I should be visible'); - this.elemtRef.nativeElement.appendChild(textnode); - } -} - -test('allows for a custom wrapper', async () => { - const { getByText } = await render('', { - declarations: [FixtureComponent], - wrapper: WrapperComponent, - }); - - getByText('I should be visible'); -}); diff --git a/src/app/examples/08-directive.spec.ts b/src/app/examples/08-directive.spec.ts new file mode 100644 index 00000000..31007b68 --- /dev/null +++ b/src/app/examples/08-directive.spec.ts @@ -0,0 +1,64 @@ +import { render } from '@testing-library/angular'; + +import { SpoilerDirective } from './08-directive'; + +test('it is possible to test directives', async () => { + const component = await render(SpoilerDirective, { + template: `
`, + }); + + expect(component.queryByText('I am visible now...')).not.toBeInTheDocument(); + expect(component.queryByText('SPOILER')).toBeInTheDocument(); + + component.mouseOver(component.debugElement.nativeElement); + expect(component.queryByText('SPOILER')).not.toBeInTheDocument(); + expect(component.queryByText('I am visible now...')).toBeInTheDocument(); + + component.mouseLeave(component.debugElement.nativeElement); + expect(component.queryByText('SPOILER')).toBeInTheDocument(); + expect(component.queryByText('I am visible now...')).not.toBeInTheDocument(); +}); + +test('it is possible to test directives with props', async () => { + const hidden = 'SPOILER ALERT'; + const visible = 'There is nothing to see here ...'; + + const component = await render(SpoilerDirective, { + template: `
`, + componentProperties: { + hidden, + visible, + }, + }); + + expect(component.queryByText(visible)).not.toBeInTheDocument(); + expect(component.queryByText(hidden)).toBeInTheDocument(); + + component.mouseOver(component.queryByText(hidden)); + expect(component.queryByText(hidden)).not.toBeInTheDocument(); + expect(component.queryByText(visible)).toBeInTheDocument(); + + component.mouseLeave(component.queryByText(visible)); + expect(component.queryByText(hidden)).toBeInTheDocument(); + expect(component.queryByText(visible)).not.toBeInTheDocument(); +}); + +test('it is possible to test directives with props in template', async () => { + const hidden = 'SPOILER ALERT'; + const visible = 'There is nothing to see here ...'; + + const component = await render(SpoilerDirective, { + template: ``, + }); + + expect(component.queryByText(visible)).not.toBeInTheDocument(); + expect(component.queryByText(hidden)).toBeInTheDocument(); + + component.mouseOver(component.queryByText(hidden)); + expect(component.queryByText(hidden)).not.toBeInTheDocument(); + expect(component.queryByText(visible)).toBeInTheDocument(); + + component.mouseLeave(component.queryByText(visible)); + expect(component.queryByText(hidden)).toBeInTheDocument(); + expect(component.queryByText(visible)).not.toBeInTheDocument(); +}); diff --git a/src/app/examples/08-directive.ts b/src/app/examples/08-directive.ts new file mode 100644 index 00000000..12a029cf --- /dev/null +++ b/src/app/examples/08-directive.ts @@ -0,0 +1,25 @@ +import { Directive, HostListener, ElementRef, Input, OnInit } from '@angular/core'; + +@Directive({ + selector: '[appSpoiler]', +}) +export class SpoilerDirective implements OnInit { + @Input() hidden = 'SPOILER'; + @Input() visible = 'I am visible now...'; + + constructor(private el: ElementRef) {} + + ngOnInit() { + this.el.nativeElement.textContent = this.hidden; + } + + @HostListener('mouseover') + onMouseOver() { + this.el.nativeElement.textContent = this.visible; + } + + @HostListener('mouseleave') + onMouseLeave() { + this.el.nativeElement.textContent = this.hidden; + } +}