Testing, formatting, linting, type-checking, and continuous integration

Eva Maxfield Brown, Ana Trišović, and Su Sun

Intro

Structure

We’ll talk a little about each topic, but the main focus is showing how to add each of these processes to your code, and how they work.

In between each section, try adding the step we just discussed to your own code. If you run into issues or are confused, say so — we can then answer that question for the whole group.

Motivation

This is ‘expected knowledge’ for software developers, but it’s rarely taught to scientists. Yet it’s exactly what keeps scientific code trustworthy: tests catch when a change silently breaks a result, and CI runs those checks automatically on every change.

Understanding testing and continuous integration (CI) well is also a highly transferable skill — it shows up in nearly every software engineering role.

Regardless of industry or academia, CI systems can run lots of different tasks — whole processing pipelines, docs builds, releases — and they’re free for open-source projects.

(Please use CI responsibly — these are shared, free resources.)

A running example

Every example below uses the same tiny package, rescale, which maps an array onto the interval [0, 1].

src/rescale/rescale.py

import numpy as np


def rescale(input_array: np.ndarray) -> np.ndarray:
    """Linearly rescale an array to the range [0, 1].

    Maps the minimum value of the input to 0 and the maximum to 1,
    scaling all other values proportionally in between.
    """
    L = np.min(input_array)
    H = np.max(input_array)
    output_array = (input_array - L) / (H - L)
    return output_array

Small enough to hold in your head, and buggy enough to be interesting — notice what happens when every input value is the same.

Testing

What testing is

Testing consists of writing small functions that check:

  • whether small units of your code function as expected
  • whether these small units integrate well together
  • whether your code takes care of edge cases
  • whether your code’s inputs and outputs are correctly treated

Running your tests lets you check that new changes to the code base did not break anything in your code (at least, what you are testing for!).

As you write tests, you get to experience what it takes to use your library, and this might lead you to refactor parts of your code. Refactoring is an important part of a software life-cycle!

Types of tests

Tests are usually grouped by scope — how much of the system one test exercises:

  • Unit — a single function or class, in isolation
  • Integration — several units working together
  • Functional / end-to-end — a whole workflow, from a user’s point of view

You don’t need all of these on day one. For most scientific code, start with unit tests — they catch the most bugs for the least effort.

Unit tests

The smallest scope: test one function or class on its own.

  • Fast, numerous, and pinpoint exactly what broke
  • No external resources (files, network, databases)
  • The bulk of most test suites
# tests/test_rescale.py
import numpy as np
from rescale.rescale import rescale


def test_rescale() -> None:
    """Test that rescale works correctly for a simple case."""
    input_array = np.array([1, 2, 3, 4, 5])
    output_array = rescale(input_array)
    expected_array = np.array([0, 0.25, 0.5, 0.75, 1])
    np.testing.assert_allclose(output_array, expected_array)

Integration and functional tests

Bigger scope, fewer of them:

  • Integration — check that units work together; e.g. creating a multi-reactor network: do the pieces connect correctly?
  • Functional / end-to-end — check a whole workflow from the user’s point of view: given this input file, does the full pipeline produce the expected output?

These catch bugs unit tests can’t — wrong assumptions about how components fit together — but they’re slower and harder to debug.

This is the test pyramid: many fast unit tests at the base, fewer integration tests above, and only a handful of slow end-to-end tests at the top.

Regression tests

Grouped by purpose, not scope: a regression test makes sure a bug that was fixed stays fixed.

The workflow when you find a bug:

  1. Write a test that reproduces it — it fails, proving the bug is real
  2. Fix the code until the test passes
  3. Keep the test — it now guards against the bug ever coming back

Our rescale divides by zero when every input value is equal (H == L). A regression test would feed it a constant array and assert it behaves sensibly — so a future change can’t silently reintroduce that crash.

Property-based testing

Instead of hand-picking inputs, state a property that must always hold and let a tool generate many inputs trying to break it.

This shines for scientific code, where outputs obey mathematical rules:

  • rescale’s output should always lie in [0, 1]
  • a sort should always return a list of the same length

See Hypothesis for the Python implementation.

Writing tests with pytest: parameterizing

Rather than copy-pasting a test for each case, hand pytest a list of them:

import numpy as np
import pytest
from rescale.rescale import rescale


