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

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

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

Числа с плавающей точкой: 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. Ему передают ожидаемое значение, а он сравнивает не побитово, а с допуском: разница меньше допуска — числа считаются равными:

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

rel для больших чисел, 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 рассказывает о провале сравнения с допуском. Задай нарочно невозможный допуск — и в отчёте появится строка со знаком «плюс-минус»:

отчёт pytest о провале approx
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
Фрагмент настоящего отчёта pytest 9 при падении: знак «плюс-минус» показывает заданный допуск, Obtained и Expected — реальные и ожидаемые значения. Видно всё: насколько промахнулись и какое окно просили.

Такой отчёт сам объясняет, что случилось: слева реальное значение, справа ожидаемое с окном допуска. Ни один хрупкий 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 — таблица кейсов для функции среднего, где все ожидания дробные:

параметризованный тест с approx
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))
Проверь себя
0 / 6

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) без дополнительных аргументов?

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

Напиши функцию half(x), возвращающую половину числа, и параметризованный тест для неё: кейсы (0.1, 0.05), (1, 0.5), (0.3, 0.15). Сравнение сделай через approx. Запусти файл через pytest.main и напечатай код выхода.

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

Почему в 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

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

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