20 марта в 20:35

Обзор uniset2-testsuite — небольшого велосипеда для функционального тестирования. Часть 2



В первой части был обзор возможностей. А в этой части рассмотрим, какие уже реализованы интерфейсы тестирования и как добавить свой…

Если что, вот ссылка на первую часть статьи

На текущий момент uniset2-testsuite поддерживает три интерфейса:
  • uniset
  • modbus
  • snmp


Интерфейс type=«uniset»


Как я писал в первой части, весь uniset2-testsuite поначалу разрабатывался для тестирования проектов, использующих libuniset. Поэтому type=«uniset» — это первый и основной интерфейс, который был разработан. В uniset-проектах основным элементом являются «датчики», которые имеют уникальный идентификатор (числовой, но можно использовать строковые имена) и какое-то значение которое можно установить или получить. Поэтому в тестовом сценарии всё крутится вокруг этих датчиков и проверки их значений. Вот пример простого сценария, реализующего алгоритм, описанный здесь.

<?xml version = '1.0' encoding = 'utf-8'?>
<TestScenario>
<TestList type="uniset">
 <test name="Processing" comment="Проверка работы процесса">
     <action set="OnControl_S=1" comment="Подаём команду 'начать работу'"/>
     <check test="CmdLoad_C=1" comment="Подана команда 'наполнение'"/>
     <check test="CmdUnload_C=0" comment="Снята команда 'опустошение'"/>
     <check test="Level_AS>=90" comment="Цистерна наполняется.." timeout="15000"/>
     <check test="CmdLoad_C=0" comment="Снята команда 'наполнение'"/>
     <check test="CmdUnload_C=1" comment="Подана команда 'опустошение'"/>
     <check test="Level_AS<=10" comment="Цистерна опустошается.." timeout="15000"/>
  </test>
  <test name="Stopped" comment="Проверка остановки процесса">
     <action set="OnControl_S=0" comment="Снимаем команду 'начать работу'"/>
     <check test="CmdLoad_C=0" comment="команда 'наполнить' не меняется" holdtime="3000"/>
     <check test="CmdUnload_C=0" comment="команда 'опустошить' не меняется" holdtime="3000"/>
     <check test="Level_AS<=80" comment="Уровень не меняется" holdtime="10000"/>
  </test>
</TestList>
</TestScenario>


Интерфейс type=«modbus»


Это интерфейс является более интересным для широкого использования, так как позволяет общаться с тестируемой системой по стандартному протоколу Modbus TCP. Тестовый сценарий с его использованием выглядит следующим образом:

<?xml version = '1.0' encoding = 'utf-8'?>
<TestScenario type="modbus">
    <Config>
        <aliases>
            <item type="modbus" alias="mb1" mbslave="localhost:2048" default="1"/>
            <item type="modbus" alias="mb2" mbslave="localhost:2049"/>
        </aliases>
    </Config>
    <TestList >
        <test name="Test simple read">
            <check test="0x10!=0"/>
            <check test="0x24:0x04!=0"/>
            <check test="0x24!=0"/>
            <check test="0x1@0x02:0x02!=0" config="mb2"/>
            <check test="0x24:0x06!=0"/>
        </test>
        <test name="Test simple write">
            <action set="0x25=10"/>
            <action set="0x25:0x10=10"/>
            <action set="0x25@0x02=10" config="mb2"/>
            <action set="0x25@0x02:0x05=10" config="mb2"/>
        </test>
        <test name="Test other 'check'">
            <check test="0x20=0x20" timeout="1000" check_pause="100"/>
            <check test="0x20=0x20"/>
        </test>
        <test name="Test 'great'">
            <action set="109=10"/>
            <check test="109>=5"/>
            <check test="109>=10"/>
        </test>
        <test name="Test 'less'">
            <action set="109=10"/>
            <check test="109<=11"/>
            <check test="109<=10"/>
        </test>
        <test name="Test other 'action'">
            <action set="20@2=1,21@0x02:5=1,103@2:0x10=12" config="mb2"/>
        </test>
    </TestList>
</TestScenario>

