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

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

Иван Цыганов (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 и объясню почему не стоит слепо верить результатам ее работы. Так же я поделюсь идеей получения честной метрики покрытия кода тестами и представлю прототип библиотеки, в которую воплотилась эта идея.

Moscow Python Meetup

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