Using GitHub Actions and arduino-cli to bring static testing to Arduino libraries

Using GitHub Actions and arduino-cli to bring static testing to Arduino libraries

2024-10/19

As the size of a library grows, it is common for existing functions to fail due to additions or changes to the functionality. In many cases, the error is not noticed until the function is actually used, and when it is noticed, it is often difficult to deal with.

Furthermore, if the library is multi-platform, it is impossible to manually test all the target environments.

To break through this egregious situation, we will show you how to use GitHub Actions and arduino-cli to perform testing automatically.

Assumption

The library must be maintained on GitHub (private repositories are also acceptable)

What is GitHub Actions?

GitHub Actions 1 is a very useful service that allows you to run arbitrary scripts on the GitHub virtual machine when you push or issue a pull request. It is free of charge, although there is a usage limit.

Create a test

Create a test that lets arduino-cli compile a test sketch on a virtual machine and fails if it encounters a compilation error.

This is only a test to check if it can be compiled, not to find bugs that can be discovered at runtime. You can check for algorithms that can be handled at compile time.

*In many cases, we can find areas where runtime errors are likely to occur at compile time.

Configure directories for testing

Create a sketch to be compiled. Since we are assuming a medium or large library, we put the header files in a subdirectory of the library. This article explains how to split files into subdirectories, if you are interested.

How to include header files in a subdirectory of the library

When you split source files for testing, you can add them under src. Under src, source files in subdirectories will also be compiled recursively.

MyLibrary
├─ .github
│  └─ workflows
│     └─ ArduinoLint.yml
├─ src
│  ├─ MyLibrary
│  │  └─ Algorithm
│  │     └─ Math.hpp
│  └─ MyLibrary.hpp
├─ test
│  └─ ArduinoLint
│     ├─ src
│     │  ├─ Algorithm
│     │  │   └─ Math.cpp            <-- Test Source Files
│     │  └─ Other
│     │      └─ LinkErrorCheck.cpp  <-- Test Source Files
│     └─ ArduinoLint.ino            <-- Test Sketches
└─ library.properties

src/MyLibrary/Algorithm/Math.hpp

#pragma once

namespace Math
{
    inline int Factorial(int n)
    {
        if (n <= 0)
            return 1;
        else
            return n * Factorial(n - 1);
    }
}

src/MyLibrary.hpp

#pragma once

#include <MyLibrary/Algorithm/Math.hpp>

library.properties

name=MyLibrary
version=1.0.0
author=HogeHoge
maintainer=HogeHoge
sentence=HogeHoge
paragraph=HogeHoge
category=Other
url=http://example.com/
architectures=*

Create test sketches

Call the functions defined in the library. All functions must be called, as they will not compile strictly if not called.

test/ArduinoLint/src/Algorithm/Math.cpp

#include <MyLibrary/Algorithm/Math.hpp>

// I won't call you from anywhere.
__attribute__((unused)) static void Test()
{
   (void)Math::Factorial(123);
}

__attribute__((unused)) is a function attribute that explicitly states that the function will not be called. If it is not present, a warning will be issued.

Since a Test function in another test source file will cause a link error, it is made a static function and has an internal linkage.

Functions with return values are cast to void (optional) to make it clear that the caller does not use the return value.


test/ArduinoLint/src/Other/LinkErrorCheck.cpp

#include <MyLibrary.hpp>

Source file to check for link errors due to multiple definitions. ArduinoLint.ino + Include headers from this file to trigger link errors.

For example, if a global function is defined in the header file, it will be a multiple definition. The way to deal with this is to inline the functions, etc.


test/ArduinoLint/ArduinoLint.ino

#include <MyLibrary.hpp>

void setup() {}

void loop() {}

To include a file in a subdirectory, it is necessary to include the file that is not in the subdirectory beforehand, so include it here. (Arduino Specifications)

Check the FQBN of the board

When compiling, the string FQBN is used to specify the board. It looks like the following

rp2040:rp2040:rpipico

To examine FQBN, go to File > Preferences and check the box to show compiler output.

Check Options

Compiling on the target board yields FQBN.

FQBN

Write test scripts

Edit .github/workflows/ArduinoLint.yml. You can test multiple board environments simultaneously with the ability to run jobs in parallel (matrix).

To add a board environment, add the FQBN you just checked to the board: with the FQBN nori of the other board. If you don’t need the environment, delete it.

name: Arduino Lint

on: # Executed on push and pull requests
  push:
    paths-ignore:
      - "**.md" # Ignore markdown file changes
  pull_request:
    paths-ignore:
      - "**.md"

