Блеск и нищета фреймворков тестирования: используем Groovy для скриптинга end-to-end тестов -1


Меня зовут Андрей, я BigData Java разработчик. В этой статье я расскажу как запускать тесты, написанные на groovy без gradle, maven и даже без предварительной компиляции этих тестов, а также почему JUnit 5 - это намного больше, чем просто хорошая библиотека для unit тестов.

Технологии приходят и уходят. Вне зависимости от внешних факторов, необходимость в разработке остается. Как остается и потребность в тестировании. Не всё можно отдать на откуп unit тестам, нужны end-to-end тесты. Желательно автоматизированные и непрерывные. В свою очередь автоматизация тестирования - это одновременно и сложно, и просто (ведь все разработчики считают, что написание тестов это плевое дело). По этому поводу на хабре недавно вышла замечательная статья Как правильно (не) использовать тестировщиков.

Хорошо, когда тестируется система, которую тестируют многие: например, для Web есть такие монстры как Selenium WebDriver.
Хорошо, когда есть удобное окружение: запустил пару десятков docker образов, и система для теста готова.
Плохо, когда надо тестировать систему, которую не любят тестировать, а удобным окружением и не пахнет.

Предположим, карты сошлись так, что у вас есть:

  • система, для работы с которой есть только библиотеки на Java;

  • "неудобное окружение для тестирования", которое предполагает отсутствие подключения к репозиториям артефактов и невозможность использовать maven/gradle для компиляции и сборки, но имеется возможность задеплоить тестовые артефакты по scp и подключиться по ssh.

Вроде всё просто:

  1. разрабатываем тестовый сценарий и реализуем его на любимом фреймворке (JUnit, Spock, TestNG, да хоть класс c psvm);

  2. собираем код для тестов на CI сервере;

  3. развертываем в тестовую среду;

  4. запускаем;

  5. собираем результаты.

Кажется никаких проблем, но начнем с самого первого шага. Предположим, у нас уже есть тестовый сценарий, и мы хотим его слегка поменять, прогнать и посмотреть, что получится. Или вообще пишем первый сценарий и хотим поэкспериментировать. Пару циклов "написать", "собрать", "задеплоить", "запустить" и вот уже пропадает всякое желание писать новый тесткейс. Тут и появляется мысль: а может можно скриптами? Немного vim или nano и код теста можно изменить прямо в консоле тестовой среды.

Размышления о скриптах в JVM рано или поздно приводят к Groovy. В свою очередь, в Groovy заложена важность выполнения тестов. Можете почитать об этом прямо в документации. Groovy cli умеет запускать тесты, написанные для различных библиотек из коробки.

Возьмем для примера тест, проверяющий доступность google.com, написанный для Junit Jupiter.

class SampleJunit5 {
    @org.junit.jupiter.api.Test
    void pingGoogleInJunit5() {
        def address = InetAddress.getByName("google.com")
        println "Pinging google from junit5 test now..."
        assert address.isReachable(60000)
    }
}
Настройка окружения для запуска примеров

При написании статьи использовалась версия Groovy 3.0.10.
Если интересно попробовать дальнейшее самим - установите Groovy, следуя инструкциями.
Или скопируйте репозиторий с примерами из GitHub, ветка habr-sample-tests-groovy:

git clone --branch  habr-sample-tests-groovy git@github.com:alopukhov/habr-samples.git
cd habr-samples
./gradlew setupWorkspace
cd workspace
export GROOVY_HOME="${PWD}/groovy-3.0.10/"
export PATH="$GROOVY_HOME/bin:$PATH"

Настроив окружение, запустим тест командой groovy SampleJunit5.groovy и получим примерно такой выход (если у вас конечно доступен google.com):

Pinging google from junit5 test now...
JUnit5 launcher: passed=1, aborted=0, failed=0, skipped=0, time=109ms

Ожидаемая ситуация и с непроходящим тестом:

class FailingTest {
  @org.junit.jupiter.api.Test
  void thisWillFail() { assert 2 * 2 == 5 }

  @org.junit.jupiter.api.Test
  void thisWillPass() { assert 2 * 2 == 4 }
}

Запускаем groovy FailingTest.groovy и получаем:

JUnit5 launcher: passed=1, aborted=0, failed=1, skipped=0, time=75ms

Failures (1):
  JUnit Jupiter:FailingTest:thisWillFail()
    MethodSource [className = 'FailingTest', methodName = 'thisWillFail', methodParameterTypes = '']
    => Assertion failed:

assert 2 * 2 == 5
         |   |
         4   false

       org.codehaus.groovy.runtime.InvokerHelper.assertFailed(InvokerHelper.java:436)
... и еще сотни строк стектрейса ...

Схожим образом можно запустить и код для других библиотек.

Junit 4
class SampleJunit4 {
    @org.junit.Test
    void pingGoogleInJunit4() {
        def address = InetAddress.getByName("google.com")
        println "Pinging google from junit4 test now..."
        assert address.isReachable(60000)
    }
}
> groovy SampleJunit4.groovy
Pinging google from junit4 test now...
JUnit 4 Runner, Tests: 1, Failures: 0, Time: 73

Spock
class SampleSpockSpeck extends spock.lang.Specification {
    def "ping google in Spock"() {
        expect:
        println "Pinging google from spock now"
        InetAddress.getByName("google.com").isReachable(60000)
    }
}

Для запуска уже нужна библиотека Spock - она не входит в поставку Groovy.
Версия должна быть совместимой с версией Groovy. Подробнее можно узнать на сайте spockframework.org.

> groovy -cp ./libs/spock-core-2.1-groovy-3.0.jar SampleSpockSpeck.groovy
Pinging google from spock now
JUnit5 launcher: passed=1, aborted=0, failed=0, skipped=0, time=97ms

Для spock 1.x потребуется версия groovy 2.5.x.
Существенное различие в том, что начиная с версии 2, spock использует для запуска junit platform, в то время как раньше использовался Junit4 runner Sputnik.

TestNG
class SampleTestNG {
    @org.testng.annotations.Test
    void pingGoogleInTestNG() {
        def address = InetAddress.getByName("google.com")
        println "Pinging google now from testng"
        assert address.isReachable(60000)
    }
	
	@org.testng.annotations.Test
	void failInTestNg() { assert false }
}

Для запуска желательно наличие slf4j логгера, например slf4j-simple.

> groovy -cp ./libs/slf4j-simple-1.7.36.jar SampleTestNG.groovy

[main] INFO org.testng.internal.Utils - [TestNG] Running:
  Command line suite

Pinging google now from testng

===============================================
Command line suite
Total tests run: 2, Passes: 1, Failures: 1, Skips: 0
===============================================

Увы, нет даже имени упавшего теста. Вероятно, можно настроить testng на более детальный вывод. Надеюсь, в комментариях подскажут как.

Отвечают за такую возможность различные реализации GroovyRunner. Так что если есть желание запускать тесты для нетипичного фреймворка - можно написать свою реализацию.
Вывод при таком запуске в целом небогатый: summary по запущенным методам и информация о падениях. Имена запущенных тестов не отображаются. А при запуске testng можно не рассчитывать даже на имена упавших тестовых методов.

Однако такие тесты - курам на смех.
Давайте посмотрим, как обстоят дела с запуском тестов со вспомогательными классами.
Создадим 2 файла:

//content of Calculator.groovy
class Calculator {
    static long multiply(long a, long b) { a * b }
}

//content of SampleWithHelper.groovy
class SampleWithHelper {
    @org.junit.jupiter.api.Test
    void mathTest() { assert 4 == Calculator.multiply(2, 2)}
}

Вроде работает:

> groovy SampleWithHelper.groovy
groovy SampleWithHelper.groovy
JUnit5 launcher: passed=1, aborted=0, failed=0, skipped=0, time=71ms

Но если запустить с другим working directory:

> groovy ./workspace/SampleWithHelper.groovy
JUnit5 launcher: passed=0, aborted=0, failed=1, skipped=0, time=70ms

Failures (1):
  JUnit Jupiter:SampleWithHelper:mathTest()
    MethodSource [className = 'SampleWithHelper', methodName = 'mathTest', methodParameterTypes = '']
    => groovy.lang.MissingPropertyException: No such property: Calculator for class: SampleWithHelper
       org.codehaus.groovy.runtime.ScriptBytecodeAdapter.unwrap(ScriptBytecodeAdapter.java:65)
... и еще сотни строк стектрейса ...

Проблема в отсутствующем классе Calculator. Если изменим SampleWithHelper следующим образом:

import static Calculator.multiply

class SampleWithHelper {
    @org.junit.jupiter.api.Test
    void mathTest() { assert 4 == multiply(2, 2)}
}

Получим более понятное сообщение об ошибке:

> groovy ./workspace/SampleWithHelper.groovy
org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed:
C:\Users\andre\IdeaProjects\habr_scripting_tests_example\workspace\SampleWithHelper.groovy: 1: unable to resolve class Calculator
 @ line 1, column 1.
   import static Calculator.multiply
   ^

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

