diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcb1859f..de3b8eeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: install - run: npm install + run: npm install --legacy-peer-deps - name: build run: npm run build -- --skip-nx-cache - name: test diff --git a/apps/example-app-karma/.browserslistrc b/apps/example-app-karma/.browserslistrc deleted file mode 100644 index 427441dc..00000000 --- a/apps/example-app-karma/.browserslistrc +++ /dev/null @@ -1,17 +0,0 @@ -# This file is used by the build system to adjust CSS and JS output to support the specified browsers below. -# For additional information regarding the format and rule options, please see: -# https://github.com/browserslist/browserslist#queries - -# For the full list of supported browsers by the Angular framework, please see: -# https://angular.io/guide/browser-support - -# You can see what browsers were selected by your queries by running: -# npx browserslist - -last 1 Chrome version -last 1 Firefox version -last 2 Edge major versions -last 2 Safari major versions -last 2 iOS major versions -Firefox ESR -not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. diff --git a/apps/example-app-karma/project.json b/apps/example-app-karma/project.json index 75e38aab..eb966a8c 100644 --- a/apps/example-app-karma/project.json +++ b/apps/example-app-karma/project.json @@ -1,4 +1,5 @@ { + "name": "example-app-karma", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", "sourceRoot": "apps/example-app-karma/src", diff --git a/apps/example-app-karma/src/app/issues/issue-222.spec.ts b/apps/example-app-karma/src/app/issues/issue-222.spec.ts index b6ac5204..17e9a029 100644 --- a/apps/example-app-karma/src/app/issues/issue-222.spec.ts +++ b/apps/example-app-karma/src/app/issues/issue-222.spec.ts @@ -9,7 +9,7 @@ it('https://github.com/testing-library/angular-testing-library/issues/222 with r expect(screen.getByText('Hello Sarah')).toBeTruthy(); - await rerender({ name: 'Mark' }); + await rerender({ componentProperties: { name: 'Mark' } }); expect(screen.getByText('Hello Mark')).toBeTruthy(); }); diff --git a/apps/example-app-karma/src/test.ts b/apps/example-app-karma/src/test.ts index c2ab726d..60544d54 100644 --- a/apps/example-app-karma/src/test.ts +++ b/apps/example-app-karma/src/test.ts @@ -9,11 +9,5 @@ beforeEach(() => { jasmine.addMatchers(JasmineDOM); }); -declare const require: any; - // First, initialize the Angular testing environment. getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {}); -// Then we find all the tests. -const context = require.context('./', true, /\.spec\.ts$/); -// And load the modules. -context.keys().map(context); diff --git a/apps/example-app-karma/tsconfig.app.json b/apps/example-app-karma/tsconfig.app.json index 4de7101b..79a77d1b 100644 --- a/apps/example-app-karma/tsconfig.app.json +++ b/apps/example-app-karma/tsconfig.app.json @@ -3,7 +3,9 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "types": [], - "allowJs": true + "allowJs": true, + "target": "ES2022", + "useDefineForClassFields": false }, "files": ["src/main.ts", "src/polyfills.ts"], "include": ["src/**/*.d.ts"], diff --git a/apps/example-app-karma/tsconfig.spec.json b/apps/example-app-karma/tsconfig.spec.json index f4b0d715..ff71a7ee 100644 --- a/apps/example-app-karma/tsconfig.spec.json +++ b/apps/example-app-karma/tsconfig.spec.json @@ -2,7 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": ["jasmine", "node", "@testing-library/jasmine-dom"] + "types": ["jasmine", "node", "@testing-library/jasmine-dom"], + "target": "ES2022", + "useDefineForClassFields": false }, "files": ["src/test.ts", "src/polyfills.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"] diff --git a/apps/example-app/.browserslistrc b/apps/example-app/.browserslistrc deleted file mode 100644 index 427441dc..00000000 --- a/apps/example-app/.browserslistrc +++ /dev/null @@ -1,17 +0,0 @@ -# This file is used by the build system to adjust CSS and JS output to support the specified browsers below. -# For additional information regarding the format and rule options, please see: -# https://github.com/browserslist/browserslist#queries - -# For the full list of supported browsers by the Angular framework, please see: -# https://angular.io/guide/browser-support - -# You can see what browsers were selected by your queries by running: -# npx browserslist - -last 1 Chrome version -last 1 Firefox version -last 2 Edge major versions -last 2 Safari major versions -last 2 iOS major versions -Firefox ESR -not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. diff --git a/apps/example-app/project.json b/apps/example-app/project.json index dcc8b341..b31a7efd 100644 --- a/apps/example-app/project.json +++ b/apps/example-app/project.json @@ -1,4 +1,5 @@ { + "name": "example-app", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", "sourceRoot": "apps/example-app/src", @@ -75,7 +76,7 @@ "options": { "jestConfig": "apps/example-app/jest.config.ts" }, - "outputs": ["coverage/"] + "outputs": ["{workspaceRoot}/coverage/"] } }, "tags": [] diff --git a/apps/example-app/src/app/app-routing.module.ts b/apps/example-app/src/app/app-routing.module.ts index a553ba61..6a9f0b9e 100644 --- a/apps/example-app/src/app/app-routing.module.ts +++ b/apps/example-app/src/app/app-routing.module.ts @@ -95,7 +95,7 @@ export const routes: Routes = [ ]; @NgModule({ - imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })], + imports: [RouterModule.forRoot(routes, {})], exports: [RouterModule], }) export class AppRoutingModule {} diff --git a/apps/example-app/src/app/examples/04-forms-with-material.ts b/apps/example-app/src/app/examples/04-forms-with-material.ts index 69ea4636..ed510d1f 100644 --- a/apps/example-app/src/app/examples/04-forms-with-material.ts +++ b/apps/example-app/src/app/examples/04-forms-with-material.ts @@ -6,10 +6,12 @@ import { UntypedFormBuilder, Validators } from '@angular/forms'; template: `
+ Name + Score + Color {{ colorControlDisplayValue }} diff --git a/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts b/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts index 1d87edf9..53dee01f 100644 --- a/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts +++ b/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts @@ -30,7 +30,7 @@ test('should run logic in the input setter and getter while re-rendering', async expect(screen.getByTestId('value')).toHaveTextContent('I am value from setter Angular'); expect(screen.getByTestId('value-getter')).toHaveTextContent('I am value from getter Angular'); - await rerender({ value: 'React' }); + await rerender({ componentProperties: { value: 'React' } }); // note we have to re-query because the elements are not the same anymore expect(screen.getByTestId('value')).toHaveTextContent('I am value from setter React'); diff --git a/apps/example-app/src/app/examples/20-test-harness.spec.ts b/apps/example-app/src/app/examples/20-test-harness.spec.ts new file mode 100644 index 00000000..eb068002 --- /dev/null +++ b/apps/example-app/src/app/examples/20-test-harness.spec.ts @@ -0,0 +1,33 @@ +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatSnackBarHarness } from '@angular/material/snack-bar/testing'; +import { render, screen } from '@testing-library/angular'; +import user from '@testing-library/user-event'; + +import { SnackBarComponent } from './20-test-harness'; + +test('can be used with TestHarness', async () => { + const view = await render(``, { + imports: [SnackBarComponent], + }); + const loader = TestbedHarnessEnvironment.documentRootLoader(view.fixture); + + const buttonHarness = await loader.getHarness(MatButtonHarness); + const button = await buttonHarness.host(); + button.click(); + + const snackbarHarness = await loader.getHarness(MatSnackBarHarness); + expect(await snackbarHarness.getMessage()).toMatch(/Pizza Party!!!/i); +}); + +test('can be used in combination with TestHarness', async () => { + const view = await render(SnackBarComponent); + const loader = TestbedHarnessEnvironment.documentRootLoader(view.fixture); + + user.click(screen.getByRole('button')); + + const snackbarHarness = await loader.getHarness(MatSnackBarHarness); + expect(await snackbarHarness.getMessage()).toMatch(/Pizza Party!!!/i); + + expect(screen.getByText(/Pizza Party!!!/i)).toBeInTheDocument(); +}); diff --git a/apps/example-app/src/app/examples/20-test-harness.ts b/apps/example-app/src/app/examples/20-test-harness.ts new file mode 100644 index 00000000..53069075 --- /dev/null +++ b/apps/example-app/src/app/examples/20-test-harness.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; + +@Component({ + selector: 'app-harness', + standalone: true, + imports: [MatButtonModule, MatSnackBarModule], + template: ` + + `, +}) +export class SnackBarComponent { + constructor(private snackBar: MatSnackBar) {} + + openSnackBar() { + return this.snackBar.open('Pizza Party!!!'); + } +} diff --git a/apps/example-app/tsconfig.app.json b/apps/example-app/tsconfig.app.json index ab030646..6feb8d6e 100644 --- a/apps/example-app/tsconfig.app.json +++ b/apps/example-app/tsconfig.app.json @@ -4,7 +4,8 @@ "outDir": "../../dist/out-tsc", "types": [], "allowJs": true, - "target": "ES2020" + "target": "ES2022", + "useDefineForClassFields": false }, "files": ["src/main.ts", "src/polyfills.ts"], "include": ["src/**/*.d.ts"], diff --git a/nx.json b/nx.json index e7ffe758..3529497e 100644 --- a/nx.json +++ b/nx.json @@ -15,13 +15,6 @@ "environment": "all" } }, - "implicitDependencies": { - "package.json": { - "dependencies": "*", - "devDependencies": "*" - }, - ".eslintrc.json": "*" - }, "tasksRunnerOptions": { "default": { "runner": "@nrwl/nx-cloud", @@ -75,7 +68,27 @@ "$schema": "./node_modules/nx/schemas/nx-schema.json", "targetDefaults": { "build": { - "dependsOn": ["^build"] + "dependsOn": ["^build"], + "inputs": ["production", "^production"] + }, + "test": { + "inputs": ["default", "^production"] + }, + "lint": { + "inputs": ["default", "{workspaceRoot}/.eslintrc.json"] } + }, + "namedInputs": { + "default": ["{projectRoot}/**/*", "sharedGlobals"], + "sharedGlobals": [], + "production": [ + "default", + "!{projectRoot}/**/*.spec.[jt]s", + "!{projectRoot}/tsconfig.spec.json", + "!{projectRoot}/karma.conf.js", + "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", + "!{projectRoot}/jest.config.[jt]s", + "!{projectRoot}/.eslintrc.json" + ] } } diff --git a/package.json b/package.json index 447d18ef..3d70b880 100644 --- a/package.json +++ b/package.json @@ -28,51 +28,51 @@ "prepare": "git config core.hookspath .githooks" }, "dependencies": { - "@angular/animations": "14.1.3", - "@angular/cdk": "14.1.3", - "@angular/common": "14.1.3", - "@angular/compiler": "14.1.3", - "@angular/core": "14.1.3", - "@angular/material": "14.1.3", - "@angular/platform-browser": "14.1.3", - "@angular/platform-browser-dynamic": "14.1.3", - "@angular/router": "14.1.3", + "@angular/animations": "15.0.0", + "@angular/cdk": "15.0.0", + "@angular/common": "15.0.0", + "@angular/compiler": "15.0.0", + "@angular/core": "15.0.0", + "@angular/material": "15.0.0", + "@angular/platform-browser": "15.0.0", + "@angular/platform-browser-dynamic": "15.0.0", + "@angular/router": "15.0.0", "@ngrx/store": "14.0.2", - "@nrwl/angular": "14.5.8", - "@nrwl/nx-cloud": "14.4.1", + "@nrwl/angular": "15.2.1", + "@nrwl/nx-cloud": "15.0.2", "@testing-library/dom": "^8.11.1", "rxjs": "7.5.6", "tslib": "~2.3.1", "zone.js": "~0.11.4" }, "devDependencies": { - "@angular-devkit/build-angular": "14.1.3", + "@angular-devkit/build-angular": "15.0.0", "@angular-eslint/builder": "14.0.2", - "@angular-eslint/eslint-plugin": "14.0.2", - "@angular-eslint/eslint-plugin-template": "14.0.2", + "@angular-eslint/eslint-plugin": "14.0.4", + "@angular-eslint/eslint-plugin-template": "14.0.4", "@angular-eslint/schematics": "14.0.2", - "@angular-eslint/template-parser": "14.0.2", - "@angular/cli": "~14.1.0", - "@angular/compiler-cli": "14.1.3", - "@angular/forms": "14.1.3", - "@angular/language-service": "14.1.3", - "@nrwl/cli": "14.5.8", - "@nrwl/eslint-plugin-nx": "14.5.8", - "@nrwl/jest": "14.5.8", - "@nrwl/linter": "14.5.8", - "@nrwl/node": "14.5.8", - "@nrwl/nx-plugin": "14.5.8", - "@nrwl/workspace": "14.5.8", + "@angular-eslint/template-parser": "14.0.4", + "@angular/cli": "~15.0.0", + "@angular/compiler-cli": "15.0.0", + "@angular/forms": "15.0.0", + "@angular/language-service": "15.0.0", + "@nrwl/cli": "15.2.1", + "@nrwl/eslint-plugin-nx": "15.2.1", + "@nrwl/jest": "15.2.1", + "@nrwl/linter": "15.2.1", + "@nrwl/node": "15.2.1", + "@nrwl/nx-plugin": "15.2.1", + "@nrwl/workspace": "15.2.1", "@swc-node/register": "^1.4.2", "@swc/core": "^1.2.173", "@testing-library/jasmine-dom": "^1.2.0", "@testing-library/jest-dom": "^5.15.1", "@testing-library/user-event": "^13.5.0", "@types/jasmine": "4.0.3", - "@types/jest": "28.1.7", + "@types/jest": "28.1.8", "@types/node": "18.7.1", - "@typescript-eslint/eslint-plugin": "5.29.0", - "@typescript-eslint/parser": "5.29.0", + "@typescript-eslint/eslint-plugin": "5.36.1", + "@typescript-eslint/parser": "5.36.1", "cpy-cli": "^3.1.1", "eslint": "8.15.0", "eslint-config-prettier": "8.3.0", @@ -84,15 +84,15 @@ "jasmine-core": "4.2.0", "jasmine-spec-reporter": "7.0.0", "jest": "28.1.3", - "jest-environment-jsdom": "^28.1.1", - "jest-preset-angular": "12.1.0", + "jest-environment-jsdom": "28.1.3", + "jest-preset-angular": "12.2.2", "karma": "6.4.0", "karma-chrome-launcher": "^3.1.0", "karma-jasmine": "5.1.0", "karma-jasmine-html-reporter": "2.0.0", "lint-staged": "^12.1.6", - "ng-packagr": "14.1.0", - "nx": "14.5.8", + "ng-packagr": "15.0.0", + "nx": "15.2.1", "postcss": "^8.4.5", "postcss-import": "14.1.0", "postcss-preset-env": "7.5.0", @@ -102,6 +102,6 @@ "semantic-release": "^18.0.0", "ts-jest": "28.0.8", "ts-node": "10.9.1", - "typescript": "4.7.4" + "typescript": "4.8.4" } } diff --git a/projects/testing-library/package.json b/projects/testing-library/package.json index 0106b937..c74d252b 100644 --- a/projects/testing-library/package.json +++ b/projects/testing-library/package.json @@ -29,10 +29,10 @@ "migrations": "./schematics/migrations/migration.json" }, "peerDependencies": { - "@angular/common": ">= 14.0.0", - "@angular/platform-browser": ">= 14.0.0", - "@angular/router": ">= 14.0.0", - "@angular/core": ">= 14.0.0" + "@angular/common": ">= 14.1.0", + "@angular/platform-browser": ">= 14.1.0", + "@angular/router": ">= 14.1.0", + "@angular/core": ">= 14.1.0" }, "dependencies": { "@testing-library/dom": "^8.0.0", diff --git a/projects/testing-library/project.json b/projects/testing-library/project.json index f9b45ed8..b460aa28 100644 --- a/projects/testing-library/project.json +++ b/projects/testing-library/project.json @@ -1,4 +1,5 @@ { + "name": "testing-library", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "library", "sourceRoot": "projects/testing-library/src", @@ -6,7 +7,7 @@ "targets": { "build-package": { "executor": "@nrwl/angular:package", - "outputs": ["dist/@testing-library/angular"], + "outputs": ["{workspaceRoot}/dist/@testing-library/angular"], "options": { "project": "projects/testing-library/ng-package.json", "updateBuildableProjectDepsInPackageJson": false @@ -29,7 +30,7 @@ "outputs": ["{options.outputFile}"] }, "build": { - "executor": "@nrwl/workspace:run-commands", + "executor": "nx:run-commands", "options": { "parallel": false, "commands": [ @@ -50,7 +51,7 @@ "options": { "jestConfig": "projects/testing-library/jest.config.ts" }, - "outputs": ["coverage/projects/testing-library"] + "outputs": ["{workspaceRoot}/coverage/projects/testing-library"] } }, "tags": [] diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 09339528..51fe5f7c 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -57,32 +57,38 @@ export interface RenderResult extend * Re-render the same component with different properties. * This creates a new instance of the component. */ - rerender: (rerenderedProperties: Partial) => Promise; - + rerender: ( + properties?: Pick< + RenderTemplateOptions, + 'componentProperties' | 'componentInputs' | 'componentOutputs' + >, + ) => Promise; /** * @description * Keeps the current fixture intact and invokes ngOnChanges with the updated properties. */ change: (changedProperties: Partial) => void; + /** + * @description + * Keeps the current fixture intact, update the @Input properties and invoke ngOnChanges with the updated properties. + */ + changeInput: (changedInputProperties: Partial) => void; } export interface RenderComponentOptions { /** * @description - * Will call detectChanges when the component is compiled + * Automatically detect changes as a "real" running component would do. * * @default * true * * @example * const component = await render(AppComponent, { - * detectChanges: false + * autoDetectChanges: false * }) - * - * @deprecated - * Use `detectChangesOnRender` instead */ - detectChanges?: boolean; + autoDetectChanges?: boolean; /** * @description * Invokes `detectChanges` after the component is rendered @@ -172,7 +178,7 @@ export interface RenderComponentOptions; + /** + * @description + * An object to set `@Input` properties of the component + * + * @default + * {} + * + * @example + * const component = await render(AppComponent, { + * componentInputs: { + * counterValue: 10 + * } + * }) + */ + componentInputs?: Partial; + /** + * @description + * An object to set `@Output` properties of the component + * + * @default + * {} + * + * @example + * const component = await render(AppComponent, { + * componentOutputs: { + * send: (value) => { ... } + * } + * }) + */ + componentOutputs?: Partial; /** * @description * A collection of providers to inject dependencies of the component. @@ -220,7 +256,6 @@ export interface RenderComponentOptions[]; /** @@ -232,14 +267,12 @@ export interface RenderComponentOptions | any[])[]; + componentImports?: (Type | any[])[]; /** * @description * Queries to bind. Overrides the default set from DOM Testing Library unless merged. diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 732a68c7..1b1428df 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -29,7 +29,7 @@ import { RenderComponentOptions, RenderTemplateOptions, RenderResult, ComponentO import { getConfig } from './config'; const mountedFixtures = new Set>(); -const inject = TestBed.inject || TestBed.get; +const safeInject = TestBed.inject || TestBed.get; export async function render( component: Type, @@ -46,8 +46,8 @@ export async function render( ): Promise> { const { dom: domConfig, ...globalConfig } = getConfig(); const { - detectChanges: detectChangesDeprecated = true, - detectChangesOnRender: detectChangesOnRenderInput, + detectChangesOnRender = true, + autoDetectChanges = true, declarations = [], imports = [], providers = [], @@ -55,25 +55,18 @@ export async function render( queries, wrapper = WrapperComponent as Type, componentProperties = {}, + componentInputs = {}, + componentOutputs = {}, componentProviders = [], childComponentOverrides = [], - ɵcomponentImports: componentImports, + componentImports: componentImports, excludeComponentDeclaration = false, routes = [], removeAngularAttributes = false, defaultImports = [], } = { ...globalConfig, ...renderOptions }; - const detectChangesOnRender = - detectChangesOnRenderInput === undefined ? detectChangesDeprecated : detectChangesOnRenderInput; - dtlConfigure({ - eventWrapper: (cb) => { - const result = cb(); - detectChangesForMountedFixtures(); - return result; - }, - ...domConfig, - }); + dtlConfigure(domConfig); TestBed.configureTestingModule({ declarations: addAutoDeclarations(sut, { @@ -102,19 +95,50 @@ export async function render( const componentContainer = createComponentFixture(sut, wrapper); + const zone = safeInject(NgZone); + const router = safeInject(Router); + + if (typeof router?.initialNavigation === 'function') { + if (zone) { + zone.run(() => router.initialNavigation()); + } else { + router.initialNavigation(); + } + } + let fixture: ComponentFixture; let detectChanges: () => void; - await renderFixture(componentProperties); + await renderFixture(componentProperties, componentInputs, componentOutputs); + + const rerender = async ( + properties?: Pick, 'componentProperties' | 'componentInputs' | 'componentOutputs'>, + ) => { + await renderFixture( + properties?.componentProperties ?? {}, + properties?.componentInputs ?? {}, + properties?.componentOutputs ?? {}, + ); + }; + + const changeInput = (changedInputProperties: Partial) => { + if (Object.keys(changedInputProperties).length === 0) { + return; + } + + setComponentInputs(fixture, changedInputProperties); - const rerender = async (rerenderedProperties: Partial) => { - await renderFixture(rerenderedProperties); + fixture.detectChanges(); }; const change = (changedProperties: Partial) => { - const changes = getChangesObj(fixture.componentInstance, changedProperties); + if (Object.keys(changedProperties).length === 0) { + return; + } + + const changes = getChangesObj(fixture.componentInstance as Record, changedProperties); - setComponentProperties(fixture, { componentProperties: changedProperties }); + setComponentProperties(fixture, changedProperties); if (hasOnChangesHook(fixture.componentInstance)) { fixture.componentInstance.ngOnChanges(changes); @@ -123,17 +147,6 @@ export async function render( fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges(); }; - const zone = inject(NgZone); - - const router = inject(Router); - if (typeof router?.initialNavigation === 'function') { - if (zone) { - zone.run(() => router?.initialNavigation()); - } else { - router?.initialNavigation(); - } - } - const navigate = async (elementOrPath: Element | string, basePath = ''): Promise => { const href = typeof elementOrPath === 'string' ? elementOrPath : elementOrPath.getAttribute('href'); const [path, params] = (basePath + href).split('?'); @@ -170,7 +183,6 @@ export async function render( result = doNavigate(); } - detectChanges(); return result ?? false; }; @@ -181,6 +193,7 @@ export async function render( navigate, rerender, change, + changeInput, // @ts-ignore: fixture assigned debugElement: fixture.debugElement, // @ts-ignore: fixture assigned @@ -193,13 +206,15 @@ export async function render( ...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)), }; - async function renderFixture(properties: Partial) { + async function renderFixture(properties: Partial, inputs: Partial, outputs: Partial) { if (fixture) { cleanupAtFixture(fixture); } fixture = await createComponent(componentContainer); - setComponentProperties(fixture, { componentProperties: properties }); + setComponentProperties(fixture, properties); + setComponentInputs(fixture, inputs); + setComponentOutputs(fixture, outputs); if (removeAngularAttributes) { fixture.nativeElement.removeAttribute('ng-version'); @@ -208,16 +223,21 @@ export async function render( fixture.nativeElement.removeAttribute('id'); } } + mountedFixtures.add(fixture); let isAlive = true; fixture.componentRef.onDestroy(() => (isAlive = false)); - if (hasOnChangesHook(fixture.componentInstance)) { + if (hasOnChangesHook(fixture.componentInstance) && Object.keys(properties).length > 0) { const changes = getChangesObj(null, componentProperties); fixture.componentInstance.ngOnChanges(changes); } + if (autoDetectChanges) { + fixture.autoDetectChanges(true); + } + detectChanges = () => { if (isAlive) { fixture.detectChanges(); @@ -232,7 +252,7 @@ export async function render( async function createComponent(component: Type): Promise> { /* Make sure angular application is initialized before creating component */ - await inject(ApplicationInitStatus).donePromise; + await safeInject(ApplicationInitStatus).donePromise; return TestBed.createComponent(component); } @@ -249,7 +269,7 @@ function createComponentFixture( function setComponentProperties( fixture: ComponentFixture, - { componentProperties = {} }: Pick, 'componentProperties'>, + componentProperties: RenderTemplateOptions['componentProperties'] = {}, ) { for (const key of Object.keys(componentProperties)) { const descriptor = Object.getOwnPropertyDescriptor((fixture.componentInstance as any).constructor.prototype, key); @@ -275,6 +295,24 @@ function setComponentProperties( return fixture; } +function setComponentOutputs( + fixture: ComponentFixture, + componentOutputs: RenderTemplateOptions['componentOutputs'] = {}, +) { + for (const [name, value] of Object.entries(componentOutputs)) { + (fixture.componentInstance as any)[name] = value; + } +} + +function setComponentInputs( + fixture: ComponentFixture, + componentInputs: RenderTemplateOptions['componentInputs'] = {}, +) { + for (const [name, value] of Object.entries(componentInputs)) { + fixture.componentRef.setInput(name, value); + } +} + function overrideComponentImports(sut: Type | string, imports: (Type | any[])[] | undefined) { if (imports) { if (typeof sut === 'function' && ɵisStandalone(sut)) { @@ -295,21 +333,21 @@ function overrideChildComponentProviders(componentOverrides: ComponentOverride(componentInstance: SutType): componentInstance is SutType & OnChanges { return ( - 'ngOnChanges' in componentInstance && typeof (componentInstance as SutType & OnChanges).ngOnChanges === 'function' + componentInstance !== null && + typeof componentInstance === 'object' && + 'ngOnChanges' in componentInstance && + typeof (componentInstance as SutType & OnChanges).ngOnChanges === 'function' ); } -function getChangesObj>( - oldProps: Partial | null, - newProps: Partial, -) { +function getChangesObj(oldProps: Record | null, newProps: Record) { const isFirstChange = oldProps === null; return Object.keys(newProps).reduce( (changes, key) => ({ ...changes, [key]: new SimpleChange(isFirstChange ? null : oldProps[key], newProps[key], isFirstChange), }), - {} as SutType, + {} as Record, ); } @@ -359,8 +397,6 @@ async function waitForWrapper( inFakeAsync = false; } - detectChanges(); - return await dtlWaitFor(() => { setTimeout(() => detectChanges(), 0); if (inFakeAsync) { diff --git a/projects/testing-library/tests/change.spec.ts b/projects/testing-library/tests/change.spec.ts index 1ba67513..d6d30f46 100644 --- a/projects/testing-library/tests/change.spec.ts +++ b/projects/testing-library/tests/change.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; import { render, screen } from '../src/public_api'; @Component({ @@ -40,32 +40,43 @@ test('changes the component with updated props while keeping other props untouch @Component({ selector: 'atl-fixture', - template: ` {{ name }} `, + template: ` {{ propOne }} {{ propTwo }}`, }) class FixtureWithNgOnChangesComponent implements OnChanges { - @Input() name = 'Sarah'; - @Input() nameChanged?: (name: string, isFirstChange: boolean) => void; - - ngOnChanges(changes: SimpleChanges) { - if (changes.name && this.nameChanged) { - this.nameChanged(changes.name.currentValue, changes.name.isFirstChange()); - } - } + @Input() propOne = 'Init'; + @Input() propTwo = ''; + + // eslint-disable-next-line @angular-eslint/no-empty-lifecycle-method, @typescript-eslint/no-empty-function + ngOnChanges() {} } -test('will call ngOnChanges on change', async () => { - const nameChanged = jest.fn(); - const componentProperties = { nameChanged }; - const { change } = await render(FixtureWithNgOnChangesComponent, { componentProperties }); - expect(screen.getByText('Sarah')).toBeInTheDocument(); +test('calls ngOnChanges on change', async () => { + const componentInputs = { propOne: 'One', propTwo: 'Two' }; + const { change, fixture } = await render(FixtureWithNgOnChangesComponent, { componentInputs }); + const spy = jest.spyOn(fixture.componentInstance, 'ngOnChanges'); - const name = 'Mark'; - change({ name }); + expect(screen.getByText(`${componentInputs.propOne} ${componentInputs.propTwo}`)).toBeInTheDocument(); - expect(screen.getByText(name)).toBeInTheDocument(); - expect(nameChanged).toHaveBeenCalledWith(name, false); + const propOne = 'UpdatedOne'; + const propTwo = 'UpdatedTwo'; + change({ propOne, propTwo }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(screen.getByText(`${propOne} ${propTwo}`)).toBeInTheDocument(); }); +test('does not invoke ngOnChanges on change without props', async () => { + const componentInputs = { propOne: 'One', propTwo: 'Two' }; + const { change, fixture } = await render(FixtureWithNgOnChangesComponent, { componentInputs }); + const spy = jest.spyOn(fixture.componentInstance, 'ngOnChanges'); + + expect(screen.getByText(`${componentInputs.propOne} ${componentInputs.propTwo}`)).toBeInTheDocument(); + + change({}); + expect(spy).not.toHaveBeenCalled(); + + expect(screen.getByText(`${componentInputs.propOne} ${componentInputs.propTwo}`)).toBeInTheDocument(); +}); @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'atl-fixture', diff --git a/projects/testing-library/tests/changeInputs.spec.ts b/projects/testing-library/tests/changeInputs.spec.ts new file mode 100644 index 00000000..8a970829 --- /dev/null +++ b/projects/testing-library/tests/changeInputs.spec.ts @@ -0,0 +1,97 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; +import { render, screen } from '../src/public_api'; + +@Component({ + selector: 'atl-fixture', + template: ` {{ firstName }} {{ lastName }} `, +}) +class FixtureComponent { + @Input() firstName = 'Sarah'; + @Input() lastName?: string; +} + +test('changes the component with updated props', async () => { + const { changeInput } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + const firstName = 'Mark'; + changeInput({ firstName }); + + expect(screen.getByText(firstName)).toBeInTheDocument(); +}); + +test('changes the component with updated props while keeping other props untouched', async () => { + const firstName = 'Mark'; + const lastName = 'Peeters'; + const { changeInput } = await render(FixtureComponent, { + componentInputs: { + firstName, + lastName, + }, + }); + + expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); + + const firstName2 = 'Chris'; + changeInput({ firstName: firstName2 }); + + expect(screen.getByText(`${firstName2} ${lastName}`)).toBeInTheDocument(); +}); + +@Component({ + selector: 'atl-fixture', + template: ` {{ propOne }} {{ propTwo }}`, +}) +class FixtureWithNgOnChangesComponent implements OnChanges { + @Input() propOne = 'Init'; + @Input() propTwo = ''; + + // eslint-disable-next-line @angular-eslint/no-empty-lifecycle-method, @typescript-eslint/no-empty-function + ngOnChanges() {} +} + +test('calls ngOnChanges on change', async () => { + const componentInputs = { propOne: 'One', propTwo: 'Two' }; + const { changeInput, fixture } = await render(FixtureWithNgOnChangesComponent, { componentInputs }); + const spy = jest.spyOn(fixture.componentInstance, 'ngOnChanges'); + + expect(screen.getByText(`${componentInputs.propOne} ${componentInputs.propTwo}`)).toBeInTheDocument(); + + const propOne = 'UpdatedOne'; + const propTwo = 'UpdatedTwo'; + changeInput({ propOne, propTwo }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(screen.getByText(`${propOne} ${propTwo}`)).toBeInTheDocument(); +}); + +test('does not invoke ngOnChanges on change without props', async () => { + const componentInputs = { propOne: 'One', propTwo: 'Two' }; + const { changeInput, fixture } = await render(FixtureWithNgOnChangesComponent, { componentInputs }); + const spy = jest.spyOn(fixture.componentInstance, 'ngOnChanges'); + + expect(screen.getByText(`${componentInputs.propOne} ${componentInputs.propTwo}`)).toBeInTheDocument(); + + changeInput({}); + expect(spy).not.toHaveBeenCalled(); + + expect(screen.getByText(`${componentInputs.propOne} ${componentInputs.propTwo}`)).toBeInTheDocument(); +}); + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'atl-fixture', + template: `
Number
`, +}) +class FixtureWithOnPushComponent { + @Input() activeField = ''; +} + +test('update properties on change', async () => { + const { changeInput } = await render(FixtureWithOnPushComponent); + const numberHtmlElementRef = screen.queryByTestId('number'); + + expect(numberHtmlElementRef).not.toHaveClass('active'); + changeInput({ activeField: 'number' }); + expect(numberHtmlElementRef).toHaveClass('active'); +}); diff --git a/projects/testing-library/tests/fire-event.spec.ts b/projects/testing-library/tests/fire-event.spec.ts index ebb85017..ace4ba82 100644 --- a/projects/testing-library/tests/fire-event.spec.ts +++ b/projects/testing-library/tests/fire-event.spec.ts @@ -1,17 +1,48 @@ import { Component } from '@angular/core'; import { render, fireEvent, screen } from '../src/public_api'; +import { FormsModule } from '@angular/forms'; -@Component({ - selector: 'atl-fixture', - template: ` `, -}) -class FixtureComponent {} +describe('fireEvent', () => { + @Component({ + selector: 'atl-fixture', + template: ` +
Hello {{ name }}
`, + }) + class FixtureComponent { + name = ''; + } -test('does not call detect changes when fixture is destroyed', async () => { - const { fixture } = await render(FixtureComponent); + it('automatically detect changes when event is fired', async () => { + await render(FixtureComponent, { + imports: [FormsModule], + }); - fixture.destroy(); + fireEvent.input(screen.getByTestId('input'), { target: { value: 'Tim' } }); - // should otherwise throw - fireEvent.input(screen.getByTestId('input'), { target: { value: 'Bonjour' } }); + expect(screen.getByText('Hello Tim')).toBeInTheDocument(); + }); + + it('can disable automatic detect changes when event is fired', async () => { + const { detectChanges } = await render(FixtureComponent, { + imports: [FormsModule], + autoDetectChanges: false, + }); + + fireEvent.input(screen.getByTestId('input'), { target: { value: 'Tim' } }); + + expect(screen.queryByText('Hello Tim')).not.toBeInTheDocument(); + + detectChanges(); + + expect(screen.getByText('Hello Tim')).toBeInTheDocument(); + }); + + it('does not call detect changes when fixture is destroyed', async () => { + const { fixture } = await render(FixtureComponent); + + fixture.destroy(); + + // should otherwise throw + fireEvent.input(screen.getByTestId('input'), { target: { value: 'Bonjour' } }); + }); }); diff --git a/projects/testing-library/tests/issues/issue-318.spec.ts b/projects/testing-library/tests/issues/issue-318.spec.ts new file mode 100644 index 00000000..3f1430e8 --- /dev/null +++ b/projects/testing-library/tests/issues/issue-318.spec.ts @@ -0,0 +1,43 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {Router} from '@angular/router'; +import {RouterTestingModule} from '@angular/router/testing'; +import {Subject, takeUntil} from 'rxjs'; +import {render} from "@testing-library/angular"; + +@Component({ + selector: 'atl-app-fixture', + template: '', +}) +class FixtureComponent implements OnInit, OnDestroy { + unsubscribe$ = new Subject(); + + constructor(private router: Router) {} + + ngOnInit(): void { + this.router.events.pipe(takeUntil(this.unsubscribe$)).subscribe((evt) => { + this.eventReceived(evt) + }); + } + + ngOnDestroy(): void { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } + + eventReceived(evt: any) { + console.log(evt); + } +} + + +test('it does not invoke router events on init', async () => { + const eventReceived = jest.fn(); + await render(FixtureComponent, { + imports: [RouterTestingModule], + componentProperties: { + eventReceived + } + }); + expect(eventReceived).not.toHaveBeenCalled(); +}); + diff --git a/projects/testing-library/tests/render-template.spec.ts b/projects/testing-library/tests/render-template.spec.ts index 9dd20d01..a6892dbc 100644 --- a/projects/testing-library/tests/render-template.spec.ts +++ b/projects/testing-library/tests/render-template.spec.ts @@ -135,7 +135,7 @@ describe('removeAngularAttributes', () => { }); test('updates properties and invokes change detection', async () => { - const view = await render('
', { + const view = await render<{ value: string }>('
', { declarations: [UpdateInputDirective], componentProperties: { value: 'value1', diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index 4200bc94..12a1f628 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -22,16 +22,18 @@ import { render, fireEvent, screen } from '../src/public_api'; }) class FixtureComponent {} -test('creates queries and events', async () => { - const view = await render(FixtureComponent); - - /// We wish to test the utility function from `render` here. - // eslint-disable-next-line testing-library/prefer-screen-queries - fireEvent.input(view.getByTestId('input'), { target: { value: 'a super awesome input' } }); - // eslint-disable-next-line testing-library/prefer-screen-queries - expect(view.getByDisplayValue('a super awesome input')).toBeInTheDocument(); - // eslint-disable-next-line testing-library/prefer-screen-queries - fireEvent.click(view.getByText('button')); +describe('DTL functionality', () => { + it('creates queries and events', async () => { + const view = await render(FixtureComponent); + + /// We wish to test the utility function from `render` here. + // eslint-disable-next-line testing-library/prefer-screen-queries + fireEvent.input(view.getByTestId('input'), { target: { value: 'a super awesome input' } }); + // eslint-disable-next-line testing-library/prefer-screen-queries + expect(view.getByDisplayValue('a super awesome input')).toBeInTheDocument(); + // eslint-disable-next-line testing-library/prefer-screen-queries + fireEvent.click(view.getByText('button')); + }); }); describe('standalone', () => { @@ -79,8 +81,8 @@ describe('standalone with child', () => { expect(screen.getByText('A child fixture')).toBeInTheDocument(); }); - it('renders the standalone component with child given ɵcomponentImports', async () => { - await render(ParentFixtureComponent, { ɵcomponentImports: [MockChildFixtureComponent] }); + it('renders the standalone component with a mocked child', async () => { + await render(ParentFixtureComponent, { componentImports: [MockChildFixtureComponent] }); expect(screen.getByText('Parent fixture')).toBeInTheDocument(); expect(screen.getByText('A mock child fixture')).toBeInTheDocument(); }); @@ -88,7 +90,7 @@ describe('standalone with child', () => { it('rejects render of template with componentImports set', () => { const view = render(`
`, { imports: [ParentFixtureComponent], - ɵcomponentImports: [MockChildFixtureComponent], + componentImports: [MockChildFixtureComponent], }); return expect(view).rejects.toMatchObject({ message: /Error while rendering/ }); }); @@ -201,13 +203,13 @@ describe('Angular component life-cycle hooks', () => { } ngOnChanges(changes: SimpleChanges) { - if (changes.name && this.nameChanged) { - this.nameChanged(changes.name.currentValue, changes.name.isFirstChange()); + if (this.nameChanged) { + this.nameChanged(changes.name?.currentValue, changes.name?.isFirstChange()); } } } - it('will call ngOnInit on initial render', async () => { + it('invokes ngOnInit on initial render', async () => { const nameInitialized = jest.fn(); const componentProperties = { nameInitialized }; const view = await render(FixtureWithNgOnChangesComponent, { componentProperties }); @@ -218,7 +220,7 @@ describe('Angular component life-cycle hooks', () => { expect(nameInitialized).toHaveBeenCalledWith('Initial'); }); - it('will call ngOnChanges on initial render before ngOnInit', async () => { + it('invokes ngOnChanges with componentProperties on initial render before ngOnInit', async () => { const nameInitialized = jest.fn(); const nameChanged = jest.fn(); const componentProperties = { nameInitialized, nameChanged, name: 'Sarah' }; @@ -231,29 +233,66 @@ describe('Angular component life-cycle hooks', () => { expect(nameChanged).toHaveBeenCalledWith('Sarah', true); /// expect `nameChanged` to be called before `nameInitialized` expect(nameChanged.mock.invocationCallOrder[0]).toBeLessThan(nameInitialized.mock.invocationCallOrder[0]); + expect(nameChanged).toHaveBeenCalledTimes(1); }); -}); -test('waits for angular app initialization before rendering components', async () => { - const mock = jest.fn(); - - await render(FixtureComponent, { - providers: [ - { - provide: APP_INITIALIZER, - useFactory: () => mock, - multi: true, - }, - ], + it('invokes ngOnChanges with componentInputs on initial render before ngOnInit', async () => { + const nameInitialized = jest.fn(); + const nameChanged = jest.fn(); + const componentInput = { nameInitialized, nameChanged, name: 'Sarah' }; + + const view = await render(FixtureWithNgOnChangesComponent, { componentInputs: componentInput }); + + /// We wish to test the utility function from `render` here. + // eslint-disable-next-line testing-library/prefer-screen-queries + expect(view.getByText('Sarah')).toBeInTheDocument(); + expect(nameChanged).toHaveBeenCalledWith('Sarah', true); + /// expect `nameChanged` to be called before `nameInitialized` + expect(nameChanged.mock.invocationCallOrder[0]).toBeLessThan(nameInitialized.mock.invocationCallOrder[0]); + expect(nameChanged).toHaveBeenCalledTimes(1); + }); + + it('does not invoke ngOnChanges when no properties are provided', async () => { + @Component({ template: `` }) + class TestFixtureComponent implements OnChanges { + ngOnChanges() { + throw new Error('should not be called'); + } + } + + const { fixture, detectChanges } = await render(TestFixtureComponent); + const spy = jest.spyOn(fixture.componentInstance, 'ngOnChanges'); + + detectChanges(); + + expect(spy).not.toHaveBeenCalled(); }); +}); + +describe('initializer', () => { + it('waits for angular app initialization before rendering components', async () => { + const mock = jest.fn(); - expect(TestBed.inject(ApplicationInitStatus).done).toBe(true); - expect(mock).toHaveBeenCalled(); + await render(FixtureComponent, { + providers: [ + { + provide: APP_INITIALIZER, + useFactory: () => mock, + multi: true, + }, + ], + }); + + expect(TestBed.inject(ApplicationInitStatus).done).toBe(true); + expect(mock).toHaveBeenCalled(); + }); }); -test('gets the DebugElement', async () => { - const view = await render(FixtureComponent); +describe('DebugElement', () => { + it('gets the DebugElement', async () => { + const view = await render(FixtureComponent); - expect(view.debugElement).not.toBeNull(); - expect(view.debugElement.componentInstance).toBeInstanceOf(FixtureComponent); + expect(view.debugElement).not.toBeNull(); + expect(view.debugElement.componentInstance).toBeInstanceOf(FixtureComponent); + }); }); diff --git a/projects/testing-library/tests/rerender.spec.ts b/projects/testing-library/tests/rerender.spec.ts index 0edf69ea..d0ee43b2 100644 --- a/projects/testing-library/tests/rerender.spec.ts +++ b/projects/testing-library/tests/rerender.spec.ts @@ -15,7 +15,26 @@ test('rerenders the component with updated props', async () => { expect(screen.getByText('Sarah')).toBeInTheDocument(); const firstName = 'Mark'; - await rerender({ firstName }); + await rerender({ componentProperties: { firstName } }); + + expect(screen.getByText(firstName)).toBeInTheDocument(); +}); + +test('rerenders without props', async () => { + const { rerender } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + await rerender(); + + expect(screen.getByText('Sarah')).toBeInTheDocument(); +}); + +test('rerenders the component with updated inputs', async () => { + const { rerender } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + const firstName = 'Mark'; + await rerender({ componentInputs: { firstName } }); expect(screen.getByText(firstName)).toBeInTheDocument(); }); @@ -33,8 +52,8 @@ test('rerenders the component with updated props and resets other props', async expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); const firstName2 = 'Chris'; - rerender({ firstName: firstName2 }); + await rerender({ componentProperties: { firstName: firstName2 } }); expect(screen.queryByText(`${firstName2} ${lastName}`)).not.toBeInTheDocument(); - expect(screen.queryByText(firstName2)).not.toBeInTheDocument(); + expect(screen.queryByText(`${firstName} ${lastName}`)).not.toBeInTheDocument(); }); diff --git a/projects/testing-library/tsconfig.lib.json b/projects/testing-library/tsconfig.lib.json index 8d9b29fc..0938741e 100644 --- a/projects/testing-library/tsconfig.lib.json +++ b/projects/testing-library/tsconfig.lib.json @@ -5,7 +5,9 @@ "declaration": true, "declarationMap": true, "inlineSources": true, - "types": ["node", "jest"] + "types": ["node", "jest"], + "target": "ES2022", + "useDefineForClassFields": false }, "exclude": ["src/test-setup.ts", "**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], "include": ["**/*.ts"] diff --git a/projects/testing-library/tsconfig.lib.prod.json b/projects/testing-library/tsconfig.lib.prod.json index bf43f045..1f041c94 100644 --- a/projects/testing-library/tsconfig.lib.prod.json +++ b/projects/testing-library/tsconfig.lib.prod.json @@ -1,7 +1,9 @@ { "extends": "./tsconfig.lib.json", "compilerOptions": { - "declarationMap": false + "declarationMap": false, + "target": "ES2022", + "useDefineForClassFields": false }, "angularCompilerOptions": { "compilationMode": "partial"