В начале в секции Config определяются параметры двух узлов, с которыми ведётся работа. Условно им присвоены имена mb1 и mb2. Один из них при этом назначается как умолчательный default=«1». Поэтому везде далее по тексту, если не указан параметр config=".." используется узел по умолчанию.
Сам формат записи тестов выглядит так:
"mbreg@mbaddr:mbfunc:nbit:vtype"
Где:
  • mbaddr — адрес устройства на шине. По умолчанию: 0x01
  • mbfunc — функция опроса или записи. По умолчанию для опроса используется mbfunc=0x04, а для записи mbfunc=0x06
  • nbit — номер бита [0...15]. Для случая, если опрос ведётся функцией чтения «слова», а при этом данные хранятся в каком-то бите. По умолчанию nbit=-1 — что означает не использовать.
  • vtype — тип запрашиваемого значения, задаётся строковым значением. По умолчанию «signed».
    Поддерживаемые vtype
    • F2 — двойное слово float(4 байта)
    • F4 — 8-х байтовое слово (double)
    • byte — байт
    • unsigned — беззнаковое целое (2 байта)
    • signed — знаковое целое (2 байта)
    • I2 — целое (4 байта)
    • U2 — беззнаковое целое (4 байта)

    ВНИМАНИЕ: Следует иметь ввиду, что в текущей реализации работа ведётся только с целыми значениями, поэтому все 'float'-значения округляются до целых.

Обращаю внимание, что поля mbadrr, mbfunc, nbit и vtype не являются обязательными и имеют значения по умолчанию.
В итоге этот интерфейс позволяет Вам писать тестовые сценарии для работы с многими устройствами. Например Вы можете управлять каким-то своим тестовыми стендом для подачи тестовых воздействий на ваше устройство и проверять его реакцию. Всё это по протоколу Modbus TCP, который поддерживается практически всеми производителями устройств, используемых в АСУ (и не только).

Интерфейс type=«snmp»


Данный интерфейс предназначен для работы с тестируемой системой по протоколу snmp.
Немного деталей по реализации
У меня было несколько реализаций на основе python-модулей pysnmp, netsnmp. Но в итоге возникшие трудности с разными версиями на разных платформах привели меня к
более «дубовому» решению. А именно, к использованию вызовов утилит семейства net-snmp-clients и обработке результатов через popen. Как ни парадоксально это звучит, но такой способ оказался более «портабельным», чем использование чистых питон-модулей.

Пример тестового сценария с использованием snmp приведён ниже:
<?xml version="1.0" encoding="utf-8"?>
<TestScenario type="snmp">
<Config>
     <aliases>
       <item type="snmp"  alias="snmp1" snmp="conf/snmp.xml" default="1"/>
       <item type="snmp" alias="snmp2" snmp="conf/snmp2.xml"/>
     </aliases>
</Config>
<TestList>
  <test name="SNMP read tests" comm="Чтение по snmp">
       <check test="uptime@node1>1" comment="Uptime"/>
	<check test="uptimeName@node2>=1" comment="Статус батареи" config="snmp2"/>
  </test>
  <test name="SNMP: FAIL READ" ignore_failed="1">
	<check test="sysServ2@node2>=1" comment="fail read test" config="snmp2"/>
  </test>
  <test name="SNMP write tests" comm="Запись по snmp">
	<action set="sysName@ups3=10" comment="save sysName"/>
	<action set="sysServ2@ups3=10" comment="save sysServ"/>
   </test>
   <test name="SNMP: FAIL WRITE" ignore_failed="1">
	<action set="sysServ2@node1=10" comment="FAIL SAVE TEST"/>
   </test>
</TestList>
</TestScenario>

Этот интерфейс для своего использования ещё требует специальный конфигурационный файл, который указывается в секции Config (snmp="..").
Конфигурационный файл snmp.xml для snmp сценария
<?xml version='1.0' encoding='utf-8'?>
<SNMP>
  <Nodes defaultProtocolVersion="2c" defaultTimeout='1' defaultRetries='2' defaultPort='161'>
    <item name="node1" ip="192.94.214.205" comment="UPS1" protocolVersion="2" timeout='1' retries='2'/>
    <item name="node2" ip="test.net-snmp.org" comment="UPS2"/>
    <item name="node3" ip="10.16.11.3" comment="UPS3"/>
  </Nodes>
  <MIBdirs>
      <dir path="conf/" mask="*.mib"/>
      <dir path="conf2/" mask="*.mib"/>
  </MIBdirs>
  <Parameters defaultReadCommunity="demopublic" defaultWriteCommunity="demoprovate">
    <item name="uptime" OID="1.3.6.1.2.1.1.3.0" r_community="demopublic"/>
    <item name="uptimeName" ObjectName="sysUpTime.0"/>
    <item name="bstatus" OID="1.3.6.1.2.1.33.1.2.1.0" ObjectName="BatteryStatus"/>
    <item name="btime" OID=".1.3.6.1.2.1.33.1.2.2.0" ObjectName="TimeOnBattery"/>
    <item name="bcharge" OID=".1.3.6.1.2.1.33.1.2.4.0" ObjectName="BatteryCharge"/>
    <item name="sysName" ObjectName="sysName.0" w_community="demoprivate" r_community="demopublic"/>
  </Parameters>
</SNMP>


