|

GitHub Actions CI/CD (2/10): Unit Tests — Fast Feedback

← Previous GitHub Actions CI/CD (2/10) Next →

Summary: Add pytest to the helloci package and write unit tests for the greet() function and the CLI. These fast, deterministic tests become the first gate in your CI pipeline.

KeyValue
Package namehelloci
Python version3.12
Working directory~/projects/helloci
Test frameworkpytest 8.x
Test directorytests/

0. Prerequisites

  • The helloci package from Part 1 installed in editable mode
  • Virtual environment activated (source .venv/bin/activate)

1. Install pytest

pip install pytestCode language: Shell Session (shell)
Successfully installed pytest-8.x.x

Verify it works.

pytest --versionCode language: Shell Session (shell)
pytest 8.x.x

pytest is the most widely used Python test framework. It discovers test files automatically, runs them, and gives you a clear pass/fail report — exactly what CI needs.


2. Create the Test Directory

mkdir tests
touch tests/__init__.pyCode language: Shell Session (shell)

The __init__.py file makes tests/ a Python package so that pytest can import your test modules reliably. Your directory now looks like this.

~/projects/helloci/
├── pyproject.toml
├── src/
│   └── helloci/
│       ├── __init__.py
│       └── cli.py
└── tests/
    └── __init__.pyCode language: Shell Session (shell)

3. Write Unit Tests for greet()

Create the file tests/test_greet.py.

"""Tests for the greet() function."""

import pytest

from helloci import greet


def test_greet_returns_greeting():
    assert greet("Alice") == "Hello, Alice!"


def test_greet_with_different_name():
    assert greet("Bob") == "Hello, Bob!"


def test_greet_with_single_character():
    assert greet("X") == "Hello, X!"


def test_greet_with_spaces_in_name():
    assert greet("Ada Lovelace") == "Hello, Ada Lovelace!"


def test_greet_empty_string_raises():
    with pytest.raises(ValueError, match="name must not be empty"):
        greet("")


def test_greet_whitespace_only_raises():
    with pytest.raises(ValueError, match="name must not be empty"):
        greet("   ")


def test_greet_none_raises():
    with pytest.raises((ValueError, AttributeError)):
        greet(None)Code language: Python (python)

Each test function does one thing and has a name that describes what it checks. When a test fails, the name tells you what broke without reading the code.

The pytest.raises context manager verifies that the function raises the expected exception. The match parameter checks the error message.


4. Write Unit Tests for the CLI

Create the file tests/test_cli.py.

"""Tests for the helloci CLI."""

import pytest

from helloci.cli import main


def test_cli_greet(capsys):
    main(["greet", "Alice"])
    captured = capsys.readouterr()
    assert captured.out.strip() == "Hello, Alice!"


def test_cli_greet_different_name(capsys):
    main(["greet", "Bob"])
    captured = capsys.readouterr()
    assert captured.out.strip() == "Hello, Bob!"


def test_cli_no_command(capsys):
    with pytest.raises(SystemExit) as exc_info:
        main([])
    assert exc_info.value.code == 1


def test_cli_greet_missing_name():
    with pytest.raises(SystemExit) as exc_info:
        main(["greet"])
    assert exc_info.value.code == 2Code language: Python (python)

The capsys fixture is built into pytest. It captures anything printed to stdout and stderr so you can check the output without spawning a subprocess.

Passing ["greet", "Alice"] to main() simulates running helloci greet Alice on the command line. This is why the argv parameter was designed into the CLI in Part 1.


5. Run the Tests

pytest -vCode language: Shell Session (shell)
tests/test_cli.py::test_cli_greet PASSED
tests/test_cli.py::test_cli_greet_different_name PASSED
tests/test_cli.py::test_cli_no_command PASSED
tests/test_cli.py::test_cli_greet_missing_name PASSED
tests/test_greet.py::test_greet_returns_greeting PASSED
tests/test_greet.py::test_greet_with_different_name PASSED
tests/test_greet.py::test_greet_with_single_character PASSED
tests/test_greet.py::test_greet_with_spaces_in_name PASSED
tests/test_greet.py::test_greet_empty_string_raises PASSED
tests/test_greet.py::test_greet_whitespace_only_raises PASSED
tests/test_greet.py::test_greet_none_raises PASSED

11 passedCode language: Shell Session (shell)

The -v flag (verbose) shows each test by name. All 11 tests pass.


6. Understand What a Failing Test Looks Like

Break a test on purpose to see what failure output looks like. Open tests/test_greet.py and temporarily change one assertion.

def test_greet_returns_greeting():
    assert greet("Alice") == "Hi, Alice!"Code language: Python (python)

Run the tests again.

pytest -vCode language: Shell Session (shell)
FAILED tests/test_greet.py::test_greet_returns_greeting - AssertionError:
    assert 'Hello, Alice!' == 'Hi, Alice!'Code language: Shell Session (shell)

pytest shows you the exact values on both sides of the ==. This is the information you will see in CI logs when a test fails on a pull request.

Change the test back to the correct assertion before continuing.

def test_greet_returns_greeting():
    assert greet("Alice") == "Hello, Alice!"Code language: Python (python)

7. Add pytest to Your Project Dependencies

Add a test dependency group to pyproject.toml so that anyone (or any CI runner) can install your test tools with one command. Open pyproject.toml and add the [project.optional-dependencies] section.

[project.optional-dependencies]
test = [
    "pytest>=8.0",
]Code language: TOML, also INI (ini)

Now install the test dependencies.

pip install -e ".[test]"Code language: Shell Session (shell)

This installs the package and pytest together. In Part 4, your CI workflow will use this exact command.


Summary

You added pytest to helloci and wrote 11 unit tests covering the greet() function and the CLI. The tests are fast (milliseconds), deterministic (no network, no database), and give clear pass/fail output.

  • tests/test_greet.py — 7 tests covering normal input, edge cases, and error handling
  • tests/test_cli.py — 4 tests covering CLI output and error exits
  • pyproject.toml now declares pytest as a test dependency

Unit tests are the first gate in any CI pipeline. They answer a simple question: does the code do what it claims? In Part 3 you will add a second gate — linting and formatting — that catches bugs before tests even run.

Similar Posts

Leave a Reply