2025, Nov 01 22:16
Как тестировать NaN в Python: сравнение списков в unittest
Разбираем, почему списки с NaN не равны в Python по IEEE‑754, и как тестировать их в unittest: матчер NaNToken, pandas rolling и подробная диагностика.
Тестирование кода, который выдаёт NaN, нередко оборачивается ловушкой: NaN означает «не число», и по правилам IEEE‑754 любое сравнение с NaN ложно, включая NaN == NaN. Когда ваша функция законно возвращает значения NaN, прямое сравнение на равенство последовательностей падает, даже если оба списка выглядят одинаково. Ниже — как сделать такие тесты надёжными, не теряя полезной диагностики unittest.
Воспроизводим сбой
Сценарий: скользящее среднее с окном из трёх элементов. Первые две позиции не вычисляются и равны NaN, поэтому в ожидаемом списке NaN стоят в начале. Тест ниже напрямую сравнивает два списка и падает, хотя числа выглядят идентично.
from math import nan
import unittest
import pandas as pd
class DataBin:
def __init__(self, seed: list) -> None:
self.seed = seed
self.values = []
def compute_window_mean(self):
self.values = pd.Series(self.seed).rolling(3).mean().to_list()
class RollingMeanSpec(unittest.TestCase):
def setUp(self):
self.bin = DataBin(seed=[10, 20, 30, 40])
def test_three_point_window_on_list(self):
expected = [nan, nan, 20.0, 30.0]
self.bin.compute_window_mean()
self.assertSequenceEqual(self.bin.values, expected)
unittest.main()
Ошибка утверждения показывает несоответствие уже на самом первом элементе:
AssertionError: Sequences differ: [nan, nan, 20.0, 30.0] != [nan, nan, 20.0, 30.0]
First differing element 0:
nan
nan
[nan, nan, 20.0, 30.0]
Что на самом деле ломается
Все значения представляют NaN, но это не идентичные объекты и они не равны при сравнении. Минимальный пример выделяет первопричину:
import math
import unittest
class CheckNaNEquality(unittest.TestCase):
def test_single_nan_item(self):
self.assertEqual([float("nan")], [math.nan])
if __name__ == "__main__":
unittest.main()
AssertionError: Lists differ: [nan] != [nan]
First differing element 0:
nan
nan
[nan]
Проблема в случае со скользящим средним в том, что pd.Series.to_list возвращает новые значения float("nan"), а не math.nan. Оба — NaN, но они не равны друг другу. Поэтому прямое равенство списков не срабатывает.
Примечание: обычно можно вызывать self.assertEqual и позволить ему выбрать подходящую реализацию сравнения.
Точное решение с матчером
Корректный способ проверить «здесь должно быть NaN» — сопоставлять по свойству «is NaN», а не по тождеству или строгому равенству. Это легко оформить небольшим вспомогательным классом для тестов.
import math
import typing as tp
class NaNToken:
def __eq__(self, other: tp.Any) -> bool:
return math.isnan(other)
def __repr__(self) -> str:
return "nan"
С ним в ожидаемом списке можно явно указать позиции NaN и при падении сохранить подробные различия по элементам:
import unittest
import pandas as pd
class DataBin:
def __init__(self, seed: list) -> None:
self.seed = seed
self.values = []
def compute_window_mean(self):
self.values = pd.Series(self.seed).rolling(3).mean().to_list()
class RollingMeanSpec(unittest.TestCase):
def setUp(self):
self.bin = DataBin(seed=[10, 20, 30, 40])
def test_three_point_window_on_list(self):
expected = [NaNToken(), NaNToken(), 20.0, 30.0]
self.bin.compute_window_mean()
self.assertEqual(self.bin.values, expected)
if __name__ == "__main__":
unittest.main()
Если расходится значение, отличное от NaN, unittest по‑прежнему показывает точные различия, а не сворачивает всё в один булев результат:
AssertionError: Lists differ: [nan, nan, 20.0, 30.0] != [nan, nan, 20.0, 35.0]
First differing element 3:
30.0
35.0
- [nan, nan, 20.0, 30.0]
? ^
+ [nan, nan, 20.0, 35.0]
?
Для контраста: подход, который сводит проверку к одному булеву значению, лишает вас всей диагностики:
AssertionError: False is not true
Почему это важно
Когда функция законно выдаёт NaN, наивная проверка равенства подталкивает к хрупким тестам или непрозрачным обходным решениям. Сопоставление по свойству «is NaN» сохраняет исходный смысл, предотвращает ложные отрицания из‑за разных экземпляров NaN и оставляет в силе детальную пометку различий по элементам от unittest. А ещё позволяет держать ожидаемые структуры читаемыми и близкими к предметному результату, который вы проверяете.
Итоги
Если тест сравнивает последовательности, где могут встречаться NaN, сравнивайте по предикату «is NaN», а не на равенство. pd.Series.to_list возвращает новые значения float("nan"), которые не равны ни math.nan, ни друг другу, поэтому прямое равенство списков не работает. Небольшой матчер на основе math.isnan исправляет сравнение, сохраняя подробные различия; при этом можно пользоваться self.assertEqual, чтобы unittest сам выбрал подходящую логику сравнения.
Статья основана на вопросе на StackOverflow от Charizard_knows_to_code и ответе от jonrsharpe.