In this article, you will learn how to set up an automated test workflow for a mockup SvelteKit project enforcing TypeScript code checks, linting, Vitest unit tests, Playwright for end-to-end testing and a simple build test. You will learn to leverage a self-hosted GitHub Actions runner and a pre-built dockerized testbed to optimize performance. By dockerizing the testbed, pushing it to GHCR (GitHub Container Registry - where docker images live) and taking advantage of the caching mechanisms inherent to Docker, you will significantly reduce test execution time and ensure a consistent testing environment for all your team members.

Why is this important? In professional software development, automated code checks and testing are essential to maintaining code quality and preventing regressions. GitHub Actions allows you to enforce these checks before merging changes into the main branch or making new releases.

So do yourself a favour and read Build and deploy locally using GitHub actions and Webhooks and Automating releases with GitHub actions - if you haven't already!

Now for implementing code checks for our mockup project (which by the way is right here).

Preparing the testbed

To optimize our testing process, we build a reusable testbed image containing static dependencies. This image is cached and reused across multiple test runs, saving time and ensuring a stable testing environment.

Dockerfile for the testbed

For the testbed, you need a Dockerfile that includes all your more or less static dependencies. The testbed will be built once, and re-used many times with caching. The first testbed build will take maybe 3-10 minutes depending on your hardware, but subsequent builds will take almost no time.

Our testbed includes a node image and playwright. Installing playwright with all the browser dependencies takes some time - so it's really nice to cache it in a pre-built image.

# Official node image
FROM node:22

# Install Playwright browsers and other static heavy dependencies
RUN npx playwright install
RUN npx playwright install-deps

Initializing the test environment

To test our project, we must also set up our project. However, the project's code is highly dynamic. We may frequently add and remove npm packages, change versions, and of course, add code, tests, etc. So, to ensure our project-specific dependencies and fresh code are installed, we always extend the testbed with a new Dockerfile with the latest code (we'll see how to include the latest code in a moment).

Dockerfile for running tests on our newest code

# Use the static testbed image as the base (because we've published the image, it's easy to share with our team)
FROM ghcr.io/example-org/sveltekit-testbed:v1 AS base

# Set working directory 
WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dynamic dependencies (which might change frequently)
RUN npm install

# Copy the rest of your dynamic application code
COPY . .

To re-iterate, this Dockerfile builds on top of the cached testbed image, installing only the project-specific dependencies.

Setting up the GitHub Actions workflow

This is kind of where the magic and automation happens. The workflow is triggered on every pull request to main. It pulls or builds our images and performs a series of checks and tests before allowing the merge.

Create a new workflow file in .github/workflows/test.yaml:

name: Code Checks and Testing

on:
  pull_request:
    branches:
      - main

jobs:
  check_and_test:
    runs-on: self-hosted

    steps:
    - name: Checkout repository
      uses: actions/checkout@v3

    - name: Attempt to pull testbed image for building speedup
      run: |
        docker pull ghcr.io/example-org/sveltekit-testbed:v1 || echo "Cached image not found; will build a new one."

    - name: Build testbed docker image from cache
      run: |
        docker build \
          --cache-from ghcr.io/example-org/sveltekit-testbed:v1 \
          -t ghcr.io/example-org/sveltekit-testbed:v1 \
          -f Dockerfile.testbed .

    - name: Build Docker image
      run: docker build -t workflow-testing -f Dockerfile.tests .

    - name: Run type checking
      run: docker run --rm workflow-testing npm run check

    - name: Run linter
      run: docker run --rm workflow-testing npm run lint

    - name: Run unit tests
      run: docker run --rm workflow-testing npm run test:unit

    - name: Run E2E tests
      run: docker run --rm --network host workflow-testing npm run test:e2e

    - name: Build project
      run: docker run --rm workflow-testing npm run build
  • The first step of all checks out your code in the state you are creating a PR to main.
  • Then the workflow attempts to pull the testbed image. If a cached version is available, it will be used directly. Otherwise, the image will be built from scratch in the next step.
  • In the third step, we'll build the image and publish it, but it will be very quick if there are no changes to the existing image. If there is no existing image, the build will occur and this will take some time.
  • In the fourth step, we'll build an image with our actual code, on top of the testbed
  • From the fifth step on, we'll run the various tests we've defined in our packages.json file for our SvelteKit project.

This will effectively give us a very clear overview in the GitHub Gui what checks pass, what checks fail and why.

Enforcing checks with branch protection rules

To ensure that no untested code gets merged into main, we configure branch protection rules in GitHub:

  1. Navigate to Settings in your GitHub repository.
  2. Go to Branches and find the Branch protection rules section.
  3. Click Add rule and enter main as the protected branch.
  4. Enable Require status checks to pass before merging.
  5. Select the required jobs (e.g., check_and_test).
  6. Save changes.

With these rules in place, a pull request cannot be merged unless all automated checks pass successfully.

Conclusion

By leveraging GitHub Actions, a pre-built testbed, and a self-hosted runner, we have optimized our CI/CD pipeline for efficiency and consistency. This setup ensures that all code changes are validated before merging, reducing errors and maintaining high code quality. And we don't spend a minute of precious GitHub action time - so it's free!

I did some benchmarks, and experienced that by caching the testbed image, we minimize redundant setup time, saving around 5 minutes per test run. Additionally, publishing the test image to GHCR allows the entire team to work with a consistent testing environment. I tried running the different tests in paralell, but that was more than twice as slow as running them in series for this little mock project.

With these steps, I hope your development workflow becomes more robust, efficient, and scalable.

Previous Post