Секция Nodes задаёт список узлов (устройств) с которыми будет происходит обмен. При этом возможно задавать следующие параметры:
  • name — название узла используемое в дальнейшем в тесте
  • ip — адрес устройства (ip или hostname)
  • timeout — таймаут на одну попытку связи, в секундах. Необязательный параметр. По умолчанию 1 сек.
  • retries — количество попыток считать параметр. Необязательный параметр. По умолчанию 2.
  • port — Порт для связи с устройством. Необязательный параметр. По умолчанию 161.
  • comment — комментарий к названию. Необязательный параметр, на данный момент не используется.
Непосредственно в секции Nodes можно задать параметры по умолчанию для всех узлов.
  • defaultProtocolVersion
  • defaultTimeout
  • defaultRetries
  • defaultPort

В секции MIBdirs задаются каталоги с mib-файлами, для проверки корректности OID
  • path — путь до каталога
  • mask — маска для файлов. Если не указана, загружаются все файлы из каталога

Секция Parameters задаёт список параметров, которые будут участвовать в тестах:
  • name — название параметра используемое в дальнейшем в тесте
  • r_community — установка 'community string' при чтении параметра (см. snmp протокол).
  • w_community — установка 'community string' для записи параметра (см. snmp протокол).
  • OID — идентификатор параметра в соответствии с протоколом SNMP.
  • ObjectName — название параметра в соответствии с протоколом SNMP. Не обязательный параметр.
  • ignoreCheckMIB — не проверять параметр по mib-файлу (в режиме --check-scenario)

Параметры OID и ObjectName являются взаимозаменяемыми. Если заданы оба параметра, используется OID. Непосредственно в секции Parameters можно задать параметры по умолчанию для всех узлов.
  • defaultReadCommunity
  • defaultWriteCommunity


Использование нескольких интерфейсов в одном сценарии


Просто покажу пример:
<?xml version="1.0" encoding="utf-8"?>
<TestScenario>
<Config>
  <aliases>
    <item type="uniset" alias="u" confile="configure.xml" default="1"/>
    <item type="modbus" alias="mb" mbslave="localhost:2048"/>
    <item type="snmp" alias="snmp" snmp="conf/snmp.xml"/>
  </aliases>
</Config>
<RunList after_run_pause="5000">
...
</RunList>
<TestList>
   <test name="check: Equal test">
     <action set="111=10"/>
     <check test="111=10"/>
     <check test="uptime@node1>1" config="snmp"/>
     <check test="0x10!=0" config="mb"/>
    </test>
</TestList>
</TestScenario>

Т.е. для каждой проверки можно указать, какой интерфейс использовать, задав параметр config=«aliasname»

Как реализовать свой интерфейс


Вводная


Во время работы над базовыми интерфейсами, сформировались видение о минимальном API тестового интерфейса для встраивания его в uniset2-testsuite. И оказалось, что для основной функциональности достаточно реализовать всего две функции (ну почти)
    def get_value(self, name, context):
        ...
    def set_value(self, name, value, context):
        ...

В основе этого лежит простая идея. Если посмотреть на тестовый сценарий, то можно видеть, что в общем виде check или action можно записать в виде
сheck="[NAME]=[VALUE]".
Вместо '=' на самом деле может стоять что-то из этого [=,>,>=,<,<=,!=].
Т.е. у нас есть некий [NAME] как название параметра который мы проверяем. И конкретный интерфейс знает как его распарсить. И есть [VALUE] — это значение с которым происходит сравнение результата или которое мы выставляем. В итоге uniset2-testsuite берёт на себя работу по разбору теста на NAME и VALUE и вызывает функцию get_value или set_value конкретной реализации интерфейса. При этом интерфейс сам отвечает за то, как у него в поле [NAME] зашифрован параметр с которым производится работа. Например:
  • в «uniset» формат NAME: id@node
  • в «modbus» формат NAME: "mbreg@mbaddr:mbfunc:nbit:vtype"
  • в «snmp» формат NAME: "varname@node"

Т.е. разработчик интерфейса сам решает какой формат для NAME ему выбрать.
Важно иметь ввиду, что в текущей реализации такие параметры тестового сценария как timeout, check_time, holdtime обрабатываются на уровне uniset2-testsuite. И поэтому, когда в тесте написано <check test="varname=34" timeout="15000" check_time="3000"/>, это означает, что каждые 3 секунды будет вызываться функция get_value(varname), пока не истечёт timeout или не будет получено значение 34.
Еще одно важное ограничение — это то, что в текущей реализации поддерживается [VALUE] только как число. На самом деле переделать для поддержки «любого типа» (по сути строки) не сильно сложно, просто не было пока необходимости реализовывать поддержку VALUE не как числа. Напомню, что есть проверка типа compare, которая позволяет сравнивать не с числом, а со значением другого параметра.

