pytest is the testing framework used in this guide.
Tests help verify that the project behaves as expected.
They are also an important part of a clean development workflow.
A project without tests can still work.
The problem is that every change becomes riskier.
Without tests, it is harder to answer questions such as:
- Did this change break existing behavior?
- Does this function handle edge cases?
- Can another person safely modify this code?
- Can this project be checked automatically in CI?
Tests do not make a project perfect, but they make it easier to trust.
The example package is intentionally small.
It contains simple text utilities such as:
- whitespace normalization,
- word counting,
- character counting.
These functions are simple enough to understand quickly, but still useful for demonstrating testing.
The goal is not to build an advanced text processing library.
The goal is to show how tests fit into a maintainable Python project.
Tests are stored in the tests/ directory:
tests/
└── test_text_stats.py
Keeping tests separate from source code makes the project easier to navigate.
The source code lives in:
src/text_toolkit/
The tests live in:
tests/
This separation makes it clear which files implement behavior and which files verify it.
The test file is named:
test_text_stats.py
This name follows a common pytest convention.
By default, pytest discovers files whose names start with test_ or end with _test.py.
Inside those files, test functions usually start with test_.
Example:
def test_count_characters_includes_whitespace_by_default() -> None:
result = count_characters("hello world")
assert result == 11The function name describes the expected behavior.
Good test names should be readable.
A person should be able to understand what is being tested without reading the whole implementation first.
Many tests in this guide follow a simple structure:
Arrange → Act → Assert
This means:
- prepare the input,
- call the function,
- check the result.
Example:
def test_count_characters_can_ignore_whitespace() -> None:
result = count_characters("hello world", include_whitespace=False)
assert result == 10This style makes tests easier to read and review.
Sometimes several inputs should follow the same behavior. Copying the entire test for each input would make the test file repetitive.
pytest can run one test function with several sets of values:
import pytest
@pytest.mark.parametrize(
("text", "expected"),
[
("Python is fun", "Python is fun"),
("Python\tis\nfun", "Python is fun"),
(" \t\n ", ""),
(" Python project ", "Python project"),
],
)
def test_normalize_whitespace_handles_common_inputs(text: str, expected: str) -> None:
result = normalize_whitespace(text)
assert result == expectedThe names text and expected match the two values in every tuple. pytest
runs the function once for each tuple and reports every case separately.
This example covers repeated spaces, tabs and newlines, whitespace-only text, and leading or trailing spaces without duplicating the test logic.
Parametrization is useful when the action and assertion stay the same. Use separate tests when cases describe different behavior or need different setup.
The project configures pytest in pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]The testpaths option tells pytest where tests are located.
The project defines a build system in pyproject.toml. When you run:
uv syncuv installs the project into its environment in editable mode.
For example, tests can import:
from text_toolkit import count_wordsNo pytest-specific pythonpath shortcut is needed. Tests use the installed
package, while editable installation keeps changes in src/ immediately
available during development.
Tests can be run with:
uv run pytestThis command runs pytest inside the project environment managed by uv.
Using uv run is important because it makes sure the command uses the dependencies installed for this project.
Good tests should be:
- small,
- readable,
- focused on behavior,
- easy to run,
- independent from each other.
A test should usually check one idea.
If a test checks many unrelated things at once, it becomes harder to understand what failed.
Tests should avoid:
- depending on execution order,
- requiring manual steps,
- testing too many things at once,
- duplicating implementation details,
- using unclear names.
Tests should describe expected behavior, not simply repeat how the function is implemented.
Before opening a pull request with code changes, run:
uv run pytestIf tests fail, fix the problem before pushing the branch.
Later, the same command will also run automatically in GitHub Actions.
This means tests will become part of both:
- the local development workflow,
- the pull request review workflow.
Tests are not something added at the end of a project.
They are part of how the project grows.
A small project with a few clear tests is better than a large project that nobody can safely change.