diff --git a/content/blog/2025-10-31-how-to-calculate-css-code-coverage-with-playwright.md b/content/blog/2025-10-31-how-to-calculate-css-code-coverage-with-playwright.md new file mode 100644 index 0000000..bd7932f --- /dev/null +++ b/content/blog/2025-10-31-how-to-calculate-css-code-coverage-with-playwright.md @@ -0,0 +1,149 @@ +--- +title: How to calculate CSS code coverage with @playwright/test +excerpt: Collect and analyze CSS coverage using Playwright tests to detect and prevent unused CSS in production. +--- + + + +One of the most common questions around analyzing CSS quality is how to detect unused CSS. Shipping unused CSS to the browser is a waste so we want to avoid it! This post shows how to collect CSS code coverage data with the help of [`@playwright/test`](https://playwright.dev/docs/intro). + +## Table of contents + +1. [Writing Playwright tests](#writing-playwright-tests) +2. [Collect CSS Coverage from Playwright tests](#collect-css-coverage-from-playwright-tests) +3. [Bonus: Analyzing and linting CSS Coverage](#bonus-analyzing-and-linting-css-coverage) + +## Writing Playwright tests + +Start with writing Playwright tests for our website. Playwright tests run in a headless browser and they emulate user behaviour to verify that your website works as intended. The Project Wallace website has 230 of these tests. Here is one of them: + +```ts +import { test, expect } from './tests/fixtures' + +test('Navigation: pressing Escape on the popover closes the popover', async ({ page }) => { + let trigger = page.getByRole('navigation').getByLabel('Additional navigation items') + let popover = page.locator('.nav-popover') + + await page.setViewportSize({ width: 420, height: 800 }) + await trigger.click() + + // Press Escape + await page.keyboard.press('Escape') + + // Check that the popover is not visible + await expect.soft(popover).not.toBeVisible() + await expect.soft(trigger).not.toHaveAttribute('aria-expanded', 'true') +}) +``` + +That's just one test, but imagine you have your whole website covered by these sort of tests. Extensive test coverage is essential for calculating CSS coverage. + +Notice the import of a fixture instead of the default `import { test, expect } from playwright/test'`. In the next step we'll look at how to collect the data using these tests and why we use this fixture. + +## Collect CSS Coverage from Playwright tests + +Now that we have tests, we can start collecting data! Playwright provides two functions related to CSS Coverage: [`coverage.startCSSCoverage()`](https://playwright.dev/docs/api/class-coverage#coverage-start-css-coverage) and [`coverage.stopCSSCoverage()`](https://playwright.dev/docs/api/class-coverage#coverage-stop-css-coverage). If you combine these functions with Playwright [fixtures](https://playwright.dev/docs/test-fixtures#creating-a-fixture) you can collect coverage data from within your Playwright tests: + +```ts +type Fixtures = { + cssCoverage: void +} + +export const test = base_test.extend({ + cssCoverage: [ + async ({ page }, use, testInfo) => { + // start before test + await page.coverage.startCSSCoverage() + // run the test + await use() + // stop after test + let coverage = await page.coverage.stopCSSCoverage() + + // write coverage to disk + await fs.writeFile('path-to-file.json', JSON.stringify(coverage)) + }, + { auto: true } + ] +}) +``` + +You can peek at [our implementation on GitHub](https://github.com/projectwallace/projectwallace.com/blob/68080570ce614335bd6c10d5980f767c2628de86/tests/fixtures.ts#L6-L41). That one is a bit more involved because it automatically generates a unique file name for each coverage file and attaches it to the test. + +Let's break it down: + +### Creating the fixture + +This creates the actual [fixture](https://playwright.dev/docs/test-fixtures#creating-a-fixture) _and_ it tells Playwright to [automatically](https://playwright.dev/docs/test-fixtures#automatic-fixtures) set up this fixture for every test. + +```ts +type Fixtures = { + cssCoverage: void +} + +export const test = base_test.extend({ + cssCoverage: [ + async ({ page }, use, testInfo) => { + // etc. + }, + { auto: true } + ] +}) +``` + +### Collecting coverage + +The core of the fixture is surprisingly small! Start collecting, run the test and stop collecting. + +```ts +// start before test +await page.coverage.startCSSCoverage() +// Run the test +await use() +// stop after test +let coverage = await page.coverage.stopCSSCoverage() +``` + +### Write coverage to disk + +To be able to use the coverage data later on we need to write it to disk. In practice you'll give this JSON file a unique name, probably the name or path of your actual test. + +```ts +await fs.writeFile('./css-coverage/path-to-file.json', JSON.stringify(coverage)) +``` + +That's all! Because of the auto-fixture every test will now self-report all CSS Coverage data. + +## Bonus: Analyzing and linting CSS Coverage + +After running our 230 Playwright tests we have megabytes of coverage data that we can start analyzing. As you could read in my [previous blog post](https://www.projectwallace.com/blog/better-coverage-ranges) there are a lot of gaps to fill. On top of that many tests report the same coverage ranges for the same files so there is also a lot deduplication to do. And prettifying the CSS because no-one likes inspecting minified CSS. This is where our new [@projectwallace/css-code-coverage](https://github.com/projectwallace/css-code-coverage) package comes in handy. It does all that for us and generates handy statistics. It even ships with a CLI that works as a CSS Coverage linter! + +The previous step generated a `/css-coverage` folder full with 230 JSON files. We're going to use @projectwallace/css-code-coverage to make sure that our coverage is at an acceptable number: + +```sh +css-coverage --coverage-dir=./css-coverage --min-line-coverage=.9 --min-file-line-coverage=.7 --show-uncovered=all +``` + +See [our implementation here](https://github.com/projectwallace/projectwallace.com/blob/68080570ce614335bd6c10d5980f767c2628de86/package.json#L18) as a `package.json` script. + +This command: + +- Tells the linter that our coverage JSON files live at `./css-coverage` +- Sets the threshold for line coverage to .9 (or 90%) meaning that at least 90% of _all_ lines of CSS combined must be covered +- Sets the threshold of file line coverage to .7 (or 70%) meaning that each individual CSS file must at least have 70% line coverage +- Instructs the linter to report all CSS files that don't have 100% coverage + +This is the result when running it for projectwallace.com's repository: + +
+ + + +
The CLI tool highlights which lines are not covered and even gives a hint with how many lines to go to meet the threshold.
+
+ +The @projectwallace/css-code-coverage package focuses on lines and not on bytes because as developers we also look at lines of code. + +Running this CLI in a GitHub action after the Playwright test gives you have accurate test data in each run and helps you catch shipping unused CSS! diff --git a/src/lib/img/blog/2025-10-31-how-to-calculate-css-code-coverage-with-playwright/css-code-coverage-linter-cli.png b/src/lib/img/blog/2025-10-31-how-to-calculate-css-code-coverage-with-playwright/css-code-coverage-linter-cli.png new file mode 100644 index 0000000..94f8abb Binary files /dev/null and b/src/lib/img/blog/2025-10-31-how-to-calculate-css-code-coverage-with-playwright/css-code-coverage-linter-cli.png differ