Тесты для JSON и файлов: tmp_path на практике
Сохранить заказы в JSON, прочитать обратно и не разочароваться: round-trip тесты, кириллица с encoding utf-8, битые файлы через pytest.raises и tmp_path — папка, которая достаётся каждому тесту своя.
Редакция Питоники
Модуль работает с заказами: функция save_orders пишет список в JSON-файл, load_orders читает обратно. Половина багов такого кода не ловится тестами «в памяти»: запись падает не на данных, а на файловой системе — кодировка, права доступа, битый файл от чужой программы, незакрытый файловый дескриптор. У pytest на этот случай всё готово: tmp_path раздаёт тестам временные папки, pytest.raises ловит ошибки чтения, а round-trip — шаблон, который проверяет запись и чтение одним махом. Соберём тесты для модуля save/load по всем правилам.
Маршрут урока: сначала погоняем модуль руками — увидим, как save/load ведёт себя на живом файле; затем переложим наблюдения в round-trip тест с tmp_path; проверим кириллицу; зафиксируем поведение на битом файле через pytest.raises и убедимся, что чистая папка на каждый тест — не роскошь, а условие выживания.
Модуль: save_orders и load_orders
Два дублёра вокруг json: save_orders сериализует список словарей в файл, load_orders читает его обратно. В боевой версии обеим функциям нужен encoding="utf-8" — не украшение, а страховка от кириллицы в данных, сейчас объясню.
import json
def save_orders(path, orders):
with open(path, "w", encoding="utf-8") as f:
json.dump(orders, f, ensure_ascii=False)
def load_orders(path):
with open(path, encoding="utf-8") as f:
try:
return json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"битый JSON: {e}")
orders = [
{"id": 1, "customer": "Анна", "total": 1200},
{"id": 2, "customer": "Борис", "total": 350},
]
path = "orders.json"
save_orders(path, orders)
with open(path, encoding="utf-8") as f:
print(f.read())
back = load_orders(path)
print("Round-trip:", back == orders)
import os
os.remove(path)
[{"id": 1, "customer": "Анна", "total": 1200}, {"id": 2, "customer": "Борис", "total": 350}]
Round-trip: TrueВ файле — то, что мы писали: русские имена читаются, структура на месте, обратное чтение дало словарь, равный исходному. Этот ручной прогон и есть будущий тест — осталось переложить проверки на assert, а файл из рабочей папки убрать. Именно убрать: тесты, пишущие файлы в проект, засоряют репозиторий и сталкиваются лбами при параллельном запуске. Для этого у pytest есть tmp_path.
Пара слов про обёртку ValueError. Стандартная библиотека на битый файл отвечает json.JSONDecodeError — тип, привязанный к модулю json. Бросать его наружу значит заставлять весь проект знать, что внутри заказы хранятся именно в JSON: поменяешь формат хранения на CSV — и тесты, ловившие JSONDecodeError, разом устареют. ValueError — нейтральный контракт «данные не читаются», и тест проверяет его через pytest.raises. Так тестируемая функция остаётся чёрным ящиком, а детали формата — внутренним делом модуля.
tmp_path: своя папка на каждый тест
tmp_path — встроенная фикстура, которая передаёт тесту объект Path на свежую временную папку. Ключевые свойства: папка у каждого теста своя, внутри пусто, а после прогона pytest прибирает старые каталоги сам. Тесту остаётся собрать путь оператором слэш и работать как с обычным файлом.
from pathlib import Path
import sys
Path("orders_mod.py").write_text('''import json
def save_orders(path, orders):
with open(path, "w", encoding="utf-8") as f:
json.dump(orders, f, ensure_ascii=False)
def load_orders(path):
with open(path, encoding="utf-8") as f:
try:
return json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"битый JSON: {e}")
''', encoding="utf-8")
Path("test_orders.py").write_text('''from orders_mod import save_orders, load_orders
def test_round_trip(tmp_path):
path = tmp_path / "orders.json"
orders = [{"id": 1, "customer": "Анна", "total": 1200}]
save_orders(path, orders)
assert load_orders(path) == orders
def test_cyrillic_survives(tmp_path):
path = tmp_path / "orders.json"
save_orders(path, [{"customer": "Анна"}])
text = path.read_text(encoding="utf-8")
assert "Анна" in text
''', encoding="utf-8")
for name in ("orders_mod", "test_orders"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_orders.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.06s exit: 0
tmp_path — полноценный объект pathlib.Path, и все его навыки доступны: mkdir для подпапок, name и suffix для разбора имени, exists для проверок существования. Соберём вложенную структуру, как в настоящем экспорте — папка выгрузок, внутри неё файл данных.
from pathlib import Path
tmp = Path("demo-tmp") # здесь просто имитация Path
exports = tmp / "exports" # подпапка через слэш
data = exports / "orders.json"
print(data.name) # имя файла
print(data.suffix) # расширение
print(data.parent.name) # папка-родитель
orders.json .json exports
test_round_trip — тот самый шаблон: сохранить через save_orders, прочитать через load_orders, сравнить с исходным списком. Один assert покрывает обе функции и их согласованность: сломается любая половина пары — тест покраснеет. А test_cyrillic_survives читает файл как ТЕКСТ и ищет в нём русские буквы — проверка, что в файле лежат не escapes и не каша от неправильной кодировки, а настоящая кириллица.
# ПЛОХО: файл в текущей папке — затирание и мусор
def test_save_bad():
save_orders("orders.json", orders)
assert load_orders("orders.json") == orders
# ХОРОШО: путь от tmp_path — изоляция и уборка
def test_save_good(tmp_path):
path = tmp_path / "orders.json"
save_orders(path, orders)
assert load_orders(path) == orders
def test_no_ascii_escapes(tmp_path):
path = tmp_path / "orders.json"
save_orders(path, [{"customer": "Анна"}])
raw = path.read_text(encoding="utf-8")
assert "Анна" in raw
assert "\\u0410" not in raw # без ensure_ascii=False было бы так
Битый JSON: pytest.raises(ValueError)
Файл может прийти от другой программы и оказаться битым: обрезанный файл, чужой формат, ручная правка. load_orders на такое отвечает ValueError — и это часть контракта функции, которую надо проверить. Для «функция обязана бросить исключение» у pytest есть контекстный менеджер pytest.raises.
from pathlib import Path
import sys
Path("orders_mod.py").write_text('''import json
def save_orders(path, orders):
with open(path, "w", encoding="utf-8") as f:
json.dump(orders, f, ensure_ascii=False)
def load_orders(path):
with open(path, encoding="utf-8") as f:
try:
return json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"битый JSON: {e}")
''', encoding="utf-8")
Path("test_broken.py").write_text('''import pytest
from orders_mod import load_orders
def test_broken_json_raises(tmp_path):
path = tmp_path / "broken.json"
path.write_text("{oops", encoding="utf-8")
with pytest.raises(ValueError):
load_orders(path)
def test_good_json_ok(tmp_path):
path = tmp_path / "good.json"
path.write_text('[{"id": 1}]', encoding="utf-8")
assert load_orders(path) == [{"id": 1}]
''', encoding="utf-8")
for name in ("orders_mod", "test_broken"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_broken.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.04s exit: 0
Связка такая: готовим битый файл честной write_text, зовём функцию внутри with pytest.raises(ValueError) — и pytest считает тест пройденным только если исключение действительно вылетело. Не вылетело — тест упал с Failed: DID NOT RAISE. Бросился другой тип — упадёт снова: pytest.raises строг к типу исключения. Подробно про match и проверку текста ошибки — в уроке про pytest.raises, здесь важно другое: ошибки входа — тоже спецификация, их тестируют наравне с удачным путём.
def test_broken(tmp_path):
path = tmp_path / "broken.json"
path.write_text("{oops", encoding="utf-8")
load_orders(path) # исключение никто не ловит
F [100%] =========================== short test summary info =========================== FAILED test_broken.py::test_broken - ValueError: битый JSON: Expecting value: line 1 column 1 (char 0) 1 failed in 0.02s
Если хочешь заодно проверить ТЕКСТ ошибки, у pytest.raises есть параметр match: with pytest.raises(ValueError, match="битый JSON") — тест пройдёт, только если сообщение содержит подстроку. Это защищает от случая, когда функция бросает ValueError «не о том»: тип совпал, а причина другая. Но не превращай match в проверку полного текста: сообщения правят чаще, чем поведение, и хрупкий тест надоедает чинить.
Чистая папка на каждый тест
Финальный штрих — изоляция. Оба теста ниже пишут файл с одним и тем же именем orders.json — и не мешают друг другу, потому что tmp_path выдаёт каждому свою папку.
from pathlib import Path
import sys
Path("test_isolated.py").write_text('''def test_writer_a(tmp_path):
path = tmp_path / "orders.json"
path.write_text("A", encoding="utf-8")
assert path.read_text(encoding="utf-8") == "A"
def test_writer_b(tmp_path):
path = tmp_path / "orders.json"
path.write_text("B", encoding="utf-8")
assert path.read_text(encoding="utf-8") == "B"
''', encoding="utf-8")
sys.modules.pop("test_isolated", None)
import pytest
rc = pytest.main(["test_isolated.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.03s exit: 0
Поменяй в test_writer_b фикстуру tmp_path на общее имя файла — и тесты начнут гоняться за порядком запуска. Именно поэтому в тестах файлов не существует «записать в ./orders.json»: путь всегда собирается от tmp_path. Бонусом — уборка: pytest держит последние три прогона временных папок и удаляет более старые сам, диск не зарастает.
Вторая причина изоляции, о которой редко думают заранее, — параллельный запуск. pytest умеет гонять тесты в несколько процессов одновременно, и два теста, пишущие один файл в общую папку, начинают портить данные друг друга с вероятностью, которая растёт вместе с проектом. С tmp_path этой проблемы не существует в принципе: у каждого процесса своя папка. Правило дешёвое — а избавляет от целого класса гонок, которые в отчёте выглядят как «то проходит, то нет».
Кстати, про «посмотреть, что же там записалось»: если тест упал, зайди в папку tmp_path глазами — pytest печатает её путь в отчёте подробного запуска, а хранит папки трёх последних прогонов. Открыл, увидел кривой файл, починил функцию. Для отладки это быстрее, чем добавлять print в тест: файл и есть ответ.
- round-trip: save → load → сравнить с исходником — минимум для любой пары функций записи и чтения;
- кириллица в данных + encoding="utf-8" в обеих сторонах — тест на кодировку пишется один раз и спасает навсегда;
- битый файл → pytest.raises(ValueError) — ошибки входа такая же часть контракта, как и удачный путь;
- все тестовые файлы — только внутри tmp_path, никаких записей в корень проекта;
- упал тест — загляни в его tmp_path глазами: записанный файл и есть главный свидетель.
| Что даёт tmp_path | Что это значит на практике |
|---|---|
| свежая папка на каждый тест | тесты не видят чужих файлов и не зависят от порядка запуска вообще |
| объект Path | пути собираются слэшем: tmp_path / "orders.json" |
| пустой старт | внутри ничего нет — тест сам решает, что создать |
| автоочистка | pytest хранит три последних прогона и удаляет устаревшие папки сам |
Что дальше
Модуль save/load теперь под защитой: round-trip ловит несогласованность записи и чтения, кириллический тест стоит на страже кодировки, pytest.raises фиксирует поведение на битых данных, а tmp_path держит всю файловую суету в одноразовых папках. Заметь: тестов получилось четыре, а уверенности в модуле — на порядок больше, чем после одного ручного прогона. В следующем уроке поднимемся на уровень выше и поговорим о стратегии: КАКИЕ тесты писать для функции, где у неё границы и сколько тестов достаточно, — а финальный проект соберёт эти save/load-тесты в общий сьют с остальными.
Round-trip — самый честный тест файлового кода: сохранил, прочитал, сравнил с исходником — и обе функции проверены одним assert.
Сначала предскажи ответ в голове — это главный навык программиста.
import json
data = {"name": "Чай", "price": 120}
text = json.dumps(data, ensure_ascii=True)
print(text)
def test_save(tmp_path):
path = tmp_path / "note.txt"
path.write_text("привет", encoding="utf-8")
assert path.read_text(encoding="utf-8") == "привет"
def test_other(tmp_path):
import os
assert not os.path.exists(tmp_path / "note.txt")
# pytest.main(["test_x.py", "-q", "--no-header"])
import pytest
def parse(text):
if not text.strip():
raise ValueError("пустой файл")
return text.upper()
def test_empty():
with pytest.raises(ValueError):
parse(" ")
# pytest.main(["test_parse.py", "-q", "--no-header"])
1. Что проверяет round-trip тест save_orders/load_orders?
2. Зачем в open при записи и чтении JSON с кириллицей нужен encoding="utf-8"?
3. Какой тип фикстуры tmp_path?
4. Тест вызвал load_orders на битом файле БЕЗ pytest.raises. Что произошло?
5. Два теста пишут файл orders.json через tmp_path. Что между ними?
Протестируй модуль настроек: функция save_settings(path, settings) пишет словарь в JSON с encoding="utf-8" и ensure_ascii=False, load_settings(path) читает его обратно. Напиши файл test_settings.py с двумя тестами на tmp_path: test_round_trip проверяет, что словарь с русским ключом-значением («город»: «Москва») выживает после save/load, test_broken — что load_settings на файле «{nope» бросает ValueError. Запусти и напечатай код выхода.
Как протестировать функцию, которая записывает JSON-файл?
Через round-trip: вызови save-функцию с путём из tmp_path, прочитай файл её парой (или json.load) и сравни с исходными данными — assert load(path) == orders. Дополнительно полезен текстовый тест: прочитай файл как текст и проверь, что кириллица лежит не escapes, — это ловит потерю encoding="utf-8" и ensure_ascii=False.
Куда pytest складывает папки tmp_path и не засорит ли они диск?
В системный временный каталог, в подпапки с именем вида test_name0. pytest хранит папки трёх последних прогонов: можно заглянуть в них при отладке, а более старые он удаляет сам при следующих запусках. В репозиторий ничего не попадает — это главная причина не писать тестовые файлы рядом с кодом.
Почему в тестах нельзя писать файлы в текущую папку проекта?
Три причины: тесты затирают друг друга и реальные файлы проекта с одинаковыми именами; порядок запуска начинает влиять на результат; в репозитории остаётся мусор после каждого прогона. Фикстура tmp_path решает всё разом — уникальная чистая папка на тест, уборка автоматическая.
Как проверить, что функция бросает исключение на битом JSON?
Подготовь битый файл в tmp_path (path.write_text("{oops")) и оберни вызов в with pytest.raises(ValueError): load_orders(path). Тест пройдёт только если исключение вылетело; не вылетело — pytest сообщит DID NOT RAISE. Уточнить текст ошибки помогает параметр match — подробнее в уроке про pytest.raises.
Понравился урок? Сошлитесь на него
«Бонусом — уборка: pytest держит последние три прогона временных папок и удаляет более старые сам, диск не зарастает.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 9
Проверка исключений: pytest.raises
Хорошая функция на плохом входе не молчит, а бросает исключение — и тест обязан это проверять. pytest.raises делает это одной строкой внутри with.
pytest · Урок 11
Встроенные фикстуры: capsys и tmp_path
Половину бытовых задач тестирования закрывают две фикстуры из коробки: capsys читает то, что функция напечатала, tmp_path выдаёт чистую временную папку — без декораторов и настроек.
pytest · Урок 16
Тестирование классов: методы, состояние и Test-классы
Корзина магазина как подопытная: фикстура раздаёт каждому тесту свежий объект, Test-класс наводит порядок в методах — и ни одно состояние не протекает.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
json · Урок 4
Файлы JSON: json.dump и json.load
Данные, которые переживают скрипт: json.dump пишет словарь в файл, json.load читает обратно, а round-trip подтверждает — сохранил, прочитал, совпало.
json файл python сохранитьjson.dump python
json · Урок 17
Сохраняем результаты: json.dump итоговых данных
Отфильтровали, отсортировали, посчитали — теперь результат должен пережить скрипт: json.dump пишет итог в файл с кириллицей и лесенкой.
python сохранить результат в json файлjson dump python
pytest · Урок 20
Финальный проект: полный тест-сьют модуля работы с заказами
Выпускной: модуль заказов со скидками, классом Order, JSON и валютой — и полный сьют из шестнадцати тестов, который держит его весь целиком и гоняется одной командой.
pytest финальный проектpytest тест-сьют