A test that only runs on your laptop is half a test. Until the same suite runs on every PR, every commit to main, and every nightly schedule — automatically, without anyone remembering to type pytest — bugs sneak in between manual runs. GitHub Actions is the most direct path from "tests on my machine" to "tests in CI" for a Playwright Python project: free for open source, generous for private repos, and the workflow file is YAML you commit alongside your code. This lesson covers the canonical workflow, the --with-deps browser install that's almost always missed, environment variables, matrix strategies for cross-browser runs, and starting a local server before the tests fire.
The canonical workflow
Drop this file at .github/workflows/playwright.yml:
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
playwright install --with-deps
- name: Run tests
run: pytest tests/ --browser chromium --browser firefox
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: |
test-results/
reports/
retention-days: 30Five steps:
actions/checkout— clones the repo into the runner.actions/setup-python— installs the Python version you specify.- Install dependencies —
pip installyour requirements andplaywright install --with-depsto fetch browsers and Linux libs. - Run tests —
pytestwith whatever flags your team uses. - Upload artefacts —
if: always()ensures artefacts upload even on failure (which is when you want them most).
The trigger on: [push, pull_request] runs the workflow on every push to any branch and on every pull request. For a typical project, narrow this — push: branches: [main] plus pull_request is the conventional default.
playwright install --with-deps — the easy-to-miss step
On a fresh Ubuntu runner, the browsers Playwright bundles also need OS libraries (libnss3, libatk1.0-0, libxss1, several others) that aren't preinstalled. Without them, browser launch fails with cryptic shared-library errors.
playwright install --with-deps does both:
- Downloads Chromium, Firefox, and WebKit binaries.
- Runs
apt-get installfor every system library each browser needs.
The flag is essential on Linux CI. On macOS and Windows runners, the OS libs are already there and --with-deps is a no-op — but harmless. Always include it.
Caching browsers across runs
Browser downloads are ~200 MB. Without caching, every workflow run pays that download cost. Cache ~/.cache/ms-playwright keyed by the Playwright version:
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('requirements.txt') }}
- run: playwright install --with-depsThe hashFiles('requirements.txt') cache key means the cache is invalidated only when you bump the Playwright version — small change, big speedup (typically 60-90 seconds saved per run).
Running against a deployed environment
The simplest CI flow: tests run against a deployed staging URL. Pass --base-url from a secret:
- name: Run tests against staging
run: pytest tests/ --base-url ${{ secrets.STAGING_URL }}The URL never appears in your repo; the secret is set in the repo's GitHub Settings → Secrets. Your pytest.ini's base_url becomes a default; the CLI --base-url overrides it.
Starting a local server before the tests
When the app under test isn't deployed yet, start it inside the workflow before pytest runs:
- name: Install app dependencies
run: pip install -r app/requirements.txt
- name: Start the server
run: python app/manage.py runserver &
# The & runs the server in the background
- name: Wait for server
run: |
for i in {1..30}; do
curl -fs http://localhost:8000/health && break
sleep 1
done
- name: Run tests
run: pytest tests/ --base-url http://localhost:8000Three things to know:
&backgrounds the process so subsequent steps can run.- Always poll for health before running tests —
sleep 5is unreliable; a curl loop with a timeout is robust. pytest --base-url http://localhost:8000points the suite at the running server.
For containerised apps, prefer docker-compose up -d over backgrounded shell processes — covered in lesson 3.
Environment variables and secrets
Anything sensitive — passwords, API keys, third-party tokens — comes from GitHub Secrets, never from the YAML directly:
env:
BASE_URL: ${{ secrets.STAGING_URL }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
steps:
- run: pytest tests/The secrets are exposed as environment variables to the test process; your conftest.py or test code reads them with os.environ["TEST_PASSWORD"]. Job-level env: is preferable to setting variables inline on each step — it keeps the secret list visible in one place.
Matrix strategy for cross-browser runs
A single job that runs all three browsers serially is slow. Split into three parallel jobs with a matrix:
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: |
pip install -r requirements.txt
playwright install --with-deps ${{ matrix.browser }}
- run: pytest tests/ --browser ${{ matrix.browser }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: results-${{ matrix.browser }}
path: test-results/Three machines run in parallel — chromium, firefox, webkit — each installing only its own browser. fail-fast: false prevents one job's failure from cancelling the others (you want to see all three results, not just the first to fail).
The same pattern applies to Python versions, OSes, or test slices. Stack the matrix axes — python-version: ['3.11', '3.12'] × browser: [chromium, firefox] — and you get four parallel jobs.
The CI pipeline in shape
A complete production workflow
Putting everything together — caching, matrix, secrets, server start, artefact upload:
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 2 * * *" # nightly at 02:00 UTC
env:
BASE_URL: ${{ secrets.STAGING_URL }}
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('requirements.txt') }}
- run: |
pip install -r requirements.txt
playwright install --with-deps ${{ matrix.browser }}
- run: pytest tests/ --browser ${{ matrix.browser }} --alluredir=allure-results
- uses: actions/upload-artifact@v4
if: always()
with:
name: results-${{ matrix.browser }}
path: |
test-results/
allure-results/
retention-days: 30The schedule trigger runs the suite nightly even if no one pushes; matrix runs all three browsers in parallel; caching speeds up reinstalls; secrets keep credentials out of the file. This is the shape most production projects converge on.
Coming from Playwright TypeScript?
The TS course's GitHub Actions workflow is structurally identical:
- TS
actions/setup-node@v4→ Pythonactions/setup-python@v5 - TS
npm ci→ Pythonpip install -r requirements.txt - TS
npx playwright install --with-deps→ Pythonplaywright install --with-deps - TS
npx playwright test→ Pythonpytest tests/ - TS
--reporter=htmlartefacts → Python--alluredir=allure-resultsor--html=...artefacts
Only the language-toolchain steps change. The rest — caching, matrix, secrets, artefact upload — is identical YAML.
⚠️ Common mistakes
- Forgetting
--with-depsand getting cryptic shared-library errors.playwright installalone fetches browsers but skips OS libs on Linux. The error (error while loading shared libraries: libnss3.so.0) is opaque on first encounter. Always pairinstallwith--with-depson CI. - Not using
if: always()on the artefact upload. Without it, artefacts upload only on success — exactly when you don't need them. Failures are when you want the test-results, screenshots, and traces to download. Addif: always()and never wonder where your debugging artefacts went. - Hardcoding browser binaries in the cache key.
key: playwright-browsers(no hash) means the cache never invalidates, so you keep using stale browsers when you bump Playwright. Always key the cache on something that changes when the browser version changes —hashFiles('requirements.txt')is the simplest correct answer.
🎯 Practice task
Wire your project up to GitHub Actions end-to-end. 30-40 minutes.
-
Create
.github/workflows/playwright.ymlin your test project. Start from the canonical workflow at the top of this lesson. -
Commit and push. The action runs on the next push or PR. Open the Actions tab in GitHub to watch the run.
-
First run will probably fail — typical reasons:
- Missing
requirements.txt→ add it. - Missing
--with-depson a Linux runner → already in the workflow, but check. - Tests rely on
--headedfrompytest.ini→ drop the flag for CI by overriding via the run command:pytest tests/ --browser chromium.
- Missing
-
Add caching: insert the
actions/cache@v4step beforepip install. Re-run the workflow; the second run should be ~60 seconds faster. -
Add a matrix. Replace the single
pyteststep with a strategy matrix over[chromium, firefox, webkit]. Push, watch three parallel jobs run. -
Add a secret. Set a dummy
TEST_PASSWORDin repo Secrets, reference it asenv: TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}, and have a test printos.environ["TEST_PASSWORD"](with-sto see it). Confirm the print shows the secret value, not an empty string. -
Add a schedule. Add
schedule: - cron: "0 2 * * *"to run the suite at 02:00 UTC nightly. The next scheduled run shows up in the Actions tab the following morning. -
Stretch: add a
concurrencyblock to cancel old runs when a new commit lands on the same branch:concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truePush twice in quick succession; the first run cancels and the second takes over.
CI is the safety net the rest of the suite hangs from. The next lesson is the speedup — running tests in parallel with pytest-xdist so the wall time of your suite shrinks linearly with worker count.