Introduction
The GitHub actions service was launched exactly one year ago (November 2019).
Since then it has got lots of positive feedback and it has quickly convinced many developers to shift the integration and deployment pipelines of their applications from external CI/CD services to GitHub. Having the possibility to build, test and deploy code from a central service greatly helps developers, who are notoriously lazy (or at least, I am ๐).
Usually when creating a new GitHub action, you don’t get everything set up correctly the very first time. Just like almost any other part of our jobs, it’s a trial and error process which can take some time to get it right. However the feedback from GitHub is quite slow because you have to commit/push the code and then wait for the action to pick up and run the changes.
For this reason, in this post we will be exploring how GitHub actions can be run and debugged locally.
In order to describe the process we will be covering a workflow focused on testing a Laravel application, triggered by a push
event.
The Workflow
A workflow is a series of steps required to complete a CI/CD process. GitHubโs documentation explains them as follows:
Workflows are custom automated processes that you can set up in your repository to build, test, package, release, or deploy any project on Github.
Workflows are written in YAML and they are stored and committed as part of the repository, inside the .github/workflows
directory of the project. Once the code gets pushed to GitHub, workflows are detected and processed by the GitHub actions service, based on the instructions that they contain.
The most basic workflow for testing a Laravel application is similar to the following:
name: Laravel CI
on: push
jobs:
phpunit:
runs-on: ubuntu-latest
container:
image: kirschbaumdevelopment/laravel-test-runner:7.3
steps:
- uses: actions/checkout@v2
- name: Install composer dependencies
run: composer install --no-scripts
- name: Copy Laravel environment Laravel Application
run: cp .env.ci .env
- name: Generate Key
run: php artisan key:generate
- name: Run Testsuite
run: vendor/bin/phpunit
Let’s quickly break down each part of this file.
name: CI Workflow
on: push
The initial part of the YAML file above defines the name of the workflow and then tells GitHub to run the Action whenever the code gets pushed. Of course you’re not limited to push
events, but you can trigger actions in many ways.
Let’s check the next section:
jobs:
phpunit:
runs-on: ubuntu-latest
container:
image: kirschbaumdevelopment/laravel-test-runner:7.3
steps:
- uses: actions/checkout@v2
- name: Install composer dependencies
run: composer install --no-scripts
- name: Copy Laravel environment Laravel Application
run: cp .env.ci .env
- name: Generate Key
run: php artisan key:generate
- name: Run Testsuite
run: vendor/bin/phpunit
Here we defined a job called phpunit
whose purpose is to run tests using the PHPUnit testing tool on the Laravel application.
runs-on
identifies the type of machine to run the job on.ubuntu-latest
means Ubuntu 18.04, even though Ubuntu 20.04 is alrady available 1container
tells GitHub that the actions will run inside a Docker container. We are using thekirschbaumdevelopment/laravel-test-runner:7.3
container, kindly provided by the folks at Kirschbaum, whose enviroment is usually complete enough to cover most testing requirements.
Notice: you can declare more than one job inside the yaml file. For example you might want to add a job to test frontend code using Jest, or perhaps a job that compiles frontend assets, and so on.
It’s important to note that inside a GitHub Action you can use other actions, written and maintained by the community. For example the first step of our phpunit
workflow uses the actions/checkout@v2
action, which lets you check-out your repository so the workflow can access it.
You can find a comprehensive list of GitHub actions in this repository. They will definitely make your life easier when building workflows.
Use a database
So far, so good. Now what if we need a database in our tests?
Luckily it’s very easy to set up a database like MySQL in GitHub Actions. You just need add a services
key to your yaml file.
Service containers are Docker containers that provide a simple and portable way for you to host services that you might need to test or operate your application in a workflow.
GitHub will create a fresh Docker container for each service configured in the workflow, and will destroy the service container when the job completes. Steps in a job can communicate with all service containers that are part of the same job.
For MySQL we can change the configuration like the following:
jobs:
phpunit:
runs-on: ubuntu-latest
container:
image: kirschbaumdevelopment/laravel-test-runner:7.3
services:
mysql:
image: mysql:5.7
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_USER: username
MYSQL_PASSWORD: password
MYSQL_DATABASE: test
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
[...]
PostgreSQL? No problem!
services:
postgres:
image: postgres:10.8
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
Test locally
Testing a GitHub Action locally definitely helps when you’re approaching this technology for the first time, and you have to quickly iterate in your trial and error process.
Getting feedback directly from GitHub is slow and can be frustrating. Also if you’re coming from other CI services, such as CircleCI or TravisCI, you might be familiar with their debugging mode 2 3 which basically lets you SSH into the virtual machines or containers in order to troubleshoot any problems. As far as I know, GitHub doesn’t offer this option, so you’re forced to update your configuration locally and then push again.
Act
“Think globally, act locally”
Act is an interesting project, written in Go, that allows you to test GitHub Actions locally.
For obvious reasons it doesn’t provide a full featured replica of the environment provided by GitHub, but it’s definitely powerful enough to cover most of the cases.
After installing it, you can run act -l
to see the list of your workflows. If the YAML file is semantically correct you will see the name of the configured job:
$ act -l
โญโโโโโโโโโโฎ
โ phpunit โ
โฐโโโโโโโโโโฏ
after that you can try to run the workflow by running act
.
Act will use the Docker API to either pull or build the necessary images, as defined in your workflow files. It will then use the Docker API to run containers for each action based on the images prepared earlier.
If you’re testing your application without any database or with a SQLite in-memory database you will probably be able to run it successfully the very first time.
$ act
[Laravel CI/phpunit] ๐ Start image=kirschbaumdevelopment/laravel-test-runner:7.3
[Laravel CI/phpunit] ๐ณ docker run image=kirschbaumdevelopment/laravel-test-runner:7.3 entrypoint=["/usr/bin/tail" "-f" "/dev/null"] cmd=[]
[Laravel CI/phpunit] ๐ณ docker cp src=/home/maurizio/code/laravel-app/. dst=/github/workspace
[Laravel CI/phpunit] โญ Run actions/checkout@v2
[Laravel CI/phpunit] โ
Success - actions/checkout@v2
[Laravel CI/phpunit] โญ Run Install composer dependencies
[Laravel CI/phpunit] โ
Success - Install composer dependencies
[Laravel CI/phpunit] โญ Run Run Testsuite against SQLite
| PHPUnit 8.5.8 by Sebastian Bergmann and contributors.
|
| Runtime: PHP 7.3.18-1+ubuntu18.04.1+deb.sury.org+1
| Configuration: /github/workspace/phpunit.xml.dist
|
| ............................................................... 63 / 228 ( 27%!)(MISSING)
| ............................................................... 126 / 228 ( 55%!)(MISSING)
| ............................................................... 189 / 228 ( 82%!)(MISSING)
| ....................................... 228 / 228 (100%!)(MISSING)
|
| Time: 7.15 seconds, Memory: 56.50 MB
|
| OK (228 tests, 562 assertions)
[Laravel CI/phpunit] โ
Success - Run Testsuite against SQLite
If instead the action fails, you can SSH into the Docker container and debug it from there.
First let’s list the active containers:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b7649a8ef09e kirschbaumdevelopment/laravel-test-runner:7.3 "/usr/bin/tail -f /dโฆ" About a minute ago Up About a minute act-Laravel-CI-phpunit
then after checking the Container ID, you can run docker exec -it b7649 bash
to enter the container. From there it’s way easier to spot any configuration errors.
Adding a Database
Adding a real database layer to the tests gradually increases the complexity, which is actually inherently related to the management of environment variables.
Act does not fully support defined services
, or at least it has some issues. I noticed that using the nektos/act-environments-ubuntu:18.04
image I was not able to use MySQL, which is a known issue. However I was able to overcome this problem by spinning up the MySQL container myself.
For example when you run an action that uses MySQL on GitHub you will see something like this, under the Initialize containers
section
/usr/bin/docker create
--name a864a8672f73421e867ed5b93bd8b9e4_mysql57_1e0ef3
--label 1e5c35
--network github_network_92a3d72e5a104bd7abfb9e31dfaa7e51
--network-alias mysql
-p 3306:3306
--health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
-e "MYSQL_ALLOW_EMPTY_PASSWORD=true"
-e "MYSQL_DATABASE=test"
-e "MYSQL_USER=username"
-e "MYSQL_PASSWORD=password"
-e GITHUB_ACTIONS=true
-e CI=true
mysql:5.7
we can use this command as a reference to spin up a MySQL Docker container on our local machine to use it as database for our tests.
If you read carefully you will notice that the environment variables used in the previous command are the same variables defined in the env
section of the YAML file.
We can create the container using a command like the following
$ docker create
--name mysql57
-p 3306:3306
--health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
-e "MYSQL_ALLOW_EMPTY_PASSWORD=true"
-e "MYSQL_DATABASE=test"
-e "MYSQL_USER=username"
-e "MYSQL_PASSWORD=password"
mysql:5.7
next we can start it, using the hash value returned by the previous command
$ docker start e7d5021c91d932447870eb56680153f49758ce0492ac2559b5754e7a38f3765
finally we can inspect the container to retrieve the low-level information on the Docker object, including the assigned IP address
$ docker inspect mysql57
by default, the command will render all results in a JSON array. You can use the following to grab only the IP address:
$ docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mysql57
That same IP address will be used as the value for the DB_HOST
environment variable.
The final configuration file will be:
|
|
As you can see on line 28 we’re using a .env.ci
file that only contains the smallest amout of environment variables required to run the Action. It’s helpful so you don’t have to write them directly multiple times inside the YAML file.
Notice:
use the env
key in the YAML only when you specifically have to override the values defined in the .env.ci
file. Also make sure that the same variables aren’t also defined in the phpunit.xml
file.
# .env.ci
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_USERNAME=username
DB_PASSWORD=password
DB_DATABASE=test
You might be wondering why in this file the DB_HOST
variable contains mysql
, instead of an IP address. This is not an error.
On GitHub you can configure jobs in a workflow to run directly on a runner machine or in a Docker container (like we are doing in this example). Communication between a job and its service containers is different depending on whether a job runs directly on the runner machine or in a container.
When you run jobs in a container, GitHub connects service containers to the job using Docker’s user-defined bridge networks.
Running the job and services in a container simplifies network access. For this reason you can access a service container using the label you configured in the workflow, which in this case is mysql
. This value will be mapped to the hostname of the service container.
To test things locally however it is just easier to inspect the container and use the IP address, rather than setting up a network. Unless of course you have to deal with multiple services that need to talk to each other.
PostgreSQL
Running PostgreSQL is as easy as it gets.
$ docker create
--name postgres11
--health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
-p 5432:5432
-e POSTGRES_USER=homestead
-e POSTGRES_PASSWORD=secret
-e POSTGRES_DB=mydatabase
postgres:11
The process is the same: inspect the container, retrieve the IP Address and update the env
key in the YAML file.
In the end the final YAML file will look like this. PHPUnit tests will run on both MySQL and PostgreSQL.
on: push
name: Laravel CI
jobs:
phpunit:
runs-on: ubuntu-latest
container:
image: kirschbaumdevelopment/laravel-test-runner:7.3
services:
mysql:
image: mysql:5.7
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_USER: username
MYSQL_PASSWORD: password
MYSQL_DATABASE: test
ports:
- 3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
postgres:
image: postgres:11
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v2
- name: Install composer dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: Copy Laravel CI environment
run: cp .env.ci .env
- name: Generate Key
run: php artisan key:generate
- name: Migrate database
run: php artisan migrate
- name: Run Testsuite against MySQL
run: vendor/bin/phpunit
env:
DB_CONNECTION: mysql
DB_HOST: 172.17.0.2
- name: Run Testsuite against Postgres
run: vendor/bin/phpunit
env:
DB_CONNECTION: pgsql
DB_HOST: 172.17.0.3
Finally we can run act
and check if the workflows passes.
Warning:
Before running the GitHub action locally make sure that you working directory it’s in a clean status. In fact act
copies the repository fro the local directory, instead of downloading from GitHub.
$ act
[Laravel CI/phpunit] ๐ Start image=kirschbaumdevelopment/laravel-test-runner:7.3
[Laravel CI/phpunit] ๐ณ docker run image=kirschbaumdevelopment/laravel-test-runner:7.3 entrypoint=["/usr/bin/tail" "-f" "/dev/null"] cmd=[]
[Laravel CI/phpunit] ๐ณ docker cp src=/home/maurizio/code/laravel-app/. dst=/github/workspace
[Laravel CI/phpunit] โญ Run actions/checkout@v2
[Laravel CI/phpunit] โ
Success - actions/checkout@v2
[Laravel CI/phpunit] โญ Run Install composer dependencies
[Laravel CI/phpunit] โ
Success - Install composer dependencies
[Laravel CI/phpunit] โญ Run Migrate database
[Laravel CI/phpunit] โ
Success - Migrate database
[Laravel CI/phpunit] โญ Run Run Testsuite against MySQL
| PHPUnit 8.5.5 by Sebastian Bergmann and contributors.
|
| Runtime: PHP 7.3.18-1+ubuntu18.04.1+deb.sury.org+1
| Configuration: /github/workspace/phpunit.xml.dist
|
| ............................................................... 63 / 157 ( 40%!)(MISSING)
| ............................................................... 126 / 157 ( 80%!)(MISSING)
| ............................... 157 / 157 (100%!)(MISSING)
|
| Time: 4.37 seconds, Memory: 48.50 MB
|
| OK (157 tests, 350 assertions)
[Laravel CI/phpunit] โ
Success - Run Testsuite against MySQL
[Laravel CI/phpunit] โญ Run Run Testsuite against Postgres
| PHPUnit 8.5.5 by Sebastian Bergmann and contributors.
|
| Runtime: PHP 7.3.18-1+ubuntu18.04.1+deb.sury.org+1
| Configuration: /github/workspace/phpunit.xml.dist
|
| ............................................................... 63 / 157 ( 40%!)(MISSING)
| ............................................................... 126 / 157 ( 80%!)(MISSING)
| ............................... 157 / 157 (100%!)(MISSING)
|
| Time: 4.72 seconds, Memory: 48.50 MB
|
| OK (157 tests, 350 assertions)
[Laravel CI/phpunit] โ
Success - Run Testsuite against Postgres
Potential issues
- If you get this error
Doctrine\DBAL\Driver\PDOException: SQLSTATE[HY000] [2002] Connection refused
make sure that you have the database port configured correctly in thedatabase.php
config file. I noticed that in some old Laravel versions it’s not present.
Running a workflow from the runner machine
Running a workflow from the runner machine is not that different, you just have to pay more attention to the network configuration.
Service containers can be accessed using localhost:<port>
or 127.0.0.1:<port>
. GitHub configures the container network to enable communication from the service container to the Docker host. However the service running in the Docker container does not expose its ports to the job on the runner by default. You need to map ports on the service container to the Docker host.
Let’s see how it can be done by examining the following YAML file:
|
|
In this workflow we’re not using a container image. Instead we’re using another action provided by the community. In this case the Setup PHP Action is used to setup PHP with the required extensions.
We’re also using GitHub caching to cache dependencies and speed up the workflow. The composer dependencies will be downloaded and stored in a local cache, so that they will already be available for the next runs. The key used to identify the cache depends on the hashed value of the composer.lock
, therefore if your dependencies change you will get freshly downloaded packages.
Finally you can see that on line 55 we’re using interpolation ${{ job.services.mysql.ports['3306'] }}
to retrieve the port of the MySQL service from the context. In fact, unless you explicitly map the Docker host port to the service container port, the container port will randomly be assigned to a free port.
If running act
with this configuration you get this error act
::error::ENOENT: no such file or directory, open '/opt/hostedtoolcache/linux.sh'
, you can solve it by running act -P ubuntu-latest=shivammathur/node:latest
.
Conclusions
That’s all there is to it. I hope you find this post helpful!
Update: see also this interesting article https://dev.to/patinthehat/testing-laravel-packages-locally-with-docker-3gm5
Thanks Will for proof reading this post ๐