Automated Python Unit Testing Made Easy with Pytest and GitHub Actions
Continuous Integration (CI) is an essential practice in software development. It ensures you release small and quick.
Unit and Integration Testing form a vital piece of this CI/CD Pipeline. After all, what good is untested code?
But, does the thought of setting up and maintaining a CI/CD server like Jenkins, Ansible or Code Commit cause you stress?
Whether you have a dedicated platform team or you’re a small startup doing everything yourself, it’s good to keep things simple.
GitHub Actions, is a feature-rich CI/CD platform and offers an easy and flexible way to automate your testing processes.
It’s developer-friendly, uses easy-to-understand Yaml files and has an active thriving community with loads of supported plugins (called Actions).
In this article, we will explore how to run Pytest Unit Tests on GitHub Actions to create a robust CI pipeline for your Python projects.
We will cover the basics of GitHub Actions and PyTest, and walk through the process of setting up and running automated tests on your GitHub repository.
Let’s get started then?
Objectives
By the end of this tutorial you should be able to:
- Write a basic GitHub Actions Workflow with various triggers.
- Trigger Pytest runs from GitHub Actions as part of a CI/CD Pipeline
- Generate and View Coverage Reports
Overview of GitHub Actions
GitHub Actions is a powerful and flexible automation tool built into the GitHub platform.
It allows you to create custom workflows to automate various tasks, such as building and testing code, deploying applications, and releasing software packages.
It offers a wide range of features, including support for multiple programming languages, customizable workflows, and integration with various third-party tools.
You can easily define and execute workflows that are triggered by specific events, such as code commits, pull requests, or releases.
These workflows can be run on GitHub-hosted virtual machines or on custom infrastructure and can be configured to run tests, build and deploy applications, and perform other tasks.
You only pay for the minutes of execution time, which is awesome as you don’t have to keep the server running all the time.
It also provides a marketplace of pre-built workflows and actions, which can be used to quickly set up common automation tasks.
Being developer friendly and a thriving marketplace, GitHub Actions is by far my go-to tool for automation and CI/CD work.
Project Set Up
In this project, we’ll test some simple Python code with Pytest and automate the testing with GitHub Actions.
The project has the following structure
Getting Started
To get started, clone the repo here or you can create your own repo by creating a folder and running git init
to initialise it.
Prerequisites
In this project, we’ve used Python 3.10.10.
Create a virtual environment and install the requirements (packages) using1
pip install -r requirements.txt
You should now be ready to run the source code and Unit Tests.
Source Code
The source code for this example is a simple Python function that calculates the area of a square, given its length.
area.py
1
2
3
4
5
6
7
8
9def calculate_area_square(length: int | float) -> int | float:
"""
Function to calculate the area of a square
:param length: length of the square
:return: area of the square
"""
if not isinstance(length, (int, float)) or length <= 0:
raise TypeError("Length must be a positive non-zero number")
return length * length
We do a quick type check on the argument and only process it if its of type int
or float
. If not, raise a TypeError.
Unit Tests
The Unit Tests for this function are fairly simple.
test_area.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import pytest
from src.area import calculate_area_square
def test_calculate_area_square():
assert calculate_area_square(2) == 4
assert calculate_area_square(2.5) == 6.25
def test_calculate_area_square_negative():
with pytest.raises(TypeError):
calculate_area_square(-2)
def test_calculate_area_square_string():
with pytest.raises(TypeError):
calculate_area_square("2")
def test_calculate_area_square_list():
with pytest.raises(TypeError):
calculate_area_square([2])
The above file has 4 test cases to calculate the area for various inputs
- Positive, non-zero integer or float
- Negative integers (raise TypeError)
- String (raise TypeError)
- List (raise TypeError)
You can get as fancy as you like and handle all kinds of exceptions but here we’re focused on GitHub Actions, so let’s keep it simple.
Running The Unit Test
To run the unit tests, simply run1
pytest tests/unit/test_area.py -v -s
GitHub Actions — Workflow File
Now that we’re happy with our code and unit tests, it’s time to deploy to GitHub Actions.
GitHub Actions mainly consist of files called workflows.
These are YAML files and need to be placed in the directory /.github/workflows/
for GitHub Actions to recognise them as workflows.
You can go through the anatomy of a workflow file and what each of the fields means in the Official Documentation.
The workflow file contains a job or several jobs that consist of a sequence of steps.
/.github/workflows/run_test.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32name: Run Unit Test via Pytest
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with Ruff
run: |
pip install ruff
ruff --format=github --target-version=py310 .
continue-on-error: true
- name: Test with pytest
run: |
coverage run -m pytest -v -s
- name: Generate Coverage Report
run: |
coverage report -m
Let’s break down this file.1
2
3
4
5
6
7
8
9
10name: Run Unit Test via Pytest
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
Here we define the name, the trigger for the workflow and some boilerplate including the type of GitHub Actions runner and what version of Python to run on.
Triggers can be events like a Pull Request, merge to x
branch, release tag and so on.
The documentation covers this in detail.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with Ruff
run: |
pip install ruff
ruff --format=github --target-version=py310 .
continue-on-error: true
- name: Test with pytest
run: |
coverage run -m pytest -v -s
- name: Generate Coverage Report
run: |
coverage report -m
The Job contains a sequence of steps.
- Check out the current repository
- Set up Python on the runner
- Install dependencies
- Lint with Ruff
- Run the Unit test (using Pytest) and generate a coverage report
- Print the coverage report to Console
You can include any amount of steps and essentially do whatever you like — send an email, write to an S3 bucket, generate logs and push to Elasticsearch, and build a Pypi package.
With the ability to easily create your own actions (run your own code) there are endless possiblities.
Running the Workflow and Output
This is the best part.
Running the workflow is automatic and is handled based on the triggers set in the workflow file.
A simple merge or push to the branch will trigger your workflow, no need to write any additional code or maintain triggers.
You can track the workflow execution state and check the output in the Actions
tab of your GitHub Repo.
Here we can see our tests ran successfully on GitHub Actions including our coverage report.
Conclusion
In this article, you learnt how to write a basic GitHub Actions workflow, set triggers, write jobs, steps and even view coverage reports.
To improve your development workflow, you can add unit testing to pre-commit hooks too (but that’s for a whole new article).
Using GitHub Actions with PyTest can greatly enhance your CI pipeline and get you up and running with a really good CI/CD pipeline within minutes.
It ensures that testing is easily integrated into your development workflow, allowing you to focus on more important tasks like core logic, instead of worrying about setting up infrastructure to run your tests.
Ticks all the boxes, at least for me and probably for you too.
If you have ideas for improvement or like for me to cover anything specific, please send me a message via Twitter, GitHub or Email.
Till the next time… Cheers!