Тестирование в Scala с помощью ScalaTest и определение степени покрытия кода тестами

6 июля 2015

Несмотря на то, что Scala является языком со строгой статической типизацией, что устраняет множество ошибок в коде еще на этапе компиляции, это не отменяет необходимости писать тесты. Ведь нормально проверить логику работы вашего приложения могут только тесты. Среди великого множества тестовых фреймворков для Scala довольно большой популярностью пользуется ScalaTest. С ним мы сегодня и познакомимся.

Помните, мы писали простой REST-сервис на Finagle? Внимательные читатели могли обратить внимание, что в репозитории с проектом также имеются и тесты, проверяющие работу сервиса, обращаясь к нему через Finagle’овский HTTP клиент.

Так к проекту подключается ScalaTest:

"org.scalatest" %% "scalatest" % "2.2.4" % "test"

Обратите внимание на часть % "test". Она говорит, что пакет нужен только для выполнения тестов, и что его не нужно включать в само приложение.

А вот так выглядит один из тестов:

package me.eax.finagle_example.tests

import com.twitter.finagle.{Http, Service}
import com.twitter.util.Await
import me.eax.finagle_example.FinagleServiceExample
import org.jboss.netty.buffer.ChannelBuffers
import org.jboss.netty.handler.codec.http._
import org.jboss.netty.util.CharsetUtil

import org.scalatest._

class RestSpec extends FunSpec with Matchers {
  val service = new FinagleServiceExample
  val server = Http.serve(":8080", service)
  val client: Service[HttpRequest, HttpResponse] = {
    Http.newService("localhost:8080")
  }

  describe("Server") {
    // ... skipped ...

    it("returns 'bad request' for bad requests") {
      val request = {
        new DefaultHttpRequest(
          HttpVersion.HTTP_1_1,
          HttpMethod.PUT,
          "/some-key"
        )
      }
      val response = Await.result(client(request))
      response.getStatus shouldBe HttpResponseStatus.BAD_REQUEST
    }
  }
}

На что тут нужно обратить внимание:

import org.scalatest._

Подключаем ScalaTest.

