Testing Tutorial
Every function in py-simple-wrap has tests, and every pull request runs them automatically. This page walks you through writing your first one. You don't need to know anything about testing already — if you can write a Python function, you can write a test.
Modeled on the real tests already in the repo (like tests/test_strings.py and tests/test_json.py), so what you learn here is exactly what you'll see in the codebase.
What is a test, really?
A test is a small function that answers one question: "when I give my function this input, do I get the output I expected?"
You already do this by hand. You write a function, run it with a few values, look at what prints, and think "yep, that looks right." A test is the same thing, except you write your expectation down once and let the computer check it — every time, in a second, forever. If someone changes the code later and accidentally breaks something, the test notices before anyone else has to.
Why bother?
- You find out when something breaks. Not a week later when someone opens an issue.
- You can change code without fear. Run the tests after your change. Green means you didn't break anything.
- Reviewers trust your pull request more. A PR with a test says "I checked this works," and it makes the review quicker.
Setting up
You only need pytest. If you followed CONTRIBUTING.md to set up the project, you already have it. To check:
pytest --version
If that prints a version number, you're ready. (If it says "command not found", install the project with pip install -e .[all,test,docs] or uv sync --all-extras --dev, as described in CONTRIBUTING.)
Your first test
Say you're adding a test for count_words, which counts the words in a piece of text. Create a practice file in the tests/ folder (test_strings.py already exists for real, so we'll call ours test_count_words_demo.py). The name must start with test_ — that's how pytest knows it's a test file.
# tests/test_count_words_demo.py
from py_simple_package.src.py_simple.easy_strings import count_words
def test_count_words_counts_a_sentence():
result = count_words("Hello world! How are you?")
assert result == 5
That's the whole test. Let's read it top to bottom:
- The import brings in the function you want to test. Every test file in this repo imports from
py_simple_package.src.py_simple.<module>. def test_...— the function name must start withtest_, or pytest will ignore it. Make the rest of the name say what you're checking.result = count_words(...)— call your function with an example input.assert result == 5— this is the actual check.assertmeans "I expect this to be true." If it is, the test passes. If it isn't, the test fails.
Running it
From the project's main folder, run:
pytest -v
-v means "verbose", which shows each test by name. (The project already turns this on for you in pyproject.toml, but it doesn't hurt to know what it does.)
Example output:
tests/test_count_words_demo.py::test_count_words_counts_a_sentence PASSED [100%]
============================== 1 passed in 0.02s ===============================
PASSED and 1 passed are what you want to see. Congratulations, that's a working test.
What a failing test looks like
Failing tests are not bad news. They're the whole point — they're the test doing its job. Let's break ours on purpose by changing 5 to 6:
assert result == 6
Run pytest again:
=================================== FAILURES ===================================
______________________ test_count_words_counts_a_sentence __________________
def test_count_words_counts_a_sentence():
result = count_words("Hello world! How are you?")
> assert result == 6
E assert 5 == 6
tests/test_count_words_demo.py:6: AssertionError
=========================== short test summary info ============================
FAILED tests/test_count_words_demo.py::test_count_words_counts_a_sentence - a...
============================== 1 failed in 0.03s ===============================
How to read this:
- The
>arrow points at the exact line that failed. - The
Elines say why:assert 5 == 6means "the function gave back5, but you said it should be6." - The last line tells you which test failed, so you know where to look.
When a test fails, there are two possibilities: either the function has a bug, or your expectation was wrong. Figuring out which one is part of the job — and here, the function was right (it is five words) and the test was wrong. Change it back to 5 and it goes green again.
Testing lots of inputs at once
A function should work for more than one input, including weird ones (empty text, only spaces). Writing a separate test for each gets repetitive, so pytest has parametrize: one test, many examples.
import pytest
from py_simple_package.src.py_simple.easy_strings import remove_extra_spaces
@pytest.mark.parametrize(
"text, expected",
[
(" hello world ", "hello world"),
("hello world", "hello world"),
("", ""),
(" ", ""),
],
)
def test_remove_extra_spaces(text, expected):
assert remove_extra_spaces(text) == expected
Read the list as a little table: each row is one example, with the input first and the answer you expect second. pytest runs the test once per row and reports each one separately, so if row three fails you'll know exactly which input caused it.
To add a new case later, you just add one more row. This is the pattern used all over tests/test_strings.py.
Testing that a function raises an error
Sometimes the correct behaviour is an error. In this project, functions raise a clear custom error (see the Custom Exception Template) instead of letting a confusing one leak through. You test that with pytest.raises:
import pytest
from py_simple_package.src.py_simple.easy_json import EasyJsonError, open_json
def test_open_json_missing_file_raises(tmp_path):
missing_file = tmp_path / "nope.json"
with pytest.raises(EasyJsonError):
open_json(str(missing_file))
Two new things here:
with pytest.raises(EasyJsonError):means "the code inside this block must raiseEasyJsonError." If it does, the test passes. If it raises nothing, or a different error, the test fails.tmp_pathis a built-in pytest helper that gives your test its own temporary folder. Anything you create there is deleted afterwards, so your tests never leave junk files behind or touch real ones. Just addtmp_pathas a parameter and pytest fills it in for you. Use it any time your test needs a file.
The template
Copy this block into a tests/test_<module>.py file and fill in the placeholders:
````python
import pytest
from py_simple_package.src.py_simple.easy_<module> import <function_name>
def test_<function_name>_<what_you_are_checking>():
result = <function_name>(<example input>)
assert result == <expected output>
@pytest.mark.parametrize(
"<input_name>, expected",
[
(<normal example>, <expected>),
(<edge case, e.g. empty>, <expected>),
],
)
def test_<function_name>_many_inputs(<input_name>, expected):
assert <function_name>(<input_name>) == expected
def test_<function_name>_raises_on_bad_input():
with pytest.raises(<ModuleError>):
<function_name>(<bad input>)
````
Delete the parts you don't need. A function that never raises doesn't need the third test.
What should I test?
You don't have to test everything. For each function, aim for:
- One normal example — the thing the function is meant to do.
- One or two edge cases — empty text, an empty list, zero, a single item. Edge cases are where bugs hide.
- One error case — only if the function is supposed to raise something.
That's usually three to five tests per function, and it's plenty.
Rules of thumb
- Test names describe the behaviour.
test_count_words_counts_a_sentencetells the reader what broke without opening the file.test_1doesn't. - One idea per test. If a test fails, you want to know immediately which thing went wrong.
- Run the test before you trust it. Make sure it can actually fail. If you've never seen it fail, you don't know it's checking anything.
- Use
tmp_pathfor files. Never read or write real files from a test. - Don't hit the internet or paid APIs in tests. Tests must run anywhere, quickly, and for free. Look at
tests/test_web.pyandtests/test_ai.pyto see how the project fakes those calls. - Put the file in the right place. Tests go in
tests/, namedtest_<module>.py, one file per module.
Running just your tests
Once the project has lots of tests, you don't want to wait for all of them while you work. Run one file:
pytest tests/test_strings.py
Or only the tests whose name contains a word:
pytest tests/test_strings.py -k count_words
Example output:
tests/test_strings.py::test_count_words[hello 123 world-3] PASSED [100%]
======================= 8 passed, 53 deselected in 0.04s =======================
Before you open your pull request, run plain pytest once with no extras, so you know the whole suite is still green. GitHub will run it again on Python 3.10 through 3.14 when you push.
Still stuck?
That's okay. Open a draft pull request with your test file as it is — even a failing one — and say what you're stuck on. Someone will help you get it green. See CONTRIBUTING.md for how to reach out.