> groovy -cp ./workspace ./workspace/SampleWithHelper.groovy
JUnit5 launcher: passed=1, aborted=0, failed=0, skipped=0, time=75ms

Итак, по одному тесты запускать научились. Осталось запускать скопом.

И тут на помощь приходит JUnit platform. JUnit 5 не просто новое удобное API, но целая платформа для запуска тестов от разных фреймворков.
Немного библиотек и мы можем составить набор тестов для запуска при помощи Suite API:

import org.junit.platform.suite.api.*

@Suite
@SuiteDisplayName("Sample Suite for Habr")
@SelectClasses([SampleJunit4, SampleJunit5, SampleTestNG, SampleSpockSpeck])
class SuiteTest {
}
> groovy -cp "libs/*" ./SuiteTest.groovy
Pinging google from junit5 test now...
Pinging google from spock now
Pinging google now from testng
Pinging google from junit4 test now...
JUnit5 launcher: passed=4, aborted=0, failed=1, skipped=0, time=226ms

Failures (1):
  JUnit Platform Suite:Sample Suite for Habr:TestNG:SampleTestNG:failInTestNg
    MethodSource [className = 'SampleTestNG', methodName = 'failInTestNg', methodParameterTypes = '']
    => Assertion failed:
... more...

N.B. При указании wildcard classpath важно, чтобы символ * не был раскрыт как glob pattern. В Windows просто используйте groovy.bat для запуска команд и оборачивайте пути в двоеточия.

Теперь все тесты запустились при помощи JUnit5 launcher, вдобавок даже стало видно, какой метод упал в TestNG.

Но можно лучше. В JUnit Platform есть модуль junit-platform-console для запуска тестов из командной строки.
Простой скрипт:

//content of RunJunitConsole.groovy
org.junit.platform.console.ConsoleLauncher.main(args)

И теперь можно получить куда более детальный вывод:

> groovy -cp "libs/*" RunJunitConsole.groovy --select-class SuiteTest --disable-banner --disable-ansi-colors
Pinging google from junit4 test now...
Pinging google from junit5 test now...
Pinging google from spock now
Pinging google now from testng
.
+-- JUnit Jupiter [OK]
+-- JUnit Platform Suite [OK]
| '-- Sample Suite for Habr [OK]
|   +-- JUnit Vintage [OK]
|   | '-- SampleJunit4 [OK]
|   |   '-- pingGoogleInJunit4 [OK]
|   +-- JUnit Jupiter [OK]
|   | '-- SampleJunit5 [OK]
|   |   '-- pingGoogleInJunit5() [OK]
|   +-- Spock [OK]
|   | '-- SampleSpockSpeck [OK]
|   |   '-- ping google in Spock [OK]
|   '-- TestNG [OK]
|     '-- SampleTestNG [OK]
|       +-- failInTestNg [X] assert false
|       '-- pingGoogleInTestNG [OK]
+-- JUnit Vintage [OK]
+-- Spock [OK]
'-- TestNG [OK]

Failures (1):
  JUnit Platform Suite:Sample Suite for Habr:TestNG:SampleTestNG:failInTestNg
    MethodSource [className = 'SampleTestNG', methodName = 'failInTestNg', methodParameterTypes = '']

... a bit of stack trace ...

Test run finished after 229 ms
[        14 containers found      ]
[         0 containers skipped    ]
[        14 containers started    ]
[         0 containers aborted    ]
[        14 containers successful ]
[         0 containers failed     ]
[         5 tests found           ]
[         0 tests skipped         ]
[         5 tests started         ]
[         0 tests aborted         ]
[         4 tests successful      ]
[         1 tests failed          ]

Можно запускать несколько тестов без использования Suite:

> groovy -cp "libs/*" RunJunitConsole.groovy --disable-banner --disable-ansi-colors -e junit-jupiter -c SampleJunit5 -c FailingTest
Pinging google from junit5 test now...
.
'-- JUnit Jupiter [OK]
  +-- SampleJunit5 [OK]
  | '-- pingGoogleInJunit5() [OK]
  '-- FailingTest [OK]
    +-- thisWillFail() [X] assert 2 * 2 == 5
    |              |   |
    |              4   false
    '-- thisWillPass() [OK]

... failure details ...

Но самое главное можно указать директорию --reports-dir, куда будут сгенерированы отчеты в JUnit xml формате. Эти отчеты далее можно собрать для отображения.