class RestSpec extends FunSpec with Matchers {

Набор тестов — это обычный класс, в который подмешивается несколько трейтов из ScalaTest. В ScalaTest предусмотрено больше одного DSL для написания тестов (в их терминологии — testing styles). В рамках данной заметки мы сосредоточим свое внимание на стиле FunSpec, хотя вам, возможно, больше по вкусу придется один из других стилей. Трейт Matchers позволяет использовать в тестах комбинаторы вроде shouldBe вместо обычного assert. Как вы сможете скоро убедиться, благодаря Matchers код тестов действительно становится более читаемым.

describe("Server") {
  it("does something") {
    // ...
  }

  it("does something else") {
    // ...
  }
}

При использовании FunSpec тесты организуются в иерархическую структуру из describe-блоков и вложенных it-блоков. Кроме того, внутри describe’ов могут быть и другие describe’ы.

response.getStatus shouldBe HttpResponseStatus.BAD_REQUEST

Фактически, выражение x shouldBe y эквивалентно assert(x == y). Кроме того, предусмотрен ряд других полезных комбинаторов. Рассмотрим некоторые из них.

something should be >= 0
something should be > 0

Проверка больше, меньше, больше или равно, и так далее.

something should not be empty

Проверка, что коллекция не пустая — лучше, чем проверять .size.

something should contain alpha
something should not contain beta

Убеждаемся, что коллекция содержит или не содержит заданный элемент.

something shouldBe a[SomeType]

Проверка, что переменная хранит определенный тип.

name should matchPattern { case Name("Alex", _, _) => }

Паттерн матчинг.

val eps = 0.00001
double1 shouldBe double2 +- eps

Сравнение значений с точностью до заданной эпсилон.

an[Exception] should be thrownBy {
  // ...
}

Проверяем, что выражение бросает указанное исключение.

the[ArithmeticException] thrownBy 1 / 0 should have message "/ by zero"

val foo = intercept[Exception] {
  // ...
}
foo.bar shouldBe baz

Проверяем содержимое пойманного исключения.

import org.scalatest.concurrent._

class MySpec extends FunSpec with Matchers with Eventually {
  describe("aaa") {
    it("bbb") {
      eventually {
        // ...
      }

      eventually(timeout(2.seconds)) {
        // ...
      }
    }
  }
}

Проверка, что какое-то условие будет выполнено в конечном счете. Можно указать время ожидания, а также через какие интервалы производить проверки. Очень удобно, когда приложение делает что-то в фоне и неизвестно точно, когда пользователю станет доступен результат. Не правда ли, этот вариант будет получше, чем Thread.sleep(1000)?

Если вы делаете какой-то сложный рефакторинг, из-за которого некий тест постоянно падает, можно временно его отключить:

describe("Server") {
  ignore("does something") { // FIXME!
    // ...
  }
}

Главное — чтобы такое по ошибке не попадало в основную ветку, потому что потом времени исправить тесты ни у кого не находится!

Пример запуска тестов из консоли:

$ sbt test
... skipped ...
[info] RestSpec:
[info] Server
[info] - returns 'not found' for non-exiting key
[info] - saves, returns and deletes values
[info] - returns 'bad request' for bad requests
[info] Run completed in 1 second, 203 milliseconds.
[info] Total number of tests run: 3
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 3, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.

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

$ sbt 'test-only me.eax.finagle_example.tests.RestSpec'

Можно даже запустить не всю спеку, а только отдельный тест из нее:

$ sbt test:console
scala> import me.eax.finagle_example.tests._
scala> val spec = new RestSpec;
scala> spec.execute(
     | testName = "Server returns 'bad request' for bad requests"
     | )
RestSpec:
Server
- returns 'bad request' for bad requests

Или даже множество тестов, у которых имя начинается одинаково:

scala> spec.execute(testName = "Server returns")
RestSpec:
Server
- returns 'not found' for non-exiting key
- returns 'bad request' for bad requests

Почти то же самое прямо из bash:

sbt 'testOnly *RestSpec -- -z saves'

Запускает все тесты, у которых имя где угодно (то есть, не только в начале) содержит «saves».

Бывает удобно параметризовать тесты через консоль. Например, указать путь до каких-нибудь тестовый данных на диске, переключиться между тестированием на реальной базе данных и моком, и так далее. Это можно сделать, перегрузив метод runTests:

protected override def runTests(name: Option[String], args: Args) = {
  val useRealDB = args.configMap
                    .getOrElse("db.use.real", "false")
                    .asInstanceOf[String]
  // ...
}

Передача аргументов почему-то работает только через test-only, не через test:

./sbt "test-only me.eax.* -- -Ddb.use.real=true"

В IntelliJ IDEA эти параметры можно прописать в Run → Edit Configurations → Configuration → Test options.

С помощью плагина:

addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.1.0")

… можно померить степень покрытия кода тестами:

$ sbt coverage test

Плагин пишет отчеты о покрытии в форматах HTML и XML, а также выводит степень покрытия в консоль:

[info] Coverage reports completed
[info] All done. Coverage was [73.91%]

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

ScoverageSbtPlugin.ScoverageKeys.coverageMinimum := 73

ScoverageSbtPlugin.ScoverageKeys.coverageFailOnMinimum := true

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

Лишние пакеты и классы при желании можно заэксклудить:

coverageExcludedPackages := """me.eax.package1.Ololo;me.eax.package2"""

При работе со ScalaTest было замечено, что IntelliJ IDEA по умолчанию запускает тесты последовательно, один за одним, в то время, как SBT запускает все тесты параллельно. В результате тесты нормально выполняются при запуске из IDE и падают в TeamCity. Если вы столкнетесь с этой проблемой, допишите в build.sbt:

parallelExecution in Test := false

Многие возможности ScalaTest, к сожалению, выходят за рамки данной заметки. Как уже отмечалось, фреймворк поддерживает множество DSL. Также в нем есть тэги, фикстуры, поддержка property-based тестов и много чего еще. Подробности можно найти в ScalaTest User Guide. В частности, про матчеры более подробно рассказывается здесь. В случае возникновения проблем со ScalaTest всегда можно попросить помощи в их списке рассылки.

Метки: , , .


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