Let’s Actually Make A Test (For Real)#

Overview

Teaching: 5 min | Exercises: 20 min

Questions

  • How does a realistic workflow look for a physics analysis?

  • How to generate reports?

Objectives

  • Actually add a test on the output of running physics code

Why testing?#

We have already seen how CI helps to ensure that our code compiles and runs as expected, and to store the output of the execution to manually check that the result is acceptable.

However, as a software development project scales, manual checks become increasingly time-consuming and error-prone. It becomes increasingly important to have a set of tests that can be run automatically to ensure that the output of the code is as expected. This is especially true when multiple people are working on the same codebase.

Testing itself is a broad topic, and we will not cover it in detail here. We will just illustrate how to run a simple test in our CI pipeline, and how to keep track of the results. Your homework will be to explore the topic further to cope with more complex scenarios.

ABC of testing#

Broadly speaking, there are different types of tests:

  • Unit tests are minimal tests that check that a single function or class works as expected. They are usually written by the developer of the function or class, and are run frequently during development.

  • Integration tests are tests that check that multiple functions or classes work together as expected. Also written by the developers and run frequently during development.

  • System tests are tests that check that the whole system works as expected. Usually written by the developer of the function or class, and are run less frequently during development.

  • Regression tests protects existing features to ensure code updates did not break previously stable functionality.

Commonly, CI is used to run unit and integration tests, as they are designed to be run frequently.

There are many frameworks to write tests, depending on the programming language. A few popular examples are:

No matter the framework, the general idea is the same: write a test that checks that a function or class works as expected (like checking the output), and run it. If the test fails, then the function or class is broken, and needs to be fixed.

Some ideas on what tests to automate in HEP analysis:

  • Check that the output histogram is compatible with a reference histogram.

  • Do a simple cut-and-count analysis, and check that the number of events is acceptable.

  • Perform a fit, and check that the fit parameters are within a reasonable range. Such tasks must be performed in a small subset of the data or simulation, so that the tests can be run frequently.

Pytest#

We will use pytest, the most widely used testing framework for Python. The idea is simple: you write small functions that check one thing each, and pytest finds them, runs them, and reports which ones passed or failed.

It can be installed with pip:

pip install pytest

How pytest finds your tests#

You don’t register tests anywhere, pytest discovers them by naming convention:

  • Files named test_*.py (or *_test.py), typically in a tests/ directory.

  • Inside those files, functions named test_*.

Running the pytest command from the repository root collects and runs everything that matches.

Writing a test: assert is all you need#

A test is a plain function that uses Python’s built-in assert statement. If every assert holds, the test passes; if one fails, pytest reports it with a detailed explanation of the values involved.

Let’s look at the tests for histograms.py. They are in tests/test_histograms.py.

A basic check: every histogram has at least one bin, and the lower edge is below the upper edge.

import histograms

def test_ranges_are_valid():
    for variable, (nbins, low, high) in histograms.ranges.items():
        assert nbins > 0, f"{variable} has no bins"
        assert low < high, f"{variable} has an empty or inverted range"

bookHistogram books a histogram of a variable from an RDataFrame, so let’s build a tiny dataset in memory (no input file needed!) and check the result:

import ROOT

def test_book_histogram():
    # 100 events with pt_1 = 30 GeV and unit weight
    df = ROOT.RDataFrame(100).Define("pt_1", "30.0").Define("weight", "1.0")
    h = histograms.bookHistogram(df, "pt_1", histograms.ranges["pt_1"])
    # Number of bins must be the same
    assert h.GetNbinsX() == histograms.ranges["pt_1"][0]
    # Entries must be the same as the number of events in the dataset
    assert h.GetEntries() == 100

test_histograms.py also contains examples about Fixtures and Parametrization, that you can explore in the file itself.

These are examples of unit tests: they check individual functions of histograms.py with small in-memory datasets, so they are fast enough to run on every push.

Putting it together#

To run the tests at tests/test_histograms.py, you need to have pytest installed, and run the following commands from the repository root:

pytest                                                # run everything
pytest -v                                             # verbose: one line per test
pytest tests/test_histograms.py                       # run one file
pytest tests/test_histograms.py::test_book_histogram  # run one test

Note

ROOT and Python bindings are required to run the tests. If using the JupyterHub instance provided for this tutorial, you can run the tests in a terminal with the following commands:

apptainer shell /cvmfs/singularity.opensciencegrid.org/opensciencegrid/osgvo-el9:latest
source /cvmfs/sw.hsf.org/key4hep/setup.sh
pytest

We will explore more about containers on Wednesday.

Running tests in GitHub Actions#

