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.