В ходе разработки сложной многокомпонентной системы для автоматизированного тестирования сканера безопасности, возникла проблема контроля целостности и проведения ревизий кода отдельных тестовых функций. Функциональных тестов, запускаемых системой, было написано уже около 2 сотен и их число продолжает увеличивается. В нашем случае один функциональный тест - это одна функция. Как правило, после разработки теста и постановки ему статуса "готов" в некотором трекере, о нём забывают и надолго. Однако, в процессе разработки других тестовых функций часто возникает необходимость проведения рефакторинга, которая по невнимательности тестировщика-автоматизатора может затронуть и уже готовые, отлаженные тесты.
Сам по себе рефакторинг любой программы, даже затрагивающий множество модулей и функций, - явление обычное и полезное. Однако, в случае функций-тестов, это не всегда так. Каждый тест разрабатывается для реализации конкретного алгоритма проверки, и даже при незначительных изменениях в его коде может быть нарушена логика, заложенная автором.
Для снижения последствий от таких ситуаций, предлагается механизм ревизий кода тестовых функций, который позволяет одновременно и контролировать целостность функций и дублировать их код.
1. Суть идеи.
(func_hash, func_source)
Все критичные функции могут быть добавлены в словарь ревизий:
{"funcName1": (funcName1_hash, funcName1_source),
"funcName2": (funcName2_hash, funcName2_source), ...}
Например, для нас критичными являются все функции с уже разработанными тестами. Хранить все ревизии можно в файле-ревизий, обычном текстовом файле, содержащем список из даты последней ревизии и словарь с ревизиями:
[revision's last date-n-time, {revisions}]
Перед очередным релизом системы тестирования ответственный за ревизии может отслеживать изменения в коде функций и, в случае необходимости, быстро восстановить код старых тестов путём их простого копирования из ревизии.
Конечно же, имеются и альтернативные варианты решения проблемы: код-ревью и использование инструментов в код-репозитариях (git, svn etc). Однако, код-ревью бесполезен в случае внесения автоматических изменений в сотнях тестов, а отслеживать изменения в коде, используя инструменты репозитария, после нескольких мержей и трудно и долго. Также механизм ревизий позволяет решить проблему того, что на функции-тесты обычно не пишут юниттесты, но при этом необходимо контролировать их качество и неизменность.
2. Программный код.
Для реализации описанной идеи, на Python 3.2 был написан небольшой модуль FileRevision.py. Имеющийся в нём класс Revision() можно импортировать в свой проект и добавить ревизии для нужных вам функций. При небольших доработках, можно дополнительно реализовать, например, сжатие файла ревизий, но для небольших по объему проектов это не критично.
Программный код доступен архивом по ссылке:
Код на GitHub:
Реализация модуля:
class Revision():
__init__() # Инициализация параметров.
def __init__(self, fileRevision='revision.txt'):
self.fileRevision = fileRevision
self.mainRevision = self._ReadFromFile(self.fileRevision) # get main revision first
_ReadFromFile() # Получение ревизий из файла.
def _ReadFromFile(self, file=None):
"""
Helper function that parse and return revision from file.
"""
revision = [None, {}]
if file == None:
file = self.fileRevision
try:
if os.path.exists(file) and os.path.isfile(file):
with open(file) as fH:
revision = eval(fH.read())
except:
traceback.print_exc()
finally:
return revision
_WriteToFile() # Запись ревизий в файл.
def _WriteToFile(self, revision=[None, {}], file=None):
"""
Helper procedure than trying to write given revision to file.
"""
status = False
if file == None:
file = self.fileRevision
try:
with open(file, "w") as fH:
fH.write(str(revision))
status = True
except:
traceback.print_exc()
finally:
return status
_GetOld() # Получение предыдущей ревизии для функции.
def _GetOld(self, func=None):
"""
Get old revision for given function and return tuple: (old_hash, old_source).
"""
funcHashOld = None # old code is None if function not exist in previous revision
funcSourceOld = None # old hash is None if function not exist in previous revision
try:
if func.__name__ in self.mainRevision[1]:
funcHashOld = self.mainRevision[1][func.__name__][0] # field with old hash of function
funcSourceOld = self.mainRevision[1][func.__name__][1] # field with old code of function
except:
traceback.print_exc()
finally:
return (funcHashOld, funcSourceOld)
_GetNew() # Получение новой ревизии для функции.
def _GetNew(self, func=None):
"""
Get new revision for given function and return tuple: (new_hash, new_source).
"""
funcSourceNew = None # if function doesn't exist, its also doesn't have code
funcHashNew = None # hash is None if function not exist
try:
funcSourceNew = inspect.getsource(func) # get function's source
funcHashNew = hash(funcSourceNew) # new hash of function
except:
traceback.print_exc()
finally:
return (funcHashNew, funcSourceNew)
_Similar() # Сравнение двух ревизий.
def _Similar(self, hashOld, sourceOld, hashNew, sourceNew):
"""
Checks if given params for modified then return tuple with revision's diff:
(old_revision, new_revision), otherwise return None.
"""
similar = True # old and new functions are similar, by default
if hashNew != hashOld:
if sourceOld != sourceNew:
similar = False # modified if hashes are not similar and functions not contains similar code
return similar
Update() # Обновление ревизии для указанной функции.
def Update(self, func=None):
"""
Set new revision for function.
revision = [revision date-n-time,
{"funcName1": (funcName1_hash, funcName1_source),
{"funcName2": (funcName2_hash, funcName2_source), ...}]
"""
status = False
if func:
try:
funcSourceNew = inspect.getsource(func) # get function's source
funcHashNew = hash(funcSourceNew) # new hash of function
revisionDateNew = datetime.now().strftime('%d.%m.%Y %H:%M:%S') # revision's date
funcRevisionNew = {func.__name__: [funcHashNew, funcSourceNew]} # form for function's revision
self.mainRevision[0] = revisionDateNew # set new date for main revision
self.mainRevision[1].update(funcRevisionNew) # add function's revision to main revision
if self._WriteToFile(self.mainRevision): # write main revision to file
status = True
except:
traceback.print_exc()
finally:
return status
DeleteAll() # Удаление всех ревизий из файла.
def DeleteAll(self):
"""
Helper function that parse and return revision from file.
"""
status = False
try:
self.mainRevision = [None, {}] # clean revision
if self._WriteToFile(self.mainRevision): # write main revision to file
status = True
except:
traceback.print_exc()
finally:
return status
ShowOld() # Вывод информации о предыдущей ревизии для функции.
def ShowOld(self, func=None):
"""
Function return old revision for given function.
"""
funcHashOld, funcSourceOld = self._GetOld(func) # get old revision for given function
dateStr = "Last revision: " + str(self.mainRevision[0])
hashStr = "\nOld function's hash: " + str(funcHashOld)
codeStr = "\nOld function's code:\n" + "- " * 30 + "\n" + str(funcSourceOld) + "\n" + "- " * 30
oldRevision = dateStr + hashStr + codeStr
return oldRevision
ShowNew() # Вывод информации о новой ревизии для функции.
def ShowNew(self, func=None):
"""
Function return old revision for given function.
"""
funcHashNew, funcSourceNew = self._GetNew(func) # get old revision for given function
hashStr = "New function's hash: " + str(funcHashNew)
codeStr = "\nNew function's code:\n" + "- " * 30 + "\n" + str(funcSourceNew) + "\n" + "- " * 30
newRevision = hashStr + codeStr
return newRevision
Diff() # Сравнение ревизий и вывод диффа для функции при необходимости.
def Diff(self, func=None):
"""
Checks if given function modified then return tuple with revision's diff:
(old_revision, new_revision), otherwise return None.
"""
funcHashOld, funcSourceOld = self._GetOld(func) # get old revision for given function
funcHashNew, funcSourceNew = self._GetNew(func) # get new revision for given function
# check old and new revisions:
if self._Similar(funcHashOld, funcSourceOld, funcHashNew, funcSourceNew):
diff = None # not difference
else:
diff = ("Last revision: " + str(self.mainRevision[0]) +
"\nOld function's hash: " + str(funcHashOld) +
"\nOld function's code:\n" + "- " * 30 + "\n" +
str(funcSourceOld) + "\n" + "- " * 30,
"\nNew function's hash: " + str(funcHashNew) +
"\nNew function's code:\n" + "- " * 30 + "\n" +
str(funcSourceNew) + "\n" + "- " * 30) # if new function not similar old function
return diff
_testFunction() # Фейковая функция для проверки работы модуля.
def _testFunction(a=None):
"""
This is fake test function for module.
"""
# this is comment
if a:
return True
else:
return False
if __name__ == '__main__':() # Примеры использования модуля, при его отдельном запуске.
func = _testFunction # set function for review in revision
revision = Revision('revision.txt') # init revision class for using with revision.txt
# how to use this module for review revision of function:
print(MSG_CHECK, func.__name__)
funcModified = revision.Diff(func) # get function's diff as tuple (old_revision, new_revision)
if funcModified:
print(MSG_MODIFIED)
print(funcModified[0]) # old revision
print(funcModified[1]) # new revision
else:
print(MSG_NOT_MODIFIED)
# how to use this module for update revision:
action = input("Update function's revision? [y/n]: ")
if action == 'y':
print(MSG_UPDATE, func.__name__)
if revision.Update(func):
print(MSG_UPDATED)
else:
print(MSG_UPDATE_ERROR)
# how to use this module for clean file-revision:
action = input("Clean file-revision now? [y/n]: ")
if action == 'y':
print(MSG_DELETE)
if revision.DeleteAll():
print(MSG_DELETED)
else:
print(MSG_DELETE_ERROR)
# how to use this module for show old review:
action = input('Show old revision for function? [y/n]: ')
if action == 'y':
print(revision.ShowOld(func))
# how to use this module for show new review:
action = input('Show new revision for function? [y/n]: ')
if action == 'y':
print(revision.ShowNew(func))
Для того, чтобы посмотреть примеры использования данного модуля, достаточно его запустить:
python FileRevision.py
При первом запуске будет обнаружено, что для фейковой функции нет ревизии, затем будет предложено обновить информацию о ней, очистить файл ревизий, вывести информацию о предыдущей ревизии и о новой ревизии. Файл revision.txt, с ревизиями для примеров, будет создан рядом с .py файлом.
Таким образом, используя предложенный нами модуль и назначив ответственного за формирование файла-ревизий для кода, вы сможете повысить вероятность сохранности ваших тестовых функций.