Единственная незадача - все имена тестов нужно указывать явно. Classpath и package scanning недоступны, т.к. существующие утилиты ожидают наличие файлов с классами.

Однако, немного поработав напильником, можно написать свой TestEngine.
Так, с некоторыми оговорками получилось написать Sybok Test Engine, позволяющий использовать поиск тестов в директории и запускать тесты без помощи полного дистрибутива groovy.

> java -cp ".\libs\*;.\sybok-libs\*" org.junit.platform.console.ConsoleLauncher --disable-banner --disable-ansi-colors -e sybok-engine --config sybok.script-roots=. --config sybok.delegate-engine-ids=spock,junit-jupiter,junit-vintage -d . -n ".*"
Pinging google from spock now
Pinging google from junit4 test now...
Pinging google from junit5 test now...
.
'-- Sybok [OK]
  +-- Spock [OK]
  | '-- SampleSpockSpeck [OK]
  |   '-- ping google in Spock [OK]
  +-- JUnit Vintage [OK]
  | '-- SampleJunit4 [OK]
  |   '-- pingGoogleInJunit4 [OK]
  '-- JUnit Jupiter [OK]
    +-- SampleJunit5 [OK]
    | '-- pingGoogleInJunit5() [OK]
    +-- FailingTest [OK]
    | +-- thisWillFail() [X] assert 2 * 2 == 5
    | |              |   |
    | |              4   false
    | '-- thisWillPass() [OK]
    '-- SampleWithHelper [OK]
      '-- mathTest() [OK]
	  
... failure details ...

Он (пока) не может запускать TestNG классы, но думаю это поправимо.

А если еще немного постараться, можно будет заставить запускаться тесты, написанные на другом скриптовом языке под JVM. Например, Kotlin script.

Надеюсь, статья была полезной. Расскажите в комментариях: что бы вы использовали для запуска тестов на JVM?




Комментарии (5):

  1. aleksandy
    /#24353454 / +2

    Что-то я не понял, maven/gradle, значит, поставить и использовать нельзя, а groovy - пожалуйста. Какие-то странные условия, не находите?

    • alopukhov
      /#24354340

      Условия странные, но они бывают.

      Самое простое: может отсутствовать jdk (в наличии только jre).

      Может отсутствовать доступ к репозиториями и соответственно придется настривать maven/gradle на использование локальных библиотек.

      Кто-то может посчитать, mvn/gradle просто лишним слоем абстракции для запуска тестов. Для запуска с лихвой хватает junit-platform-console.

      Сводится все в принципе к одному: по той или иной причине хочется scripting для описания тестов на "jvm compatible" языке.

      "Устанавливать" groovy (как дистрибутив) не обязательно - можно например добавить пару библиотек в classpath и использовать custom test engine, как я показал в последнем примере.

  2. aleksandy
    /#24355258 / +1

    Условия странные, но они бывают.

    Это не условия странные, а прямо таки эталонная реализация принципа "Зачем просто, когда можно сложно?".

    Сводится все в принципе к одному: по той или иной причине хочется scripting для описания тестов на "jvm compatible" языке.

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

    • alopukhov
      /#24355632

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

      Так я об этом и писал) Вот оно:

      "...Предположим, у нас уже есть тестовый сценарий, и мы хотим его слегка поменять, прогнать и посмотреть, что получится. Или вообще пишем первый сценарий и хотим поэкспериментировать... Немного vim или nano и код теста можно изменить прямо в консоли тестовой среды."

      Это не условия странные, а прямо таки эталонная реализация принципа "Зачем просто, когда можно сложно?"

      А в чем сложность? Я как раз хотел показать насколько просто использовать для тестирования код написанный на Groovy без его предварительной компиляции. А если еще и через junit console, так вообще красота.

      Увы, условия какие есть - их не изменить. Буду очень признателен, если подскажите простое решение для прототипирования/экспериментов, кроме "автоматизировать процесс сборки, deploy'я и запуска кода еще не находящегося в scm".

      • alopukhov
        /#24355742

        Давайте еще чуть уточню условия из статьи: есть удаленный хост под управлением Linux. Взаимодействие с объектом тестирования возможно только с этого хоста, только при помощи java библиотек. На хосте нет ни maven, ни gradle. Нет доступа до интернета, репозиториев с артефактами и gradle distribution. Нельзя поднять proxy или ssh туннель до рабочей среды разработчика/тестировщика. Всё во имя безопасной безопасности. Есть только доступ по ssh, scp для копирования файлов и возможность запускать произвольные программы под JVM.