04 · CI Integration (GitHub Actions)¶
A test suite that only runs on someone's laptop protects nobody. This module wires a Playwright/pytest suite into GitHub Actions so every pull request runs the full suite automatically, with browsers cached, artifacts uploaded on failure, and results visible without anyone needing local setup.
A minimal workflow¶
# .github/workflows/e2e.yml
name: E2E Tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: playwright install --with-deps chromium
- run: pytest tests/ --junitxml=results.xml
# `playwright install --with-deps` installs both the browser
# binaries AND the OS-level shared libraries (fonts, codecs,
# etc.) Chromium needs to run headless on a bare Ubuntu runner —
# skipping --with-deps is the #1 cause of "works locally, fails
# in CI" for a fresh CI setup
Caching browser binaries between runs¶
Downloading Chromium/Firefox/WebKit on every run wastes minutes. Cache the Playwright browser directory keyed on the installed version:
- name: Get Playwright version
id: pw-version
run: echo "version=$(pip show playwright | grep Version | cut -d' ' -f2)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- run: playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: playwright install-deps chromium
if: steps.playwright-cache.outputs.cache-hit == 'true'
# on a cache hit, the browser binary itself is restored from
# cache (skipping the download), but install-deps still runs
# because the cache only holds ~/.cache/ms-playwright, not the
# apt-installed OS libraries the runner image may not persist
Uploading traces and screenshots on failure¶
The whole point of tracing (Level 2/4) is worthless if the trace file disappears when the CI job ends. Upload it as a build artifact:
- run: pytest tests/ --tracing=retain-on-failure --screenshot=only-on-failure
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: test-results/
retention-days: 7
# `if: failure()` means this step only runs when a prior step
# failed, so passing runs don't waste storage — a teammate can
# download the artifact zip and run
# `playwright show-trace trace.zip` locally, seeing exactly
# what the CI browser saw
Sharding across parallel jobs¶
For suites too large for one runner, split the test IDs across a matrix and merge results, rather than accepting a 40-minute serial run:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt && playwright install --with-deps chromium
- run: pytest tests/ --shard=${{ matrix.shard }}/4 --junitxml=results-${{ matrix.shard }}.xml
- uses: actions/upload-artifact@v4
with:
name: results-${{ matrix.shard }}
path: results-${{ matrix.shard }}.xml
# `--shard=N/M` is provided by pytest-playwright: each of the
# 4 matrix jobs runs a disjoint quarter of the collected tests
# in parallel, cutting a 40-minute suite to roughly 10 minutes
# wall-clock (fail-fast: false so one shard failing doesn't
# cancel the others mid-run)
Required status check on pull requests¶
# repository settings, not YAML — but the practical effect:
# Settings > Branches > Branch protection rule for `main`
# -> Require status checks to pass before merging
# -> select the "test" job from e2e.yml
# once configured, GitHub blocks the "Merge" button on any PR
# until this workflow's job reports success — turning "please
# run the tests before merging" into something enforced rather
# than a norm people can forget
Full worked example: complete workflow¶
# .github/workflows/e2e.yml
name: E2E Tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- uses: actions/cache@v4
id: pw-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
- run: playwright install --with-deps chromium
if: steps.pw-cache.outputs.cache-hit != 'true'
- run: playwright install-deps chromium
if: steps.pw-cache.outputs.cache-hit == 'true'
- run: >
pytest tests/ --shard=${{ matrix.shard }}/2
--tracing=retain-on-failure
--junitxml=results-${{ matrix.shard }}.xml
- uses: actions/upload-artifact@v4
if: failure()
with:
name: traces-shard-${{ matrix.shard }}
path: test-results/
How It Actually Works¶
playwright install --with-deps is really two independent operations
happening back to back: playwright install downloads the pinned browser
build (Level 1 Module 2) into ~/.cache/ms-playwright, while --with-deps
additionally shells out to the runner's package manager (apt-get on
Ubuntu images) to install the shared libraries — font rendering libraries,
GTK/NSS/audio codecs — that a headless Linux Chromium process needs just
to start up and produce correct output, since a bare CI container image
typically ships none of them. Caching ~/.cache/ms-playwright skips the
download but not the apt-get step, which is exactly why the workflow
still runs install-deps separately on a cache hit — the OS package
manager state isn't part of what gets cached.
--shard=N/M doesn't change how any individual test runs — it operates
purely at pytest's collection phase, partitioning the already-collected
list of test items into M disjoint groups (typically by simple index modulo,
similar to the hand-rolled sharder in Level 2 Module 6) before any browser
is launched. Each shard then runs its own completely independent
Playwright session — separate driver process, separate browser process —
which is why fail-fast: false matters for sharded CI: one shard's browser
crashing or one shard's assertion failing has zero effect on the CDP
sessions running in the other matrix jobs, since they share nothing beyond
the git checkout.
🔀 Related lessons on other tracks¶
Exercise¶
- Add a GitHub Actions workflow to a real (or sample) repository that
installs dependencies, installs Chromium with
--with-deps, and runspytest. - Add browser-binary caching keyed on the Playwright version and confirm a second run shows a cache hit in the Actions log.
- Add
--tracing=retain-on-failureand anupload-artifactstep gated onif: failure(); deliberately break a test, push, and download the resulting trace artifact to open locally withplaywright show-trace. - Split the suite across a 2-job shard matrix using
--shard=N/Mand confirm both shards together still run every test exactly once (compare total test counts before and after sharding).