Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 17 из 20 Средний 30 мин 120 XP

Тесты для 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" — не украшение, а страховка от кириллицы в данных, сейчас объясню.

orders_mod.py — модуль из проекта
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
ensure_ascii=False оставляет кириллицу как есть: без него json.dump упакует русские буквы в escapes вида \u0410 и файл станет нечитаемым для человека.

В файле — то, что мы писали: русские имена читаются, структура на месте, обратное чтение дало словарь, равный исходному. Этот ручной прогон и есть будущий тест — осталось переложить проверки на assert, а файл из рабочей папки убрать. Именно убрать: тесты, пишущие файлы в проект, засоряют репозиторий и сталкиваются лбами при параллельном запуске. Для этого у pytest есть tmp_path.

Пара слов про обёртку ValueError. Стандартная библиотека на битый файл отвечает json.JSONDecodeError — тип, привязанный к модулю json. Бросать его наружу значит заставлять весь проект знать, что внутри заказы хранятся именно в JSON: поменяешь формат хранения на CSV — и тесты, ловившие JSONDecodeError, разом устареют. ValueError — нейтральный контракт «данные не читаются», и тест проверяет его через pytest.raises. Так тестируемая функция остаётся чёрным ящиком, а детали формата — внутренним делом модуля.

tmp_path: своя папка на каждый тест

tmp_path — встроенная фикстура, которая передаёт тесту объект Path на свежую временную папку. Ключевые свойства: папка у каждого теста своя, внутри пусто, а после прогона pytest прибирает старые каталоги сам. Тесту остаётся собрать путь оператором слэш и работать как с обычным файлом.

round-trip тест: сохранили, прочитали, сравнили
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 / "orders.json" — сборка пути оператором слэш: tmp_path уже объект Path, никаких склеек строк. В обоих тестах имя файла одно, а папки разные.

tmp_path — полноценный объект pathlib.Path, и все его навыки доступны: mkdir для подпапок, name и suffix для разбора имени, exists для проверок существования. Соберём вложенную структуру, как в настоящем экспорте — папка выгрузок, внутри неё файл данных.

tmp_path — это Path: собираем пути слэшем
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
Демонстрация pathlib вне pytest: в настоящем тесте вместо demo-tmp будет tmp_path — встроенная фикстура, а механика та же: Path / "папка" / "файл.json" собирает путь любой глубины.

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
Фрагмент тест-файла: разница только в пути. Плохой вариант оставляет orders.json в проекте и падает, если файл занят другой программой.
допроверка: кириллица не упакована в escapes
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 было бы так
Фрагмент тест-файла: вторая строка проверяет, что в файле нет escape-последовательностей. Сломаешь 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
Внутри with pytest.raises(ValueError) исключение — ожидаемое поведение: тест зелёный. Если load_orders вдруг перестанет бросать ValueError, тест упадёт.

Связка такая: готовим битый файл честной write_text, зовём функцию внутри with pytest.raises(ValueError) — и pytest считает тест пройденным только если исключение действительно вылетело. Не вылетело — тест упал с Failed: DID NOT RAISE. Бросился другой тип — упадёт снова: pytest.raises строг к типу исключения. Подробно про match и проверку текста ошибки — в уроке про pytest.raises, здесь важно другое: ошибки входа — тоже спецификация, их тестируют наравне с удачным путём.

а без 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
Распечатка падения: без with pytest.raises исключение из функции уходит в pytest и красит тест. Разница ровно одна — обёртка with.

Если хочешь заодно проверить ТЕКСТ ошибки, у 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
Если бы оба теста писали orders.json в общую папку, второй затёр бы файл первого — и порядок запуска начал бы влиять на результат.

Поменяй в 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"])
Проверь себя
0 / 5

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. Что между ними?

Карточки терминов
Запомнено: 0 / 6
Практика

Протестируй модуль настроек: функция 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. Запусти и напечатай код выхода.

practice.py
Вопросы и ответы по уроку

Как протестировать функцию, которая записывает 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.