Кофигурирование


Каждый интерфейс сам определяет, какие конфигурационные параметры ему нужны для работы. В uniset2-testsuite для их записи предусмотрена секция Config/aliases, где в виде xml-свойств можно записать параметры. При создании интерфейса ему будет передан конфигурационный узел, из которого он считает всё, что ему нужно. Если требуется слишком много всего дополнительного определять, то, например, в snmp-интерфейсе в конфигурационном узле определяется только где взять конфиг-файл (snmp.xml), а уже там определяется всё, что необходимо интерфейсу для работы. В свою очередь для интерфейса modbus, например, достаточно только определить ip и port для связи с устройством, и эти параметры записываются напрямую в секции Config.

Загрузка плагинов (интерфейсов)


Загрузка интерфейсов построена на простом принципе. Есть каталог 'plugins.d' из которого загружаются все лежащие там интерфейсы. Есть каталог «системный», а также плагины ищутся в подкаталоге plugins.d в текущем каталоге, где запускается тест. Соответственно, пользователь может просто разместить свои плагины там же, где находится тест и они автоматически подхватятся.
Каждая реализация интерфейса оформляется в виде отдельного python-файла, который обязан содержать функцию uts_plugin_name(). Например, в snmp интерфейсе она выглядит так
def uts_plugin_name():
    return "snmp"

В итоге сама загрузка интерфейсов построена на следующем механизме:
..
<Config>
  <aliases>
    <item type="uniset" alias="u" confile="configure.xml" default="1"/>
    <item type="modbus" alias="mb" mbslave="localhost:2048"/>
    <item type="snmp" alias="snmp" snmp="conf/snmp.xml"/>
  </aliases>
</Config>
...

Начиная обработку тестового сценария, uniset2-testsuite составляет список доступных плагинов по именам, загружая их из каталогов plugins.d. Далее при прохождении
по секции Config смотрится type=«xxxx» и ищется соответствующий интерфейс,
для создания которого вызывается специальная функция
uts_create_from_xml(xmlConfNode), которой передаётся xml-узел
в качестве параметра. Далее уже в ней реализуется создание интерфейса и его инициализация.
Если коротко:
uts_plugin_name() - чтобы найти нужный интерфейс указанный в type="..."
uts_create_from_xml(xmlConfNode) - чтобы его создать


Проверка корректности конфигурационных параметров


Выше я писал, что для реализации своего интерфейса, достаточно реализовать только две функции set_value() и get_value(). На самом деле, желательно (но не обязательно) реализовать ещё две:
 def validate_parameter(self, name):
   ...
 def validate_configuration(self):
   ...

Они необходимы для режима --check-scenario, когда происходит проверка корректности параметров теста, без фактического их исполнения. validate_parameter — вызывается для проверки корректности параметра name. И надо иметь ввиду, что она вызывается для каждой проверки в тесте. А validate_configuration — вызывается один раз, для проверки конфигурационных параметров всего теста. Например в snmp-интерфейсе, в ней проверяется, доступность узлов, а также проверка валидности OID и ObjectName по mib-файлам (если указаны соответствующие каталоги где их найти).

Реализация


Ну вот наконец-то мы добрались и до реализации. Размышляя над тем какой интерфейс реализовать в качестве показательного примера, я решил, что разработаю ''самый универсальный'' из возможных интерфейсов — а именно интерфейс, который в качестве проверок будет запускать скрипты. Назову его «scripts». Сразу хочу отметить, что под словом «скрипт» подразумевается не только bash, а всё что «можно запустить». Т.к. мы фактически запускаем на каждую проверку программу через оболочку.

Итак.
Для начала надо придумать формат для NAME (что имеется ввиду см. выше). Немного поразмышляв, а если честно, то и попробовав, я пришёл к такому простому формату:
 <test name="check scripts">
   <check test="scriptname.sh >= VALUE" params="param1 param2 param3" ../>
   <check test="../myscritps/scriptname.sh >= VALUE" params="param1 param2 param3" ../>
   ..
 </test>

О поиске формата
Сперва мне хотелось сделать формат, а-ля GET-запрос: scriptname?param1&param2&param3.
Но я столкнулся с ограничением в том, что у нас xml. А в нём нельзя так просто взять и использовать '&'. Только если его записать в виде '&'amp;. Что понятное дело уже сводит всё удобство на нет.


Т.е. в test=".." записывается название скрипта (можно с указанием пути). Т.к. нам ведь хочется передавать и параметры скрипту, то для этого введём специальное поле (расширение) params="...".

