Разделы портала

Онлайн-тренинги

.
Руководство: Cucumber + Java
08.11.2017 12:19

Автор: Сурин Анатолий, ведущий инженер по тестированию АО «СберТех»

Оригинальная публикация: https://habrahabr.ru/post/332754/

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

В данной статье мы рассмотрим один из самых популярных фреймворков для автоматизации тестирования с использованием BDD-подхода – Cucumber. Также посмотрим, как он работает и какие средства предоставляет.

Первоначально Cucumber был разработан Ruby-сообществом, но со временем был адаптирован и для других популярных языков программирования. В данной статье рассмотрим работу Cucumber на языке Java.

Gherkin

BDD тесты – это простой текст, на человеческом языке, написанный в форме истории (сценария), описывающей некоторое поведение.

В Cucumber для написания тестов используется Gherkin-нотация, которая определяет структуру теста и набор ключевых слов. Тест записывается в файл с расширением *.feature и может содержать как один, так и более сценариев.

Рассмотрим пример теста на русском языке с использованием Gherkin:

# language: ru
@all
Функция: Аутентификация банковской карты
  Банкомат должен спросить у пользователя PIN-код банковской карты
  Банкомат должен выдать предупреждение, если пользователь ввел неправильный PIN-код
  Аутентификация успешна, если пользователь ввел правильный PIN-код

  Предыстория:
    Допустим пользователь вставляет в банкомат банковскую карту
    И банкомат выдает сообщение о необходимости ввода PIN-кода

  @correct
  Сценарий: Успешная аутентификация
    Если пользователь вводит корректный PIN-код
    То банкомат отображает меню и количество доступных денег на счету

  @fail
  Сценарий: Некорректная аутентификация
    Если пользователь вводит некорректный PIN-код
    То банкомат выдает сообщение, что введённый PIN-код неверный


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

Обратите внимание на структуру сценария:

1. Получить начальное состояние системы;
2. Что-то сделать;
3. Получить новое состояние системы.

В примере жирным выделены ключевые слова. Ниже представлен полный список ключевых слов на русском языке:

  1. Дано, Допустим, Пусть – используются для описания предварительного, ранее известного состояния;
  2. Когда, Если – используются для описания ключевых действий;
  3. И, К тому же, Также – используются для описания дополнительных предусловий или действий;
  4. Тогда, То – используются для описания ожидаемого результата выполненного действия;
  5. Но, А – используются для описания дополнительного ожидаемого результата;
  6. Функция, Функционал, Свойство – используется для именования и описания тестируемого функционала. Описание может быть многострочным;
  7. Сценарий – используется для обозначения сценария;
  8. Предыстория, Контекст – используется для описания действий, выполняемых перед каждым сценарием в файле;
  9. Структура сценария, Примеры – используется для создания шаблона сценария и таблицы параметров, передаваемых в него.

Ключевые слова, перечисленные в пунктах 1-5, используются для описания шагов сценария, Cucumber их технически не различает. Вместо них можно использовать символ *, но делать так не рекомендуется. У этих слов есть определенная цель, и они были выбраны именно для неё.

Список зарезервированных символов:

