Successfully reported this slideshow.
Your SlideShare is downloading. ×

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

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

Download to read offline

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

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

More Related Content

Viewers also liked

Related Books

Free with a 30 day trial from Scribd

See all

Related Audiobooks

Free with a 30 day trial from Scribd

See all

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

×