Quality Assurance: Testing and Linting

Last updated on 2026-02-11 | Edit this page

Overview

Questions

  • How do I keep development tools separate from my library dependencies?
  • How can I automatically fix style errors?
  • How do I ensure my code works as expected?
  • What are pre-commit hooks?

Objectives

  • Use uv add --dev to install tools for developers (linting, testing).
  • Configure ruff to format code and catch bugs.
  • Write and run a simple test suite with pytest.
  • Automate checks using prek.

The “Works on My Machine” Problem (Again)


We have a pyproject.toml that defines what our package needs to run (e.g., numpy).

But as developers, we need more tools. We need tools to:

  1. Format code (so it looks professional).
  2. Lint code (to catch bugs before running).
  3. Test code (to verify correctness).

We don’t want to force our users to install these tools just to use our library. We need Development Dependencies.

Development Dependencies with uv


We will use uv to add tools to a special dev group. This keeps them separate from the main dependencies.

BASH

# Add Ruff (linter), Pytest (testing), and plugins for coverage/randomization
uv add --dev ruff pytest pytest-cov pytest-randomly

This updates pyproject.toml:

TOML

[dependency-groups]
dev = [
    "pytest>=8.0.0",
    "ruff>=0.1.0",
]
Diagram showing how end users only get runtime dependencies while developers get both runtime and dev tools
Diagram showing how end users only get runtime dependencies while developers get both runtime and dev tools

Linting and Formatting with ruff


Ruff is an extremely fast static analysis tool that replaces older tools like flake8 (linting), black (formatting), and isort (sorting imports).

Let’s see how messy our code is. Open src/chemlib/geometry.py and make it “ugly”: add some unused imports or bad spacing.

PYTHON

# src/chemlib/geometry.py
import os  # Unused import!
import numpy as np

def center_of_mass(atoms):
    x = 1    # Unused variable!
    print("Calculating...")
    data = np.array(atoms)
    return np.mean(data, axis=0)

Now, run the linter:

BASH

uv run ruff check
src/chemlib/geometry.py:2:8: F401 [*] =os= imported but unused
src/chemlib/geometry.py:6:5: F841 [*] Local variable =x= is assigned to but never used
Found 2 errors.

ruff found code-smell. Now let’s fix the formatting automatically:

BASH

uv run ruff format

Your code is now perfectly spaced and sorted according to community standards.

Testing with pytest


Now that the code looks right, does it work right?

We need to write a test. By convention, tests live in a tests/ directory at the root of your project.

BASH

mkdir tests
# Create __init__.py to allow relative imports within the tests directory
touch tests/__init__.py

Create a test file tests/test_geometry.py:

SH

import numpy as np
import pytest
from chemlib.geometry import center_of_mass

def test_center_of_mass_simple():
    """Test COM of a simple diatomic molecule."""
    atoms = [[0, 0, 0], [2, 0, 0]]
    expected = [1.0, 0.0, 0.0]

    result = center_of_mass(atoms)

    # Use numpy's assertion helper for float comparisons
    np.testing.assert_allclose(result, expected)

def test_center_of_mass_cube():
    """Test COM of a unit cube."""
    atoms = [
        [0,0,0], [1,0,0], [0,1,0], [0,0,1],
        [1,1,0], [1,0,1], [0,1,1], [1,1,1]
    ]
    expected = [0.5, 0.5, 0.5]
    result = center_of_mass(atoms)
    np.testing.assert_allclose(result, expected)

Run the tests using uv run. We include the --cov flag (from pytest-cov) to see which lines of code our tests executed:

BASH

uv run pytest --cov=src
tests/test_geometry.py ..                                            [100%]

---------- coverage: platform linux, python 3.14 ----------
Name                        Stmts   Miss  Cover
-----------------------------------------------
src/chemlib/__init__.py         0      0   100%
src/chemlib/geometry.py         4      0   100%
-----------------------------------------------
TOTAL                           4      0   100%

========================== 2 passed in 0.04s ==========================

Why did this work?

Because of the Src Layout established earlier, uv run installs the package in editable mode. pytest imports chemlib as if it existed as a standard installed library.

Challenge

Challenge: Break the Test

Modify src/chemlib/geometry.py to introduce a bug (e.g., divide by len(data) - 1 instead of the true mean).

  1. Run uv run pytest. What happens?
  2. Run uv run ruff check. Does the linter catch this logic error?
  1. Pytest Fails: It will show exactly where the numbers mismatch (AssertionError).
  2. Ruff Passes: Linters check syntax and style, not logic. This is why we need both!

Automating best practices


Flowchart showing git commit triggering ruff checks, which either pass to the repo or fail and require fixes
Flowchart showing git commit triggering ruff checks, which either pass to the repo or fail and require fixes

We have tools, but we have to remember to run them. pre-commit hooks automate this by running checks before you can commit code. We will use prek, a Rust rewrite of the venerable pre-commit.

First, add it to our dev tools:

BASH

uv add --dev prek

Create a configuration file .pre-commit-config.yaml in the root directory:

SH

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.1.0
    hooks:
      - id: ruff
        args: [ --fix ]
      - id: ruff-format

Now, run the hook:

BASH

uv run prek

.. and finally install the action as a git hook

BASH

uv run prek install
prek installed at .git/hooks/pre-commit

Now, try to commit messy code. git will stop you, run ruff, fix the file, and ask you to stage the clean file. You can no longer commit ugly code by accident!

Key Points
  • Development Dependencies (uv add --dev) keep tools like linters separate from library requirements.
  • Ruff is the modern standard for fast Python linting and formatting.
  • Pytest verifies code correctness; Src Layout makes test discovery reliable.
  • Pre-commit hooks ensure no bad code ever enters your version control history.