You probably already guessed it: you can add a job on the GitHub Actions pipeline to run the tests.

Add a test job

Add a test job to .github/workflows/main.yml that installs pytest and runs the test suite.

Think about two things:

  1. Which container image does the job need?

  2. Does it need to wait for any of the other jobs (needs), or can it run in parallel with them?

If any assert fails, pytest exits with a non-zero status code, the step fails, and the whole workflow is marked as failed — exactly the red ❌ next to the commit that tells you (and your collaborators) not to trust that version of the code.

Try it out: break the ranges on purpose (e.g. unvalid range in a variable), push, and watch the parametrized tests catch it.

Report test results#

When a test fails, it should be easy to detect what was expected and determine how to pinpoint the problem. For this, it is important to report the test results in a way that is easy to understand. As the number of tests implemented scale up, it is also important to quickly identify the tests that are failing, and the main reason for the failure. Looking at the pipeline log is not the best way to do this.

Actions provide a way to report test results in a standard format, so that they can be easily visualized in the pipeline results. This is done by using the JUnit test report XML format (JUnit is a popular testing framework for Java, but the XML format is language-agnostic and the reports have become a standard).

Each framework has its own way to generate the JUnit XML report (check the documentation of your favorite tool). For Pytest, we can use the --junitxml= <file> option. Once the XML is generated, we need to tell GitHub where to find it. This is done by the action test-summary/action@v1:

    - name: Create test summary
      uses: test-summary/action@v1
      with:
        paths: <file>
      if: always()

It is important to add the when: always option, so that the report is generated even if the test fails.

Adding the report, our YAML file now looks like this:

name: example
on: push

jobs:
  build_skim:
    runs-on: ubuntu-latest
    container: rootproject/root:6.32.04-ubuntu24.04
    steps:
      - name: checkout repository
        uses: actions/checkout@v6

      - name: build
        run: |
          COMPILER=$(root-config --cxx)
          FLAGS=$(root-config --cflags --libs)
          $COMPILER -g -O3 -Wall -Wextra -Wpedantic -o skim skim.cxx $FLAGS

      - uses: actions/upload-artifact@v7
        with:
          name: skim
          path: skim

  skim:
    needs: build_skim
    runs-on: ubuntu-latest
    container: rootproject/root:6.32.04-ubuntu24.04
    steps:
      - name: checkout repository
        uses: actions/checkout@v6
        
      - uses: actions/download-artifact@v8
        with:
          name: skim
          
      - name: install XRootD client
        run: apt-get update && apt-get install -y xrootd-client
        
      - name: skim
        run: |
          chmod +x ./skim
          ./skim root://eospublic.cern.ch//eos/root-eos/HiggsTauTauReduced/GluGluToHToTauTau.root skim_ggH.root 19.6 11467.0 0.1

      - uses: actions/upload-artifact@v7
        with:
          name: skim_ggH
          path: skim_ggH.root
          retention-days: 7

  plot:
    needs: skim
    runs-on: ubuntu-latest
    container: rootproject/root:6.32.04-ubuntu24.04
    steps:
      - name: checkout repository
        uses: actions/checkout@v6

      - uses: actions/download-artifact@v8
        with:
          name: skim_ggH

      - name: plot
        run: python3 histograms.py skim_ggH.root ggH hist_ggH.root

      - uses: actions/upload-artifact@v7
        with:
          name: histograms
          path: hist_ggH.root

  test:
    runs-on: ubuntu-latest
    container: rootproject/root:6.32.04-ubuntu24.04
    steps:
      - name: checkout repository
        uses: actions/checkout@v6

      - name: install pytest
        run: apt-get update && apt-get install -y python3-pytest

      - name: run tests
        run: pytest -v --junitxml=pytest.xml

      - name: Create test summary
        uses: test-summary/action@v1
        with:
          paths: pytest.xml
        if: always()

Update the pipeline, and check the results. Can you figure out where the test report is on the Web interface? If something fails, how to quickly identify the source of the failure?

Extend your pipeline#

We have covered the basics, and now you have a working CI/CD pipeline that builds your code, runs it, and runs a test. You can extend it to add more stages, more tests, and more features.

There is a lot more to learn about, and from now on is your turn to explore and learn more about it. Keep at hand the GitHub Actions reference, and see how you can extend your pipeline to cover the needs of your projects.

Whenever you start a new project, keep in mind the tests you want to run, and the stages you want to have in your pipeline. You can use this repository as a template, and you will have a working pipeline ready to use.

Key Points

  • pytest discovers tests by naming convention: test_* functions in test_*.py files, and a plain assert is all a test needs.

  • A failing test fails the CI job, flagging the commit before broken code reaches your collaborators.