concurrency: # Stop previous execution in case of continuous push
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  ArduinoLint:
    name: ${{ matrix.board.fqbn }}

    runs-on: ubuntu-latest

    strategy:
      fail-fast: false # The default is to abort if one of the jobs fails, so do not abort

      matrix:
        board: # board group
          - fqbn: arduino:avr:nano
            platform: arduino:avr
          - fqbn: arduino:avr:uno
            platform: arduino:avr
          - fqbn: teensy:avr:teensy35
            platform: teensy:avr
            url: https://www.pjrc.com/teensy/td_156/package_teensy_index.json # Can also be installed from URL
          - fqbn: teensy:avr:teensy40
            platform: teensy:avr
            url: https://www.pjrc.com/teensy/td_156/package_teensy_index.json
          - fqbn: rp2040:rp2040:rpipico
            platform: rp2040:rp2040
            url: https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json
          - fqbn: rp2040:rp2040:rpipicow
            platform: rp2040:rp2040
            url: https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install Arduino CLI
        run: |
          curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh
          echo "./bin" >> $GITHUB_PATH          

      - name: Install board
        run: |
          arduino-cli config init
          arduino-cli config set library.enable_unsafe_install true
          if [ -n "${{ matrix.board.url }}" ]; then
            arduino-cli config add board_manager.additional_urls ${{ matrix.board.url }}
          fi
          arduino-cli core update-index
          arduino-cli core install ${{ matrix.board.platform }}          

      # Dependent libraries, if any (public repositories only)
      # - name: Install libraries
      #   run: |
      #     arduino-cli lib install --git-url https://github.com/adafruit/Adafruit_BusIO.git
      #     arduino-cli lib install --git-url https://github.com/adafruit/Adafruit_Sensor.git
      #     arduino-cli lib install --git-url https://github.com/adafruit/Adafruit_BNO055.git

      - name: Run test
        run: arduino-cli compile --library . -b ${{ matrix.board.fqbn }} ./test/ArduinoLint/ArduinoLint.ino
  • There is a dubious script arduino-cli config set library.enable_unsafe_install true that is required to use the library based on the remote repository URL.

  • arduino-cli compile --library . to make the repository itself a library. So you can test private repositories as well.

arduino/compile-sketches There is also a way to use the action (Execution time will be a little longer)
name: Arduino Lint

on:
  push:
    paths-ignore:
      - "**.md"
  pull_request:
    paths-ignore:
      - "**.md"

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  ArduinoLint:
    name: ${{ matrix.board.fqbn }}

    runs-on: ubuntu-latest

    strategy:
      fail-fast: false

      matrix:
        board:
          - fqbn: arduino:avr:nano
          - fqbn: teensy:avr:teensy40
            platforms: |
              - name: teensy:avr
                source-url: https://www.pjrc.com/teensy/td_156/package_teensy_index.json              
          - fqbn: rp2040:rp2040:rpipico
            platforms: |
              - name: rp2040:rp2040
                source-url: https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json              

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Arduino-cli test
        uses: arduino/compile-sketches@v1

        with:
          fqbn: ${{ matrix.board.fqbn }}
          platforms: ${{ matrix.board.platforms }}
          sketch-paths: |
            - ./test/ArduinoLint            
          libraries: |
            - source-path: ./            
          # - source-url: https://github.com/adafruit/Adafruit_BusIO.git
          # - source-url: https://github.com/adafruit/Adafruit_Sensor.git
          # - source-url: https://github.com/adafruit/Adafruit_BNO055.git

Push to GitHub

After pushing the changes, you should see the test running on the Actions tab.

Actions Tab

Click on a running workflow to see details. If the test passes, the test item will be marked with ✅.

Tests being run

You can also view the log output at runtime.

Logs being output

Status badge in README.md (optional)

The image URL of the status batch can be obtained from the top right corner of the screen in markdown format.

Top right of screen

Badge markdown

If you stick it in a markdown file such as README.md, you can see the status like this. If the test is failing, it will show Falling.

Arduino Lint

Completion “(-”"-)"

The same content as the article is available on GitHub.

CaseyNelson314/MyLibrary

We have also introduced this test in our club libraries.

udonrobo/UdonLibrary

Run in local environment

You can test it by opening ArduinoLint.ino in the Arduino IDE and compiling it. To run in multiple environments, you have to switch boards manually. if you create a script that compiles with arduino-cli, you can test locally in parallel.

Note for those developing on Windows

Since it runs on an Linux (Ubuntu) machine, unlike Windows, it is case-sensitive for files. Therefore, case must be handled strictly. For example, Arduino.h as arduino.h is accidental.

#include <Arduino.h>
// #include <arduino.h>  // NO!!

Also, Linux uses / as a path separator (Arduino for Windows uses \ or /). So it is easy to adapt the include statement to the Ubuntu version.

#include <MyLibrary/Algorithm/Math.hpp>
// #include <MyLibrary\Algorithm\Math.hpp>  // NO!

The End

Introducing tests makes it mentally considerably easier to ensure that other functions are alive (approximately) when changing functions.

It only takes a little effort to implement, so why not give it a try?

If you have any comments or suggestions, please feel free to leave a comment. Thank you for reading this article.