Теперь надо понять как нам получить результат. Т.к. принято что программа возвращает 0 — в случае успеха и «не ноль» в случае провала, то использовать код возврата, в качестве результата, мне кажется, не лучшая идея. В итоге я решил, что наиболее простым способом будет, чтобы программа выводила в stdout специальный маркер с результатом. Т.е. с одной стороны мы не запрещаем программе выводить какие-то свои сообщения, но с другой нам нужно получить от неё результат. Маркером будет строка вида: TEST_SCRIPT_RESULT: VALUE

Т.е. мы запускаем скрипт и ловим в выводе маркер, вырезая оттуда результат. Ну вот да, ничего лучшего я не придумал.

Следующий вопрос, это обработка ошибок. Тут всё стандартно. Если код возврата != 0, значит ошибка. В качестве деталей ошибки берём всё что было выведено программой в stderr.

Глобальные параметры конфигурирования интерфейса: ну пока считаем что для нашего простого интерфейса они не нужны. Хотя потом я для примера один сделал.

На этом описательная часть закончена. Что получилось

  • ФОРМАТ ТЕСТА: <check test="testscript=VALUE" params="param1 param2 param3..".../>
  • РЕЗУЛЬТАТ: В качестве результата скрипт должен вывести на экран (stdout) строку TEST_SCRIPT_RESULT: VALUE
  • ОШИБКИ: Если код возврата !=0 считается что произошла ошибка! В случае успеха скрипт должен вернуть код возврата 0.
  • КОНФИГУРИРОВАНИЕ: отсутствует

Тогда приступаем к реализации…

Для того, чтобы наш модуль мог загружаться необходимо реализовать в нём две глобальные функции uts_create_from_xml(..) и uts_plugin_name().
Они простые
def uts_create_from_xml(xmlConfNode):
    """
    Создание интерфейса
    :param xmlConfNode: xml-узел с настройками
    :return: объект наследник UTestInterface
    """
    return UTestInterfaceScripts(xmlConfNode=xmlConfNode)


def uts_plugin_name():
    return "scripts"



Сам интерфейс должен наследоваться от базового класса UTestInterface и реализовать необходимые функции. Для начала покажу как выглядит класс UTestInterface.py
UTestInterface.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

from TestSuiteGlobal import *


class UTestInterface():
    """Базовый интерфейс тестирования"""

    def __init__(self, itype, **kwargs):
        self.itype = itype
        self.ignore_nodes = False

    def set_ignore_nodes(self, state):
        """
        set ignore 'node' for tests (id@node)
        :param state: TRUE or FALSE
        """
        self.ignore_nodes = state

    def get_interface_type(self):
        return self.itype

    def get_conf_filename(self):
        return ''

    def validate_parameter(self, name):
        """
        Validate test parameter (id@node)
        :param name: parameter from <check> or <action>
        :return: [ RESULT, ERROR ]
        """
        return [False, "(validateParam): Unknown interface.."]

    def validate_configuration(self):
        """
        Validate configuration parameters  (check-scenario-mode)
        :return: [ RESULT, ERROR ]
        """
        return [False, "(validateConfiguration): Unknown interface.."]

    def get_value(self, name):
        raise TestSuiteException("(getValue): Unknown interface..")

    def set_value(self, name, value, supplier_id):
        raise TestSuiteException("(setValue): Unknown interface...")



Начнём с реализации главной функции get_value(self, name, context).
Нам передаётся name — в нашем случае это по сути и есть название скрипта.
Но поскольку мы ввели ещё дополнительное поле params, то нам надо обработать и его.
Для того, что бы достучаться до нашего дополнительного поля, воспользуемся таким полезным параметром как context. Что в нём ещё есть интересного написано в документации, мы же вытащим xml-узел текущего теста, с учётом того, что его может и не быть (тогда мы выкинем исключение).
def get_value(self, name, context):

   xmlnode = None
   if 'xmlnode' in context:
       xmlnode = context['xmlnode']

   if not xmlnode:
      raise TestSuiteException("(scripts:get_value): Unknown xmlnode for '%s'" % name)

   ...

Для удобства я выделил отдельную функцию (мало ли формат будет меняться потом) которая на вход получает name и context, а возвращает scriptname и parameters.

Вот она:
parse_name()
  @staticmethod
    def parse_name(name, context):
        """
        Разбор строки вида: <check test="scriptname=XXX" params="param1 param2 param3" .../>
        :param name: исходный параметр (по сути и есть наш scriptname)
        :param context:
        :return: [scriptname, parameters]
        """

        if 'xmlnode' in context:
            xmlnode = context['xmlnode']
            return [name, uglobal.to_str(xmlnode.prop("params"))]

        return [name, ""]


Дальше наш get_value() должен
  • запустить скрипт
  • обработать ошибки
  • распарсить результат

