Successfully reported this slideshow.
We use your LinkedIn profile and activity data to personalize ads and to show you more relevant ads. You can change your ad preferences anytime.

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

635 views

Published on

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

Published in: Engineering
  • Be the first to comment

  • Be the first to like this

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

  1. 1. Цыганов Иван Positive Technologies Почему 100% покрытие это плохо
  2. 2. Обо мне ✤ Спикер PyCon Russia 2016, PiterPy, PyCon Siberia 2016 ✤ Люблю OpenSource ✤ Не умею frontend
  3. 3. ✤ 15 лет практического опыта на рынке ИБ ✤ Более 650 сотрудников в 9 странах ✤ Каждый год находим более 200 уязвимостей нулевого дня ✤ Проводим более 200 аудитов безопасности в крупнейших компаниях мира ежегодно
  4. 4. MaxPatrol ✤ Pentest. Тестирование на проникновение. ✤ Audit. Системные проверки. ✤ Compliance. Соответствие стандартам. ✤ Одна из крупнейших баз знаний в мире Система контроля защищенности и соответствия стандартам.
  5. 5. ✤ Тестирование на проникновение (Pentest) ✤ Системные проверки (Audit) ✤ Соответствие стандартам (Compliance) ✤ Одна из крупнейших баз знаний в мире Система контроля защищенности и соответствия стандартам. ✤ Системные проверки (Audit) MaxPatrol
  6. 6. > 50 000 строк кода
  7. 7. Зачем мы тестируем? ✤ Уверенность, что написанный код работает ✤ Ревью кода становится проще ✤ Гарантия, что ничего не сломалось при изменениях
  8. 8. Зачем проверять покрытие? ✤ Видно какой именно код протестирован ✤ Позволяет увидеть все ветви исполнения
  9. 9. Зачем проверять покрытие? ✤ Видно какой именно код протестирован ✤ Позволяет увидеть все ветви исполнения ✤ Метрика качества тестов (?)
  10. 10. Зачем нам 100%? ✤ Ачивка «У нас в проекте 100% coverage»
  11. 11. Зачем нам 100%? ✤ Ачивка «У нас в проекте 100% coverage» ✤ Уверенность, что код протестирован полностью
  12. 12. 100% coverage != 100% протестировано
  13. 13. coverage.py ✤ Позволяет проверить покрытие кода тестами ✤ Есть плагин для pytest ✤ В основном работает
  14. 14. 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] 
  15. 15. 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%
  16. 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 >>> apply_discount([200])
  17. 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]) Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 5, in apply_discount KeyError: 'Discount'
  18. 18. 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
  19. 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. 20. Как считать покрытие? Все строки Реально выполненные строки- Непокрытые строки=
  21. 21. Все строки Source coverage.parser.PythonParser Statements
  22. 22. coverage.parser.PythonParser ✤ Обходит все токены и отмечает «интересные» факты ✤ Компилирует код. Обходит code-object и сохраняет номера строк
  23. 23. Обход токенов ✤ Запоминает определения классов ✤ «Сворачивает» многострочные выражения ✤ Исключает комментарии
  24. 24. Обход байткода ✤ Полностью повторяет метод dis.findlinestarts ✤ Анализирует code_obj.co_lnotab ✤ Генерирует пару (номер байткода, номер строки)
  25. 25. Как считать coverage --branch? Все переходы Реально выполненные переходы- Непокрытые переходы=
  26. 26. Все переходы Source coverage.parser.AstArcAnalyzer (from_line, to_line) coverage.parser.PythonParser
  27. 27. coverage.parser.AstArcAnalyzer ✤ Обходит AST с корневой ноды ✤ Обрабатывает отдельно каждый тип нод отдельно
  28. 28. Обработка ноды class While(stmt): _fields = ( 'test', 'body', 'orelse', ) while i<10: print(i) i += 1
  29. 29. Обработка ноды class While(stmt): _fields = ( 'test', 'body', 'orelse', ) while i<10: print(i) i += 1 else: print('All done')
  30. 30. Выполненные строки 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.
  31. 31. PyTracer «call» event ✤ Сохраняем данные предыдущего контекста ✤ Начинаем собирать данные нового контекста ✤ Учитываем особенности генераторов
  32. 32. PyTracer «line» event ✤ Запоминаем выполняемую строку ✤ Запоминаем переход между строками
  33. 33. PyTracer «return» event ✤ Отмечаем выход из контекста ✤ Помним о том, что yield это тоже return
  34. 34. Отчет ✤ Что выполнялось ✤ Что должно было выполниться ✤ Ругаемся
  35. 35. Зачем такие сложности? 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'¯_(ツ)_/¯')
  36. 36. Серебряная пуля?
  37. 37. Не совсем…
  38. 38. Что может пойти не так? def make_user(name, email): return dict( ID=get_id(), Name=name, Role='Editor' if check_employee(email) else 'Guest' )  
  39. 39. Что может пойти не так? def positive_squares(items): return map(lambda x: x **2 if x>0 else x, items)
  40. 40. Что может пойти не так? def positive_squares(items): return [ item **2 for item in items if item>0 ]
  41. 41. Что может пойти не так? 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' )  
  42. 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. 43. Непокрываемый код def some_method(a, b, c): if a and b or c: return True return False
  44. 44. sys.settrace(tracefunc) ✤ Устанавливаем свою функцию трассировки ✤ Смотрим что происходит и делаем выводы
  45. 45. sys.settrace(tracefunc) Ограниченное количество событий: ✤ call ✤ line ✤ return ✤ exception
  46. 46. sys.settrace(tracefunc) Ограниченное количество событий: ✤ call ✤ line ✤ return ✤ exception
  47. 47. ast.NodeTransformer ✤ Обходим ноды ✤ Оборачиваем в «нечто» каждую ноду ✤ Запускаем и отслеживаем что выполнялось
  48. 48. ast.NodeTransformer ✤ Сложно обернуть код, не изменив логику ✤ Не все ноды можно обернуть
  49. 49. ast.NodeTransformer ✤ Сложно обернуть код, не изменив логику ✤ Не все ноды можно обернуть
  50. 50. Идея ✤ Перехватить контроль во время импорта ✤ Обойти байткод модуля ✤ Добавить вызов функции ✤ Собрать code-object
  51. 51. Идея ✤ Перехватить контроль во время импорта ✤ Обойти байткод модуля ✤ Добавить вызов функции ✤ Собрать code-object
  52. 52. OpTrace https: //github.com/tsyganov-ivan/OpTrace
  53. 53. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object ✤ Запускаем тесты ✤ Анализируем результаты
  54. 54. Import hook. Finder. ✤ Пропускаем неинтересные модули ✤ Создаем свой Loader для нужных модулей
  55. 55. Import hook. Loader. ✤ Получаем байт-код модуля ✤ Получаем исходный код модуля ✤ Модифицируем байт-код ✤ Возвращаем измененный байт-код
  56. 56. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object ✤ Запускаем тесты ✤ Анализируем результаты
  57. 57. 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 # ...
  58. 58. 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
  59. 59. 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
  60. 60. 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 ...
  61. 61. Wrap code. Все опкоды. ✤ Просто вызываем функцию, переданную из Loader’a self.mark(codeobj_id, st)
  62. 62. Wrap code. Трассировка. ✤ Добавляем lambda-функцию в константы constants.append( lambda co_id=codeobj_id, opcode=st: self.visit(co_id, opcode) )
  63. 63. 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, /* ... */ )
  64. 64. 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']
  65. 65. Wrap code. Трассировка. ✤ Добавляем lambda-функцию в константы ✤ Добавляем байт-код для вызова ✤ Не забываем про оригинальный опкод и его параметры!
  66. 66. Wrap code. Трассировка. ✤ Добавляем lambda-функцию в константы ✤ Добавляем байт-код для вызова ✤ Не забываем про оригинальный опкод и его параметры! ✤ Учитываем смещение в последующих опкодах
  67. 67. 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
  68. 68. 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
  69. 69. 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
  70. 70. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object ✤ Запускаем тесты ✤ Анализируем результаты
  71. 71. Тестируем. Все опкоды. 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 ...
  72. 72. Тестируем. Непокрытые опкоды. 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)
  73. 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', 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)
  74. 74. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object ✤ Запускаем тесты ✤ Анализируем результаты
  75. 75. Способа однозначно перевести любой опкод к строке кода не существует
  76. 76. Способа однозначно перевести любой опкод к строке кода не существует
  77. 77. Отчет. Ищем строки. ✤ При обходе сохраняем текущую строку ✤ При выводе опкода выводим текущую строку
  78. 78. Отчет. Ищем строки. 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) ✤ При обходе сохраняем текущую строку ✤ При выводе опкода выводим текущую строку
  79. 79. ✤ При обходе сохраняем текущую строку ✤ При выводе опкода выводим текущую строку Отчет. Ищем строки. 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)
  80. 80. Отчет. Позиция в строке. ✤ Строка уже известна ✤ Вычислим позицию в строке для каждого типа опкода
  81. 81. Отчет. Позиция в строке. 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' )
  82. 82. Отчет. Позиция в строке. 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='' )
  83. 83. Отчет. Позиция в строке. ✤ Покрыв 70 типов опкодов удалось получить отчет ✤ Многие опкоды невозможно покрыть
  84. 84. Отчет. Позиция в строке. ✤ Покрыв 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
  85. 85. Отчет. Позиция в строке. ----------- 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 типов опкодов удалось получить отчет ✤ Многие опкоды невозможно покрыть
  86. 86. OpTrace. Что не так? ✤ Переменные в отчете не всегда отмечаются правильно ✤ Часть опкодов приходится пропускать ✤ Производительность неизвестна
  87. 87. OpTrace. Что так? ✤ Трассировка работает хорошо ✤ Идея имеет право на жизнь
  88. 88. OpTrace. Планы. ✤ Услышать мнение и критику сообщества
  89. 89. OpTrace. Планы. ✤ Услышать мнение и критику сообщества ✤ Рефакторинг ✤ Тестирование ✤ Работа над улучшением отчета ✤ Плагин для pytest
  90. 90. К чему это все?
  91. 91. Библиотеки несовершенны
  92. 92. 100% coverage расслабляет команду Библиотеки несовершенны
  93. 93. 100% coverage расслабляет команду Библиотеки несовершенны 100% coverage - просто ачивка
  94. 94. Спасибо за внимание! Вопросы? mi.0-0.im tsyganov-ivan.com

×