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.tsand 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(),
],
});
});