← Back to list

Visual Testing That Doesn’t Suck: Playwright + GitHub Actions = Chef’s Kiss 👌

Modern teams rely on visual regression testing to keep UI changes intentional. In this guide, I’ll show how to run Playwright visual tests…

Berkan Sezer in Insider One Engineering · 2026-01-12 07:02 · 45 claps · 7.7 min read
#playwright-test #ci-cd-pipeline #github-actions #visual-testing
Open on Medium ↗
Wiki topics: ML · Machine Learning ☁️ · DevOps & Cloud 🔓 · Open Source 🍳 · Food & Cooking

Visual Testing That Doesn’t Suck: Playwright + GitHub Actions = Chef’s Kiss 👌

Modern teams rely on visual regression testing to keep UI changes intentional. In this guide, I’ll show how to run Playwright visual tests on every pull request, publish rich HTML reports to AWS S3, comment the results back to the PR, and provide a manual workflow to safely update snapshots when changes are expected.

CI Flow of playwright.yml

CI Flow of playwright.yml

What you will get from this article.

  • Visual diffs: Compare screenshots to detect regressions.
  • Rich reporting: Upload the HTML report and logs to S3, link them in the PR.
  • Snapshot management: Manually trigger a second workflow to update snapshots and commit them back to the same PR.
  • Traceability: GitHub App token for safe commits; S3 path keyed by branch and short SHA.

Prerequisites

  • A working *Playwright* test suite, including visual snapshot tests.
  • AWS S3 bucket access (programmatic credentials for CI).
  • GitHub App credentials (App ID, private key) for making commits from CI.
  • A self‑hosted runner(optional but strongly recommended) or GitHub‑hosted runner with Chromium dependencies. The examples below run on self-hosted.
  • Project secrets configured in your repo settings:
CI_GITHUB_APP_ID
CI_GITHUB_APP_PRIVATE_KEY
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_REGION
AWS_S3_BUCKET
  • Any app‑specific runtime envs (example: APP_PLUGIN_ID, APP_SECRET_KEY, APP_ROLE).

https://github.com/{company}/{repository}/settings/secrets/actions

https://github.com/{company}/{repository}/settings/secrets/actions

Workflow 1: Run Playwright on every PR and publish reports 📷

Create a file like .github/workflows/playwright.yml to run on pull_request to develop. Key responsibilities:

  • Install dependencies and Playwright browsers
  • Start your web app under test
  • Execute Playwright
  • Upload HTML report and logs to S3 (scoped by branch/commit for preventing cache and rerun issues)
  • Comment on the PR with a friendly summary and deep links

Here’s the essential structure:

name: Playwright Tests
on:
  pull_request:
    branches:
      - develop
jobs:
  test:
    timeout-minutes: 60
    runs-on: self-hosted #change it with your runner name.

And you need to add your Playwright job here, step by step.

Add your GitHub App token generator:

    steps:
      - name: Create GitHub App token
        id: github-app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ secrets.GITHUB_APP_ID }}
          private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }}

      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*

Install dependencies & Playwright Browsers:

      - name: Install dependencies
        run: npm ci --legacy-peer-deps

      - name: Install Playwright Browsers
        run: npx playwright install --with-deps chromium # We just need chromium

Start your web app with nohup: Nohup keeps your project open in the background, and you can continue to use the terminal.

      - name: Start web server
        run: |
          nohup npm start > web.log 2>&1 &
          sleep 10
        env:
          APP_PLUGIN_ID: ${{ secrets.APP_PLUGIN_ID }}
          APP_SECRET_KEY: ${{ secrets.APP_SECRET_KEY }}
          APP_ROLE: ${{ secrets.APP_ROLE }}

Start running Playwright Tests:

Use --reporter=dot,html to generate both concise CI output and a browsable HTML report.

      - name: Run Playwright tests
        id: playwright-tests
        run: |
          npx playwright test --reporter=dot,html
          echo "exit_code=$?" >> $GITHUB_OUTPUT #we need this later
        env:
          APP_PLUGIN_ID: ${{ secrets.APP_PLUGIN_ID }}
          APP_SECRET_KEY: ${{ secrets.APP_SECRET_KEY }}
          APP_ROLE: ${{ secrets.APP_ROLE }}

Configure AWS Credentials:

      - name: Configure AWS Credential for S3
        if: always()
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION_EU_WEST }}