@pytest.mark.parametrize(
    "input_array, expected_array",
    [
        (np.array([1, 2, 3, 4, 5]), np.array([0, 0.25, 0.5, 0.75, 1])),
        (np.array([5, 4, 3, 2, 1]), np.array([1, 0.75, 0.5, 0.25, 0])),
    ],
)
def test_rescale_parameterized(
    input_array: np.ndarray,
    expected_array: np.ndarray,
) -> None:
    """Test that rescale works correctly for multiple cases."""
    output_array = rescale(input_array)
    np.testing.assert_allclose(output_array, expected_array)

Writing tests with pytest: fixtures

A fixture loads a resource once and hands it to any test that asks for it by name — useful when setting the resource up is slow.

# tests/conftest.py
import numpy as np
import pytest


@pytest.fixture
def preloaded_data() -> np.ndarray:
    """Load a resource once and reuse it in multiple tests."""
    return np.array([-1, 0, 0, 0, 1])
# tests/test_rescale.py  -- just name the fixture as an argument
def test_rescale_with_preloaded_data(preloaded_data: np.ndarray) -> None:
    output_array = rescale(preloaded_data)
    expected_array = np.array([0, 0.5, 0.5, 0.5, 1])
    np.testing.assert_allclose(output_array, expected_array)

Testing that an error is raised

Sometimes the correct behavior is an exception:

@pytest.mark.parametrize(
    "input_array",
    [
        pytest.param(
            "some bad value",
            marks=pytest.mark.xfail(raises=ValueError),
        ),
    ],
)
def test_rescale_invalid_params(input_array: np.ndarray) -> None:
    """Test that rescale raises an error when passed invalid parameters."""
    rescale(input_array)

Running your tests

pytest tests/

Configure it once in pyproject.toml so everyone runs the same thing:

[project.optional-dependencies]
test = ["pytest>=7,<8", "pytest-cov"]

[tool.pytest.ini_options]
testpaths = ["tests"]

Formatting

What formatting does

  1. Stops all the arguments about tabs vs. spaces, new lines in the middle of operations, etc. No more pointless arguments!

MORE IMPORTANTLY

  1. Keeps a consistent style across the whole project. Whether you wrote the code or someone else did, it will look the same.

Some code that needs it

# messy_code.py
x = {  'a':1,'b':2 ,'c':3}


def  add_all( a,b ,c ):
    total = 0
    return     a+b+ c


def greet(name='world'):
    print( "hello, "+name+'!' )
    return None
black --check --diff messy_code.py

Configuring the formatter

# pyproject.toml
[tool.black]
line-length = 88
target-version = ["py310", "py311", "py312"]

black is the long-standing default; ruff format is a fast, near-drop-in replacement that also does your linting.

Setup: install pre-commit (once)

The formatting, linting, and type-checking hooks are tied together by pre-commit:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/psf/black
    rev: 23.9.1
    hooks:
      - id: black

  - repo: https://github.com/charliermarsh/ruff-pre-commit
    rev: v0.0.291
    hooks:
      - id: ruff
        args: ["--fix"]

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.5.1
    hooks:
      - id: mypy

Install it once per clone:

pip install pre-commit   # or add it to your dev extra
pre-commit install       # register the git hook

After pre-commit install, the hooks run automatically on every git commit, on the changed files. To run them on demand across the whole repository:

pre-commit run --all-files

Warning

Without pre-commit install, committing won’t trigger any of these checks — they’re silently skipped. (Invoking black / ruff / mypy directly still works, but the git hooks won’t fire on commit until you set this up.)

Linting

Formatting vs. linting

Formatting focuses on directly changing the code to some “standard” style. Linting looks for common problems in the code.

A nice example of the difference: formatting won’t change the following,

from package_a import foo
from package_b import bar
from package_a import foo, baz

but linting should inform you (if not automatically fix) that foo from package_a is imported twice, and that a nicer import block looks like:

from package_a import baz, foo  # alphabetical
from package_b import bar

What else a linter catches

  • It will alert you to unused variables that you may have forgotten to use, or accidentally left behind from debugging.
  • It will remind you about function and module documentation standards.
  • It will help you fix code which is doing extra bits of work (accidental double looping, possible standard library replacements).

I won’t list all the rules it checks against, but there are a lot.

Configuring the linter

# pyproject.toml
[tool.ruff]
line-length = 88
target-version = "py311"
extend-select = [
    "E",      # style errors
    "F",      # flakes
    "D",      # pydocstyle
    "I001",   # isort
    "UP",     # pyupgrade
    "N",      # pep8-naming
    "B",      # flake8-bugbear
    "RUF",    # ruff-specific rules
]
extend-ignore = [
    "D100",   # Missing docstring in public module
    "D107",   # Missing docstring in __init__
]
ruff check --diff messy_code.py

