Ensure code from a Pull Request is unit tested before enabling merge with github actions

Ensure code from a Pull Request is unit tested before enabling merge with github actions

So as a team lead you try to enforce unit testing within your team by making sure every piece of code in a PR is properly unit tested when you review. This can be extra time consuming. Can we make the process of checking for unit tests for a new functionality automatically handled via a script run from github actions on PR events and concentrate on reviewing the actual code for quality? That's what we'll be looking at in this article.

Note:
The following have been tested with the jest testing utility for JavaScript related projects but can be slightly edited to work for other languages / frameworks. This also assumes that you have setup unit test in your JavaScript project

We'll start by ensuring branch protection rules for the branches that require protection. In most cases the main branch and the develop branches. You can check out this documentation from github on how to setup branch protection rules. We'll create these rules on our branch (in this case main) which includes requiring a PR to merge and enabling status checks to pass. From the image below, you'll realize github can't find any status check. We'll get to that in a bit

Next we'll create a workflow directory in a .github directory at the root of our project repository.

There we'll create a file and call it must-unit-test.yml .

name: Must Unit Test

on:
  pull_request:
    branches:
      - main
      - develop

jobs:
  check_for_unittest:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
  
      - name: Check for unit test
        run: |
          bash must-unit-test.sh
        env:
          MERGING_BRANCH: ${{ github.head_ref }}
          BASE_BRANCH: ${{ github.base_ref }}

      - name: Set conclusion based on script results
        if: ${{ success() && steps.run_script.outputs.success == 'true' }}
        run: |
          echo "Checks passed."
          exit 0

      - name: Set failed conclusion
        if: ${{ failure() || steps.run_script.outputs.success == 'false' }}
        run: |
          echo "Checks failed. See script output for details."
          exit 1

    env:
      CI: true

In the above workflow yaml file, we are

  • Listening for a pull request event on the main and develop branches of our repository
  • Running a must-unit-test.sh bash script. This script which we will look at in a bit will be expecting a two env variables MERGING_BRANCH and BASE_BRANCH that will the branch we are about to merge from and the branch we are about to merge to respectively
  • The rest of instructions simply checks the outcome of the our script and will exit successfully if the PR has unit test or in a failed state if not

Let's create our bash script must-unit-test.sh at the root directory of our repository with the following content

#!/bin/bash

get_coverage_summary() {

    local coverage_summary="$1"
    
    # Extract values from the coverage summary
    local statements_covered=$(echo "$coverage_summary" | grep 'Statements' | awk '{print $5}' | cut -d'/' -f1)
    local statements_total=$(echo "$coverage_summary" | grep 'Statements' | awk '{print $5}' | cut -d'/' -f2)
    local branches_covered=$(echo "$coverage_summary" | grep 'Branches' | awk '{print $5}' | cut -d'/' -f1)
    local branches_total=$(echo "$coverage_summary" | grep 'Branches' | awk '{print $5}' | cut -d'/' -f2)
    local functions_covered=$(echo "$coverage_summary" | grep 'Functions' | awk '{print $5}' | cut -d'/' -f1)
    local functions_total=$(echo "$coverage_summary" | grep 'Functions' | awk '{print $5}' | cut -d'/' -f2)
    local lines_covered=$(echo "$coverage_summary" | grep 'Lines' | awk '{print $5}' | cut -d'/' -f1)
    local lines_total=$(echo "$coverage_summary" | grep 'Lines' | awk '{print $5}' | cut -d'/' -f2)

    # Calculate percentages to six decimal places using bc
    local statements=$(echo "scale=6; ($statements_covered/$statements_total)*100" | bc)
    local branches=$(echo "scale=6; ($branches_covered/$branches_total)*100" | bc)
    local functions=$(echo "scale=6; ($functions_covered/$functions_total)*100" | bc)
    local lines=$(echo "scale=6; ($lines_covered/$lines_total)*100" | bc)

    # Assign values to a global array
    COVERAGE_VALUES=("$statements" "$branches" "$functions" "$lines")
}

# Define the color code for red
RED='\033[0;31m'
NC='\033[0m' # No Color

# Function to echo text in red
echo_red() {
    echo -e "${RED}$1${NC}"
}

merging_branch=$MERGING_BRANCH
base_branch=$BASE_BRANCH

if [ -n "$mergin_branch" ] && [ -n "$base_branch" ]; then
    # Fetch the base branch, checkout to it and npm i
    git fetch origin $base_branch 2>&1
    git checkout $base_branch 2>&1
    npm i
    # Get the coverage test summary for the base branch
    coverage_summary=$(npx jest --coverage --coverageReporters="text-summary" | awk '/Coverage summary/ {flag=1; next} /=========================/ {flag=0} flag')
    if [ $? -eq 0 ]; then
        get_coverage_summary "$coverage_summary"
        remote_statements=${COVERAGE_VALUES[0]}
        remote_branches=${COVERAGE_VALUES[1]}
        remote_functions=${COVERAGE_VALUES[2]}
        remote_lines=${COVERAGE_VALUES[3]}

        # Fetch the base branch, checkout to it and npm i
        git fetch origin $merging_branch 2>&1
        git checkout $merging_branch
        npm i
        
        # Get the coverage test summary for the merging branch
        coverage_summary=$(npx jest --coverage --coverageReporters="text-summary" 2>&1 | awk '/Coverage summary/ {flag=1; next} /=========================/ {flag=0} flag')
        if [ $? -eq 0 ]; then
            get_coverage_summary "$coverage_summary"
            local_statements=${COVERAGE_VALUES[0]}
            local_branches=${COVERAGE_VALUES[1]}
            local_functions=${COVERAGE_VALUES[2]}
            local_lines=${COVERAGE_VALUES[3]}
            
            if [ 1 -eq "$(echo "$local_statements < $remote_statements" | bc)" ] || [ 1 -eq "$(echo "$local_branches < $remote_branches" | bc)" ] || [ 1 -eq "$(echo "$local_functions < $remote_functions" | bc)" ] || [ 1 -eq "$(echo "$local_lines < $remote_lines" | bc)" ]; then
                echo_red "You are attempting to push an update without a unit test"
                exit 1
            fi
        fi
    fi
fi

exit 0

Code Breakdown:
Assuming a member of our team created a new feature in a branch feature/less-work-more-output and initiate a PR into develop branch for merge. The above script will

  • Checkout to the develop branch, and install package dependencies. Then run a jest unit test coverage on the project. We will repeat same for the feature/less-work-more-output branch. The output of the unit test command after running through the awk utility will look like this
    Statements : 0.5% ( 21/4175 )
    Branches : 0.42% ( 10/2359 )
    Functions : 0.47% ( 6/1261 )
    Lines : 0.67% ( 21/3100 )
  • We pass the output of the unit test command through a get_coverage_summary function. This will extract the digits in brackets for each of statements, branches, functions and lines and perform the division to six decimal places for higher precision. We could easily use the percentage values directly but this will be low precision and will not catch smaller unit of code changes without test
  • For each of the outputs of the unit test summary for both branches, we compare with the help of the bc shell utility. The assumption here is: If the merging branch has lesser test coverage for each of statements, branches, functions and lines, compared to the base branch, then it means there is code in the merging branch without unit test and the PR fails the check

Next we'll commit and push our changes. We now have our github workflow action setup that will ensure that every piece of added code is unit tested before merging to the main or develop branch. But we are not done yet. We have to add that to our branch protection rules. Remember when we setup status checks, github didn't find any status checks right? That's not the case now. You can go back to your branch protection rules and in the Require status checks section, you should see a search input field where you can search for the job you created check_for_unittest

After the search, click on it and it should be added to the list of checks before merging. Now you'll realise that when you make a PR into the main or develop branches, the worfklow will automatically run, and must pass before you can proceed to merge.

Please note that our script doesn't handle the case where a unit test fails. You can definitely add that which will make it even better. Please do well to subscribe and leave a comment if you have updated the script to check for failed test or if you want me to update this article with updates on that