This library brings Angular support to Playwright's **experimental ** Component Testing.
This will allow you to test your Angular components with Playwright without building the whole app and with more control.
@jscutlery/playwright-ct-angular
currently supports:
- β Testing Angular components/directives/pipes
- π Controlling inputs/outputs in a type-safe fashion
- π₯Έ Overriding providers
- π³ Testing with templates
playwright-ct-angular.mp4
- Playwright Component Testing for Angular (experimental)
- Table of Contents
- π Writing your first test
β οΈ Known Limitations- π¦ Setup
First, you will have to set up Playwright Component Testing as mentioned below.
Then, you can write your first test in .../src/greetings.component.pw.ts
:
import { expect, test } from '@jscutlery/playwright-ct-angular';
import { GreetingsComponent } from './greetings.component';
test(`GreetingsComponent should be polite`, async ({ mount }) => {
const locator = await mount(GreetingsComponent);
expect(locator).toHaveText('π Hello!');
});
import { expect, test } from '@jscutlery/playwright-ct-angular';
import { GreetingsComponent } from './greetings.component';
test(`GreetingsComponent should be polite`, async ({ mount }) => {
const locator = await mount(GreetingsComponent, { props: { name: 'Edouard' } });
expect(locator).toHaveText('π Hello Edouard!');
});
You can also pass custom output callback functions for some extreme cases or if you want to use a custom spy implementation for example or just debug.
await mount(NameEditorComponent, {
on: {
nameChange(name) {
console.log(name);
}
}
});
Due to the limitations described below, the recommended approach for providing test doubles or importing additional modules is to create a test container component in another file.
// recipe-search.component.pw.ts
import { defer } from 'rxjs';
import { RecipeSearchTestContainer } from './recipe-search.test-container';
test('...', async ({ mount }) => {
await mount(RecipeSearchTestContainer, {
props: {
recipes: [
beer,
burger
]
}
})
})
// recipe-search.test-container.ts
@Component({
standalone: true,
imports: [RecipeSearchComponent],
template: '<jc-recipe-search></jc-recipe-search>',
providers: [
RecipeRepositoryFake,
{
provide: RecipeRepository,
useExisting: RecipeRepositoryFake,
},
],
})
export class RecipeSearchTestContainer {
recipes = input<Recipe[]>([]);
#repo = inject(RecipeRepositoryFake);
#syncRecipesWithRepo = effect(() => {
this.#repo.setRecipes(this.recipes());
});
}
/* Cf. https://github.com/jscutlery/devkit/tree/main/tests/playwright-ct-angular-wide/src/testing/recipe-repository.fake.ts
* for a better example. */
class RecipeRepositoryFake implements RecipeRepositoryDef {
#recipes: Recipe[] = [];
searchRecipes() {
return defer(() => of(this.#recipes));
}
setRecipes(recipes: Recipe[]) {
this.#recipes = recipes;
}
}
In order to import styles that are shared between your tests, you can do so by importing them in playwright/index.ts
.
You can also customize the shared playwright/index.html
nearby.
If you want to load some specific styles for a single test, you might prefer using a test container component:
import styles from './some-styles.css';
@Component({
template: '<jc-greetings></jc-greetings>',
encapsulation: ViewEncapsulation.None,
styles: [styles]
})
class GreetingsTestContainer {
}
As mentioned in Versatile Angular Style Blog Post, Angular Material and other Angular libraries might use a Conditional "style" Export that allows us to import prebuilt styles ( Cf. Angular Package Format managing assets in a library).
In that case, we can add the following configuration to our playwright-ct.config.ts
:
const config: PlaywrightTestConfig = {
// ...
use: {
// ...
ctViteConfig: {
resolve: {
/* @angular/material is using "style" as a Custom Conditional export to expose prebuilt styles etc... */
conditions: ['style']
}
}
}
};
Cf. /tests/playwright-ct-angular-demo/src
The way Playwright Component Testing works is different from the way things work with Karma, Jest, Vitest, Cypress etc... Playwright Component Testing tests run in a Node.js environment and control the browser through Chrome DevTools Protocol, while the component is rendered in a browser.
This causes a couple of limitations as we can't directly access the TestBed
's or the component's internals,
and we can only exchange serializable data with the component.
// π this won't work
const cmp = MyComponent;
await mount(cmp);
// π this won't work
test(MyComponent.name, async ({ mount }) => {
});
// π this won't work
@Component({ ... })
class GreetingsComponent {
}
test('should work', async ({ mount }) => {
await mount(GreetingsComponent);
});
import { provideAnimations } from '@angular/platform-browser/animations';
import { MY_PROVIDERS } from './my-providers';
import { MyFake } from './my-fake';
@Injectable()
class MyLocalFake {
// ...
}
// π this won't work because the result of `provideAnimations()` is not serializable
mount(GreetingsComponent, { providers: [provideAnimations()] })
// π this won't work because `MyLocalFake` is not "importable"
mount(GreetingsComponent, { providers: [{ provide: MyService, useClass: MyLocalFake }] })
// β
this works
mount(GreetingsComponent, { providers: MY_PROVIDERS });
// β
this works
mount(GreetingsComponent, { providers: [{ provide: MY_VALUE, useValue: 'my-value' }] });
// β
this works
mount(GreetingsComponent, { providers: [{ provide: MyService, useClass: MyFake }] });
The magical workaround behind the scenes is that at build time:
- Playwright analyses all the calls to
mount()
, - it grabs the arguments (e.g. the component class),
- replaces the component class with a unique string (constructed from the component class name and es-module),
- adds the component's ES module to Vite entrypoints,
- and finally creates a map matching each unique string to the right ES module.
This way, when calling mount()
, Playwright will communicate the unique string to the browser who will know which
ES module to load.
Cf. https://youtu.be/y3YxX4sFJbM
# You can run this command in an existing workspace.
npm create playwright -- --ct
# Choose React
# ? Which framework do you use? (experimental) β¦
# β― react
# vue
# svelte
# solid
npm add -D @jscutlery/playwright-ct-angular @jscutlery/swc-angular unplugin-swc
npm uninstall -D @playwright/experimental-ct-react
- Update
playwright-ct.config.ts
and replace:
import { defineConfig, devices } from '@playwright/experimental-ct-react';
with
import { defineConfig, devices } from '@jscutlery/playwright-ct-angular';
import { swcAngularUnpluginOptions } from '@jscutlery/swc-angular'
import swc from 'unplugin-swc';
- Configure vite plugin:
export default defineConfig({
use: {
// ...
ctViteConfig: {
// ...
plugins: [
swc.vite(swcAngularUnpluginOptions())
]
}
}
});
In order to avoid collisions with other tests (e.g. Jest / Vitest),
You can replace the default matching extension .spec.ts
with .pw.ts
:
const config: PlaywrightTestConfig = {
testDir: './',
testMatch: /pw\.ts$/,
...
}
If you want to use zoneful testing, you have to import zone.js
in your playwright/index.ts
:
// playwright/index.ts
import 'zone.js';
For zoneless testing, you have to provide provideExperimentalZonelessChangeDetection()
in your playwright/index.ts
:
// playwright/index.ts
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { beforeMount } from '@jscutlery/playwright-ct-angular/hooks';
beforeMount(async ({ TestBed }) => {
TestBed.configureTestingModule({
providers: [
provideExperimentalZonelessChangeDetection(),
],
});
});