Type checking

Why bother

Type checking is the most thorough analysis of your code you can do before you even run it.

Python added optional types in version 3.5. At first they were simply for analysis of the code prior to running, to check for even more bugs and possible error cases, but in recent versions they are also being used to speed up your programs.

They can be annoying to add and work with — very annoying, because they are optional in the language, so there are a lot of hacky ways they can be manipulated.

The basics

Typing is handled by annotations in the code:

def example(a):
    print(f"Hello {a}")

def example_typed(a: str) -> None:
    print(f"Hello {a}")

And they can get complicated…

import random

# yay only a single value
var_int: int = 5
var_float: float = 0.5
var_bool: bool = False
var_string: str = "wow!"

# oh no multiple values
list_of_strings: list[str] = ["hello", "world"]
list_of_mixed: list[str | int | bool] = ["hello", 3, True]

# lists don't have any constraints on size but tuples do
tuple_of_two_values: tuple[str, int] = ("number", 10)
tuple_of_n_values: tuple[str, ...] = tuple(
    ["python why did you add this" for i in range(random.randint(1, 5))]
)

# sub-objects
dict_of_dicts: dict[str, dict[str, str | int]] = {
    "alice": {"name": "Alice Smith", "age": 30},
    "bob": {"name": "Bob Boberson"},
}

An example with the factory pattern

from abc import ABC, abstractmethod
from typing import Type

class Localizer(ABC):

    @abstractmethod
    def localize(self, text: str) -> str:
        raise NotImplementedError()

class EnglishLocalizer(Localizer):

    def localize(self, text: str) -> str:
        return "Hello world"

localizers = {
    "en": EnglishLocalizer,
}

def get_localizer_not_initialized(lang: str) -> Type[Localizer]:
    return localizers[lang]

def get_localizer_initialized(lang: str) -> Localizer:
    return localizers[lang]()

Tips: don’t worry too much

You rarely have to hand-write hard types — libraries ship their own.

numpy, pandas, and most scientific libraries provide types you can reuse directly in your annotations:

import numpy as np

var_array: np.ndarray = np.random.random((3, 2))

Tips: when nesting gets ugly, use a dataclass

A deeply nested dict[str, dict[str, ...]] is technically correct but hard to read, and the checker can’t tell you which keys must exist.

from dataclasses import dataclass

# Technically correct, but the checker can't catch a misspelled key:
dict_of_dicts: dict[str, dict[str, str | int]] = {
    "alice": {"name": "Alice Smith", "age": 30},
    "bob": {"name": "Bob Boberson"},
}

# Define the sub-object once, with named + typed fields:
@dataclass
class PersonDetails:
    name: str
    age: int | None = None

people: dict[str, PersonDetails] = {
    "alice": PersonDetails(name="Alice Smith", age=30),
    "bob": PersonDetails(name="Bob Boberson"),
}

# The payoff: attribute access is typed and checked
# (autocompletes, and a typo like .agee would be flagged)
people["alice"].age

Catching a bug before running

def repeat(text: str, times: int) -> str:
    return text * times

# mypy flags this BEFORE the program ever runs:
message: str = repeat("hi", "four")   # "times" expects int, got str
mypy messy_code.py
# pyproject.toml
[tool.mypy]
ignore_missing_imports = true
disallow_untyped_defs = true
check_untyped_defs = true
show_error_codes = true

Ship a py.typed marker file inside your package so downstream users get your type information too.

It is hard to learn, but do practice using it. It will make writing code easier the more you do it, as it will check a lot of assumptions for you.

Some resources to continue learning:

Continuous integration

What CI is for

Continuous integration (CI) is meant to reduce or remove bugs entering code over time — whether the code base is changing (new features, bug fixes) or upstream dependencies are changing (new releases).

The main idea: by checking your tests, formatting, linting, and types for each commit or PR, you know that nothing is breaking.

If something does break, you know the exact commit or PR which broke it.

Testing beyond your own machine

Further, it lets you test on more machine setups than your own. For example, you might have a Linux laptop and a Windows desktop, using Python 3.11 on both — but have users on macOS with Python 3.10.

CI systems (GitHub Actions, GitLab CI/CD, Azure Pipelines, etc.) let you run the same suite of tests across all of these machine setups.

