Настройка тестирования интеграции SBT

Я хотел бы добавить этап тестирования интеграции в мое приложение SBT + Spray.

В идеале это было бы точно так же, как Maven, со следующими фазами:

  • compile: приложение построено
  • test: выполняются модульные тесты
  • pre-integration-test: приложение запускается в отдельном процессе
  • integration-test: выполняются тесты интеграции; они выдают запросы к приложению, работающему в фоновом режиме, и проверяют, что возвращаются правильные результаты.
  • post-integration-test: экземпляр ранее запущенного приложения отключен

У меня много проблем с тем, чтобы это работало. Есть ли обработанный пример, которым я могу следовать?

1) Разделите "it" codebase:

Я начал с добавления кода, показанного в "Тестирование интеграции" в документах SBT, в новый файл в project/Build.scala.

Это позволило мне добавить некоторые интеграционные тесты в разделе "src/it/ scala" и запустить их с помощью "sbt it: test", но я не вижу, как добавить тэг pre-integration-test.

Вопрос " "Задача" повторного запуска" автоматически запускается до него: test ", похоже, относится к тому, как настроить такой крючок, но ответ doesn ' t для меня (см. мой комментарий там).

Кроме того, добавление вышеуказанного кода в мою сборку build.scala остановило задачу "sbt re-start" от работы вообще: она пытается запустить приложение в режиме "it", а не в режиме "по умолчанию".

2) Тесты интеграции в тестовой кодовой базе:

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

Я попытался вставить вместо этого код из Дополнительные тестовые конфигурации с общими источниками" из документов SBT, но я получаю ошибку компиляции

[error] E:\Work\myproject\project\Build.scala:14: not found: value testOptions
[error]         testOptions in Test := Seq(Tests.Filter(unitFilter)),

Есть ли обработанный пример, который я могу выполнить?

Я рассматриваю возможность отказаться от настройки этого метода через SBT и вместо этого добавить тестовый флаг, чтобы пометить тесты как "интеграцию" и написать внешний script для обработки этого.

Ответ 1

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

  • Я обнаружил, что преобразование моего файла build.sbt в project/Build.scala фиксировало большую часть ошибок компиляции (и сделало ошибки компиляции в целом намного легче исправить, поскольку IntelliJ мог бы помочь намного легче).

  • Самый лучший способ, который я мог найти для запуска приложения в фоновом процессе, - использовать sbt-start-script и вызвать этот script в новом процессе.

  • Убивание фонового процесса было очень трудным для Windows.

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

Соответствующий код из project/Build.scala:

object MyApp extends Build {
  import Dependencies._

  lazy val project = Project("MyApp", file("."))

    // Functional test setup.
    // See http://www.scala-sbt.org/release/docs/Detailed-Topics/Testing#additional-test-configurations-with-shared-sources
    .configs(FunctionalTest)
    .settings(inConfig(FunctionalTest)(Defaults.testTasks) : _*)
    .settings(
      testOptions in Test := Seq(Tests.Filter(unitTestFilter)),
      testOptions in FunctionalTest := Seq(
        Tests.Filter(functionalTestFilter),
        Tests.Setup(FunctionalTestHelper.launchApp _),
        Tests.Cleanup(FunctionalTestHelper.shutdownApp _)),

      // We ask SBT to run 'startScriptForJar' before the functional tests,
      // since the app is run in the background using that script
      test in FunctionalTest <<= (test in FunctionalTest).dependsOn(startScriptForJar in Compile)
    )
    // (other irrelvant ".settings" calls omitted here...)


  lazy val FunctionalTest = config("functional") extend(Test)

  def functionalTestFilter(name: String): Boolean = name endsWith "FuncSpec"
  def unitTestFilter(name: String): Boolean = !functionalTestFilter(name)
}

Этот вспомогательный код находится в project/FunctionTestHelper.scala:

import java.net.URL
import scala.concurrent.{TimeoutException, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
import scala.sys.process._

/**
 * Utility methods to help with the FunctionalTest phase of the build
 */
object FunctionalTestHelper {

  /**
   * The local port on which the test app should be hosted.
   */
  val port = "8070"
  val appUrl = new URL("http://localhost:" + port)

  var processAndExitVal: (Process, Future[Int]) = null

  /**
   * Unfortunately a few things here behave differently on Windows
   */
  val isWindows = System.getProperty("os.name").startsWith("Windows")

  /**
   * Starts the app in a background process and waits for it to boot up
   */
  def launchApp(): Unit = {

    if (canConnectTo(appUrl)) {
      throw new IllegalStateException(
        "There is already a service running at " + appUrl)
    }

    val appJavaOpts =
      s"-Dspray.can.server.port=$port " +
      s"-Dmyapp.integrationTests.itMode=true " +
      s"-Dmyapp.externalServiceRootUrl=http://localhost:$port"
    val javaOptsName = if (isWindows) "JOPTS" else "JAVA_OPTS"
    val startFile = if (isWindows) "start.bat" else "start"

    // Launch the app, wait for it to come online
    val process: Process = Process(
      "./target/" + startFile,
      None,
      javaOptsName -> appJavaOpts)
        .run()
    processAndExitVal = (process, Future(process.exitValue()))

    // We add the port on which we launched the app to the System properties
    // for the current process.
    // The functional tests about to run in this process will notice this
    // when they load their config just before they try to connect to the app.
    System.setProperty("myapp.integrationTests.appPort", port)

    // poll until either the app has exited early or we can connect to the
    // app, or timeout
    waitUntilTrue(20.seconds) {
      if (processAndExitVal._2.isCompleted) {
        throw new IllegalStateException("The functional test target app has exited.")
      }
      canConnectTo(appUrl)
    }
  }

  /**
   * Forcibly terminates the process started in 'launchApp'
   */
  def shutdownApp(): Unit = {
    println("Closing the functional test target app")
    if (isWindows)
      shutdownAppOnWindows()
    else
      processAndExitVal._1.destroy()
  }

  /**
   * Java processes on Windows do not respond properly to
   * "destroy()", perhaps because they do not listen to WM_CLOSE messages
   *
   * Also there is no easy way to obtain their PID:
   * http://stackoverflow.com/info/4750470/how-to-get-pid-of-process-ive-just-started-within-java-program
   * http://stackoverflow.com/info/801609/java-processbuilder-process-destroy-not-killing-child-processes-in-winxp
   *
   * http://support.microsoft.com/kb/178893
   * http://stackoverflow.com/info/14952948/kill-jvm-not-forcibly-from-command-line-in-windows-7
   */
  private def shutdownAppOnWindows(): Unit = {
    // Find the PID of the server process via netstat
    val netstat = "netstat -ano".!!

    val m = s"(?m)^  TCP    127.0.0.1:${port}.* (\\d+)$$".r.findFirstMatchIn(netstat)

    if (m.isEmpty) {
      println("FunctionalTestHelper: Unable to shut down app -- perhaps it did not start?")
    } else {
      val pid = m.get.group(1).toInt
      s"taskkill /f /pid $pid".!
    }
  }

  /**
   * True if a connection could be made to the given URL
   */
  def canConnectTo(url: URL): Boolean = {
    try {
      url.openConnection()
        .getInputStream()
        .close()
      true
    } catch {
      case _:Exception => false
    }
  }

  /**
   * Polls the given action until it returns true, or throws a TimeoutException
   * if it does not do so within 'timeout'
   */
  def waitUntilTrue(timeout: Duration)(action: => Boolean): Unit = {
    val startTimeMillis = System.currentTimeMillis()
    while (!action) {
      if ((System.currentTimeMillis() - startTimeMillis).millis > timeout) {
        throw new TimeoutException()
      }
    }
  }
}