Upload to AWS S3: Here, it creates a unique path for your test results like playwright/TASK-ID/SHA

      - name: Upload test report to S3
        if: always()
        run: |
          # Create short sha for unique folder names
          COMMIT_SHA=${GITHUB_SHA:0:7}
          BRANCH_NAME=$(echo "${{ github.head_ref }}" | cut -d'/' -f2)
          PROJECT_NAME=playwright

          # Determine test status
          if [ -f "playwright-report/index.html" ]; then
            TEST_STATUS="completed"
          else
            TEST_STATUS="failed"
          fi

          # Upload HTML report
          if [ -d "playwright-report" ]; then
            aws s3 sync playwright-report/ s3://${{ secrets.AWS_S3_BUCKET }}/$PROJECT_NAME/$BRANCH_NAME/$COMMIT_SHA/ --delete
            echo "Report uploaded to: s3://${{ secrets.AWS_S3_BUCKET }}/$PROJECT_NAME/$BRANCH_NAME/$COMMIT_SHA/"
          fi

          # Upload server logs if they exist
          if [ -f "web.log" ]; then
            aws s3 cp web.log s3://${{ secrets.AWS_S3_BUCKET }}/$PROJECT_NAME/$BRANCH_NAME/$COMMIT_SHA/web.log
          fi

          if [ -f "test.log" ]; then
            aws s3 cp test.log s3://${{ secrets.AWS_S3_BUCKET }}/$PROJECT_NAME/$BRANCH_NAME/$COMMIT_SHA/test.log
          fi

          # Create a summary file with metadata
          cat > report-metadata.json << EOF
          {
            "repository": "$GITHUB_REPOSITORY",
            "branch": "$BRANCH_NAME",
            "commit": "$GITHUB_SHA",
            "workflow_run_id": "$GITHUB_RUN_ID",
            "test_status": "$TEST_STATUS",
            "report_url": "s3://${{ secrets.AWS_S3_BUCKET }}/$PROJECT_NAME/$BRANCH_NAME/$COMMIT_SHA/"
          }
          EOF

          aws s3 cp report-metadata.json s3://${{ secrets.AWS_S3_BUCKET }}/$PROJECT_NAME/$BRANCH_NAME/$COMMIT_SHA/metadata.json

          # Output the S3 URL for easy access
          echo "::notice::Playwright report uploaded to S3: s3://${{ secrets.AWS_S3_BUCKET }}/$PROJECT_NAME/$BRANCH_NAME/$COMMIT_SHA/"

          # Save S3 URL to a file for the comment step
          echo "https://${{ secrets.AWS_S3_BUCKET }}.s3.eu-west-1.amazonaws.com/$PROJECT_NAME/$BRANCH_NAME/$COMMIT_SHA" > s3_report_url.txt

Prepare a comment message for sending to the Pull Request

Since links in Github Comment’s not opening on new tab, i added a 💡 Tip message for developers

      - name: Comment PR with S3 URL
        if: always() && github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const s3Url = fs.readFileSync('s3_report_url.txt', 'utf8').trim();
            const testStatus = '${{ steps.upload-test-report.outputs.test_status }}' || 'unknown';
            const statusIcon = testStatus === 'completed' ? '✅' : '❌';

            const comment = `## Playwright Test Results ${statusIcon}

            **Status:** ${testStatus === 'completed' ? 'All tests passed' : 'Some tests failed'}

            **📊 Test Report:** [View Report](${s3Url}/index.html)

            **🔍 Logs:**
            - [Web Server Log](${s3Url}/web.log) (if available)
            - [Test Server Log](${s3Url}/test.log) (if available)
            - [Metadata](${s3Url}/metadata.json)

            ---
            *💡 Tip: Right-click links and select "Open in new tab" or use Cmd+Click*
            *Report generated at: ${new Date().toLocaleString('tr-TR', { 
              day: '2-digit', 
              month: '2-digit', 
              year: 'numeric', 
              hour: '2-digit', 
              minute: '2-digit', 
              second: '2-digit',
              hour12: false 
            }).replace(/\//g, '.')}*
            *Workflow Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})*`;

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: comment
            });```

Add some scenario if it fails

      - name: Fail if tests failed
        if: steps.playwright-tests.outputs.exit_code != '0'
        run: exit 1

⚠️ Before running this on your branch, you have to push to develop. Otherwise it will not trigger.

Now, you have a CI for playwright testing. Now, you can open a pull request to develop branch, and you will be able to see the action under your PR.

It will send a message when it is done.

Example for failing cases.

Example for failing cases.

Workflow 2: Manually update visual snapshots 📸

When a change is intentional (new UI, redesign), you can update snapshots safely via a separate, manual workflow. This keeps the validation workflow strict and only updates snapshots on demand.

CI Flow of playwright-update-snapshots.yml

CI Flow of playwright-update-snapshots.yml

Create a file to .github/workflows/playwright-update-snapshots.yml like this

It triggers manually because of workflow_dispatch

name: Update Playwright Snapshots
on:
  workflow_dispatch:

jobs:
  update-snapshots:
    timeout-minutes: 60
    runs-on: self-hosted #change it with your runner name

Create a GitHub token and checkout:

    steps:
      - name: Create GitHub App token
        id: github-app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ secrets.GITHUB_APP_ID }}
          private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }}

      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: lts/*

Install Dependencies & Start Web Server:


      - name: Install dependencies
        run: npm ci --legacy-peer-deps

      - name: Install Playwright Browsers
        run: npx playwright install --with-deps chromium # We just need chromium

      - name: Start web server
        run: |
          nohup npm start > web.log 2>&1 &
          sleep 10
        env:
          APP_PLUGIN_ID: ${{ secrets.APP_PLUGIN_ID }}
          APP_SECRET_KEY: ${{ secrets.APP_SECRET_KEY }}
          APP_ROLE: ${{ secrets.APP_ROLE }}

Let the fight begin: Run the update command for Playwright:

- name: Update Playwright snapshots
        id: update-snapshots
        run: |
          npx playwright test --update-snapshots --reporter html
          echo "exit_code=$?" >> $GITHUB_OUTPUT

(Optional) You can save results as a GitHub artifact for debugging:

      - name: Upload Playwright Report as Artifact
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 10

Now, Screenshots updated 🎉 Let’s commit them to the PR.


      - name: Commit updated snapshots
        id: commit-snapshots
        run: |
          git config --local user.email "github-actions[bot]@users.noreply.github.com"
          git config --local user.name "Visual Snapshot Updater [bot]"
          git add __tests__/visual #give your test path here
          git diff --staged --quiet || git commit -m "Update Playwright snapshots [skip ci]"
          git push
          echo "snapshots_updated=true" >> $GITHUB_OUTPUT

Also developer should know the result of this runner. So, let's send a message to PR.

Also, I added the username of who triggered the job.


      - name: Comment on PR with results
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const updateExitCode = '${{ steps.update-snapshots.outputs.exit_code }}';
            const snapshotsUpdated = '${{ steps.commit-snapshots.outputs.snapshots_updated }}';

            let statusIcon, statusMessage, detailsMessage;
            if (updateExitCode === '0') {
              if (snapshotsUpdated === 'true') {
                statusIcon = '✅';
                statusMessage = 'Visual tests passed and snapshots updated successfully!';
                detailsMessage = 'The visual regression tests have been completed and any necessary snapshot updates have been committed to your branch.';
              } else {
                statusIcon = '✅';
                statusMessage = 'Visual tests passed - no snapshot updates needed';
                detailsMessage = 'All visual tests passed and your existing snapshots are up to date. No changes were committed.';
              }
            } else {
              statusIcon = '❌';
              statusMessage = 'Visual tests failed - check the logs for details';
              detailsMessage = 'The visual regression tests encountered failures. Please review the test output and logs to identify the issues.';
            }

            const comment = `## 🎨 Visual Test Results ${statusIcon}\n\n` +
              `**Status:** ${statusMessage}\n\n` +
              `**Details:** ${detailsMessage}\n\n` +
              `---\n` +
              `*This workflow was manually triggered to update Playwright visual test snapshots.*`;

            const { data: pulls } = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              head: context.repo.owner + ':' + context.ref.replace('refs/heads/', ''),
              state: 'open'
            });

            if (pulls.length > 0) {
              await github.rest.issues.createComment({
                issue_number: pulls[0].number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: comment
              });
            } else {
              console.log('No open PR found for this branch, skipping comment');
            }

