Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Иван Цыганов (Positive Technologies) - Почему 1...

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

Доклад с Moscow Python Conf 2016 (http://conf.python.ru)
Видео: https://conf.python.ru/pochemu-100-pokrytie-eto-ploho/

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

Avatar for Moscow Python Meetup

Moscow Python Meetup PRO

October 12, 2016
Tweet

More Decks by Moscow Python Meetup

Other Decks in Programming

Transcript

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

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

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

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

    Соответствие стандартам (Compliance) ✤ Одна из крупнейших баз знаний в мире Система контроля защищенности и соответствия стандартам. ✤ Системные проверки (Audit) MaxPatrol
  5. Зачем мы тестируем? ✤ Уверенность, что написанный код работает ✤

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

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

    coverage» ✤ Уверенность, что код протестирован полностью
  8. 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]
  9. 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%
  10. 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])
  11. 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'
  12. 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
  13. 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
  14. coverage.parser.PythonParser ✤ Обходит все токены и отмечает «интересные» факты ✤

    Компилирует код. Обходит code-object и сохраняет номера строк
  15. Обработка ноды class While(stmt): _fields = ( 'test', 'body', 'orelse',

    ) while i<10: print(i) i += 1 else: print('All done')
  16. Выполненные строки 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.
  17. PyTracer «call» event ✤ Сохраняем данные предыдущего контекста ✤ Начинаем

    собирать данные нового контекста ✤ Учитываем особенности генераторов
  18. Зачем такие сложности? 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'¯\_(ツ)_/¯')
  19. Что может пойти не так? def make_user(name, email): return dict(

    ID=get_id(), Name=name, Role='Editor' if check_employee(email) else 'Guest' )
  20. Что может пойти не так? 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' )
  21. Что может пойти не так? 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' )
  22. ast.NodeTransformer ✤ Обходим ноды ✤ Оборачиваем в «нечто» каждую ноду

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

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

    модуля ✤ Добавить вызов функции ✤ Собрать code-object
  25. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object

    ✤ Запускаем тесты ✤ Анализируем результаты
  26. Import hook. Loader. ✤ Получаем байт-код модуля ✤ Получаем исходный

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

    ✤ Запускаем тесты ✤ Анализируем результаты
  28. 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
  29. 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
  30. 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 ...
  31. 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, /* ... */ )
  32. 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']
  33. Wrap code. Трассировка. ✤ Добавляем lambda-функцию в константы ✤ Добавляем

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

    байт-код для вызова ✤ Не забываем про оригинальный опкод и его параметры! ✤ Учитываем смещение в последующих опкодах
  35. 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
  36. 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
  37. 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
  38. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object

    ✤ Запускаем тесты ✤ Анализируем результаты
  39. Тестируем. Все опкоды. 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 ...
  40. Тестируем. Непокрытые опкоды. 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)
  41. Тестируем. Непокрытые опкоды. 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)
  42. План ✤ Устанавливаем import hook ✤ Модифицируем и подменяем code-object

    ✤ Запускаем тесты ✤ Анализируем результаты
  43. Отчет. Ищем строки. ✤ При обходе сохраняем текущую строку ✤

    При выводе опкода выводим текущую строку
  44. Отчет. Ищем строки. 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) ✤ При обходе сохраняем текущую строку ✤ При выводе опкода выводим текущую строку
  45. ✤ При обходе сохраняем текущую строку ✤ При выводе опкода

    выводим текущую строку Отчет. Ищем строки. 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)
  46. Отчет. Позиция в строке. ✤ Строка уже известна ✤ Вычислим

    позицию в строке для каждого типа опкода
  47. Отчет. Позиция в строке. 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' )
  48. Отчет. Позиция в строке. 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='' )
  49. Отчет. Позиция в строке. ✤ Покрыв 70 типов опкодов удалось

    получить отчет ✤ Многие опкоды невозможно покрыть
  50. Отчет. Позиция в строке. ✤ Покрыв 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
  51. Отчет. Позиция в строке. ----------- 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 типов опкодов удалось получить отчет ✤ Многие опкоды невозможно покрыть
  52. OpTrace. Что не так? ✤ Переменные в отчете не всегда

    отмечаются правильно ✤ Часть опкодов приходится пропускать ✤ Производительность неизвестна
  53. OpTrace. Планы. ✤ Услышать мнение и критику сообщества ✤ Рефакторинг

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