Fortunately, once you have testing, formatting, linting, and type-checking set up locally, it is pretty easy to add CI to your repository!

A CI workflow, in full

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:
    branches: [main]
  workflow_dispatch:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.11", "3.12"]
        os: [ubuntu-latest, macOS-latest, windows-latest]
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-python@v5
      with:
        python-version: ${{ matrix.python-version }}
    - run: pip install .[test]
    - run: pytest tests/

…and the lint job

  lint:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-python@v5
      with:
        python-version: "3.11"
    - run: pip install pre-commit
    - run: pre-commit run --all-files --show-diff-on-failure

The same pre-commit command you run locally — so CI can never disagree with your machine about what “clean” means.

Separate workflows by when they run

Don’t cram everything into one workflow. Split them by trigger and purpose:

  • ci.yml — tests and lint. Runs on every push and pull request, so you get feedback on every change.
  • docs.yml — builds and deploys the documentation. Runs only on push to main, and only when the docs sources change (a paths: filter).

Why separate them?

  • Right trigger for the job — checks belong on PRs; deploys belong on main only.
  • Save time and resourcespaths: filters skip work the change can’t affect.
  • Independent failures — a broken docs build doesn’t block a code PR, and vice versa.
  • Least privilege — deploying needs pages: write / id-token: write; testing doesn’t. Keeping them apart limits what each job can do.

Code coverage

What it measures

Once you have tests, a natural question is: how much of my code do they actually exercise?

Code coverage measures which lines (and branches) of your source code are run when your test suite executes. It is usually reported as a percentage:

  • Line coverage — what fraction of lines were executed
  • Branch coverage — what fraction of if/else branches were taken

A line that is never run by any test is a line whose behavior you are not verifying.

A word of caution

High coverage is not the same as good tests.

  • 100% coverage only means every line ran — not that you asserted the right things
  • It is easy to “cover” a line without meaningfully testing it
  • Chasing a coverage number can lead to lots of low-value tests

Use coverage as a guide to find untested code, not as a goal in itself.

Measuring it

We measure coverage with pytest-cov, a plugin built on coverage.py:

pytest tests/ --cov=rescale --cov-report=term-missing

--cov=rescale measures the rescale package, and --cov-report=term-missing prints which lines were not covered.

# pyproject.toml -- so everyone gets the same settings
[tool.coverage.run]
source = ["rescale"]
branch = true

[tool.coverage.report]
show_missing = true
exclude_lines = [
    "pragma: no cover",
    "if __name__ == .__main__.:",
]

Coverage in CI

Add a coverage job so every commit and PR reports it. It generates a machine-readable report and uploads it to a service such as Codecov, which then comments on each PR showing how coverage changed:

  coverage:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-python@v5
      with:
        python-version: "3.11"
    - run: pip install .[test]
    - run: pytest tests/ --cov=rescale --cov-report=xml
    - uses: codecov/codecov-action@v4

Uploading to Codecov needs a CODECOV_TOKEN repository secret, which a repository admin configures from the Codecov dashboard.

Continuous delivery: shipping a release

Releasing should be as automated as testing

A release job lives alongside the test, lint, and coverage jobs, but it runs only when you cut a release — typically triggered by a version tag (e.g. v0.1.0) or a published GitHub Release, not on every commit.

on:
  push:
    tags:
      - "v*"

The release job

  release:
    needs: [test, lint]
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write     # required for PyPI Trusted Publishing (OIDC)
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-python@v5
      with:
        python-version: "3.11"
    - name: Build distribution
      run: |
        pip install build
        python -m build
    - name: Publish to PyPI
      uses: pypa/gh-action-pypi-publish@release/v1

Authentication uses PyPI Trusted Publishing: PyPI trusts your GitHub repository directly, so there is no API-token secret to manage or leak.

Everything at once

Tying it together

Everything above lives in one repository, and a task runner such as just gives it a single front door:

# Justfile
install:
    pip install -e .[test,dev,docs]

test:
    pytest tests/

lint:
    pre-commit run --all-files
just install   # install the package with test, dev, and docs extras
just test      # run the test suite
just lint      # formatting, linting, and type-checking via pre-commit

…and CI runs the same three commands on every push and pull request.

Credit

Much of the written content came directly from pydev-guide.github.io.

It is still under development, but some tutorials are already available. Highly recommend starring it and checking back every few months.

These materials are adapted from lessons created by Eva Maxfield Brown for the URSSI Winter School, and updated for the June 2026 school.