If Fails unexpectedly, let us upload the results to the artifact for debugging:

      - name: Upload logs (if failed)
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: server-logs
          path: |
            web.log
            test.log

⚠️ Before running this on your branch, you have to push to develop. Otherwise it will not trigger.

Now we can test 🎉

  • You can reach this action in this path REPO_URL/actions/workflows/playwright-update-snapshots.yml
  • Click to Run Workflow , choose your branch and Run.
  • You will see it succeed.

Success page of playwright-update-snapshots.yml

Success page of playwright-update-snapshots.yml

Also success message has already been sent to the PR

Commit and Success Message for playwright-update-snapshots.yml

Commit and Success Message for playwright-update-snapshots.yml

Why a separate workflow ❓

  • Keeps the PR check deterministic: it must pass against current snapshots.
  • Snapshot updates are an intentional, auditable action.
  • Teams can review diffs before accepting new baselines.

Improvement Suggestions ⚡

  • You can add a CODEOWNER check for the second runner. It provides updates to screenshots by inexperienced developers.
  • Do not forget to configure your TTL(time to live) for S3 buckets and GitHub Artifacts to prevent extra charges.
  • Create your custom runner for better performance.

Conclusion 🗞️

With two small workflows, you get robust PR‑level visual validation and a safe, auditable path for updating snapshots. Developers get fast feedback and rich diagnostics; reviewers get confidence that UI changes are intentional.

Drop these YAMLs into .github/workflows, wire up the secrets, and you’ll have a production‑grade visual testing pipeline that scales with your team.

If you are interested in more performance tricks, you can check my other article.


메타데이터
post_id
efc27e0e8d83
slug
visual-testing-that-doesnt-suck-playwright-github-actions-chef-s-kiss-efc27e0e8d83
url
https://medium.com/insiderengineering/visual-testing-that-doesnt-suck-playwright-github-actions-chef-s-kiss-efc27e0e8d83
canonical_url
https://medium.com/insiderengineering/visual-testing-that-doesnt-suck-playwright-github-actions-chef-s-kiss-efc27e0e8d83
author_url
https://medium.com/@kberkansezer
status
ok
fetched_at
2026-06-28 14:26:31