Я просто приведу полную реализацию
get_value()
def get_value(self, name, context):

      xmlnode = None
      if 'xmlnode' in context:
          xmlnode = context['xmlnode']

      if not xmlnode:
         raise TestSuiteException("(scripts:get_value): Unknown xmlnode for '%s'" % name)

      scriptname, params = self.parse_name(name, context)

      if len(scriptname) == 0:
         raise TestSuiteException("(scripts:get_value): Unknown script name for '%s'" % name)

      test_env = None
      if 'environment' in context:
          test_env = context['environment']

      s_out = ''
      s_err = ''
      cmd = scriptname + " " + params

      try:
          p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=test_env, close_fds=True, shell=True)

          s_out = p.stdout.read(self.max_read)
          s_err = p.stderr.read(self.max_read)
          retcode = p.wait()
          if retcode != 0:
             emessage = "SCRIPT RETCODE(%d) != 0. stderr: %s" % (retcode, s_err.replace("\n", " "))
             raise TestSuiteException("(scripts:get_value): %s" % emessage)

      except subprocess.CalledProcessError, e:
          raise TestSuiteException("(scripts:get_value): %s for %s" % (e.message, name))

      if xmlnode.prop("show_output"):
         print s_out

      ret = self.re_result.findall(s_out)
      if not ret or len(ret) == 0:
         return None

      lst = ret[0]
      if not lst or len(lst) < 1:
         return None

      return uglobal.to_int(lst)


Небольшие пояснения.

Для запуска скрипта мы ещё воспользовались возможностью передать в него свои переменные окружения. В документации это можно почитать в разделе про скрипты
Словарик с переменными окружения мы опять же достаём из контекста: test_env = context['environment']

Я позволил себе в реализацию ввести два дополнительных параметра show_output=«1» — это параметр уровня check. Включающий вывод на экран всего того, что скрипт там выведет в stdout. По умолчанию скрипты запускаются с отключённым выводом. А второй параметр который я ввёл, это глобальная настройка max_read — которая определяет сколько (первых) байт из вывода нужно прочитать для получения результата. Во первых мне нужно было продемонстрировать как использовать глобальные настройки. А во вторых, я подумал, что ограничить буфер на чтение, неплохая идея. Тем более можно сделать, что если размер задан <=0, то читать всё.
В обработке кода возврата, если код не нулевой, в качестве текста ошибки забираем stderr (делая его попутно в одну строку).
Результат парсим при помощи регулярного выражения.
Небольшая деталь
В данном случае можно увидеть, что мы ждём завершения запущенной программы. Т.е. подразумеваем, что программы будут достаточно немногословны и
быстро завершающиеся. Всё-таки было бы странно, если программа будет работать «много часов подряд». С другой стороны, можно было бы завершать программу принудительно по timeout-у.

Для проверки нашего интерфейса, создадим каталог plugins.d и поместим туда наш модуль. А так же напишем небольшой тест
Тест для проверки интерфейса
<?xml version="1.0" encoding="utf-8"?>
<TestScenario>
  <Config>
    <environment>
      <item name="MyTEST_VAR1" value="MyTEST_VALUE1"/>
      <item name="MyTEST_VAR2" value="MyTEST_VALUE2"/>
      <item name="MyTEST_VAR3" value="MyTEST_VALUE3"/>
    </environment>
    <aliases>
      <item type="scripts" alias="s" default="1"/>
    </aliases>
  </Config>
  <TestList type="scripts">
    <test name="Test run script" ignore_failed="1">
      <check test="./test-script.sh != 10" params="--param1 3 --param2 4" timeout="2000"/>
      <check test="./test-script.sh = 100" params="param1=3,param2=4"/>
      <check test="./test-script-negative-number.sh = -20"  show_output="1"/>
      <check test="./test-script-longtime.sh = 100" timeout="3000"/>
      <check test="./test-script-error.sh > 10"/>
    </test>
  </TestList>
</TestScenario>


Обращаю внимание, что в секции Config, указано, что у нас default-интерфейс type=«scripts». А также указано в теге TestList что весь сценарий у нас этого типа.
Помимо сценария не забудем создать и указанные bash-скриптики, примерно такие:
test-script.sh
#!/bin/sh
echo "TEST SCRIPT: $*"
echo "SHOW OUTOUT..."
echo "TEST_SCRIPT_RESULT: 100"


Заодно выведем переменные окружения, чтобы посмотреть на них
test-script-negative-number.sh
#!/bin/sh
echo "TEST SCRIPT: $*"

echo  "SHOW ENV VARIABLES: .."
env | grep MyTEST
env | grep UNISET_TESTSUITE

echo "TEST_SCRIPT_RESULT: -20"



В итоге если мы запустим наш сценарий ту увидим такую картину (с учётом параметра show_output=«1» у одного из скриптов и параметром ignore_failed=«1» у теста):
команда для запуска
uniset2-testsuite-xmlplayer --testfile tests-scripts-interface.xml --log-show-actions --log-show-tests




