Как я разрабатываю и тестирую API со своим «велосипедом» PieceofScript

    PieceofScript — простой язык для написания сценариев автоматического тестирования HTTP JSON API.

    PieceofScript позволяет:

    • описывать методы API в формате YAML, с названием метода на почти естественном языке, что удобно для чтения тестов
    • достаточно гибко описывать модели в формате YAML и генерировать по ним рандомные данные
    • писать сложные сценарии вызовов API на легкочитаемом языке с несложным синтаксисом
    • получать результаты тестирования в форматах JUnit и HTML

    Я написал этот «велосипед» потому что меня вгонял в уныние интерфейс SoapUI. Хотелось просто и понятно описывать тесты в текстовом редакторе без специального GUI. Кроме того, git плохо переваривает огромный xml-файл, который выдает SoapUI, поэтому тесты на конкретную задачу сложно положить в той же ветке, где сделана сама задача. Интерфейс Postman куда приятнее, но при разработке много времени уходит на составление/изменение там запросов и повторение их в нужной последовательности. Это хотелось автоматизировать. Я изучил еще и другие инструменты тестирования, у каждого был "фатальный недостаток", поэтому в припадке NIH-синдрома я открыл IDE.

    Вот что из этого вышло.



    Интерпретатор написан на PHP и представляет собой phar-архив, требует версию PHP 7.2, хотя возможно работает и на 7.1. Исходный код и документация https://github.com/maximw/PieceofScript. Документация в разработке. Это, как оказалось, самая трудная и нудная часть.

    Проект тестирования, его структура и запуск
    Сценарий тестирования
    Методы тестируемого API
    Вызов метода API
    Генерация моделей и тестовых данных
    Встроенные функции
    Тест-кейсы
    Переменные и области видимости
    Типы и операции
    Сохранение данных между запусками
    Вывод в stdout и отчеты
    Примеры — хватит слов, покажи код!
    Замечания и планы на будущее, если оно будет

    Проект тестирования, его cтруктура и запуск


    Проект представляет собой директорию с набором файлов-сценариев, файлов описания методов API и генераторов тестовых данных.

    В минимальной версии проект выглядит так:

    ./tests
      endpoints.yaml        - Методы API
      generators.yaml       - Генераторы
      start.pos             - Стартовый сценарий тестирования

    Стартовый файл — это сценарий с которого начинается процесс тестирования. Он задается при запуске:

    pos.phar run ./start.pos --junit=junit_report.xml -vvv --config=config.yaml

    Все относительные пути считаются от рабочей директории, содержащей стартовый файл.
    Конфигурационный файл можно задать в командной строке опцией --config или положить config.yaml в рабочую директорию. Конфиг не обязателен, туда нужно лезть по необходимости. Подробнее про конфиг.

    Сценарий тестирования


    Для себя решил писать сценарии в файлах с расширением .pos, чтоб можно было сделать настройки подсветки кода в IDE с привязкой по расширению. Но интерпретатору совершенно безразлично расширение.

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

    require "./globals.pos" // Здесь определим глобальные переменные, например $domain 
    include "./globals_local.pos" // Здесь можно переопределить глобальные переменные, для другого окружения 
    include "./user/*.pos" // Подключаем все файлы что найдем в ./user и ./post
    include "./post/*.pos"  
    
    // Сгенерируем несколько моделей пользователей и поста
    var $author = User()
    var $reader = User()
    var $banned = User() 
    var $post = Post()
    
    Register $author // Вызов метода API, который регистрирует переданного пользователя	
    must $response.code == 201 // Регистрация должна пройти успешно 
    Register $reader 
    must $response.code == 201 
    Register $banned 
    must $response.code == 201 
    Add $banned to blacklist of $author //$banned больше не может видеть посты $author
    
    Create post $post by $author // Вызов метода API создания поста
    must $response.code == 201 // Проверка что метод API ответил кодом 201
    // ...и, на всякий случай, что содержимое созданного поста не изменилось
    assert $response.body.post.content == $post.content 
    var $postId = $response.body.post.id // Id созданного поста еще пригодится
    
    Read post $postId by $author // Прочитать пост самим автором
    must $response.code == 200
    assert $response.body.post.content == $post.content
    
    Read post $postId by $reader // И прочитать пост другим пользователем
    must $response.code == 200
    assert $response.body.post.content == $post.content
    
    Read post $postId by $banned // А для этого пост будет не найден
    assert $response.code == 404
    

    Да, без подсветки выглядит не очень.

    Каждая строка сценария начинается с оператора, либо является вызовом метода API. Если вдруг имя метода API начинается со слова совпадающего с одним из операторов, можно использовать символ ">":

    >Include $user to group $userGroup

    Операторы регистронезависимы. assert, ASSERT или aSsErT (но зачем так писать?) будут работать.
    Каждый оператор или вызов метода API должны находиться на отдельной строке. Но возможен и перенос строк, если последний символ строки \ (привет, Python).

    Неинтересные подробности про переносы строк и отступы
    Если перенос строки использовать в комментарии, то следующая строка будет тоже считаться частью комментария. При переносе строк внутри блоков (testcase, if, while, foreach) важно соблюдать отступы, чтоб следующая строка попала в тот же блок.

    var $n = 20
    var $i = 2
    var $fib1 = 1; \
        $fib2 = 1
    while $i <= $n
        var $fib_sum = \
            $fib2 + $fib1
        print toString($i) + " число Фиббоначи:" + \
            toString($fib_sum)
        var $fib1 = $fib2
        var $fib2 = $fib_sum
        var $i = $i + 1

    При выполнении блочных операторов (testcase, if, while, foreach) блок определяется отступами его строк. Отступ считается как количество пробельных символов в начале строки. И пробел, и табуляция считаются за один символ, но табуляция обычно отображается в редакторах как несколько пробелов. Поэтому чтобы не было неразберихи лучше использовать или табы, или пробелы, но не все вместе.

    Полный список операторов


    require fileName — подключить файл в место вызова оператора. Тут же начнет выполняться подключенный файл с первой его строки. По завершению выполнения, интерпретатор вернется к следующей строке исходного файла. Если запрашиваемый файл не доступен для чтения, то будет выдана ошибка. Относительный путь считается от рабочей директории.

    include fileMask — аналогично require, но если запрашиваемый файл не доступен для чтения, то ошибки не будет. Это удобно, например, для создания настроек для разных окружений тестирования. Кроме того, include умеет подключать сразу все файлы по маске. Так, например, можно загрузить целые директории файлов, содержащих тесткейсы. Но при этом не гарантируется какая-либо очередность загрузки файлов.

    var $variable1 = expression1; $variable2 = expression2;…; $variableN = expressionN — присвоить значения переменным. Если переменной еще нет, она будет создана в текущем контексте.

    let $variable1 = expression1; $variable2 = expression2;…; $variableN = expressionN — присвоить значения переменным. Если переменной нет в текущем контексте, будет попытка создать или изменить переменную в глобальном контексте.

    const $const1 = expression1; $const2 = expression2;…; $constN = expressionN — установить значение констант в текущем контексте. Отличие констант от переменных только в том, что их нельзя изменять, при попытке присвоить значение константе после объявления будет выдано предупрежение. Eсли уже есть переменная с таким именем, то при попытке объявить ее константой будет выдана ошибка. В остальном все что справедливо для переменных, справедливо и для констант.

    import $variable1; $variable2;… ;$variableN — копирует переменные из глобального в текущий контекст. Может пригодиться, если надо оперировать со значением глобальной переменной, но не изменить его.

    testcase testCaseName — объявляет тест-кейс, который потом можно вызвать как единое целое оператором run. Подробнее про тест-кейсы далее в статье.

    assert expression — проверить, что выражение expression равно true, в противном случае вывести отчет о провалившемся тесте.

    must expression — то же самое, что и assert, только в случае провала теста, выполнение текущего тест-кейса будет прекращено. А вне контекста тест-кейса будет прекращено выполнение сценария вообще. Можно использовать, если поймана ошибка, с которой дальнейшие проверки не имеют смысла.

    run testCaseName — запустить на выполнение заданный тест-кейс. run без указания имени тест-кейса запустит все объявленные тест-кейсы, не требующие аргументов, в порядке их объявления.

    while expression — цикл, пока expression истинно, выполняет операторы с отступом строки больше чем у while.

    foreach $array; $element — цикл прохода по массиву, тело цикла выполняется для каждого очередного элемента массива. Возможно получение еще и ключа foreach $array; $key; $element. Переменные $key и $element создаются/перезаписываются в текущем контексте.

    if expression — если expression истинно, выполняет операторы с отступом строки больше чем у if

    print expression1; expression2;… expressionN — вывести значение выражениях expressionM в stdout. Можно использовать для отладки, работает только с уровнем «болтливости» --verbosity=1 или -v и больше.

    sleep expression — сделать паузу на заданное число, необязательно целое, секунд. Иногда надо дать тестируемому API передышку.

    pause expression — не в интерактивном режиме (опция командной строки -n) аналогичен sleep. Expression не обязательно, в этом случае паузы не будет. А в интерактивном режиме сделать паузу до нажатия Enter.

    cancel — закончить тестирование. Интерпретатор заканчивает работу, создает отчеты.

    Методы тестируемого API


    Это собственно то, что надо тестировать — вызывать с определенными параметрами и проверять соответствует ли ответ ожиданиям.

    Методы API описываются в формате YAML. По умолчанию описания должны находиться в файле endpoints.yaml текущей директории и/или в файлах *.yaml в ее поддиректории ./endpoints. Перед тестированием интерпретатор попытается вычитать сразу все эти файлы.

    Пример структуры endpoints.yaml:

    Auth $user:
        method: "POST"
        url: $domain + "/login"
        headers:
            Content-Type: "application/json"
        format: "json"
        data:
            login: $user.login
            password: $user.password
        after:
            - assert $response.code == 200
            - let $user.auth_token = $response.body.auth_token
    
    Create post $post by $user:
        method: "POST"
        url: $domain + "/posts"
        format: "json"
        data: $post 
        headers:
            auth: "Bearer " + $user.auth_token
            content-type: "application/json"
        after:     
            - assert $response.code == 201	
    
    Read post $postId by $user:
        method: "GET"
        url: $domain + "/posts/" + $postId
        headers:
            auth: "Bearer " + $user.auth_token
            content-type: "application/json"
        after:
            - assert ($response.code == 200) || ($response.code == 404)
    
    Create comment $comment on $post by $user:
        method: "POST"
        url: $domain + "/comments/create/" + $post.id
        format: "json"
        data: $comment
        headers: 
            auth: "Bearer " + $user.auth_token
            content-type: "application/json"
        after:     
            - assert $response.code == 201	
    

    Имя метода API (верхний уровень структуры YAML), по которому он может быть вызван, представляет собой строку в почти произвольном формате.

    В любом месте имени можно указать аргументы. Они должны быть отделены пробелами от остальных слов. Например $comment, $post и $user в последнем методе.

    Также в любом месте имени можно указать опциональные значения метода в двойных фигурных скобках.

    Get comments of $post {{$page=1; $perPage=$defaultGlobalPageSize}}:
        method: "GET"
        url: $domain + "/comments/" + $post.id
        query: 
            page: $page
            per_page: $perPage     
        headers: 
            auth: "Bearer " + $user.auth_token
            content-type: "application/json"
        after:     
            - assert $response.code == 200	

    В выражениях, задающих опциональные значения, доступны переменные глобального контекста.
    Опциональные значения могут быть полезны, чтобы каждый раз не указывать их при вызове метода API. Если размер страницы нужно изменить только в одном месте, зачем указывать его во всех остальных местах? Примеры вызовов этого метода:

    Get comments of $newPost // Будут использованы дефолтные значения $page и $perPage 
    Get comments of $newPost {{$page=$currentPage+1}}
    Get comments of {$newPost} {{$perPage=10;$page=100}}
    

    Остальные, используемые переменные ($domain в примере выше) будут взяты из глобального контекста. Подробнее про контексты расскажу дальше.

    Мне кажется, удобно давать методам API понятные человеку имена на естественном языке, тогда сценарий тестирования легче читать. Имена регистронезависимы, т.е метод Auth $User может быть вызван как auth $User и как AUTH $User. Однако, имена переменных регистрозависимы, подробнее про переменные ниже.

    Важное замечание. Формат YAML позволяет не заключать строки в кавычки. Но для интерпретатора строка без кавычек — выражение, которое надо вычислить. Например объявление поля url: http://example.com/login приведет к синтаксической ошибке при выполнении. Поэтому правильно будет: url: "http://example.com/login" или url: "http://"+$domain+"/login"

    Поля описания метода API


    method — HTTP-метод, обязательное

    url — собственно URL, обязательное

    headers — список HTTP-заголовков, необязательное

    cookies — список cookie, необязательное

    auth — данные для HTTP-аутентификации, необязательное

    auth:
      login: $login
      password: $password
      type: "basic" // или "digest" или "ntlm", по-умолчанию "basic"

    query — список параметров URL, необязательное

    format — одно из значений:

    • none — у запроса нет тела
    • json — отправка в JSON
    • raw — отправка строки «как есть»
    • form — в формате application/x-www-form-urlencoded
    • multipart — в формате multipart/form-data

    Необязательное, по-умолчанию none

    data — тело запроса, будет отправлено в формате заданном в format, необязательное

    • Для формата nonedata может отсутствовать, если присутствует, будет проигнорировано
    • Для формата json — любое значение
    • Для формата raw — любое скалярное значение
    • Для формата form — массив, ключи которого — названия полей:

      data:
        login: "Bob"
        password: $password
        remember_me: 1
    • Для формата multipart — массив такой структуры:

      data:
          user_id:
              value: 42
              headers:
                  X-Baz: "bar"
          avatar:
              file: "/path/to/file"
          photo:
              file: "http://url.to/file"
              filename: "custom_filename.jpg"

    Файлы, указанные в полях file, должны быть доступны для чтения. Если указан URL, то в php.ini должна быть включена опция allow_url_fopen

    before — операторы, которые будут выполнены до HTTP-запроса, необязательное

    after — операторы, которые будут выполнены после HTTP-запроса, необязательное

    Идея блоков before и after в выполнении проверок или обработке каких-либо данных, которые нужны каждый раз до или после выполнения HTTP-запроса, и продиктованы не столько нуждами тестирования, сколько бизнес-логикой. Например, копирование выданного токена авторизации в поле структуры $user для вызова всех последующих методов API от имени этого пользователя. Или для проверки HTTP-статуса ответа, чтоб не проверять его каждый раз после вызова в сценарии.

    Вызов метода API


    Чтобы вызвать метод API в сценарии нужно указать его имя и параметры, если нужно. Вот пример вызова последнего метода API из описания выше:

    Create comment $comments.1 on {$newPost} by {$postAuthor}

    Если параметр заключен в фигурные скобки, он будет передан по значению — так можно передать любое выражение. Если указать параметр без фигурных скобок, он будет передан по ссылке — это могут быть только переменные и статические обращения к элементам массива (через точку, но не через скобки []).

    Create comment {$comments[$i]} on $posts.0 by $users.1
    Read post {123} by $user
    Get comments of $users.1.id {{$page = 2}}

    При каждом вызове метода API в контексте самого вызова (в списках операторов before и after) и в контексте, где, он был вызван, создаются переменные $request и $response. Это зарезервированные имена, не рекомендую использовать их для других целей. $request доступна в обоих блоках before и after, а $response только в after, в before ее значение становится Null. В вызывающем контексте эти переменные доступны до следующего вызова метода API, где они будут заново проинициализированы.

    Структура $request


    $request.method — String — HTTP-метод
    $request.url — String — запрашиваемый URL
    $request.query — Array — список GET-параметров
    $request.headers — Array — список заголовков запроса
    $request.cookies — Array — список cookies
    $reuqest.auth — Array или Null — данные для HTTP-аутентификации
    $request.format — String — формат данных запроса
    $request.data — тип любой — то что было вычислено в поле data

    Структура $response


    $response.network — Boolean — false, если ошибка была на сетевом уровне ниже HTTP
    $response.code — Number или Null — код ответа, например, 200 или 404
    $response.status — String или Null — статус ответа, например, «204 No Content» или «401 Unauthorized»
    $response.headers — Array — список заголовков ответа, имена заголовков приведены к нижнему регистру
    $response.cookies — Array — список cookies
    $response.body — тип любой — тело ответа обработанное как JSON, если была ошибка при парсинге, то элемента body вообще не будет: @response.body == null (о проверке существования переменных)
    $response.raw — String или Null — необработанное тело ответа
    $response.duration — тип Number — длительность запроса в секундах

    Генерация моделей и тестовых данных


    Генераторы служат для описания моделей и генерации по ним тестовых данных. Описания в формате YAML должны быть в файле generators.yaml в рабочей директории и/или файлах *.yaml в поддиректории ./generators.

    User:
        body:
            login: Faker\login()
            name: Faker\name()
            email: Faker\email()
            password: Faker\text(16)
            child: Child()
            birthday: dateFormat(Faker\datetime(), "U")
            settings:
                notifications_enabled: Faker\boolean()
    
    Child:
        body:
            name: Faker\name()
            gender: Faker\integer(1, 2)
            age: Faker\integer(0, 18)
    
    Comment($user):
        body:
            content: "Hi! I'm " + $user.name
            tags:
                - "tag1"
                - "tag2"
    

    В примере выше объявлено три генератора User(), Child() и Comment(). При этом последний имеет аргумент $user и может использовать эти данные при генерации. Аргументы в генераторы всегда передаются по значению. Кроме того, в примере используются еще несколько встроенных функций: Faker\name(), Faker\email(), dateFormat(), и т.д. Раздел о встроенных функциях.

    При вызове генератора User() из примера выше будет сгенерирована структура, которая в JSON выглядит примерно так:

    {
      "login": "fgadrkq",
      "name": "Lucy Cechtelar",
      "email": "tkshlerin@collins.com",
      "password": "gbnaueyaaf",
      "child": {
        "name": "Adaline Reichel",
        "gender": 2,
        "age": 12
      },
      "birthday": 318038400,
      "settings": {
        "notifications_enabled": true
      }
    }
    

    Значением поля child будет результат работы генератора Сhild().

    Как и в описании методов API, любые строки, не заключенные в кавычки, рассматриваются как выражения, которые будут вычислены. Это может быть не только вызов другого генератора, а произвольное выражение, например, в генераторе Comment($user) поле content представляет конкатенацию строки Hi! I'm и имени переданного $user

    Имена генераторов регистронезависимы и должны начинаться с латинской буквы, могут содержать в себе латинские буквы, цифры, подчеркивание и обратный слеш.

    Поскольку синтаксис вызова генераторов и встроенных функций одинаков, они делят общее пространство имен. Обратный слеш я предлагаю по соглашению использовать в качестве разделителя для указания «вендора» или библиотеки встроенных функций, как например функции Faker\something(), основанные на библиотеке github.com/fzaninotto/Faker.

    Нюансы использования генераторов, можно не читать
    При помощи генераторов можно компоновать структуры данных:

    
    # UserСredentials основана на данных из $user
    UserСredentials($user): 
        body:
            login: $user.email
            password: $user.password
    
    # Примерная структура ответа соц. сети на поиск по постам, комментариям и пользователям
    GlobalSearchResult($posts, $comments, $users):
        body:
            posts: 
                title: "Найденные посты"
                list: $posts
            comments: 
                title: "Найденные комментарии"
                list: $comments
            users: 
                title: "Найденные пользователи"
                list: $users
    

    GlobalSearchResult представляет собой не тестовые данные, которые посылаются в запросе к методу API, а модель ответа, которую можно сверить с тем, что пришлет API, например, использованием функций similar() или identical().

    Генератор может изменять структуру получаемую в body с помощью структур вычисляемых в полях replace и remove. Лучше покажу на примере.

    Допустим, уже есть генератор User(), который создает правильную структуру данных для пользователя. Теперь надо проверить как API будет реагировать, если предоставить неправильные данные. Можно пойти двумя путями:

    • Создать генератор «неправильного» пользователя с нуля. Но тогда мы получим дублирование кода, и позже, наример, при добавлении пользователю нового поля по нуждам бизнес-логики, придется вносить правки уже в два места. DRY!
    • Можно «отнаследоваться» от уже имеющейся структуры User(), задав его в body. А в replace и remove задать, поля которые будут добавлены/изменены и удалены.

    
    # Вернет структуру как у переданного пользователя, но изменит два поля, 
    # сделав его невалидным
    InvalidUser($user):
        body: $user 
        replace:
            email: Faker\String(6, 15) # Неавлидный емейл
            password: Faker\String(1, 5) # Слишком короткий пароль
            new_field: "этого поля у правильного пользователя не было, теперь будет"
        remove:
            name: size($user.name) < 10 # Удаляем имя, если оно меньше 10 символов
    
    # А так можно сгенерировать случайного пользователя,
    # а потом изменить или удалить пару  полей
    InvalidNewUser:
        body: User() 
        replace:
            login: "!@#$%^&*" # Невалидный логин
        remove:
            about: true
            settings:
                notifications: 100500 # Неважно какое именно значение будет у поля,
                                      # главное чтоб оно было эквивалентно true      
    

    При работе генератора сначала вычисляется структура данных в body, потом перезаписывается и дополняется элементами из replace и потом удаляются поля указанные в remove, если их значение эквивалентно true. Если результат вычисления body, replace или remove не массивы, то ошибки не будет, но и смысла в этом никакого нет, т.к не будет полей которые можно было бы заменить и удалить.

    Встроенные функции


    Полный список встроенных функций. Для примера приведу только некоторые из них.
    После имени функции и списка аргументов указан тип возвращаемого значения, если он определен.

    Операции с переменными:


    similar($var, $sample, $checkTypes) Boolean — возвращает true, если аргументы имеют одинаковый тип, если $var — массив, то все строковые ключи, которые есть в $sample, должны быть в $var, при этом, если $checkTypes истинно, то типы соответствующих элементов должны совпадать. Другими словами, элементы массива $var являются подмножеством элементов $sample.
    identical($var, $sample, $checkTypes) Boolean — аналог similar(), с дополнительным обратным условием, в случае массивов, все строковые ключи в $var должны быть и в $sample. Другими словами элементы массива $var равны элементам массива $sample с точностью до типа элемента.
    max($var1, $var2,… $varN) — максимальное из переданных значений (если они поддаются сравнению).
    min($var1, $var2,… $varN) — минимальное из переданных значений.
    if($condition, $var1, $var2) — Если $condition == true, то вернет $var1, иначе $var2. Замена тренарному оператору (привет MySQL).
    choice($condition1, $var1, $condition2, $var2, ..., $conditionN, $varN) — Вернет первое встреченное $varK, если $conditionK == true.

    Работа со строками:


    size($string) Number — длина строки в кодировке UTF-8.
    regex($string, $regex) Boolean — проверка строки на регулярное выражение.
    regexMatch($string, $regex) Array — вернет массив строк — совпадений с группами регулярки $regex.

    Обработка массивов:


    array($var1, $var2,… $varN) Array — создает массив из переданных элементов.
    size($array) Number — количество элементов массива.
    keys($array) Array — список ключей массива.
    slice($array, $offset, $length) Array — часть массива от $offset длиной $length, (подробнее).
    append($array, $value) Array — добавить элемент в конец массива.
    prepend($array, $value) Array — добавить элемент в начало массива.

    Обработка дат:


    dateFormat($date, $format) String — форматирование даты, (подробнее про форматы).
    dateModify($date, $format) Date — изменение даты, удобно использовать с Relative Formats.

    Генерация случайных тестовых данных:


    Faker\integer($min, $max) Number — случайное целое от $min до $max включительно
    Faker\ipv4() String — случайный IPv4
    Faker\arrayElement($array) String — случайный элемент из массива
    Faker\name() String — случайное имя
    Faker\email() String — случайный email

    Сейчас встроенных функций не очень много. Я добавил только то, что мне кажется будет необходимым при тестировании. Добавлять новые функции можно по мере необходимости в новых версиях. А в будущем, если будет востребовано, добавлю возможность создавать динамически-подключаемые функции, реализованные в виде специальных классов на PHP.

    Тест-кейсы


    Тест-кейс это последовательность операторов, которая может быть вызвана как единое целое. Некоторый аналог процедуры в языках программирования.

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

    testcase Registration $device
       //Тест на регистрацию валидного пользователя
       var $user = User()
       Register $user on $device
       assert $response.code == 201
    
       //Тест на регистрацию пользователя с неправильной электронной почтой
       var $user = User() // хотя можно было бы сделать генератор InvalidUser()
       var $user.email = "some_bad_email"
       Register $user on $device
       assert $response.code == 400
    

    Оператор run может вызвать отдельный тест-кейс, либо все тест-кейсы, которые не требуют аргументов.

    
    run Get all users     // Запуск отдельного тест-кейса, не требующего аргументов 
    run Get user $user_id // Запуск отдельного тест-кейса с аргументом
    run                   // Запуск всех тест-кейсов, которые не требуют аргументов
    

    Идея такого запуска в том, что тест-кейсы можно использовать как отдельные независимые тесты части бизнес-логики и в качестве процедур для избежания дублирования кода в сложных сценариях тестирования.

    Аргументы в тест-кейс передаются по ссылке или по значению в полной аналогии передачи аргументов в методы API.

    Переменные и области видимости


    Имена переменных регистрозависимы и начинаются со знака $ (да-да, я пхпшник).

    Если переменная типа Array, то доступ к отдельным полям или элементам значения производится через точку: $users.12.password. Между точками допускаются либо только цифры, либо латинские буквы, подчеркивания и цифры с первой латинской буквой. Имена полей тоже регистрозависимы.

    Возможно динамическое обращение к элементу массива: $post.comments[$i + 1].content

    Есть четыре типа контекстов — областей видимости переменных.

    Глобальный контекст — создается на старте, содержит все переменные объявленные при выполнении операторов вне тест-кейсов и вне вызовов методов API.

    Контекст тест-кейса — создается новый при каждом выполнении тест-кейса оператором run.

    Контекст метода API — создается при вызове метода API, при выполнении операторов, указанных в секциях before и after.

    Контекст генератора — в генераторах нет возможности создания новых или изменения существующих переменных, поэтому доступны только для чтения переменные глобального контекста и аргументы. Переменные в контекст генератора передаются всегда по значению.

    Важное замечание. Внутри всех контекстов доступны переменные глобального контекста, если в текущем контексте не созданы их «тезки».

    Примеры операторов работы с переменными:

    const $a = 1
    let $a = 2 // Будет выдано предупреждение
    var $b = 1
    const $b = 3 // Будет выдана ошибка
    const $c = 3; $c = 4 // Будет выдана ошибка, $c уже определно в первой секции 
    

    let $a = 1; $a = $a + 1; $a = $a + 2
    print $a // 4
    

    Testcase Context example changes $argument1, $argument2 and $argument3
       var $a = "changed"
       let $b = "changed"
       const $c = "changed"
       import $i
       let $i = "changed"
       var $argument1 = "changed"
       var $argument2 = "changed"
       var $argument3 = "changed" // предупреждение, по ссылке передали константу
    
    var $a = "original"
    var $b = "original"
    var $i = "original"
    const $c = "original";
    var $paramByRef = "original"
    var $paramByVal = "original"
    const $paramConst = "original"
    
    run Context example changes $paramByRef, {$paramByVal} and $paramConst
    
    // новая $a была создана в контексте тест-кейса
    print $a // "original"
    
    //  $b не было в контексте тест-кейса, и let обратился к глобальной $b
    print $b // "changed"
    
    // $i была импортирована из глобального контекста
    print $i // "original"
    
    // константы создаются в каждом контексте свои, аналогично var создает переменные
    print $c // "original"
    
    // параметр передавался по ссылке
    print $paramByRef // "changed"
    
    // параметр передавался по значению
    print $paramByVal // "original"
    
    // константа была передана по ссылке, но измениться не может
    print $paramConst // "original"
    

    Система типов и операции


    Используется нестрогая динамическая типизация.

    Значения хранятся в «обертках» над соответствующими типами PHP. Для лучшего понимания можно ознакомиться системой типов PHP. Я сделал немного меньше свободы в динамическом преобразовании типов. Например, при сложении строки и числа "2" + 2, будет выдана ошибка, а PHP спокойно выполнит сложение. Возможно мне нужно будет пересмотреть правила динамической типизации в будущем, но пока я попытался найти баланс между удобством и строгостью, необходимой для надежных тестов.

    Типы данных, доступные в PieceofScript:

    Number — число. Из соображений простоты я не стал делать отдельные типы для Integer и Float. Единственное существенное отличие целых и вещественных чисел в PieceofScript — использование в качестве ключей массива: вещественные числа будут округлены до целых.
    7 -42 3.14159

    String — строки, заключенные в двойные кавычки, возможно экранирование слешем
    "I say \"Hello world!\""

    Null — задается регистронезависимой константой
    null

    Boolean — является результатом булевых операций и операций сравнения, задается регистронезависимыми константами
    true false

    Date — дата и время. «Под капотом» это DateTime. Константы задаются в одинарных кавычках, в одном из форматов.
    'now', '2008-08-07 18:11:31', 'last day of next month'

    Array — массив, единственный не скалярный тип. Обертка над Array. Литералов этого типа нет, но массивы могут быть результатом работы генераторов, встроенных функций (например, array() — привет PHP 5.3 и ниже), или можно просто обращаться к ключам переменных, которые будут динамически создаваться при присвоении.

    let $a.1 = 100
    let $i = 1
    let $a[$i + 1] = 200
    let $a.sum = $a.1 + $a.2
    print "Сумма "; $a.sum // Сумма 300
    var $b = array(true, 3, $a, "Hi") // [true, 3, {1: 100, 2: 200, "sum":300}, "Hi"]
    

    При обращении к несуществующей переменной или элементу массива, сценарий тестирования будет остановлен и выдана ошибка. Но при выполнении операторов assert или must, если происходит обращение к несуществующей переменной, ошибки не будет, но проверка будет считаться проваленной.

    Проверка существования и типа переменной


    Отдельно надо упомянуть конструкцию проверки существования и типа переменной @.

    Если в имени переменной вместо $ указать @, то результатом работы этой конструкции будет одно из:

    • строка с именем типа переменной или типа элемента массива, если использовались ключи;
    • null, если переменная не найдена в доступных контекстах или элемента с указанным ключем в массиве не существует.

    Эта конструкция может быть удобна при проверках структуры HTTP-ответов.

    var $a.string_field = "Hello World"
    var $a.number_field = 3.14
    var $a.bool_field = true
    var $a.date_field = '+1 day'
    var $a.null_field = null
    var $a.array_field = array(1, "2")
    
    assert @a.string_field == "String"
    assert @a.number_field == "Number"
    assert @a.bool_field == "Boolean"
    assert @a.date_field == "Date"
    assert @a.null_field == "Null"
    assert @a.array_field  == "Array"
    assert @a.array_field.0  == "Number"
    assert @a.array_field.1  == "String"
    assert @a.array_field.2  == null
    assert @notExistedVar == null
    

    Или в конструкциях типа:

    assert @comment.optional_field && $comment.optional_field > 20

    Булевые операции оптимизированы по первому операнду. Если первый операнд равен false, то операция && даже не будет пытаться вычислить второй операнд. Аналогично с ||.

    Сохранение данных между запусками


    Я использовал отдельные сценарии не только для тестирования после завершения задачи, но и в ходе разработки. Я дополнял и изменял сценарий по мере того как реализовывал фичу. Позже этот сценарий становился основой для написания тест-кейса, но при разработке раз за разом приходилось выполнять одни и те же вызовы API. При этом каждый раз в сценарии создавать новые сущности с нуля (например, регистрация пользователя) было долго, создавало мусор в базе данных и всячески мешало разработке. Поэтому я решил добавить возможность сохранять и восстанавливать значения переменных между запусками в key-value хранилище.

    Сохранение включается опцией командной строки --storage, задающей имя файла-хранилища:

    pos.phar run ./start.pos --storage=storage.yaml

    Данные сохраняются в формате YAML, что позволяет их легко читать и редактировать.

    storage\get(string $key, $defaultValue, boolean $saveValue=true) — если ключ $key не существует или файл-хранилище не задан, возвращает $defaultValue. Иначе возвращает сохраненное значение. Если аргумент $saveValue равен true, а ключ $key не найден, туда будет записано $defaultValue.

    storage\set(string $key, $value) — сохраняет $value с ключом $key, и возвращает $value. Если файл-хранилище не был задан, то просто возвращает $value.

    storage\key(string $regexp=null) Array — возвращает массив всех имеющихся ключей. Если аргумент $regexp не null, то вернутся ключи соответствующие этому регулярному выражению. Если файл-хранилище не был задан, то возвращает пустой массив.

    Вывод в stdout


    PieceofScript умеет формировать отчеты в формате JUnit и HTML. Первый нужен для интеграции с системами CI/CD, например Jenkins. Второй чтобы в удобном виде самому посмотреть результаты тестирования, например, при тестировании локально. Файлы отчетов можно задать при запуске:
    pos.phar run ./start.pos --junit=junit_report.xml --html=report.html
    Пример HTML-отчета

    Различная информация работы интерпретатора выводится в stdout. Есть 5 стандартных уровней вывода информации. Все что выводится на одном уровне, выводится и на остальных более «болтливых».

    Quiet — самый «молчаливый» уровень задается опцией командной строки -q.
    На этом уровне ничего не выводится в stdout, даже критические ошибки интерпретатора. Но по ненулевому коду возврата, можно понять, что что-то пошло не так.

    Normal — это уровень по-умолчанию, без задания опций.
    На этом уровне выводятся ошибки в работе интерпретатора. Ошибочные запросы к методам API и провалившиеся проверки assert и must.

    Verbose — задается опцией -v.
    На этом уровне выводятся результаты работы оператора print.

    Very verbose — задается опцией -vv.
    На этом уровне выводятся предупреждения интерпретатора.

    Debug — задается опцией -vvv.
    На этом уровне выводятся все выполняемые строки сценария. Все запросы и ответы методов API, результаты работы всех проверок assert и must.

    Примеры


    Пословица «Лучше один раз увидеть, чем сто раз услышать» справедлива и в интерпретации «лучше один раз увидеть код, чем сто раз прочитать его описание». Я подготовил и положил примеры в репозиторий https://github.com/maximw/PosExamples.

    Virustotal


    Virustotal.com — сервис проверки вредоносных файлов и ссылок. Документация к API. Тесты сделаны для публичной части API, за исключением методов комментирования, т.к. не хочу мусорить в реальном «боевом» API тестовыми данными.

    Для доступа к API нужно зарегистрироваться, получить ключ и добавить его в файл Virustotal/globals.pos.

    Запуск тестов:

    pos.phar run ./Virustotal/start.pos

    Че там за exe-шник лежит в репозитории?
    Для тестов я скопировал hiddeninput.exe из компонента Console репозитория Symfony. Этот файл можно удалить, а для тестов использовать любой другой размером до 32 мб.

    Pastery


    Аналог Pasebin. Документация к API.
    Для доступа к API нужно зарегистрироваться, получить ключ и добавить его в файл Pastery/globals.pos.

    Запуск тестов:

    pos.phar run ./Pastery/start.pos

    Примечательно, что этими тестами был найден баг в ограничении по количеству просмотров. Он уже исправлен разработчиками Pastery.

    The Rick and Morty


    Думаю, этот мультсериал известен многим, и многими любим. Документация к API. API состоит из трех практически идентичных разделов Character, Location и Episode. Поэтому сценарии почти одинаковые, и достаточно посмотреть тест-кейсы лишь один из разделов.

    Запуск тестов:

    pos.phar run ./RickAndMorty/20MinutesTest.pos

    Если вы знаете публичный API, который было бы интересно протестировать таким образом, напишите, пожалуйста, в личку.

    Замечания и планы на будущее, если оно будет


    0) У меня есть список небольших и больших доработок, которые мне пока не нужны, но могут быть полезны.

    Посмотреть список
    • Добавить отчет о работе в формате Json с возможностью дозаписи после нескольких запусков сценариев
    • Добавить в генераторах возможность использовать зачения из body в блоках replace и remove
    • Добавить возможность для написания своих генераторов прямо в сценариях, если не будет хватать имеющейся гибкости описания в YAML
    • Добавить замеры времени выполнения HTTP-запросов и различных частей сценария, пока эта часть статистики работает не очень
    • Добавить блоки кода аналогичные тесткейсам, но без вызова оператором run без параметров. Фактически процедуры.
    • Добавить в HTML-отчет все что выводится в stdout, как если бы была задана опция -vvv
    • Добавить настройки прокси и верификации серитфикатов для https
    • Добавить возможность посылать файлы в формате application/x-www-form-urlencoded с использованием CURLFile. Я не нашел как это сделать с Guzzle 6, возможно придется отказаться от этой библиотеки
    • Добавить возможность включения и отключения сбора статистики «на лету», так чтоб некоторые части сценария не влияли на результаты тестирования
    • Улучшить привязку ассертов к последнему вызову API, сейчас эта привязка нарушается если произошла смена и возврат в контекст выполнения
    • Переделать HTML-отчет, так как я собрал его на базе бесплатного bootstrap-шаблона «на скорую руку», там много лишнего в коде.

    1) Для валидации моделей в ответах API с использованием генераторов есть пока только две функции — similar() и identical(). Валидация с ними слишком «топорная». Конечно уже сейчас можно валидировать ответы «руками», и в некоторых случаях иначе никак, но хочу сделать это более удобным и, где можно, избежать ручной проверки ответа. Есть некоторые идеи, как сделать возможность и генерировать, и валидировать модели используя одно и то же описание модели, избегая дублирования. Но пока эти идеи не сформировались достаточно, чтоб можно было реализовать их в коде.

    2) Думаю, очень полезным будет возможность скаффолдинга для методов API на основе описания в OpenAPI (Swagger), RAML, коллекций Postman. Но это большая работа, за которую стоит сесть, если PieceofScript того стоит.

    3) Хорошо было бы сделать плагины для некоторых IDE, с подсветкой кода и автодополнением. Автодополнение имен тест-кейсов, методов API, операторов и переменных было бы просто архиудобно. Но еще не «копал» в этом направлении. Разбираюсь с созданием подсветки для Sublime Text и с Language Server Protocol. Буду рад, если найдутся единомышленники уже разбирающиеся в таких вещах.

    4) Не знаю в какой приоритет поставить возможность создания динамически подключаемых функций, реализованных на PHP. С одной стороны там все просто, достаточно разобраться с автозагрузкой и сделать спецификацию используемых классов и неймспейсов. С другой стороны сложные функции со своими зависимостями неминуемо вызовут конфликт неймспейсов у зависимостей (в худшем случае еще и разных версий). Тут тоже есть над чем думать.

    5) Хорошие системы тестирования запускают независимые тесты параллельно. Сейчас это можно сделать, запустив интерпретатор несколько раз с разными стартовыми файлами, где подключаются разные тест-кейсы. Но я думаю, надо встроить это в сам интерпретатор с автоматическим определением того, что может быть запущено параллельно.

    P.S. С одной стороны, поскольку это моя «поделка», логично было бы засунуть пост в хаб «Я пиарюсь». С другой стороны, я не пиарюсь, не преследую какой-либо коммерческой выгоды, просто инструмент который, делал для себя, решил «причесать» и выложить публично.

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

    Хочу знать мнение и тех, кто не может голосовать. Как оно вам?

    • 50,0%Piece of cake4
    • 50,0%Piece of shit4

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

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

    Самое читаемое