
Effective Playwright Testing in Micro Frontend Architectures
Playwright has become a powerful tool for end-to-end testing, offering great capabilities for validating the various pages of your application. The tooling has made it extremely easy to test entire pages by using test-fixtures, which are a critical part of isolating your test code into logical pages and allow you to define utilities for each page.
This works really well for traditional single page applications, but what about when you are developing an application using a micro frontend architecture?
The Challenge with Micro Frontend (MFE) Architectures
The problem with micro frontends is that a page is typically composed of different components that could be in completely different repositories.
You could have a scenario where you have a page for a product. This could be composed of a component from the product team, the checkout team, and another team that is responsible for delivering suggestions to the user, so there is not a single team that is responsible for the testing of an entire page.
How do you resolve this? Well, there are a few things you should do to approach this problem:
- Push as much testing into your component test layer as possible.
- Run playwright UI tests only after the page has been composed.
- Scope your test-fixtures to features, instead of pages.
Playwright has some amazing functionality, but it is not a silver bullet and just like any other tool, misusing it could be just as detrimental as not using it at all. Keep an eye out for the next post that will cover a more comprehensive list of things you should consider avoiding when determing the role Playwright holds in your testing pyramid.
Shift Left: Test Components Early
Be picky about what you run for integrated UI tests.
There are other amazing tools out there like Storybook that can be used to test components in isolation. These are slower than your unit tests because they require a browser, but they are still quite fast. This can allow you to test the user interactions with your components very early on in the development cycle and without any integration points.
Other tools like the React Testing Library can also give you confidence early in the process, these typically run faster than Storybook because they use a virtual DOM, the trade-off being that Storybook uses a real DOM to give more confidence.
If you are testing interactions with your components or features that could be tested at a lower level of the pyramid, you should do that. Leave the flows that require integration to Playwright.
Compose then Test
One of the most basic parts of writing Playwright tests is that you give it a URL to navigate to. In a normal single page application, it is easy to just bring up the site and test anything you want. Since Playwright focuses on testing how a user would use the site, you will need to wait until your site is composed with the various MFEs before you test.
This ensures you're testing the actual integrated experience that users will encounter, which is more valuable than testing disconnected pieces separately with Playwright. You should have already tested these components in isolation at a lower level of the pyramid.
Features over Pages
The traditional way to test using Playwright is to define page classes that help to interact with the real page of the web app. This allows you to define interactions for the page you are testing. This works extremely well when you have relatively small pages, or pages that are wholly owned by a single team. What about when you have a page that is composed of several Microfrontend components that are owned by multiple teams?
Here's an example of the traditional approach:
1const { expect } = require('@playwright/test');
2
3exports.PlaywrightDevPage = class PlaywrightDevPage {
4 constructor(page) {
5 this.page = page;
6 this.sizeSelector = this.page.getByRole('listitem', { name: 'Select Size' });
7 this.addToBag = this.page.getByRole('button', { name: 'Add to Bag' });
8 this.addToBagNotification = this.page.getByRole('alert', { name: 'Added to bag' });
9 this.recommendation = this.page.getByRole('listitem', { name: 'Recommendation' });
10 }
11
12 async goto(productId) {
13 await this.page.goto(`/products/${productId}`);
14 }
15
16 async selectProduct() {
17 await this.sizeSelector.first().click();
18 await expect(this.addToBag).toBeEnabled();
19 }
20
21 async addProductToBag() {
22 await this.addToBag.click();
23 await expect(this.addToBagNotification).toBeVisible();
24 }
25
26 async gotoRecommendedProduct() {
27 const recommendedItem = await this.recommendation.first().click();
28 await expect(this.page.getByRole('header')).toBeVisible();
29 }
30};
In general, you can take the same approach, but you have to extend the definition of "page" a little bit. Instead of defining the pages as a whole and the features that are included, you can instead scope the interactions to the user flows instead. This allows you to logically separate your page interactions, but not needing to rely on other teams for the rest of the features on the compose page.
Here's an approach you might take in an MFE architecture that focuses on features rather than pages:
product-mfe/ui/fixtures/size-select.js
1// Size select Feature owned by the Product Team
2class SizeSelectFeature {
3 constructor(page) {
4 this.page = page;
5 this.sizeSelector = this.page.getByRole('listitem', { name: 'Select Size' });
6 this.addToBag = this.page.getByRole('button', { name: 'Add to Bag' });
7 }
8
9 async goto(productId) {
10 await this.page.goto(`/products/${productId}`);
11 }
12
13 async selectSize() {
14 await this.sizeSelector.first().click();
15 await expect(this.addToBag).toBeEnabled();
16 }
17}
checkout-mfe/ui/fixtures/add-to-bag.js
1// checkout-mfe/ui/fixtures/add-to-bag.js
2// Add to bag Feature owned by the Checkout Team
3class AddToBagFeature {
4 constructor(page) {
5 this.page = page;
6 this.addToBag = this.page.getByRole('button', { name: 'Add to Bag' });
7 this.addToBagNotification = this.page.getByRole('alert', { name: 'Added to bag' } );
8 }
9
10 async goto(productId) {
11 await this.page.goto(`/products/${productId}`);
12 }
13
14 async addProductToBag() {
15 await this.addToBag.click();
16 await expect(this.addToBagNotification).not.toBeVisible();
17 }
18}
recommendation-mfe/ui/fixtures/product-recommendation.js
1// Product recommendation Feature owned by the Suggestions Team
2class ProductRecommedationFeature {
3 constructor(page) {
4 this.page = page;
5 this.recommendation = this.page.getByRole('listitem', { name: 'Recommendation' });
6 }
7
8 async goto(productId) {
9 await this.page.goto(`/products/${productId}`);
10 }
11
12 async gotoRecommendedProduct() {
13 const recommendedItem = await this.recommendation.first().click();
14 await expect(this.page.getByRole('header')).toBeVisible();
15 }
16}
The different feature files are all navigating to the same page, but they are only testing the content that they own.
Why Feature-Focused Testing is Better for Micro Frontends
This feature-focused approach offers several advantages for micro frontend architectures:
- Team Ownership: Each team can maintain their own feature fixtures, allowing them to update tests when their component changes without affecting other teams.
- Reduced Test Coupling: Tests are only coupled to the features they are testing, not to the entire page structure.
- Parallel Development: Teams can develop and test their features independently.
- Clearer Responsibility: When a test fails, it's clearer which team needs to investigate and fix the issue.
- Component Reuse: Feature fixtures can be reused across different pages where the same component appears.
- Test Isolation: Tests can be focused on specific features or flows, making them more stable and less prone to breaking due to changes in unrelated parts of the page.
Here's how you might use these feature fixtures in a test:
product-mfe/ui/tests/size-select.js
1test('Size select', async ({ page }) => {
2 const productId = 'productId-123'
3 const sizeSelect = new SizeSelectFeature(page);
4 await sizeSelect.goto(productId);
5 await sizeSelect.selectSize();
6
7 // Other validations and actions
8});
Conclusion
Using Playwright in a micro frontend architecture can be done effectively by following the steps above and making sure to only test after you have composed your site. By shifting left with component testing, waiting for composition before integration testing, and focusing on features rather than whole pages, you can create a robust testing strategy that works well with distributed teams and codebases.
This approach acknowledges the reality of modern web development where a single page is often the responsibility of multiple teams. It creates clearer ownership boundaries, reduces test fragility, and still provides the end-to-end coverage that gives confidence in the overall user experience.
What's next?
One major gap you might have picked up on is that while these test show how you can test the site in pieces, it does not yet cover how you can use these isolated component definitions to test full user flows for something like a smoke test. That will come in the next blog and I will link it here once it is complete.