Но это ещё не всё. Мы реализовали пока-что только одну функцию get_value(...). У нас осталось ещё несколько.

А что насчёт поддержки «действий»(action)? Т.е. реализации функции set_value()
Я решил, что в нашем интерфейсе, нет смысла делать поддержку
<action set="..."/>
потому-что есть штатный механизм
<action script=".."/>

Так что функция наша будет выглядеть так.
set_value(..)
   def set_value(self, name, value, context):
        raise TestSuiteException("(scripts:set_value): Function 'set' is not supported. Use <action script='..'> for %s" % name)



Идём далее…
У testsuite существует режим --check-scenario в котором проверяется корректность настроек и тестов без фактического их исполнения. Для поддержки этого режима необходимо реализовать две функции validate_parameter и validate_configuration. Поскольку у нас глобальных конфигурационных параметров нет, кроме max_output_read, то проверять в конфигурации нам особо и нечего. Поэтому функция validate_configuration ничего не делает. А вот validate_parameter чуть более интересна. На самом деле, по скрипту максимум что мы можем проверить (не выполняя его фактически), это:
  • задан ли он вообще
  • существует ли указанный скрипт

Всё это и отражено в реализации.
Реализации validate_parameter() и validate_configuration()
    def validate_configuration(self, context):
        return [True, ""]

    def validate_parameter(self, name, context):
        """
        :param name:  scriptname
        :param context: ...
        :return: [Result, errors]
        """
        err = []

        xmlnode = None
        if 'xmlnode' in context:
            xmlnode = context['xmlnode']

        scriptname, params = self.parse_name(name, context)

        if not scriptname:
            err.append("(scripts:validate): ERROR: Unknown scriptname for %s" % str(xmlnode))

        if not is_executable(scriptname):
            err.append("(scripts:validate): ERROR: '%s' not exist" % scriptname)

        if len(err) > 0:
            return [False, ', '.join(err)]

        return [True, ""]


Для примера вывода ошибки, я вставил в сценарий вызов «несуществующего» скрипта.
Скорректированный сценарий
<?xml version="1.0" encoding="utf-8"?>
<TestScenario>
  <Config>
    <environment>
      <item name="MyTEST_VAR1" value="MyTEST_VALUE1"/>
      <item name="MyTEST_VAR2" value="MyTEST_VALUE2"/>
      <item name="MyTEST_VAR3" value="MyTEST_VALUE3"/>
    </environment>
    <aliases>
      <item type="scripts" alias="s" default="1"/>
    </aliases>
  </Config>
  <TestList type="scripts">
    <test name="Test run script" ignore_failed="1">
      <check test="./test-script.sh != 10" params="--param1 3 --param2"/>
      <check test="./test-script.sh = 100" params="param1=3,param2=4"/>
      <check test="./test-script-negative-number.sh = -20"  show_output="1"/>
      <check test="./test-script-longtime.sh = 100" timeout="3000"/>
      <check test="./test-script-error.sh > 10"/>
      <check test="./non-existent-script.sh > 10"/>
    </test>
  </TestList>
</TestScenario>


И если запустить проверку сценария, то получится примерно такая картинка
команда для запуска
uniset2-testsuite-xmlplayer --testfile tests-scripts-interface.xml --log-show-actions --log-show-tests --check-scenario




Ну и теперь уже можно всё сложить и увидеть итоговую версию реализации нашего интерфейса:
UTestInterfaceScripts.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
import sys
import re
import subprocess
from UTestInterface import *
import uniset2.UGlobal as uglobal


