Числа с плавающей точкой: pytest.approx
0.1 + 0.2 в Python не равно 0.3 — и обычный assert на float падает непонятно почему. pytest.approx сравнивает числа с допуском и возвращает тестам спокойный сон.
Редакция Питоники
Самый известный сюрприз в Python ждёт не в экзотике, а в школьной арифметике: сложи 0.1 и 0.2 — получишь не 0.3. Новичок решает, что сломался интерпретатор. Опытный тестировщик улыбается: он знает, что float — это двоичная дробь, и десятичные числа вроде 0.1 в неё не помещаются без остатка. А ещё он знает, какой хаос этот хвост устраивает в тестах, — и каким простым инструментом он гасится.
План такой: сначала честно разберёмся, откуда берётся хвост 0.30000000000000004 — без этой картинки все оговорки про float выглядят магией. Потом сломаем обычный тест на дробях, увидим, как approx возвращает ему смысл, и настроим допуски abs и rel под реальные задачи. В финале — таблица дробных кейсов, готовая к переиспользованию в любом проекте.
Почему 0.1 + 0.2 не равно 0.3
Сначала посмотрим на сюрприз вживую — без pytest, просто в интерпретаторе:
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
print(0.1 + 0.2 - 0.3)
0.30000000000000004 False 5.551115123125783e-17
Python ничего не сломал: он честно сложил два двоичных числа. float хранится по стандарту IEEE 754 — как двоичная дробь с конечным числом разрядов, примерно как десятичная дробь с конечным числом знаков после запятой. Число «одна треть» в десятичной записи не помещается в конечный хвост — 0.33333..., и точно так же 0.1 не помещается в двоичный хвост. Python откладывает ближайшее представимое число, а при сложении хвосты накапливаются — получается 0.30000000000000004 вместо 0.3.
Хрупкий тест на float
Что делает новичок в тесте? Пишет привычный assert на равенство. Посмотрим на тест функции average, которая считает среднее — и честно получает 0.15000000000000002 вместо 0.15:
from pathlib import Path
Path("test_average.py").write_text("""
def average(nums):
return sum(nums) / len(nums)
def test_average():
assert average([0.1, 0.2]) == 0.15
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_average.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
F [100%] =========================== short test summary info =========================== FAILED test_average.py::test_average - assert 0.15000000000000002 == 0.15 1 failed in 0.01s exit: 1
Функция работает идеально: среднее двух чисел и должно быть 0.15. Падает не код, а запись ожидания: 0.15 в тесте и 0.15000000000000002 в вычислении — два разных двоичных числа, и == бессердечно честно отвечает False. Такой тест называют хрупким: он падает то от порядка слагаемых, то от версии интерпретатора, и лечится не правкой кода, а правильным сравнением.
Коварство в том, что часть сравнений на float выглядит безобидной: 0.5, 0.25, 0.75 — это степени двойки, они представимы точно, и == на них честно работает. Тесты зеленеют, команда расслабляется, а потом в код приходит 0.1 — и всё сыпется. Полагаться на везение нельзя: правило «дробное — значит approx» должно работать без исключений, тогда ни один хвост не проскочит мимо тестов.
pytest.approx: сравнение с допуском
Штатное лекарство из поставки pytest — объект approx. Ему передают ожидаемое значение, а он сравнивает не побитово, а с допуском: разница меньше допуска — числа считаются равными:
from pathlib import Path
Path("test_stat.py").write_text("""
from pytest import approx
def average(nums):
return sum(nums) / len(nums)
def test_scalar():
assert average([0.1, 0.2]) == approx(0.15)
def test_list():
assert [average([1, 2]), 0.1 + 0.2] == approx([1.5, 0.3])
def test_dict():
stats = {"mean": 0.1 + 0.2, "n": 2}
assert stats == approx({"mean": 0.3, "n": 2})
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_stat.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.01s exit: 0
Три теста — три формы сравнения. Скаляр: assert average([0.1, 0.2]) == approx(0.15) — хрупкое равенство превратилось в «примерно равно». Список: approx([1.5, 0.3]) сверяет каждый элемент со своим допуском — вручную циклом это писалось бы в пять строк. Словарь: approx({"mean": 0.3, "n": 2}) проходит по ключам и сверяет значения; целые числа при этом сравниваются точно, как и должны. Форма записи всегда одна: твоё вычисление == approx(ожидание).
| Что сравниваем | Как пишем | Что произойдёт |
|---|---|---|
| одно число | x == approx(0.3) | равенство с допуском по умолчанию |
| список чисел | xs == approx([1.5, 0.3]) | каждый элемент — со своим допуском |
| словарь | stats == approx({"mean": 0.3}) | по ключам; не-числа — точно |
| требование точнее | x == approx(0.3, abs=1e-9) | допуск задаёшь сам |
Хвосты не сидят в одном сравнении — они накапливаются. Десять раз сложи 0.1 в цикле — получишь 0.9999999999999999, а не единицу: каждая итерация добавляет свою крошечную ошибку. Тест на такую функцию без approx выглядит как паранойя: перечисли все знаки вручную? С approx — одна строка:
from pathlib import Path
Path("test_total.py").write_text("""
from pytest import approx
def total(prices):
result = 0.0
for p in prices:
result += p
return result
def test_ten_dimes():
assert total([0.1] * 10) == approx(1.0)
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_total.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
. [100%] 1 passed in 0.01s exit: 0
abs и rel: допуск под задачу
Допуск по умолчанию — относительный: rel=1e-6, то есть миллионная доля от самого числа. Для 0.3 это три десятимиллиардных, для 1200 — уже тысяча двухсот миллионных: чем больше число, тем шире допуск. Вторая ручка — abs: фиксированная погрешность, одинаковая для любых значений. Когда какая нужна, удобно показать на паре тестов:
from pathlib import Path
Path("test_money.py").write_text("""
from pytest import approx
def test_big_total_default_rel():
total = 1000 * 1.2
assert total == approx(1200)
def test_micro_value_with_abs():
assert 1e-9 + 1e-13 == approx(1e-9, abs=1e-12)
def test_compare_to_zero_with_abs():
assert 5e-10 == approx(0, abs=1e-9)
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_money.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.01s exit: 0
Первый тест: 1000 * 1.2 даёт 1200.0000000000002, и дефолтного допуска хватает — хвост в четыре десятимиллиардных для товарной суммы неважен. Второй: сравниваем числа порядка 1e-9 — относительный допуск от такого значения микроскопический, поэтому погрешность задаём абсолютно: abs=1e-12. Третий — сравнение с нулём: у нуля относительный допуск всегда ноль, и без abs pytest разрешает расхождение лишь на дефолтный минимум; для «около нуля» всегда назначай abs сам.
Кстати, о том, как pytest рассказывает о провале сравнения с допуском. Задай нарочно невозможный допуск — и в отчёте появится строка со знаком «плюс-минус»:
def test_average():
assert average([0.1, 0.2]) == approx(0.15, rel=0, abs=1e-20)
E assert 0.15000000000000002 == 0.15 ± 1.0e-20 E E comparison failed E Obtained: 0.15000000000000002 E Expected: 0.15 ± 1.0e-20
Такой отчёт сам объясняет, что случилось: слева реальное значение, справа ожидаемое с окном допуска. Ни один хрупкий assert на == не рассказывал о падении так внятно — там просто были два похожих числа и слово assert.
Когда approx не нужен: целые копейки
Есть случай, когда двоичные хвосты лучше вообще не пускать в код: деньги. Рубли удобнее хранить в копейках — целыми числами, которые в двоичном виде точны:
price = 1999 # 19.99 в копейках, int
total = price * 3 # целочисленное умножение - точно
print(total)
print(total / 100) # к дробям переходим только для показа
5997 59.97
Целые числа не имеют хвостов: 5997 есть 5997, и тест на них пишется обычным ==. В дробь переходят в последний момент — чтобы показать человеку цену. А вот модный костыль round(0.1 + 0.2, 1) == 0.3 в тестах не приживается: round подрезает знаки, но не исправляет двоичную природу числа, и на других величинах ловушка возвращается. approx честнее: он не притворяется, что погрешности нет, — он договаривается с ней о допустимом размере.
Первая программа: таблица средних
Соединим approx с параметризацией из урока 8 — таблица кейсов для функции среднего, где все ожидания дробные:
from pathlib import Path
Path("test_average.py").write_text("""
import pytest
from pytest import approx
def average(nums):
return sum(nums) / len(nums)
@pytest.mark.parametrize("nums, expected", [
([0.1, 0.2], 0.15),
([1, 2, 3], 2.0),
([10.5, 10.5], 10.5),
])
def test_average(nums, expected):
assert average(nums) == approx(expected)
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_average.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.01s exit: 0
Три кейса — три passed, и ни одно сравнение не хрупкое: как ни переставляй слагаемые, допусков хватит. Обрати внимание на строение: в кортеже лежит вход и ожидание, а approx сидит в самом assert. Так float-проверки и живут в реальных проектах: таблица кейсов плюс approx в одной строке сравнения.
Тот же approx пригодится и за пределами чистого Python: если в проекте появился NumPy, approx понимает и его массивы — сравнение двух матриц результатов сводится к той же одной строке. А в отчёте при падении approx показывает разницу по каждому элементу, поэтому крупная таблица не превращается в головоломку: сразу видно, какая ячейка вылезла за допуск.
Что дальше
Ты узнал, откуда берётся хвост 0.30000000000000004, перестал доверять == на дробных числах и получил инструмент: approx со скалярами, списками и словарями, с допуском abs и rel под задачу. Дальше — встроенные фикстуры capsys и tmp_path: проверка того, что функция напечатала, и временная папка для файловых тестов, без единого декоратора. После них — monkeypatch для подмены внешнего мира.
Дробные числа сравнивают не на равенство, а на близость: допуск задаёт тест, а не надежда на удачное сложение.
Сначала предскажи ответ в голове — это главный навык программиста.
x = 0.1 + 0.2
print(x)
print(round(x, 1) == 0.3)
from pytest import approx
checks = [
(0.1 + 0.2, 0.3),
(0.5, 0.5),
]
for got, exp in checks:
print(got == exp, got == approx(exp))
1. Что напечатает print(0.1 + 0.2 == 0.3) в Python?
2. Почему тест assert average([0.1, 0.2]) == 0.15 падает, хотя функция верна?
3. Что умеет pytest.approx кроме одиночных чисел?
4. Какой допуск у approx по умолчанию?
5. Почему для сравнения с нулём нужен abs, а не rel?
6. Что вернёт 1000 * 1.2 == approx(1200) без дополнительных аргументов?
Напиши функцию half(x), возвращающую половину числа, и параметризованный тест для неё: кейсы (0.1, 0.05), (1, 0.5), (0.3, 0.15). Сравнение сделай через approx. Запусти файл через pytest.main и напечатай код выхода.
Почему в Python 0.1 + 0.2 не равно 0.3?
Числа float хранятся в двоичном виде по стандарту IEEE 754, а 0.1 и 0.2 в двоичной системе — бесконечные дроби. Python берёт ближайшие представимые значения, и при сложении хвосты дают 0.30000000000000004. То же происходит в JavaScript, Java и C++ — это свойство аппаратной арифметики, а не ошибка языка.
Как правильно сравнивать float в тестах pytest?
Через pytest.approx: assert 0.1 + 0.2 == pytest.approx(0.3) сверяет числа с допуском вместо побитового равенства. approx работает и для списков, и для словарей, а допуск настраивается: rel= — доля от числа для больших величин, abs= — фиксированная погрешность для малых значений и нуля.
Чем abs= отличается от rel= в pytest.approx?
rel задаёт относительный допуск — долю от ожидаемого числа, поэтому для 1200 окно шире, чем для 0.3. abs задаёт абсолютную погрешность, одинаковую для любых значений, и незаменим возле нуля, где относительный допуск вырождается в ноль. По умолчанию approx использует rel=1e-6.
Работает ли pytest.approx со списками и словарями?
Да: approx([1.5, 0.3]) сверяет каждый элемент списка со своим допуском, approx({"mean": 0.3, "n": 2}) — значения словаря по ключам, при этом не-числовые значения сравниваются точно. Это избавляет от ручных циклов сравнения в тестах.
Тест на float то падает, то проходит. Что делать?
Это хрупкий тест: где-то внутри осталось сравнение дробных чисел через ==. Найди все assert на равенство с float и замени их на сравнение через pytest.approx; если число — результат суммирования, допуск особенно важен, потому что хвосты накапливаются. Проверь и условия в самом коде продукта: if total == 0.3 не сработает никогда.
Понравился урок? Сошлитесь на него
«Хрупкий тест — это не мелочь: он падает на честном коде, и вины его падения никто не найдёт.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 8
Параметризация: @pytest.mark.parametrize
Десять одинаковых тестов для десяти случаев — это десять копипаст. parametrize сжимает их в один тест и список кортежей, а pytest разворачивает обратно в десять отчётных строк.
pytest · Урок 9
Проверка исключений: pytest.raises
Хорошая функция на плохом входе не молчит, а бросает исключение — и тест обязан это проверять. pytest.raises делает это одной строкой внутри with.
pytest · Урок 11
Встроенные фикстуры: capsys и tmp_path
Половину бытовых задач тестирования закрывают две фикстуры из коробки: capsys читает то, что функция напечатала, tmp_path выдаёт чистую временную папку — без декораторов и настроек.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
flask тестирование pytest test_clientтестирование flask api pytest
NumPy · Урок 4
Операции с массивами: векторизация и почему NumPy быстрее списков в 100 раз
Умножаем миллион цен одной строкой и замеряем время через time.perf_counter: почему векторизация NumPy обгоняет цикл for в десятки раз. Ufunc, np.dot, булевы сравнения и правило «никаких for там, где есть операция».
сравнение массивов numpy
pytest · Урок 13
Метки: skip, xfail и свои mark
Не каждый тест обязан бежать в каждом прогоне: пометь его skip с причиной, xfail для известного бага или своей меткой — и управляй набором как меню, а не как свалкой.
pytest markpytest skip тест