Running your Go tests in Github Continuous Integration

GOLANG
7 min read

We're using Go to write Dolt, the world's first and only version-controlled SQL database.

dolt loves go

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 the TestReplication 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 the enginetest package, again because we run it in its own workflow.

Here's what the entire workflow looks like when GitHub actions invokes it.

github workflow

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.

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.