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
PRO

October 12, 2016
Tweet

More Decks by Moscow Python Meetup

Other Decks in Programming

Transcript

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

    View Slide

  2. Обо мне
    ✤ Спикер PyCon Russia 2016,
    PiterPy, PyCon Siberia 2016
    ✤ Люблю OpenSource
    ✤ Не умею frontend

    View Slide

  3. ✤ 15 лет практического опыта на рынке ИБ
    ✤ Более 650 сотрудников в 9 странах
    ✤ Каждый год находим более 200 уязвимостей
    нулевого дня
    ✤ Проводим более 200 аудитов безопасности в
    крупнейших компаниях мира ежегодно

    View Slide

  4. View Slide

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

    View Slide

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

    View Slide

  7. > 50 000 строк кода

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  13. 100% coverage != 100% протестировано

    View Slide

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

    View Slide

  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]

    View Slide

  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%

    View Slide

  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])

    View Slide

  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 "", line 1, in
    File "", line 5, in apply_discount
    KeyError: 'Discount'

    View Slide

  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

    View Slide

  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

    View Slide

  21. Как считать покрытие?
    Все строки
    Реально выполненные
    строки
    - Непокрытые
    строки
    =

    View Slide

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

    View Slide

  23. coverage.parser.PythonParser
    ✤ Обходит все токены и отмечает «интересные»
    факты
    ✤ Компилирует код. Обходит code-object и
    сохраняет номера строк

    View Slide

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

    View Slide

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

    View Slide

  26. Как считать coverage --branch?
    Все переходы
    Реально выполненные
    переходы
    - Непокрытые
    переходы
    =

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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.

    View Slide

  32. PyTracer «call» event
    ✤ Сохраняем данные предыдущего контекста
    ✤ Начинаем собирать данные нового контекста
    ✤ Учитываем особенности генераторов

    View Slide

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

    View Slide

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

    View Slide

  35. Отчет
    ✤ Что выполнялось
    ✤ Что должно было выполниться
    ✤ Ругаемся

    View Slide

  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'¯\_(ツ)_/¯')

    View Slide

  37. Серебряная пуля?

    View Slide

  38. Не совсем…

    View Slide

  39. Что может пойти не так?
    def make_user(name, email):
    return dict(
    ID=get_id(),
    Name=name,
    Role='Editor' if check_employee(email) else 'Guest'
    )

    View Slide

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

    View Slide

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

    View Slide

  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'
    )

    View Slide

  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'
    )

    View Slide

  44. View Slide

  45. Непокрываемый код
    def some_method(a, b, c):
    if a and b or c:
    return True
    return False

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  49. ast.NodeTransformer
    ✤ Обходим ноды
    ✤ Оборачиваем в «нечто» каждую ноду
    ✤ Запускаем и отслеживаем что выполнялось

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  54. OpTrace
    https: //github.com/tsyganov-ivan/OpTrace

    View Slide

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

    View Slide

  56. Import hook. Finder.
    ✤ Пропускаем неинтересные модули
    ✤ Создаем свой Loader для нужных модулей

    View Slide

  57. Import hook. Loader.
    ✤ Получаем байт-код модуля
    ✤ Получаем исходный код модуля
    ✤ Модифицируем байт-код
    ✤ Возвращаем измененный байт-код

    View Slide

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

    View Slide

  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
    # ...

    View Slide

  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

    View Slide

  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

    View Slide

  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 ...

    View Slide

  63. Wrap code. Все опкоды.
    ✤ Просто вызываем функцию, переданную из
    Loader’a
    self.mark(codeobj_id, st)

    View Slide

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

    View Slide

  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,
    /* ... */
    )

    View Slide

  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']

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  71. Wrap сode. Результат.
    20 LOAD_CONST 5 (.)
    23 CALL_FUNCTION 0 (0 positional, 0 keyword pair)
    26 POP_TOP
    27 LOAD_FAST 1 (b)
    30 LOAD_CONST 6 (.)
    33 CALL_FUNCTION 0 (0 positional, 0 keyword pair)
    36 POP_TOP
    37 POP_JUMP_IF_TRUE 60
    . . .
    >> 60 LOAD_CONST 9 (.)
    63 CALL_FUNCTION 0 (0 positional, 0 keyword pair)
    66 POP_TOP
    67 LOAD_CONST 1 (True)
    70 LOAD_CONST 10 (.)
    73 CALL_FUNCTION 0 (0 positional, 0 keyword pair)
    76 POP_TOP
    77 RETURN_VALUE

    View Slide

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

    View Slide

  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 ...

    View Slide

  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)

    View Slide

  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)

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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)

    View Slide

  82. Отчет. Позиция в строке.
    ✤ Строка уже известна
    ✤ Вычислим позицию в строке для каждого типа
    опкода

    View Slide

  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'
    )

    View Slide

  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=''
    )

    View Slide

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

    View Slide

  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

    View Slide

  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 типов опкодов удалось получить отчет
    ✤ Многие опкоды невозможно покрыть

    View Slide

  88. OpTrace. Что не так?
    ✤ Переменные в отчете не всегда отмечаются
    правильно
    ✤ Часть опкодов приходится пропускать
    ✤ Производительность неизвестна

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  92. К чему это все?

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide