В ходе разработки сложной многокомпонентной системы для автоматизированного тестирования сканера безопасности, возникла проблема контроля целостности и проведения ревизий кода отдельных тестовых функций. Функциональных тестов, запускаемых системой, было написано уже около 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 файлом.
Таким образом, используя предложенный нами модуль и назначив ответственного за формирование файла-ревизий для кода, вы сможете повысить вероятность сохранности ваших тестовых функций.