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).
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.
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
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).
# 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.
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
This will effectively give us a very clear overview in the GitHub Gui what checks pass, what checks fail and why.
To ensure that no untested code gets merged into main
, we configure branch protection rules in GitHub:
main
as the protected branch.check_and_test
).With these rules in place, a pull request cannot be merged unless all automated checks pass successfully.
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.