class UTestInterfaceScripts(UTestInterface):
    """
    Тестовый интерфейс основанный на вызове скриптов.

    ФОРМАТ ТЕСТА: <check test="testscript=VALUE" params="param1 param2 param3.." show_output="1".../>
    РЕЗУЛЬТАТ: В качестве результата скрипт должен вывести на экран (stdout) строку TEST_SCRIPT_RESULT: VALUE
    ОШИБКИ: Если код возврата !=0 считается что произошла ошибка! В случае успеха скрипт должен вернуть код возврата 0.

    Дополнительные параметры:
        show_output=1 - вывести на экран stdout..

    Глобальные конфигурационные параметры (секция <Config>):
      max_output_read="value" - максимальное количество первых байт читаемое из вывода скрипта, чтобы получить результат.
      По умолчанию: 1000
    """

    def __init__(self, **kwargs):
        """
        :param kwargs: параметры
        """
        UTestInterface.__init__(self, 'scripts', **kwargs)

        self.max_read = 1000

        if 'xmlConfNode' in kwargs:
            xmlConfNode = kwargs['xmlConfNode']
            if not xmlConfNode:
                raise TestSuiteValidateError("(scripts:init): Unknown confnode")

            m_read = uglobal.to_int(xmlConfNode.prop("max_output_read"))
            if m_read > 0:
                self.max_read = m_read

        self.re_result = re.compile(r'TEST_SCRIPT_RESULT: ([-]{0,}\d{1,})')

    @staticmethod
    def parse_name(name, context):
        """
        Разбор строки вида: <check test="scriptname=XXX" params="param1 param2 param3" .../>
        :param name: исходный параметр (по сути и есть наш scriptname)
        :param context:
        :return: [scriptname, parameters]
        """

        if 'xmlnode' in context:
            xmlnode = context['xmlnode']
            return [name, uglobal.to_str(xmlnode.prop("params"))]

        return [name, ""]

    def validate_configuration(self, context):
        return [True, ""]

    def validate_parameter(self, name, context):
        """

        :param name:  scriptname
        :param context: ...
        :return: [Result, errors]
        """
        err = []

        xmlnode = None
        if 'xmlnode' in context:
            xmlnode = context['xmlnode']

        scriptname, params = self.parse_name(name, context)

        if not scriptname:
            err.append("(scripts:validate): ERROR: Unknown scriptname for %s" % str(xmlnode))

        if not is_executable(scriptname):
            err.append("(scripts:validate): ERROR: '%s' not exist" % scriptname)

        if len(err) > 0:
            return [False, ', '.join(err)]

        return [True, ""]

    def get_value(self, name, context):

        xmlnode = None
        if 'xmlnode' in context:
            xmlnode = context['xmlnode']

        if not xmlnode:
            raise TestSuiteException("(scripts:get_value): Unknown xmlnode for '%s'" % name)

        scriptname, params = self.parse_name(name, context)

        if len(scriptname) == 0:
            raise TestSuiteException("(scripts:get_value): Unknown script name for '%s'" % name)

        test_env = None
        if 'environment' in context:
            test_env = context['environment']

        s_out = ''
        s_err = ''

        cmd = scriptname + " " + params

        try:
            p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=test_env, close_fds=True,
                                 shell=True)

            s_out = p.stdout.read(self.max_read)
            s_err = p.stderr.read(self.max_read)

            retcode = p.wait()

            if retcode != 0:
                emessage = "SCRIPT RETCODE(%d) != 0. stderr: %s" % (retcode, s_err.replace("\n", " "))
                raise TestSuiteException("(scripts:get_value): %s" % emessage)

        except subprocess.CalledProcessError, e:
            raise TestSuiteException("(scripts:get_value): %s for %s" % (e.message, name))

        if xmlnode.prop("show_output"):
            print s_out

        ret = self.re_result.findall(s_out)
        if not ret or len(ret) == 0:
            return None

        lst = ret[0]
        if not lst or len(lst) < 1:
            return None

        return uglobal.to_int(lst)

    def set_value(self, name, value, context):
        raise TestSuiteException(
            "(scripts:set_value): Function 'set' is not supported. Use <action script='..'> for %s" % name)


def uts_create_from_args(**kwargs):
    """
    Создание интерфейса
    :param kwargs: именованные параметры
    :return: объект наследник UTestInterface
    """
    return UTestInterfaceScripts(**kwargs)


def uts_create_from_xml(xmlConfNode):
    """
    Создание интерфейса
    :param xmlConfNode: xml-узел с настройками
    :return: объект наследник UTestInterface
    """
    return UTestInterfaceScripts(xmlConfNode=xmlConfNode)


def uts_plugin_name():
    return "scripts"



Итог


uniset2-testsuite это простенький велосипед, которого в большинстве случаев может быть достаточно, если тесты укладываются в схему «подали воздействие — проверили реакцию». Минимальный набор механизмов для тестирования присутствует:
  • Возможность запускать до начала теста всё, что нужно
  • Возможность группировать тесты
  • Возможность повторного использования тестов (механизм шаблонов и ссылок на внешние файлы
  • Настраиваемая обработка завершения тестов

Помимо этого, наличие системы плагинов позволяет легко расширять возможности под свои нужды, реализовывать интерфейс взаимодействия со своей тестовой системой по любому доступному протоколу (будь то REST API или RS485). Нужно только реализовать пару функций.
Небольшое дополнение
Вообще-то python это не мой основной язык. Сам я программист на C++. Думаю, что это повлияло на архитектуру и скорее всего python позволяет делать какие-то вещи элегантнее. Поэтому буду рад конструктивным комментариям и замечаниям.


Ссылки по теме:
@PavelVainerman
карма
11,0
рейтинг 0,0
Самое читаемое Разработка

Комментарии (0)

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.