Running your Go tests in Github Continuous Integration
We're using Go to write Dolt, the world's first and only version-controlled SQL database.
As a database, we have to write tests. Lots and lots of tests. In fact, tests are a majority of the code we write.
github.com/dolthub/doltgresql% wc **/*_test.go | tail -n1
470015 14842594 99461214 total
github.com/dolthub/doltgresql% wc **/*.go | tail -n1
762698 15990004 113162467 total
But what good are all these tests if you don't run them? No good at all, which is why we set up Continuous Integration (CI) using GitHub Actions. We'll show you how to run your Go tests this way using our own GitHub Actions workflow files.
Running the Doltgres Go tests on every PR
Doltgres is the Postgres version of Dolt, scheduled for
beta release in about a month. If you look inside the root directory for the repository, you'll find
a .github
directory. This is where we configure our GitHub CI actions.
github.com/dolthub/doltgresql% tree .github/
.github/
├── FUNDING.yml
├── actions
│ └── ses-email-action
│ ├── action.yaml
│ ├── dist
│ │ └── index.js
│ ├── index.js
│ ├── package-lock.json
│ └── package.json
├── markdown-templates
│ └── dep-bump.md
├── scripts
│ ├── performance-benchmarking
│ │ ├── get-doltgres-doltgres-job-json.sh
│ │ ├── get-postgres-doltgres-job-json.sh
│ │ ├── run-benchmarks.sh
│ │ └── validate-commentor.sh
│ ├── sql-correctness
│ │ ├── get-doltgres-correctness-job-json.sh
│ │ └── run-correctness.sh
│ └── update-perf.sh
└── workflows
├── bump-dependency.yaml
├── cd-create-release-notes.yaml
├── cd-release.yaml
├── ci-bats-unix.yaml
├── ci-check-repo.yaml
├── ci-postgres-client-tests.yaml
├── ci-staticcheck.yaml
├── email-report.yaml
├── k8s-benchmark-latency.yaml
├── k8s-sql-correctness.yaml
├── label-customer-issues.yaml
├── mini-sysbench.yml
├── nightly-performance-benchmarks-email-report.yaml
├── open-update-pr.yaml
├── performance-benchmarks-email-report.yaml
├── performance-benchmarks-pull-report.yaml
├── pull-report.yaml
├── regression-tests.yml
├── replication-tests.yaml
├── sql-correctness.yaml
├── test-enginetests.yml
└── test.yml
8 directories, 36 files
These are all of the configuration and scripts we want GitHub Actions to help us automate. But let's
focus on the .github/workflows/
directory. YAML files placed in this directory are how you
configure GitHub to run automated actions on your PRs. Let's look at the test.yml
file, where the
bulk of our unit tests are defined to run.
name: Test
on: [pull_request]
concurrency:
group: test-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
id: go
- name: Build SQL Syntax
run: ./build.sh
working-directory: ./postgres/parser
shell: bash
- name: Test
if: ${{ matrix.platform != 'ubuntu-latest' }}
run: go test -skip="TestReplication" $(go list ./... | grep -v enginetest)
- name: Test
if: ${{ matrix.platform == 'ubuntu-latest' }}
# Enginetest harness breaks with race testing, not sure why yet
run: go test -race -skip="TestReplication" $(go list ./... | grep -v enginetest)
Let's go over this line by line and discuss what it means.
name: Test
on: [pull_request]
The top section defines a display name for the action when it's running in your PR. The on
key
defines the events that the action should trigger for. We use pull_request
, but there are a bunch
you can choose.
concurrency:
group: test-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
The concurrency
section lets you control how many copies of this action should run. With the
cancel-in-progress
flag set to true, GitHub will kill any running jobs when you push new commits,
which is important to conserve your fleet of action runners. We set the group
to match the PR
number, which means we let one copy of this action run at a time per PR. Make sure that every action
has a distinct name, otherwise they'll cancel each other as they run.
jobs:
test:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
Now for the meat of the logic. The jobs
key defines one or more actions to run as part of this
workflow. Our workflow file defines one job named test
. Most of the keys here are boilerplate, and
define which OSes you want your tests to run on. We test on all three but there are plenty of people
who leave out Windows.
Next we get to the meat of the test workflow, which is the steps
key.
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
id: go
These two steps are built into GitHub actions, so you can refer to them by name in the uses
field
and it just works. As you might guess, these run a git clone
and git checkout
to get the correct
branch, then install Go if necessary.
Next we get to the Go logic itself.
- name: Build SQL Syntax
run: ./build.sh
working-directory: ./postgres/parser
shell: bash
- name: Test
if: ${{ matrix.platform != 'ubuntu-latest' }}
run: go test -skip="TestReplication" $(go list ./... | grep -v enginetest)
- name: Test
if: ${{ matrix.platform == 'ubuntu-latest' }}
# Enginetest harness breaks with race testing, not sure why yet
run: go test -race -skip="TestReplication" $(go list ./... | grep -v enginetest)
We've got three steps here. The first runs a build.sh
script in the directory named, in order to
build our Postgres SQL parser, a prerequisite to running the rest of the tests. Then the next two
steps are actually the same test, just slightly tweaked depending on the OS with the use of the if
field. The go test
command we run is slightly different on Linux, where we run it with the -race
flag. It's worth digging into this command a bit more, since this is where the magic happens.
run: go test -race -skip="TestReplication" $(go list ./... | grep -v enginetest)
This command skips two different sets of tests via two different mechanisms.
- The
-skip
flag skips theTestReplication
test (which we run in its own workflow). go list ./...
lists all the packages in the current directory and the| grep -v enginetest
filters them to exclude theenginetest
package, again because we run it in its own workflow.
Here's what the entire workflow looks like when GitHub actions invokes it.
You can see the steps that run have the name and order the file defines. And there are two steps called Test, only one of which actually ran, based on the platform. We can expand any step to see the output it produced, which is important to do in the case of a failure.
Advanced use case: invoking docker for a test
For our tests of replicating from Postgres to Doltgres, a simple Go process wasn't the right approach. We needed to spin up a full Postgres server to test that its data gets successfully replicated to a Doltgres process, which is easiest to do in GitHub actions using Docker.
There are lots of ways you'll find to run Docker based tests on GitHub actions, but this is what worked for us. It has the benefits of being dirt simple.
name: Replication Tests
on: [pull_request]
concurrency:
group: ci-replication-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
testing-job:
runs-on: ubuntu-22.04
timeout-minutes: 10
name: Run tests
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Build docker image
run: docker build -t local --file testing/ReplicationTestDockerfile .
- name: Run tests
run: docker run --detach=false local
There isn't much to see here if you already read and understood the first example. There are just two steps after we check out our code:
run: docker build -t local --file testing/ReplicationTestDockerfile .
This should be very familiar to anyone who has used Docker. We're building the file at the path provided, which is relative to the package root. We'll look at what the Docker file does next.
Finally we just have to run the image we built:
run: docker run --detach=false local
There are two important parts to get right here:
--detach=false
puts the output from the job into the GitHub logs, although it's not perfect- The script you run in the Docker container must exit with a non-zero code when the test fails, or else GitHub actions will think the job succeeded.
The Docker file itself doesn't do anything interesting if you're already familiar with Docker, mostly just copies files, but you can look at it here if you're curious. Finally, for completeness, here's the Docker entry point script we run.
#!/bin/bash
# exit if any command exits wtih non-zero
set -e
# run the original postgres setup
/usr/local/bin/docker-ensure-initdb.sh -c wal_level=logical
# Modify the WAL replication settings
echo "wal_level = logical" >> /var/lib/postgresql/data/postgresql.conf
# Start PostgreSQL as the postgres user
sudo -u postgres /usr/lib/postgresql/16/bin/pg_ctl \
-D /var/lib/postgresql/data \
-l /var/lib/postgresql/data/logfile \
start
# Wait for PostgreSQL to become ready for requests
until pg_isready -h localhost -p 5432
do
echo "Waiting for PostgreSQL to become ready..."
sleep 1
done
# Run the Go test
go test -run="TestReplication" ./...
# Run the bats test
cd testing/bats
bats replication.bats
Note that this script runs several commands including go test
and bats
, and because we set -e
it will exit with a non-zero code if any of the commands do. This is critical to make GitHub Actions
notice that our test failed.
This Docker-based workflow is what we would recommend for testing any Go program that requires the presence of another long-running binary like Postgres. It's much easier to do it this way than to try to execute another process from inside a Go test, and it's also very portable so you can run them on your local machine exactly the same way that GitHub does.
Conclusion
Want to talk about Go tests in GitHub Actions? Or maybe you're curious about Dolt or Doltgres, the world's first version-controlled SQL databases? Come by our Discord to talk to our engineering team and meet other Dolt users.