Moscow Python Conf 2016. Почему 100% покрытие это плохо?

Moscow Python Conf 2016. Почему 100% покрытие это плохо?

Я работаю над продуктом Max Patrol компании Positive Technologies. Кодовая база нашего проекта насчитывает более 50 тысяч строк кода. Без хороших тестов работа с таким объемом кода превратилась бы в кошмар. Многие программисты стремятся к 100% покрытию кода тестами и считают, что это избавит их от множества проблем. Я расскажу о том, с какими трудностями мы столкнулись и почему заветные 100% ничего не говорят о покрытии тестируемого кода. Я приведу примеры кода и тестов, которые показывают 100% покрытие и покажу почему это не так. Я рассмотрю как работает библиотека coverage.py и объясню почему не стоит слепо верить результатам ее работы. Так же я поделюсь идеей получения честной метрики покрытия кода тестами и представлю прототип библиотеки, в которую воплотилась эта идея.

15563b4bb24076f1801cd862f74ed3fe?s=128

Ivan Tsyganov

October 05, 2016
Tweet

Transcript

  1. Цыганов Иван Positive Technologies Почему 100% покрытие это плохо

  2. Обо мне ✤ Спикер PyCon Russia 2016, PiterPy, PyCon Siberia

    2016 ✤ Люблю OpenSource ✤ Не умею frontend
  3. ✤ 15 лет практического опыта на рынке ИБ ✤ Более

    650 сотрудников в 9 странах ✤ Каждый год находим более 200 уязвимостей нулевого дня ✤ Проводим более 200 аудитов безопасности в крупнейших компаниях мира ежегодно
  4. None
  5. MaxPatrol ✤ Pentest. Тестирование на проникновение. ✤ Audit. Системные проверки.

    ✤ Compliance. Соответствие стандартам. ✤ Одна из крупнейших баз знаний в мире Система контроля защищенности и соответствия стандартам.
  6. ✤ Тестирование на проникновение (Pentest) ✤ Системные проверки (Audit) ✤

    Соответствие стандартам (Compliance) ✤ Одна из крупнейших баз знаний в мире Система контроля защищенности и соответствия стандартам. ✤ Системные проверки (Audit) MaxPatrol
  7. > 50 000 строк кода

  8. Зачем мы тестируем? ✤ Уверенность, что написанный код работает ✤

    Ревью кода становится проще ✤ Гарантия, что ничего не сломалось при изменениях
  9. Зачем проверять покрытие? ✤ Видно какой именно код протестирован ✤

    Позволяет увидеть все ветви исполнения
  10. Зачем проверять покрытие? ✤ Видно какой именно код протестирован ✤

    Позволяет увидеть все ветви исполнения ✤ Метрика качества тестов (?)
  11. Зачем нам 100%? ✤ Ачивка «У нас в проекте 100%

    coverage»
  12. Зачем нам 100%? ✤ Ачивка «У нас в проекте 100%

    coverage» ✤ Уверенность, что код протестирован полностью
  13. 100% coverage != 100% протестировано

  14. coverage.py ✤ Позволяет проверить покрытие кода тестами ✤ Есть плагин

    для pytest ✤ В основном работает
  15. coverage.py def get_longest(a, b): if len(a) > len(b): return a

    return b assert get_longest([1,2,3], [4,5]) == [1,2,3] assert get_longest([1,2], [3,4,5]) == [3,4,5]
  16. coverage.py def apply_discount(prices): result = {'Total': sum(prices)} if result['Total'] >=

    1000: result['Discount'] = result['Total'] * 0.25 return result['Total'] - result['Discount'] assert apply_discount([400, 600]) == 750 Name Stmts Miss Cover Missing ---------------------------------------------------------- samples/apply_discount.py 5 0 100%
  17. coverage.py def apply_discount(prices): result = {'Total': sum(prices)} if result['Total'] >=

    1000: result['Discount'] = result['Total'] * 0.25 return result['Total'] - result['Discount'] assert apply_discount([400, 600]) == 750 >>> apply_discount([200])
  18. coverage.py def apply_discount(prices): result = {'Total': sum(prices)} if result['Total'] >=

    1000: result['Discount'] = result['Total'] * 0.25 return result['Total'] - result['Discount'] assert apply_discount([400, 600]) == 750 >>> apply_discount([200]) Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 5, in apply_discount KeyError: 'Discount'
  19. coverage.py --branch 1 def apply_discount(prices): 2 result = {'Total': sum(prices)}

    3 if result['Total'] >= 1000: 4 result['Discount'] = result['Total'] * 0.25 5 return result['Total'] - result['Discount'] 6 7 assert apply_discount([400, 600]) == 750 Name Stmts Miss Branch BrPart Cover Missing --------------------------------------------------------------------- samples/apply_discount.py 5 0 2 1 85.71% 3 ->5
  20. coverage.py --branch 1 def apply_discount(prices): 2 result = {'Total': sum(prices)}

    3 if result['Total'] >= 1000: 4 result['Discount'] = result['Total'] * 0.25 5 return result['Total'] - result['Discount'] 6 7 assert apply_discount([400, 600]) == 750 Name Stmts Miss Branch BrPart Cover Missing --------------------------------------------------------------------- samples/apply_discount.py 5 0 2 1 85.71% 3 ->5
  21. Как считать покрытие? Все строки Реально выполненные строки - Непокрытые

    строки =
  22. Все строки Source coverage.parser.PythonParser Statements

  23. coverage.parser.PythonParser ✤ Обходит все токены и отмечает «интересные» факты ✤

    Компилирует код. Обходит code-object и сохраняет номера строк
  24. Обход токенов ✤ Запоминает определения классов ✤ «Сворачивает» многострочные выражения

    ✤ Исключает комментарии
  25. Обход байткода ✤ Полностью повторяет метод dis.findlinestarts ✤ Анализирует code_obj.co_lnotab

    ✤ Генерирует пару (номер байткода, номер строки)
  26. Как считать coverage --branch? Все переходы Реально выполненные переходы -

    Непокрытые переходы =
  27. Все переходы Source coverage.parser.AstArcAnalyzer (from_line, to_line) coverage.parser.PythonParser

  28. coverage.parser.AstArcAnalyzer ✤ Обходит AST с корневой ноды ✤ Обрабатывает отдельно

    каждый тип нод отдельно
  29. Обработка ноды class While(stmt): _fields = ( 'test', 'body', 'orelse',

    ) while i<10: print(i) i += 1
  30. Обработка ноды class While(stmt): _fields = ( 'test', 'body', 'orelse',

    ) while i<10: print(i) i += 1 else: print('All done')
  31. Выполненные строки sys.settrace(tracefunc) Set the system’s trace function, which allows

    you to implement a Python source code debugger in Python. Trace functions should have three arguments: frame, event, and arg. frame is the current stack frame. event is a string: 'call', 'line', 'return', 'exception', 'c_call', 'c_return', or 'c_exception'. arg depends on the event type.
  32. PyTracer «call» event ✤ Сохраняем данные предыдущего контекста ✤ Начинаем

    собирать данные нового контекста ✤ Учитываем особенности генераторов
  33. PyTracer «line» event ✤ Запоминаем выполняемую строку ✤ Запоминаем переход

    между строками
  34. PyTracer «return» event ✤ Отмечаем выход из контекста ✤ Помним

    о том, что yield это тоже return
  35. Отчет ✤ Что выполнялось ✤ Что должно было выполниться ✤

    Ругаемся
  36. Зачем такие сложности? 1 for i in some_list: 2 if

    i == 'Hello': 3 print(i + ' World!') 4 elif i == 'Skip': 5 continue 6 else: 7 break 8 else: 9 print(r'¯\_(ツ)_/¯')
  37. Серебряная пуля?

  38. Не совсем…

  39. Что может пойти не так? def make_user(name, email): return dict(

    ID=get_id(), Name=name, Role='Editor' if check_employee(email) else 'Guest' )
  40. Что может пойти не так? def positive_squares(items): return map(lambda x:

    x **2 if x>0 else x, items)
  41. Что может пойти не так? def positive_squares(items): return [ item

    **2 for item in items if item>0 ]
  42. Что может пойти не так? def positive_squares(items): return [ item

    **2 for item in items if item>0 ] def positive_squares(items): return map(lambda x: x **2 if x>0 else x, items) def make_user(name, email): return dict( ID=get_id(), Name=name, Role='Editor' if check_employee(email) else 'Guest' )
  43. Что может пойти не так? def positive_squares(items): return [ item

    **2 for item in items if item>0 ] def positive_squares(items): return map(lambda x: x **2 if x>0 else x, items) def make_user(name, email): return dict( ID=get_id(), Name=name, Role='Editor' if check_employee(email) else 'Guest' )
  44. None
  45. Непокрываемый код def some_method(a, b, c): if a and b

    or c: return True return False
  46. sys.settrace(tracefunc) ✤ Устанавливаем свою функцию трассировки ✤ Смотрим что происходит

    и делаем выводы
  47. sys.settrace(tracefunc) Ограниченное количество событий: ✤ call ✤ line ✤ return

    ✤ exception
  48. sys.settrace(tracefunc) Ограниченное количество событий: ✤ call ✤ line ✤ return

    ✤ exception
  49. ast.NodeTransformer ✤ Обходим ноды ✤ Оборачиваем в «нечто» каждую ноду

    ✤ Запускаем и отслеживаем что выполнялось
  50. ast.NodeTransformer ✤ Сложно обернуть код, не изменив логику ✤ Не

    все ноды можно обернуть
  51. ast.NodeTransformer ✤ Сложно обернуть код, не изменив логику ✤ Не

    все ноды можно обернуть
  52. Идея ✤ Перехватить контроль во время импорта ✤ Обойти байткод

    модуля ✤ Добавить вызов функции ✤ Собрать code-object
  53. Идея ✤ Перехватить контроль во время импорта ✤ Обойти байткод

    модуля ✤ Добавить вызов функции ✤ Собрать code-object
  54. OpTrace https: //github.com/tsyganov-ivan/OpTrace

  55. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object

    ✤ Запускаем тесты ✤ Анализируем результаты
  56. Import hook. Finder. ✤ Пропускаем неинтересные модули ✤ Создаем свой

    Loader для нужных модулей
  57. Import hook. Loader. ✤ Получаем байт-код модуля ✤ Получаем исходный

    код модуля ✤ Модифицируем байт-код ✤ Возвращаем измененный байт-код
  58. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object

    ✤ Запускаем тесты ✤ Анализируем результаты
  59. Wrapper. Модифицируем байт-код. # ... wrapper = Wrapper( trace_func=self.make_visitor(module_name), mark_func=self.make_marker(module_name,

    source) ) new_code = wrapper.wrap_code(code) return new_code # ...
  60. Wrapper. Callbacks. def make_marker(self, module, source): self.module_opcodes[module] = FileOpcode(module, source)

    def mark(codeobj_id, opcode): self.module_opcodes[module].add(codeobj_id, opcode.offset, opcode) return mark def make_visitor(self, module): def visit(codeobj_id, opcode): self.module_opcodes[module].visit(codeobj_id, opcode.offset, opcode) return visit
  61. dis.dis(some_method) def some_method(a, b, c): if a and b or

    c: return True return False 2 0 LOAD_FAST 0 (a) 3 POP_JUMP_IF_FALSE 18 6 LOAD_FAST 1 (b) 9 POP_JUMP_IF_TRUE 18 12 LOAD_FAST 2 (c) 15 POP_JUMP_IF_FALSE 22 3 >> 18 LOAD_CONST 1 (True) 21 RETURN_VALUE 4 >> 22 LOAD_CONST 2 (False) 25 RETURN_VALUE
  62. dis.get_instructions(some_method) def some_method(a, b, c): if a and b or

    c: return True return False Instruction(opname='LOAD_FAST', opcode=124, arg=0, ...
 Instruction(opname='POP_JUMP_IF_FALSE', opcode=114, ...
 Instruction(opname='LOAD_FAST', opcode=124, arg=1, ...
 Instruction(opname='POP_JUMP_IF_TRUE', opcode=115, ...
 Instruction(opname='LOAD_FAST', opcode=124, arg=2, ...
 Instruction(opname='POP_JUMP_IF_FALSE', opcode=114, ...
 Instruction(opname='LOAD_CONST', opcode=100, arg=1, ...
 Instruction(opname='RETURN_VALUE', opcode=83, arg=None ...
 Instruction(opname='LOAD_CONST', opcode=100, arg=2, ...
 Instruction(opname='RETURN_VALUE', opcode=83, arg=None ...
  63. Wrap code. Все опкоды. ✤ Просто вызываем функцию, переданную из

    Loader’a self.mark(codeobj_id, st)
  64. Wrap code. Трассировка. ✤ Добавляем lambda-функцию в константы constants.append( lambda

    co_id=codeobj_id, opcode=st: self.visit(co_id, opcode) )
  65. Wrap code. Трассировка. ✤ Добавляем lambda-функцию в константы constants.append( lambda

    co_id=codeobj_id, opcode=st: self.visit(co_id, opcode) ) PyCodeObject* PyCode_New( /* ... */ PyObject *code, PyObject *consts, PyObject *names, /* ... */ )
  66. Wrap code. Трассировка. ✤ Добавляем lambda-функцию в константы ✤ Добавляем

    байт-код для вызова def make_trace(self, constant_index): yield opcode.opmap['LOAD_CONST'] yield from self.make_args(constant_index) yield opcode.opmap['CALL_FUNCTION'] yield from self.make_args(0) yield opcode.opmap['POP_TOP']
  67. Wrap code. Трассировка. ✤ Добавляем lambda-функцию в константы ✤ Добавляем

    байт-код для вызова ✤ Не забываем про оригинальный опкод и его параметры!
  68. Wrap code. Трассировка. ✤ Добавляем lambda-функцию в константы ✤ Добавляем

    байт-код для вызова ✤ Не забываем про оригинальный опкод и его параметры! ✤ Учитываем смещение в последующих опкодах
  69. Wrap сode. Результат. def some_method(a, b, c): if a and

    b or c: return True return False 2 0 LOAD_FAST 0 (a) 3 POP_JUMP_IF_FALSE 18 6 LOAD_FAST 1 (b) 9 POP_JUMP_IF_TRUE 18 12 LOAD_FAST 2 (c) 15 POP_JUMP_IF_FALSE 22 3 >> 18 LOAD_CONST 1 (True) 21 RETURN_VALUE 4 >> 22 LOAD_CONST 2 (False) 25 RETURN_VALUE
  70. Wrap сode. Результат. 6 LOAD_FAST 1 (b) 9 POP_JUMP_IF_TRUE 18

    . . . 3 >> 18 LOAD_CONST 1 (True) 21 RETURN_VALUE def some_method(a, b, c): if a and b or c: return True return False
  71. Wrap сode. Результат. 20 LOAD_CONST 5 (<function ...<locals>.<lambda>) 23 CALL_FUNCTION

    0 (0 positional, 0 keyword pair) 26 POP_TOP 27 LOAD_FAST 1 (b) 30 LOAD_CONST 6 (<function ...<locals>.<lambda>) 33 CALL_FUNCTION 0 (0 positional, 0 keyword pair) 36 POP_TOP 37 POP_JUMP_IF_TRUE 60 . . . >> 60 LOAD_CONST 9 (<function ...<locals>.<lambda>) 63 CALL_FUNCTION 0 (0 positional, 0 keyword pair) 66 POP_TOP 67 LOAD_CONST 1 (True) 70 LOAD_CONST 10 (<function ...<locals>.<lambda>) 73 CALL_FUNCTION 0 (0 positional, 0 keyword pair) 76 POP_TOP 77 RETURN_VALUE
  72. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object

    ✤ Запускаем тесты ✤ Анализируем результаты
  73. Тестируем. Все опкоды. def some_method(a, b, c): if a and

    b or c: return True return False some_method(1, 1, 0) Instruction(opname='LOAD_FAST', opcode=124, arg=0, ...
 Instruction(opname='POP_JUMP_IF_FALSE', opcode=114, ...
 Instruction(opname='LOAD_FAST', opcode=124, arg=1, ...
 Instruction(opname='POP_JUMP_IF_TRUE', opcode=115, ...
 Instruction(opname='LOAD_FAST', opcode=124, arg=2, ...
 Instruction(opname='POP_JUMP_IF_FALSE', opcode=114, ...
 Instruction(opname='LOAD_CONST', opcode=100, arg=1, ...
 Instruction(opname='RETURN_VALUE', opcode=83, arg=None ...
 Instruction(opname='LOAD_CONST', opcode=100, arg=2, ...
 Instruction(opname='RETURN_VALUE', opcode=83, arg=None ...
  74. Тестируем. Непокрытые опкоды. def some_method(a, b, c): if a and

    b or c: return True return False some_method(1, 1, 0) Instruction(opname='LOAD_FAST', arg=2, argval='c', argrepr='c', offset=12)
 Instruction(opname='POP_JUMP_IF_FALSE', arg=22, argval=22, argrepr='')
 Instruction(opname='LOAD_CONST', arg=2, argval=False, starts_line=3)
 Instruction(opname='RETURN_VALUE', arg=None, argval=None)
  75. Тестируем. Непокрытые опкоды. def some_method(a, b, c): if a and

    b or c: return True return False some_method(1, 1, 0) Instruction(opname='LOAD_FAST', arg=2, argval='c', argrepr='c', offset=12)
 Instruction(opname='POP_JUMP_IF_FALSE', arg=22, argval=22, argrepr='')
 Instruction(opname='LOAD_CONST', arg=2, argval=False, starts_line=3)
 Instruction(opname='RETURN_VALUE', arg=None, argval=None)
  76. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object

    ✤ Запускаем тесты ✤ Анализируем результаты
  77. Способа однозначно перевести любой опкод к строке кода не существует

  78. Способа однозначно перевести любой опкод к строке кода не существует

  79. Отчет. Ищем строки. ✤ При обходе сохраняем текущую строку ✤

    При выводе опкода выводим текущую строку
  80. Отчет. Ищем строки. if a and b or c: Instruction(opname='LOAD_FAST',

    arg=2, argval='c', argrepr='c', offset=12) if a and b or c: Instruction(opname='POP_JUMP_IF_FALSE', arg=22, argval=22, argrepr='')
 return False Instruction(opname='LOAD_CONST', arg=2, argval=False, starts_line=3) return False
 Instruction(opname='RETURN_VALUE', arg=None, argval=None) ✤ При обходе сохраняем текущую строку ✤ При выводе опкода выводим текущую строку
  81. ✤ При обходе сохраняем текущую строку ✤ При выводе опкода

    выводим текущую строку Отчет. Ищем строки. if a and b or c: Instruction(opname='LOAD_FAST', arg=2, argval='c', argrepr='c', offset=12) if a and b or c: Instruction(opname='POP_JUMP_IF_FALSE', arg=22, argval=22, argrepr='')
 return False Instruction(opname='LOAD_CONST', arg=2, argval=False, starts_line=3) return False
 Instruction(opname='RETURN_VALUE', arg=None, argval=None)
  82. Отчет. Позиция в строке. ✤ Строка уже известна ✤ Вычислим

    позицию в строке для каждого типа опкода
  83. Отчет. Позиция в строке. if a and b or c:

    Instruction( opname='LOAD_FAST', opcode=124, offset=12, starts_line=None, is_jump_target=True, arg=2, argval='c', argrepr=‘c' )
  84. Отчет. Позиция в строке. if a and b or c:

    Instruction( opname='LOAD_FAST', opcode=124, offset=12, starts_line=None, is_jump_target=True, arg=2, argval='c', argrepr=‘c' ) Instruction( opname='POP_JUMP_IF_FALSE', opcode=114, offset=15, starts_line=None, is_jump_target=False, arg=22, argval=22, argrepr='' )
  85. Отчет. Позиция в строке. ✤ Покрыв 70 типов опкодов удалось

    получить отчет ✤ Многие опкоды невозможно покрыть
  86. Отчет. Позиция в строке. ✤ Покрыв 70 типов опкодов удалось

    получить отчет ✤ Многие опкоды невозможно покрыть ----------- Report tests.test_code -------------- 1: if a and b or c: ^ LOAD_FAST 1: if a and b or c: ^^^^^^^^^^^^^^^^ POP_JUMP_IF_FALSE 3: return False ^^^^^ LOAD_CONST 3: return False ^^^^^^^^^^^^ RETURN_VALUE
  87. Отчет. Позиция в строке. ----------- Report tests.test_code -------------- 1: if

    a and b or c: ^ LOAD_FAST 1: if a and b or c: ^^^^^^^^^^^^^^^^ POP_JUMP_IF_FALSE 3: return False ^^^^^ LOAD_CONST 3: return False ^^^^^^^^^^^^ RETURN_VALUE ✤ Покрыв 70 типов опкодов удалось получить отчет ✤ Многие опкоды невозможно покрыть
  88. OpTrace. Что не так? ✤ Переменные в отчете не всегда

    отмечаются правильно ✤ Часть опкодов приходится пропускать ✤ Производительность неизвестна
  89. OpTrace. Что так? ✤ Трассировка работает хорошо ✤ Идея имеет

    право на жизнь
  90. OpTrace. Планы. ✤ Услышать мнение и критику сообщества

  91. OpTrace. Планы. ✤ Услышать мнение и критику сообщества ✤ Рефакторинг

    ✤ Тестирование ✤ Работа над улучшением отчета ✤ Плагин для pytest
  92. К чему это все?

  93. Библиотеки несовершенны

  94. 100% coverage расслабляет команду Библиотеки несовершенны

  95. 100% coverage расслабляет команду Библиотеки несовершенны 100% coverage - просто

    ачивка
  96. Спасибо за внимание! Вопросы? mi.0-0.im tsyganov-ivan.com