Running Playwright Python in GitHub Actions

9 min read

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: 30

Five steps:

  1. actions/checkout — clones the repo into the runner.
  2. actions/setup-python — installs the Python version you specify.
  3. Install dependenciespip install your requirements and playwright install --with-deps to fetch browsers and Linux libs.
  4. Run testspytest with whatever flags your team uses.
  5. Upload artefactsif: 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 install for 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-deps

The 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:8000

Three things to know:

  • & backgrounds the process so subsequent steps can run.
  • Always poll for health before running tests — sleep 5 is unreliable; a curl loop with a timeout is robust.
  • pytest --base-url http://localhost:8000 points 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: 30

The 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 → Python actions/setup-python@v5
  • TS npm ci → Python pip install -r requirements.txt
  • TS npx playwright install --with-deps → Python playwright install --with-deps
  • TS npx playwright test → Python pytest tests/
  • TS --reporter=html artefacts → Python --alluredir=allure-results or --html=... artefacts

Only the language-toolchain steps change. The rest — caching, matrix, secrets, artefact upload — is identical YAML.

⚠️ Common mistakes

  • Forgetting --with-deps and getting cryptic shared-library errors. playwright install alone fetches browsers but skips OS libs on Linux. The error (error while loading shared libraries: libnss3.so.0) is opaque on first encounter. Always pair install with --with-deps on 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. Add if: 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.

  1. Create .github/workflows/playwright.yml in your test project. Start from the canonical workflow at the top of this lesson.

  2. Commit and push. The action runs on the next push or PR. Open the Actions tab in GitHub to watch the run.

  3. First run will probably fail — typical reasons:

    • Missing requirements.txt → add it.
    • Missing --with-deps on a Linux runner → already in the workflow, but check.
    • Tests rely on --headed from pytest.ini → drop the flag for CI by overriding via the run command: pytest tests/ --browser chromium.
  4. Add caching: insert the actions/cache@v4 step before pip install. Re-run the workflow; the second run should be ~60 seconds faster.

  5. Add a matrix. Replace the single pytest step with a strategy matrix over [chromium, firefox, webkit]. Push, watch three parallel jobs run.

  6. Add a secret. Set a dummy TEST_PASSWORD in repo Secrets, reference it as env: TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}, and have a test print os.environ["TEST_PASSWORD"] (with -s to see it). Confirm the print shows the secret value, not an empty string.

  7. 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.

  8. Stretch: add a concurrency block to cancel old runs when a new commit lands on the same branch:

    concurrency:
      group: ${{ github.workflow }}-${{ github.ref }}
      cancel-in-progress: true

    Push 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.

// tip to track lessons you complete and pick up where you left off across devices.