From 54d285321723450920e0f1d50374c4bd0590e72a Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 1 Nov 2022 00:16:10 +0100 Subject: [PATCH] fix: possibility to override global services in Angular component tests (#24394) * fix: allow to override global services in Angular component tests * move DI tests into their own context * exclude `componentProviders` when a component is mounted with a template * - revert and remove `componentProviders` - run `eslint --fix` on `mount.cy.ts` - add tests that use `TestBed.inject` Co-authored-by: Zachary Williams --- npm/angular/src/mount.ts | 12 +- .../src/app/components/cart.component.ts | 26 ++ .../component-provider.component.ts | 20 + .../transient-services.component.ts | 25 ++ .../angular/src/app/mount.cy.ts | 342 +++++++++++------- 5 files changed, 285 insertions(+), 140 deletions(-) create mode 100644 system-tests/project-fixtures/angular/src/app/components/cart.component.ts create mode 100644 system-tests/project-fixtures/angular/src/app/components/component-provider.component.ts create mode 100644 system-tests/project-fixtures/angular/src/app/components/transient-services.component.ts diff --git a/npm/angular/src/mount.ts b/npm/angular/src/mount.ts index 7af3a98ed5e..68da248d064 100644 --- a/npm/angular/src/mount.ts +++ b/npm/angular/src/mount.ts @@ -163,22 +163,12 @@ function initTestBed ( component: Type | string, config: MountConfig, ): Type { - const { providers, ...configRest } = config - const componentFixture = createComponentFixture(component) as Type getTestBed().configureTestingModule({ - ...bootstrapModule(componentFixture, configRest), + ...bootstrapModule(componentFixture, config), }) - if (providers != null) { - getTestBed().overrideComponent(componentFixture, { - add: { - providers, - }, - }) - } - return componentFixture } diff --git a/system-tests/project-fixtures/angular/src/app/components/cart.component.ts b/system-tests/project-fixtures/angular/src/app/components/cart.component.ts new file mode 100644 index 00000000000..f139cf974ba --- /dev/null +++ b/system-tests/project-fixtures/angular/src/app/components/cart.component.ts @@ -0,0 +1,26 @@ +import { Component, Injectable } from '@angular/core' + +@Injectable({ providedIn: 'root' }) +export class Cart { + #items: string[] = [] + + add (product: string) { + this.#items.push(product) + } + + getItems () { + return this.#items + } +} + +@Component({ + template: `

Great Product

`, +}) +export class ProductComponent { + constructor (private cart: Cart) { + } + + buy () { + this.cart.add('Great Product') + } +} diff --git a/system-tests/project-fixtures/angular/src/app/components/component-provider.component.ts b/system-tests/project-fixtures/angular/src/app/components/component-provider.component.ts new file mode 100644 index 00000000000..3701e455666 --- /dev/null +++ b/system-tests/project-fixtures/angular/src/app/components/component-provider.component.ts @@ -0,0 +1,20 @@ +import { Component, Injectable } from '@angular/core' + +@Injectable({ providedIn: 'root' }) +export class MessageService { + get message () { + return 'globally provided service' + } +} + +@Component({ + template: `

{{messageService.message}}

`, + providers: [{ + provide: MessageService, + useValue: { message: 'component provided service' }, + }], +}) +export class ComponentProviderComponent { + constructor (public messageService: MessageService) { + } +} diff --git a/system-tests/project-fixtures/angular/src/app/components/transient-services.component.ts b/system-tests/project-fixtures/angular/src/app/components/transient-services.component.ts new file mode 100644 index 00000000000..efe26cec798 --- /dev/null +++ b/system-tests/project-fixtures/angular/src/app/components/transient-services.component.ts @@ -0,0 +1,25 @@ +import { Component, Injectable } from '@angular/core' + +@Injectable({ providedIn: 'root' }) +export class TransientService { + get message () { + return 'Original Transient Service' + } +} + +@Injectable({ providedIn: 'root' }) +class DirectService { + constructor (private transientService: TransientService) { + } + + get message () { + return this.transientService.message + } +} + +@Component({ + template: `

{{directService.message}}

`, +}) +export class TransientServicesComponent { + constructor (public directService: DirectService) {} +} diff --git a/system-tests/project-fixtures/angular/src/app/mount.cy.ts b/system-tests/project-fixtures/angular/src/app/mount.cy.ts index b531a03d47a..abfe151c3a7 100644 --- a/system-tests/project-fixtures/angular/src/app/mount.cy.ts +++ b/system-tests/project-fixtures/angular/src/app/mount.cy.ts @@ -1,74 +1,78 @@ -import { ParentChildModule } from "./components/parent-child.module"; -import { ParentComponent } from "./components/parent.component"; -import { CounterComponent } from "./components/counter.component"; -import { CounterService } from "./components/counter.service"; -import { ChildComponent } from "./components/child.component"; -import { WithDirectivesComponent } from "./components/with-directives.component"; -import { ButtonOutputComponent } from "./components/button-output.component"; -import { createOutputSpy } from 'cypress/angular'; -import { EventEmitter, Component } from '@angular/core'; -import { ProjectionComponent } from "./components/projection.component"; -import { ChildProvidersComponent, } from "./components/child-providers.component"; -import { ParentProvidersComponent } from "./components/parent-providers.component"; +import { ParentChildModule } from './components/parent-child.module' +import { ParentComponent } from './components/parent.component' +import { CounterComponent } from './components/counter.component' +import { CounterService } from './components/counter.service' +import { ChildComponent } from './components/child.component' +import { WithDirectivesComponent } from './components/with-directives.component' +import { ButtonOutputComponent } from './components/button-output.component' +import { createOutputSpy } from 'cypress/angular' +import { EventEmitter, Component } from '@angular/core' +import { ProjectionComponent } from './components/projection.component' +import { ChildProvidersComponent } from './components/child-providers.component' +import { ParentProvidersComponent } from './components/parent-providers.component' import { HttpClientModule } from '@angular/common/http' import { of } from 'rxjs' -import { ChildProvidersService } from "./components/child-providers.service"; -import { AnotherChildProvidersComponent } from "./components/another-child-providers.component"; +import { ChildProvidersService } from './components/child-providers.service' +import { AnotherChildProvidersComponent } from './components/another-child-providers.component' import { TestBed } from '@angular/core/testing' -import { LifecycleComponent } from "./components/lifecycle.component"; -import { LogoComponent } from "./components/logo.component"; +import { LifecycleComponent } from './components/lifecycle.component' +import { LogoComponent } from './components/logo.component' +import { TransientService, TransientServicesComponent } from './components/transient-services.component' +import { ComponentProviderComponent, MessageService } from './components/component-provider.component' +import { Cart, ProductComponent } from './components/cart.component' @Component({ - template: `Hello World` + template: `Hello World`, }) class WrapperComponent {} -describe("angular mount", () => { - it("pushes CommonModule into component", () => { - cy.mount(WithDirectivesComponent); - cy.get("ul").should("exist"); - cy.get("li").should("have.length", 3); - - cy.get("button").click(); - - cy.get("ul").should("not.exist"); - }); - - it("accepts imports", () => { - cy.mount(ParentComponent, { imports: [ParentChildModule] }); - cy.contains("h1", "Hello World from ParentComponent"); - }); - - it("accepts declarations", () => { - cy.mount(ParentComponent, { declarations: [ChildComponent] }); - cy.contains("h1", "Hello World from ParentComponent"); - }); - - it("accepts providers", () => { - cy.mount(CounterComponent, { providers: [CounterService] }); - cy.contains("button", "Increment: 0").click().contains("Increment: 1"); - }); - - it("detects changes", () => { - cy.mount(ChildComponent, { componentProperties: { msg: "Hello World from Spec" } }) - .then(({ fixture }) => - cy.contains("h1", "Hello World from Spec").wrap(fixture) - ) - .then((fixture) => { - fixture.componentInstance.msg = "I just changed!"; - fixture.detectChanges(); - cy.contains("h1", "I just changed!"); - }); - }); +describe('angular mount', () => { + it('pushes CommonModule into component', () => { + cy.mount(WithDirectivesComponent) + cy.get('ul').should('exist') + cy.get('li').should('have.length', 3) + + cy.get('button').click() + + cy.get('ul').should('not.exist') + }) + + it('accepts imports', () => { + cy.mount(ParentComponent, { imports: [ParentChildModule] }) + cy.contains('h1', 'Hello World from ParentComponent') + }) + + it('accepts declarations', () => { + cy.mount(ParentComponent, { declarations: [ChildComponent] }) + cy.contains('h1', 'Hello World from ParentComponent') + }) + + it('accepts providers', () => { + cy.mount(CounterComponent, { providers: [CounterService] }) + cy.contains('button', 'Increment: 0').click().contains('Increment: 1') + }) + + it('detects changes', () => { + cy.mount(ChildComponent, { componentProperties: { msg: 'Hello World from Spec' } }) + .then(({ fixture }) => { + return cy.contains('h1', 'Hello World from Spec').wrap(fixture) + }) + .then((fixture) => { + fixture.componentInstance.msg = 'I just changed!' + fixture.detectChanges() + cy.contains('h1', 'I just changed!') + }) + }) it('can bind the spy to the componentProperties bypassing types', () => { cy.mount(ButtonOutputComponent, { - componentProperties: { + componentProperties: { clicked: { - emit: cy.spy().as('onClickedSpy') - } as any - } + emit: cy.spy().as('onClickedSpy'), + } as any, + }, }) + cy.get('button').click() cy.get('@onClickedSpy').should('have.been.calledWith', true) }) @@ -76,12 +80,13 @@ describe("angular mount", () => { it('can bind the spy to the componentProperties bypassing types using template', () => { cy.mount('', { declarations: [ButtonOutputComponent], - componentProperties: { + componentProperties: { clicked: { - emit: cy.spy().as('onClickedSpy') - } as any - } + emit: cy.spy().as('onClickedSpy'), + } as any, + }, }) + cy.get('button').click() cy.get('@onClickedSpy').should('have.been.calledWith', true) }) @@ -98,9 +103,10 @@ describe("angular mount", () => { cy.mount('', { declarations: [ButtonOutputComponent], componentProperties: { - login: cy.spy().as('myClickedSpy') - } + login: cy.spy().as('myClickedSpy'), + }, }) + cy.get('button').click() cy.get('@myClickedSpy').should('have.been.calledWith', true) }) @@ -109,8 +115,8 @@ describe("angular mount", () => { cy.mount('', { declarations: [ButtonOutputComponent], componentProperties: { - handleClick: new EventEmitter() - } + handleClick: new EventEmitter(), + }, }).then(({ component }) => { cy.spy(component.handleClick, 'emit').as('handleClickSpy') cy.get('button').click() @@ -121,10 +127,11 @@ describe("angular mount", () => { it('can accept a createOutputSpy for an Output property', () => { cy.mount(ButtonOutputComponent, { componentProperties: { - clicked: createOutputSpy('mySpy') - } + clicked: createOutputSpy('mySpy'), + }, }) - cy.get('button').click(); + + cy.get('button').click() cy.get('@mySpy').should('have.been.calledWith', true) }) @@ -132,9 +139,10 @@ describe("angular mount", () => { cy.mount('', { declarations: [ButtonOutputComponent], componentProperties: { - clicked: createOutputSpy('mySpy') - } + clicked: createOutputSpy('mySpy'), + }, }) + cy.get('button').click() cy.get('@mySpy').should('have.been.called') }) @@ -143,46 +151,51 @@ describe("angular mount", () => { cy.mount(ButtonOutputComponent, { autoSpyOutputs: true, }) + cy.get('button').click() cy.get('@clickedSpy').should('have.been.calledWith', true) }) - it('can reference the autoSpyOutput alias on component @Outputs() with a template', () => { cy.mount('', { declarations: [ButtonOutputComponent], autoSpyOutputs: true, componentProperties: { - clicked: new EventEmitter() - } + clicked: new EventEmitter(), + }, }) + cy.get('button').click() cy.get('@clickedSpy').should('have.been.calledWith', true) }) - + it('can handle content projection with a WrapperComponent', () => { cy.mount(WrapperComponent, { - declarations: [ProjectionComponent] + declarations: [ProjectionComponent], }) + cy.get('h3').contains('Hello World') }) - + it('can handle content projection using template', () => { cy.mount('Hello World', { - declarations: [ProjectionComponent] + declarations: [ProjectionComponent], }) + cy.get('h3').contains('Hello World') }) it('can use cy.intercept', () => { cy.intercept('GET', '**/api/message', { statusCode: 200, - body: { message: "test" } + body: { message: 'test' }, }) + cy.mount(ChildProvidersComponent, { imports: [HttpClientModule], - providers: [ChildProvidersService] + providers: [ChildProvidersService], }) + cy.get('button').contains('default message') cy.get('button').click() cy.get('button').contains('test') @@ -191,13 +204,15 @@ describe("angular mount", () => { it('can use cy.intercept on child component', () => { cy.intercept('GET', '**/api/message', { statusCode: 200, - body: { message: "test" } + body: { message: 'test' }, }) + cy.mount(ParentProvidersComponent, { declarations: [ChildProvidersComponent, AnotherChildProvidersComponent], imports: [HttpClientModule], - providers: [ChildProvidersService] + providers: [ChildProvidersService], }) + cy.get('button').contains('default message').click() cy.get('button').contains('test') }) @@ -210,13 +225,14 @@ describe("angular mount", () => { { provide: ChildProvidersService, useValue: { - getMessage() { + getMessage () { return of('test') - } - } as ChildProvidersService - } - ] + }, + } as ChildProvidersService, + }, + ], }) + cy.get('button').contains('default message').click() cy.get('button').contains('test') }) @@ -225,31 +241,30 @@ describe("angular mount", () => { cy.intercept('GET', '**/api/message', { statusCode: 200, body: { - message: 'test' - } + message: 'test', + }, }) + cy.mount(AnotherChildProvidersComponent, { imports: [HttpClientModule], - providers: [ChildProvidersService] + providers: [ChildProvidersService], }) + cy.get('button').contains('default another child message').click() cy.get('button').contains('test') }) it('can use a test double for a component with a provider override', () => { - cy.mount(AnotherChildProvidersComponent, { - imports: [HttpClientModule], - providers: [ - { - provide: ChildProvidersService, - useValue: { - getMessage() { - return of('test') - } - } as ChildProvidersService - } - ] - }) + cy.mount(AnotherChildProvidersComponent, { imports: [HttpClientModule] }) + TestBed.overrideComponent(AnotherChildProvidersComponent, { add: { providers: [{ + provide: ChildProvidersService, + useValue: { + getMessage () { + return of('test') + }, + }, + }] } }) + cy.get('button').contains('default another child message').click() cy.get('button').contains('test') }) @@ -258,14 +273,16 @@ describe("angular mount", () => { cy.intercept('GET', '**/api/message', { statusCode: 200, body: { - message: 'test' - } + message: 'test', + }, }) + cy.mount(ParentProvidersComponent, { declarations: [ChildProvidersComponent, AnotherChildProvidersComponent], imports: [HttpClientModule], - providers: [ChildProvidersService] + providers: [ChildProvidersService], }) + cy.get('button').contains('default another child message').click() cy.get('button').contains('test') }) @@ -273,24 +290,26 @@ describe("angular mount", () => { it('can use a test double for a child component with a provider override', () => { TestBed.overrideProvider(ChildProvidersService, { useValue: { - getMessage() { + getMessage () { return of('test') - } - } as ChildProvidersService + }, + } as ChildProvidersService, }) + cy.mount(ParentProvidersComponent, { declarations: [ChildProvidersComponent, AnotherChildProvidersComponent], imports: [HttpClientModule], }) + cy.get('button').contains('default another child message').click() cy.get('button').contains('test') }) - + it('handles ngOnChanges on mount', () => { cy.mount(LifecycleComponent, { componentProperties: { - name: 'Angular' - } + name: 'Angular', + }, }) cy.get('p').should('have.text', 'Hi Angular. ngOnInit fired: true and ngOnChanges fired: true and conditionalName: false') @@ -300,19 +319,20 @@ describe("angular mount", () => { cy.mount('', { declarations: [LifecycleComponent], componentProperties: { - name: 'Angular' - } + name: 'Angular', + }, }) cy.get('p').should('have.text', 'Hi Angular. ngOnInit fired: true and ngOnChanges fired: true and conditionalName: false') }) - + it('creates simpleChanges from componentProperties and calls ngOnChanges on Mount', () => { cy.mount(LifecycleComponent, { componentProperties: { - name: 'CONDITIONAL NAME' - } + name: 'CONDITIONAL NAME', + }, }) + cy.get('p').should('have.text', 'Hi CONDITIONAL NAME. ngOnInit fired: true and ngOnChanges fired: true and conditionalName: true') }) @@ -320,9 +340,10 @@ describe("angular mount", () => { cy.mount('', { declarations: [LifecycleComponent], componentProperties: { - name: 'CONDITIONAL NAME' - } + name: 'CONDITIONAL NAME', + }, }) + cy.get('p').should('have.text', 'Hi CONDITIONAL NAME. ngOnInit fired: true and ngOnChanges fired: true and conditionalName: true') }) @@ -333,8 +354,9 @@ describe("angular mount", () => { it('ngOnChanges is not fired when no componentProperties given with template', () => { cy.mount('', { - declarations: [LifecycleComponent] + declarations: [LifecycleComponent], }) + cy.get('p').should('have.text', 'Hi . ngOnInit fired: true and ngOnChanges fired: false and conditionalName: false') }) @@ -343,14 +365,76 @@ describe("angular mount", () => { cy.get('img').should('be.visible').and('have.prop', 'naturalWidth').should('be.greaterThan', 0) }) + context('dependency injection', () => { + it('should not override transient service', () => { + cy.mount(TransientServicesComponent) + cy.get('p').should('have.text', 'Original Transient Service') + }) + + it('should override transient service', () => { + cy.mount(TransientServicesComponent, { providers: [{ provide: TransientService, useValue: { message: 'Overridden Transient Service' } }] }) + cy.get('p').should('have.text', 'Overridden Transient Service') + }) - describe("teardown", () => { + it('should have a component provider', () => { + cy.mount(ComponentProviderComponent) + cy.get('p').should('have.text', 'component provided service') + }) + + it('should not override component-providers via providers', () => { + cy.mount(ComponentProviderComponent, { providers: [{ provide: MessageService, useValue: { message: 'overridden service' } }] }) + cy.get('p').should('have.text', 'component provided service') + }) + + it('should override component-providers via TestBed.overrideCmponent', () => { + TestBed.overrideComponent(ComponentProviderComponent, { set: { providers: [{ provide: MessageService, useValue: { message: 'overridden service' } }] } }) + cy.mount(ComponentProviderComponent) + cy.get('p').should('have.text', 'overridden service') + }) + + it('should remove component-providers', () => { + TestBed.overrideComponent(ComponentProviderComponent, { set: { providers: [] } }) + cy.mount(ComponentProviderComponent) + cy.get('p').should('have.text', 'globally provided service') + }) + + it('should use TestBed.inject', () => { + cy.mount(ProductComponent) + cy.get('[data-testid=btn-buy]').click().then(() => { + const cart = TestBed.inject(Cart) + + expect(cart.getItems()).to.have.length(1) + }) + }) + + it('should verify a faked service', () => { + const cartFake = { + items: [] as string[], + add (product: string) { + this.items.push(product) + }, + getItems () { + return this.items + }, + } + + cy.mount(ProductComponent, { providers: [{ provider: Cart, useValue: cartFake }] }) + + cy.get('[data-testid=btn-buy]').click().then(() => { + const cart = TestBed.inject(Cart) + + expect(cart.getItems()).to.have.length(1) + }) + }) + }) + + describe('teardown', () => { beforeEach(() => { - cy.get("[id^=root]").should("not.exist"); - }); - - it("should mount", () => { - cy.mount(ButtonOutputComponent); - }); - }); -}); + cy.get('[id^=root]').should('not.exist') + }) + + it('should mount', () => { + cy.mount(ButtonOutputComponent) + }) + }) +})