# – обозначает комментарии;
@ – тэгирует сценарии или функционал;
| – разделяет данные в табличном формате;
""" – обрамляет многострочные данные.

Сценарий начинается со строки # language: ru. Эта строчка указывает Cucumber, что в сценарии используется русский язык. Если её не указать, фреймворк, встретив в сценарии русский текст, выбросит исключение LexingError и тест не запустится. По умолчанию используется английский язык.

Простой проект

Cucumber-проект состоит из двух частей – это текстовые файлы с описанием сценариев (*.feature) и файлы с реализацией шагов на языке программирования (в нашем случае — файлы *.java).

Для создания проекта будем использовать систему автоматизации сборки проектов Apache Maven.
Первым делом добавим cucumber в зависимости Maven:

<dependency>
    <groupId>info.cukes</groupId>
    <artifactId>cucumber-java</artifactId>
    <version>1.2.4</version>
</dependency>


Для запуска тестов будем использовать JUnit (возможен запуск через TestNG), для этого добавим еще две зависимости:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
</dependency>
<dependency>
    <groupId>info.cukes</groupId>
    <artifactId>cucumber-junit</artifactId>
    <version>1.2.4</version>
</dependency>


Библиотека cucumber-junit содержит класс cucumber.api.junit.Cucumber, который позволяет запускать тесты, используя JUnit аннотацию RunWith. Класс, указанный в этой аннотации, определяет каким образом запускать тесты.

Создадим класс, который будет являться точкой входа для наших тестов.

import cucumber.api.CucumberOptions;
import cucumber.api.SnippetType;
import cucumber.api.junit.Cucumber;
import org.junit.runner.RunWith;

@RunWith(Cucumber.class)
@CucumberOptions(
        features = "src/test/features",
        glue = "ru.savkk.test",
        tags = "@all",
        dryRun = false,
        strict = false,
        snippets = SnippetType.UNDERSCORE,
//        name = "^Успешное|Успешная.*"
)
public class RunnerTest {
}


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

Рассмотрим опции Cucumber:

  1. features – путь к папке с .feature файлами. Фреймворк будет искать файлы в этой и во всех дочерних папках. Можно указать несколько папок, например: features = {«src/test/features», «src/test/feat»};
  2. glue – пакет, в котором находятся классы с реализацией шагов и «хуков». Можно указать несколько пакетов, например, так: glue = {«ru.savkk.test», «ru.savkk.hooks»};
  3. tags – фильтр запускаемых тестов по тэгам. Список тэгов можно перечислить через запятую. Символ ~ исключает тест из списка запускаемых тестов, например ~@fail;
  4. dryRun – если true, то сразу после запуска теста фреймворк проверяет, все ли шаги теста разработаны, если нет, то выдает предупреждение. При false предупреждение будет выдаваться по достижении неразработанного шага. По умолчанию false.
  5. strict – если true, то при встрече неразработанного шага тест остановится с ошибкой. False — неразработанные шаги пропускаются. По умолчанию false.
  6. snippets – указывает в каком формате фреймворк будет предлагать шаблон для нереализованных шагов. Доступны значения: SnippetType.CAMELCASE, SnippetType.UNDERSCORE.
  7. name – фильтрует запускаемые тесты по названиям удовлетворяющим регулярному выражению.


Для фильтрации запускаемых тестов нельзя одновременно использовать опции tags и name.

Создание «фичи»

В папке src/test/features создадим файл с описание тестируемого функционала. Опишем два простых сценария снятия денег со счета — успешный и провальный.

# language: ru
@withdrawal
Функция: Снятие денег со счета

  @success
  Сценарий: Успешное снятие денег со счета
    Дано на счете пользователя имеется 120000 рублей
    Когда пользователь снимает со счета 20000 рублей
    Тогда на счете пользователя имеется 100000 рублей

  @fail
  Сценарий: Снятие денег со счета - недостаточно денег
    Дано на счете пользователя имеется 100 рублей
    Когда пользователь снимает со счета 120 рублей
    Тогда появляется предупреждение "На счете недостаточно денег"


Запускаем

Попробуем запустить RunnerTest со следующими настройками:

@RunWith(Cucumber.class)
@CucumberOptions(
        features = "src/test/features",
        glue = "ru.savkk.test",
        tags = "@withdrawal",
        snippets = SnippetType.CAMELCASE
)
public class RunnerTest {
}


В консоль появился результат прохождения теста:

Undefined scenarios:
test.feature:6 # Сценарий: Успешное снятие денег со счета
test.feature:12 # Сценарий: Снятие денег со счета - недостаточно денег

2 Scenarios (2 undefined)
6 Steps (6 undefined)
0m0,000s

You can implement missing steps with the snippets below:

@Дано("^на счете пользователя имеется (\\d+) рублей$")
public void наСчетеПользователяИмеетсяРублей(int arg1) throws Throwable {
    // Write code here that turns the phrase above into concrete actions
    throw new PendingException();
}

@Когда("^пользователь снимает со счета (\\d+) рублей$")
public void пользовательСнимаетСоСчетаРублей(int arg1) throws Throwable {
    // Write code here that turns the phrase above into concrete actions
    throw new PendingException();
}

@Тогда("^появляется предупреждение \"([^\"]*)\"$")
public void появляетсяПредупреждение(String arg1) throws Throwable {
    // Write code here that turns the phrase above into concrete actions
    throw new PendingException();
}


Cucumber не нашел реализацию шагов и предложил свои шаблоны для разработки.
Создадим класс MyStepdefs в пакете ru.savkk.test и перенесем в него методы, предложенные фреймворком:

import cucumber.api.PendingException;
import cucumber.api.java.ru.*;

public class MyStepdefs {

    @Дано("^на счете пользователя имеется (\\d+) рублей$")
    public void наСчетеПользователяИмеетсяРублей(int arg1) throws Throwable {
        // Write code here that turns the phrase above into concrete actions
        throw new PendingException();
    }

    @Когда("^пользователь снимает со счета (\\d+) рублей$")
    public void пользовательСнимаетСоСчетаРублей(int arg1) throws Throwable {
        // Write code here that turns the phrase above into concrete actions
        throw new PendingException();
    }

    @Тогда("^появляется предупреждение \"([^\"]*)\"$")
    public void появляетсяПредупреждение(String arg1) throws Throwable {
        // Write code here that turns the phrase above into concrete actions
        throw new PendingException();
    }
}


При запуске теста Cucumber проходит по сценарию шаг за шагом. Взяв шаг, он отделяет ключевое слово от описания шага и пытается найти в Java-классах пакета указанного в опции glue аннотацию с регулярным выражением, подходящим описанию. Найдя совпадение, фреймворк вызывает метод с найденной аннотацией. Если несколько регулярных выражений удовлетворяют описанию шага, фреймворк выбрасывает ошибку.

Как было сказано выше, для Cucumber технически нет отличия в ключевых словах, описывающих шаги, это верно и для аннотации, например:

@Когда("^пользователь снимает со счета (\\d+) рублей$")


и

@Тогда("^пользователь снимает со счета (\\d+) рублей$")


для фреймворка являются одинаковыми.

То, что в регулярных выражениях записано в скобках передается в метод в виде аргумента. Фреймворк самостоятельно определяет, что необходимо передавать из сценария в метод в виде аргумента. Это числа — (\\d+). И текст, экранированный в кавычки — \"([^\"]*)\". Это самые распространённые из передаваемых аргументов.

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

Выражение
Описание
Соответствие
.
Один любой символ (за исключение
переноса строки)
Ф
2
j
.*
0 или больше любых символов
(за исключением переноса строки)
Abracadabra
789-160-87


,
.+
Один или больше любых символов
(за исключением переноса строки)
Все, что относилось к
предыдущему, за исключением пустой
строки.
.{2}
Любые два символа (за
исключением переноса строки)
Фф
22

JJ
.{1,3}
От одного до трех любых
символов (за исключением переноса
строки)
Жжж
Уу
!
^
Якорь начала строки
^aaa соответствует aaa
^aaa соответствует aaabbb
^aaa не соответствует bbbaaa
$
Якорь конца строки
aaa$ соответствует aaa
aaa$ не соответствует aaabbb
aaa$ соответствует bbbaaa
\d*
[0-9]*
Любое число (или ничего)
12321

5323
\d+
[0-9]+
Любое число
Все, что относилось к
предыдущему, за исключением пустой
строки.
\w*
Любая буква, цифра или нижнее
подчеркивание (или ничего)
_we
_1ee
Gfd4
\s
Пробел, табуляция или перенос
строки
\t, \r
или \n
"[^\"]*"
Любой символ (или ничего) в
кавычках
"aaa"
""
"3213dsa"
?
Делает символ или группу
символов необязательными
abc?
соответствует ab
или abc, но не b или bc
|
Логическое ИЛИ
aaa|bbb
соответствует aaa
или bbb, но не aaabbb
()
Группа. В Cucumber
группа передается в определение шага
в виде аргумента.
(\d+) рублей соответствует 10 рублей,
при этом 10 передается в метод шага в
виде аргумента
(?: )
Не передаваемая группа.
Cucumber не воспринимает
группу как аргумент.
(\d+) (?:рублей|рубля) соответствует 3
рубля, при этом 3 передается в метод,
а «рубля» - нет.

Передача коллекций в аргументы

Часто возникает ситуация, когда из сценария в метод необходимо передать набор однотипных данных – коллекций. Для подобной задачи в Cucumber есть несколько решений:

  1. Фреймворк по умолчанию оборачивает данные, перечисленные через запятую, в ArrayList:

    Дано в меню доступны пункты Файл, Редактировать, О программе

    @Дано("^в меню доступны пункты (.*)$")
    public void вМенюДоступныПункты(List<String> arg) {
        // что-то сделать
    }

    Для замены разделителя, можно воспользоваться аннотацией Delimiter:

    Дано в меню доступны пункты Файл и Редактировать и О программе

    @Дано("^в меню доступны пункты (.+)$")
    public void вМенюДоступныПункты(@Delimiter(" и ") List<String> arg) {
        // что-то сделать
    }

  2. Данные, записанные в виде таблицы с одной колонкой, Cucumber также может обернуть в ArrayList:

    Дано в меню доступны пункты
      | Файл          |
      | Редактировать |
      | О программе   |

    @Дано("^в меню доступны пункты$")
    public void вМенюДоступныПункты(List<String> arg) {
        // что-то сделать
    }

  3. Данные, записанные в таблицу с двумя колонками, Cucumber может обернуть в ассоциативный массив, где данные из первой колонки – это ключ, а из второй – данные:

    Дано в меню доступны пункты
      | Файл          | true  |
      | Редактировать | false |
      | О программе   | true  |

    public void вМенюДоступныПункты(Map<String, Boolean> arg) {
        // что-то сделать
    }

  4. Передача данных в виде таблицы с большим количеством колонок возможна двумя способами:

    • DataTable

      Дано в меню доступны пункты
        | Файл          | true  | 5 |
        | Редактировать | false | 8 |
        | О программе   | true  | 2 |

      @Дано("^в меню доступны пункты$")
      public void вМенюДоступныПункты(DataTable arg) {
          // что-то сделать
      }

      DataTable – это класс, который эмулирует табличное представление данных. Для доступа к данным в нем имеется большое количество методов. Рассмотрим некоторые из них:

      public <K,V> List<Map<K,V>> asMaps(Class<K> keyType,Class<V> valueType)

      Конвертирует таблицу в список ассоциативных массивов. Первая строка таблицы используется для именования ключей, остальные как значения:

      Дано в меню доступны пункты
        | Название      | Доступен | Количество подменю |
        | Файл          | true     | 5                  |
        | Редактировать | false    | 8                  |
        | О программе   | true     | 2                  |

      @Дано("^в меню доступны пункты$")
      public void вМенюДоступныПункты(DataTable arg) {
          List<Map<String, String>> table = arg.asMaps(String.class, String.class);
          System.out.println(table.get(0).get("Название"));
          System.out.println(table.get(1).get("Название"));
          System.out.println(table.get(2).get("Название"));
      }

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

      Файл
      Редактировать
      О программе


      public <T> List<List<T>> asLists(Class<T> itemType)

      Метод преобразует таблицу в список списков:

      Дано в меню доступны пункты
        | Файл          | true  | 5 |
        | Редактировать | false | 8 |
        | О программе   | true  | 2 |

      @Дано("^в меню доступны пункты$")
      public void вМенюДоступныПункты(DataTable arg) {
          List<List<String>> table = arg.asLists(String.class);
          System.out.print(table.get(0).get(0) + " ");
          System.out.print(table.get(0).get(1) + " ");
          System.out.println(table.get(0).get(2) + " ");
      
          System.out.print(table.get(1).get(0) + " ");
          System.out.print(table.get(1).get(1) + " ");
          System.out.println(table.get(1).get(2) + " ");
      }

      На консоль будет выведено:

      Файл true 5
      Редактировать false 8


      public List<List<String>> cells(int firstRow)

      Этот метод делает то же, что и предыдущий метод, за исключением того, что нельзя определить какого типа данные находятся в таблице, всегда возвращает список строк – List. В качестве аргумента метод принимает номер первой строки:

      Дано в меню доступны пункты
        | Файл          | true  | 5 |
        | Редактировать | false | 8 |
        | О программе   | true  | 2 |

      @Дано("^в меню доступны пункты$")
      public void вМенюДоступныПункты(DataTable arg) {
          List<List<String>> table = arg.cells(1);
          System.out.print(table.get(0).get(0) + " ");
          System.out.print(table.get(0).get(1) + " ");
          System.out.println(table.get(0).get(2) + " ");
      
          System.out.print(table.get(1).get(0) + " ");
          System.out.print(table.get(1).get(1) + " ");
          System.out.println(table.get(1).get(2) + " ");
      }

      Метод выведет на консоль:

      Редактировать false 8
      О программе true 2


    • Class
      Cucumber может создать объекты из табличных данных, переданных из сценария. Существует два способа это сделать.

      Создадим для примера класс Menu:

      public class Menu {
          private String title;
          private boolean isAvailable;
          private int subMenuCount;
      
          public String getTitle() {
              return title;
          }
      
          public boolean getAvailable() {
              return isAvailable;
          }
      
          public int getSubMenuCount() {
              return subMenuCount;
          }
      }

      Для первого способа шаг в сценарии запишем в следующем виде:

      Дано в меню доступны пункты
        | title         | isAvailable | subMenuCount |
        | Файл          | true        | 5            |
        | Редактировать | false       | 8            |
        | О программе   | true        | 2            |

      Реализация:

      @Дано("^в меню доступны пункты$")
      public void вМенюДоступныПункты(List<Menu> arg) {
          for (int i = 0; i < arg.size(); i++) {
              System.out.print(arg.get(i).getTitle() + " ");
              System.out.print(Boolean.toString(arg.get(i).getAvailable()) + " ");
              System.out.println(Integer.toString(arg.get(i).getSubMenuCount()));
          }
      }

      Вывод в консоль:

      Файл true 5
      Редактировать false 8
      О программе true 2


      Фреймворк создает связанный список объектов из таблицы с тремя колонками. В первой строке таблицы должны быть указаны наименования полей класса, создаваемого объекта. Если какое-то поле не указать, оно не будет инициализировано.

      Для второго способа приведем шаг сценария к следующему виду:

      Дано в меню доступны пункты
        | title        | Файл | Редактировать | О программе |
        | isAvailable  | true | false         | true        |
        | subMenuCount | 5    | 8             | 2           |

      А в аргументе описания шага используем аннотацию @Transpose.

      @Дано("^в меню доступны пункты$")
      public void вМенюДоступныПункты(@Transpose List<Menu> arg) {
          // что-то сделать
      }

      Cucumber, как и в предыдущем примере, создаст связанный список объектов, но, в данном случае, наименования полей записывается в первой колонке таблицы.

  5. Многострочные аргументы

    Для передачи многострочных данных в аргумент метода, их необходимо экранировать тремя двойными кавычками:

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

    Данные в метод приходят в виде объекта класса String:

    @Тогда("^отображается форма с текстом$")
    public void отображаетсяФормаСТекстом(String expectedText) {
        // что-то сделать
    }

Date

Фреймворк самостоятельно приводит данные из сценария к типу данных, указанному в аргументе метода. Если это невозможно, то выбрасывает исключение ConversionException. Это справедливо и для классов Date и Calendar. Рассмотрим пример:

Дано дата создания документа 04.05.2017


@Дано("^дата создания документа (.+)$")
public void датаСозданияДокумента (Date arg) {
    // что-то сделать
}


Все прекрасно сработало, Cucumber преобразовал 04.05.2017 в объект класса Date со значением «Thu May 04 00:00:00 EET 2017».

Рассмотрим еще один пример:

Дано дата создания документа 04-05-2017


@Дано("^дата создания документа (.+)$")
public void датаСозданияДокумента (Date arg) {
    // что-то сделать
}


Дойдя до этого шага, Cucumber выбросил исключение:

cucumber.deps.com.thoughtworks.xstream.converters.ConversionException: Couldn't convert "04-05-2017" to an instance of: [class java.util.Date]


Почему первый пример сработал, а второй нет?

Дело в том, что в Cucumber встроена поддержка форматов дат чувствительных к текущей локали. Если необходимо записать дату в формате, отличающемся от формата текущей локали, нужно использовать аннотацию Format:

Дано дата создания документа 04-05-2017


@Дано("^дата создания документа (.+)$")
public void датаСозданияДокумента (@Format("dd-MM-yyyy") Date arg) {
    // что-то сделать
}


Структура сценария


Бывают случаи, когда необходимо запустить тест несколько раз с различным набором данных, в таких случая на помощь приходит конструкция «Структура сценария»:

# language: ru
@withdrawal
Функция: Снятие денег со счета

  @success
  Структура сценария: Успешное снятие денег со счета
    Дано на счете пользователя имеется <изначально> рублей
    Когда пользователь снимает со счета <снято> рублей
    Тогда на счете пользователя имеется <осталось> рублей

    Примеры:
      | изначально | снято | осталось |
      | 10000      | 1     | 9999     |
      | 9999       | 9999  | 0        |


Суть данной конструкции заключается в том, что в места, обозначенные символами <>, вставляются данные из таблицы Примеры. Тест будет запускаться поочередно для каждой строки из данной таблицы. Названия колонок должно совпадать с названием мест вставки данных.

Использование хуков

Cucumber поддерживает хуки (hooks) – методы, запускаемые до или после сценария. Для их обозначения используется аннотация Before и After. Класс с хуками должен находиться в пакете, указанном в опциях фреймворка. Пример класса с хуками:

import cucumber.api.java.After;
import cucumber.api.java.Before;

public class Hooks {
    @Before
    public void prepareData() {
        //подготовить данные
    }

    @After
    public void clearData() {
        //очистить данные
    }
}


Метод c аннотацией Before будет запускаться перед каждым сценарием, After – после.

Порядок выполнения

Хукам можно задать порядок, в котором они будут выполняться. Для этого необходимо в аннотации указать параметр order. По умолчанию значение order равно 10000.

Для Before чем меньше это значение, тем раньше выполнится метод:

@Before(order = 10)
public void connectToServer() {
    //подключиться к серверу
}

@Before(order = 20)
public void prepareData() {
    //подготовить данные
}


В данном примере первым выполнится метод connectToServer(), затем prepareData().

After отрабатывает в обратном порядке.

Тэгирование

В параметре value можно указать тэги сценариев, для которых будут отрабатывать хуки. Символ ~ означает «за исключением». Пример:

@Before(value = "@correct", order = 30)
public void connectToServer() {
    //сделай что-нибудь
}

@Before(value = "~@fail", order = 20)
public void prepareData() {
    //сделай что-нибудь
}


Метод connectToServer будет выполнен для всех сценариев с тэгом correct, метод prepareData для всех сценариев за исключением сценариев с тэгом fail.

Scenario class

Если в определении метода-хука в аргументе указать объект класса Scenario, то в данном методе можно будет узнать много полезной информации о запущенном сценарии, например:

@After
public void getScenarioInfo(Scenario scenario) {
    System.out.println(scenario.getId());
    System.out.println(scenario.getName());
    System.out.println(scenario.getStatus());
    System.out.println(scenario.isFailed());
    System.out.println(scenario.getSourceTagNames());
}


Для сценария:

# language: ru
@all
Функция: Аутентификация банковской карты
  Банкомат должен спросить у пользователя PIN-код банковской карты
  Банкомат должен выдать предупреждение если пользователь ввел неправильный PIN-код
  Аутентификация успешна если пользователь ввел правильный PIN-код

  Предыстория:
    Допустим пользователь вставляет в банкомат банковскую карту
    И банкомат выдает сообщение о необходимости ввода PIN-кода

  @correct
  Сценарий: Успешная аутентификация
    Если пользователь вводит корректный PIN-код
    То банкомат отображает меню и количество доступных денег на счету


Выведет в консоль:

аутентификация-банковской-карты;успешная-аутентификация
Успешная аутентификация
passed
false
[@correct, @all]


В заключение

Cucumber — очень мощный и гибкий фреймворк, который можно использовать в связке со многими другими популярными инструментами. Например, с Selenium – фреймворком для автоматизации веб-приложений, или Yandex.Allure – библиотекой, позволяющей создавать удобные отчеты.
Всем удачи в автоматизации.

Обсудить в форуме