В процессе разработки многокомпонентной системы автоматизированного тестирования сканера безопасности мы столкнулись с проблемой контроля целостности кода отдельных тестовых функций и проведения ревизий.
Число написанных функциональных тестов, которые запускает система, уже превысило несколько тысяч и продолжает увеличиваться. В нашем случае один функциональный тест — это одна функция. При таком методе разработки после присвоения тесту статуса «Готов» о нем надолго забывают.
Между тем в процессе разработки других тестовых функций часто возникает необходимость рефакторинга. Причем этот процесс по невнимательности тестировщика-автоматизатора может затронуть и уже готовые отлаженные тесты.
Сам по себе рефакторинг любой программы, даже если он затрагивает множество модулей и функций, — обычное и вполне полезное дело. Однако в отношении тестовых функций это не всегда так. Каждый тест разрабатывается для реализации конкретного алгоритма проверки. Логика проверки, которую закладывал автор, может быть нарушена даже при незначительных изменениях в коде теста.
Чтобы избежать негативных последствий подобных ситуаций, мы разработали механизм ревизий кода тестовых функций, с помощью которого можно одновременно и контролировать целостность функций, и дублировать их код.
Механизм
Для каждой функции может быть определена ревизия, набор из хэша и кода функции:
(func_hash, func_source)
Все критические функции могут быть добавлены в словарь ревизий:
{"funcName1": (funcName1_hash, funcName1_source),
"funcName2": (funcName2_hash, funcName2_source), ...}
Для нас, к примеру, критическими являются все функции с уже разработанными тестами. Хранить все ревизии можно в специальном текстовом файле (файл ревизий), в котором хранится список с датой последней ревизии и словарем ревизий:
[revision's last date-n-time, {revisions}]
Перед очередным релизом системы тестирования специалист, ответственный за ревизии, может отслеживать изменения в коде функций и, если необходимо, в короткие сроки восстановить код старых тестов, просто скопировав их из ревизии.
Разумеется, существуют и альтернативные варианты решения проблемы, например, инспекции кода и использование инструментов в репозиториях (например, GIT, SVN). Однако инспекции бесполезны в случае внесения автоматических изменений в сотни тестов, а отслеживание изменений в коде с помощью инструментов репозитория после нескольких мержей — процесс трудоемкий и долгий. Кроме того, обычно на тестовые функции не пишут модульные тесты, однако необходимость контроля качества и неизменности функций сохраняется — эту проблему также позволяет решить механизм ревизий.
Код
Для реализации описанной выше идеи на Python был написан небольшой модуль FileRevision.py. Имеющийся в нем класс Revision() можно импортировать в свой проект и добавить ревизии для нужных именно вам функций.
Немного доработав модуль, можно дополнительно реализовать, к примеру, сжатие файла ревизий, хотя для небольших по объему проектов это не так уж критично.
Код доступен по ссылке.
Реализация модуля
class Revision():
def __init__(self, fileRevision='revision.txt'):
self.fileRevision = fileRevision
self.mainRevision = self._ReadFromFile(self.fileRevision) # get main revision first
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
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
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)
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)
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
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
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
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
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
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
def _testFunction(a=None):
"""
This is fake test function for module.
"""
# this is comment
if a:
return True
else:
return False
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 3.2.3:
python FileRevision.py
При первом запуске скрипт обнаружит отсутствие ревизии для фейковой функции, реализованной в примере, предложит обновить информацию о ней, очистить файл ревизий, а также вывести информацию о предыдущей и новой ревизиях. Затем рядом с .py-файлом будет создан файл revision.txt с ревизиями для примеров.
Таким образом, при использовании нашего модуля и наличии сотрудника, ответственного за формирование файла ревизий кода, увеличится степень защищенности тестовых функций.
На сегодня все. Ждем ваших вопросов и предложений в комментариях. Спасибо за внимание!
Автор: Тимур Гильмуллин, группа автоматизированного тестирования Positive Technologies.
Автор: ptsecurity