Проблема с тестированием базы данных Slick/Postgres в Play2/Specs2

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

У меня есть несколько моделей данных, которые находятся в базе данных Postgres и сопоставлены с классами case с помощью Slick. Затем приложение My Play предоставляет конечные точки REST на основе JSON для указанных моделей данных. Поскольку большая часть фактического кода схожа между каждой конечной точкой, большая часть кода реализована как трейт, который смешивается с фактическими контроллерами, которые переопределяют необходимые биты.

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

[error] Can't find a constructor for class helpers.DatabaseHelper
[warn] c.z.h.HikariConfig - The jdbcConnectionTest property is now deprecated, see the documentation for connectionTestQuery
[error]
[error] cannot create an instance for class FileControllerSpec
[error]   caused by java.sql.SQLTransientConnectionException: db - Connection is not available, request timed out after 1005ms.
[error]   caused by org.postgresql.util.PSQLException: FATAL: remaining connection slots are reserved for non-replication superuser connections
[error]
[error] STACKTRACE
[error]   sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
[error]   sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
[error]   sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
[error]   java.lang.reflect.Constructor.newInstance(Constructor.java:423)
[error]   org.specs2.reflect.Classes$$anonfun$org$specs2$reflect$Classes$$createInstanceForConstructor$1.apply(Classes.scala:104)
[error]   org.specs2.control.ActionT$$anonfun$safe$1.apply(ActionT.scala:88)
[error]   org.specs2.control.ActionT$$anonfun$reader$1$$anonfun$apply$6.apply(ActionT.scala:79)
[error]   org.specs2.control.Status$.safe(Status.scala:100)
[error]   org.specs2.control.StatusT$$anonfun$safe$1.apply(StatusT.scala:62)
[error]   org.specs2.control.StatusT$$anonfun$safe$1.apply(StatusT.scala:62)
[error]   scalaz.syntax.ToApplicativeOps$$anon$1.self$lzycompute(ApplicativeSyntax.scala:29)
[error]   scalaz.syntax.ToApplicativeOps$$anon$1.self(ApplicativeSyntax.scala:29)
[error]   scalaz.syntax.ToApplicativeOps$ApplicativeIdV$$anonfun$point$1.apply(ApplicativeSyntax.scala:33)
[error]   scalaz.WriterTApplicative$$anonfun$point$1.apply(WriterT.scala:282)
[error]   scalaz.WriterTApplicative$$anonfun$point$1.apply(WriterT.scala:282)
[error]   scalaz.effect.IO$$anonfun$apply$19$$anonfun$apply$20.apply(IO.scala:136)
[error]   scalaz.effect.IO$$anonfun$apply$19$$anonfun$apply$20.apply(IO.scala:136)
[error]   scalaz.FreeFunctions$$anonfun$return_$1.apply(Free.scala:326)
[error]   scalaz.FreeFunctions$$anonfun$return_$1.apply(Free.scala:326)
[error]   scalaz.std.FunctionInstances$$anon$1$$anonfun$map$1.apply(Function.scala:56)
[error]   scalaz.Free$$anonfun$run$1.apply(Free.scala:172)
[error]   scalaz.Free$$anonfun$run$1.apply(Free.scala:172)
[error]   scalaz.Free.go2$1(Free.scala:119)
[error]   scalaz.Free.go(Free.scala:122)
[error]   scalaz.Free.run(Free.scala:172)
[error]   scalaz.effect.IO$class.unsafePerformIO(IO.scala:22)
[error]   scalaz.effect.IOFunctions$$anon$6.unsafePerformIO(IO.scala:227)
[error]   org.specs2.reflect.Classes$$anonfun$createInstance$1$$anonfun$apply$1$$anonfun$3.apply(Classes.scala:37)
[error]   org.specs2.reflect.Classes$$anonfun$createInstance$1$$anonfun$apply$1$$anonfun$3.apply(Classes.scala:36)
[error]   scala.collection.immutable.List.map(List.scala:273)
[error]   org.specs2.reflect.Classes$$anonfun$createInstance$1$$anonfun$apply$1.apply(Classes.scala:36)
[error]   org.specs2.reflect.Classes$$anonfun$createInstance$1$$anonfun$apply$1.apply(Classes.scala:29)
[error]   scala.Function1$$anonfun$andThen$1.apply(Function1.scala:52)
[error]   org.specs2.control.Status$class.fold(Status.scala:30)
[error]   org.specs2.control.Ok.fold(Status.scala:95)
[error]   org.specs2.control.Status$class.flatMap(Status.scala:48)
[error]   org.specs2.control.Ok.flatMap(Status.scala:95)
[error]   org.specs2.control.Status$class.map(Status.scala:45)
[error]   org.specs2.control.Ok.map(Status.scala:95)
[error]   org.specs2.control.StatusT$$anonfun$map$1.apply(StatusT.scala:16)
[error]   org.specs2.control.StatusT$$anonfun$map$1.apply(StatusT.scala:16)
[error]   scalaz.WriterT$$anonfun$map$1.apply(WriterT.scala:46)
[error]   scalaz.WriterT$$anonfun$map$1.apply(WriterT.scala:46)
[error]   scalaz.effect.IO$$anonfun$map$1$$anonfun$apply$8.apply(IO.scala:56)
[error]   scalaz.effect.IO$$anonfun$map$1$$anonfun$apply$8.apply(IO.scala:55)
[error]   scalaz.Free$$anonfun$map$1.apply(Free.scala:52)
[error]   scalaz.Free$$anonfun$map$1.apply(Free.scala:52)
[error]   scalaz.Free$$anonfun$flatMap$1$$anonfun$apply$1.apply(Free.scala:60)
[error]   scalaz.Free$$anonfun$flatMap$1$$anonfun$apply$1.apply(Free.scala:60)
[error]   scalaz.Free.resume(Free.scala:72)
[error]   scalaz.Free.go2$1(Free.scala:118)
[error]   scalaz.Free.go(Free.scala:122)
[error]   scalaz.Free.run(Free.scala:172)
[error]   scalaz.effect.IO$class.unsafePerformIO(IO.scala:22)
[error]   scalaz.effect.IOFunctions$$anon$6.unsafePerformIO(IO.scala:227)
[error]   org.specs2.runner.SbtRunner$$anonfun$newTask$1$$anon$4.execute(SbtRunner.scala:37)
[error]   sbt.ForkMain$Run$2.call(ForkMain.java:294)
[error]   sbt.ForkMain$Run$2.call(ForkMain.java:284)
[error]   java.util.concurrent.FutureTask.run(FutureTask.java:266)
[error]   java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
[error]   java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
[error]   java.lang.Thread.run(Thread.java:745)

DatabaseHelper — это объект, который устанавливает мои тестовые данные.

Уменьшение количества спецификаций приводит к исчезновению ошибки, поэтому я знаю, что проблема не в этой конкретной спецификации теста.

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

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

Я думаю, что происходит то, что все мои тестовые спецификации инициализируются одновременно, что означает, что все они пытаются одновременно подключиться к базе данных, и поэтому у нее заканчиваются соединения.

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

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

Большое спасибо.


Прецедент:

@RunWith(classOf[JUnitRunner])
class FileControllerSpec extends GenericControllerSpec {
  sequential

  override val componentName: String = "FileController"
  override val uriRoot: String = "/file"

  override def testParsedJsonObject(checkdata: JsLookupResult, parsed_test_json: JsValue) = {
    val object_keys = Seq("filepath","user","ctime","mtime","atime")
    val object_keys_int = Seq("storage","version")

    object_keys.map(key=>
      (checkdata \ key).as[String] must equalTo((parsed_test_json \ key).as[String])
    ) ++ object_keys_int.map(key=>
      (checkdata \ key).as[Int] must equalTo((parsed_test_json \ key).as[Int])
    )
  }

  override val testGetId: Int = 3
  override val testGetDocument: String = """{"filepath":"/path/to/a/video.mxf","storage":1,"user":"me","version":1,"ctime":"1970-01-01T04:25:45.678+0100","mtime":"1970-01-01T04:25:45.678+0100","atime":"1970-01-01T04:25:45.678+0100"}"""
  override val testCreateDocument: String =  """{"filepath":"/path/to/some/other.project","storage":1,"user":"test","version":3,"ctime":"2017-03-17T13:51:00.123+0000","mtime":"2017-03-17T13:51:00.123+0000","atime":"2017-03-17T13:51:00.123+0000"}"""
  override val minimumNewRecordId = 3
  override val testDeleteId: Int = 2
  override val testConflictId: Int = -1
}

Общая спецификация контроллера:

@RunWith(classOf[JUnitRunner])
trait GenericControllerSpec extends Specification with BeforeAfterAll {
  //can over-ride bindings here. see https://www.playframework.com/documentation/2.5.x/ScalaTestingWithGuice
  val application:Application = new GuiceApplicationBuilder()
    .overrides(bind[DatabaseConfigProvider].to[TestDatabase.testDbProvider])
    .build

  val injector:Injector = new GuiceApplicationBuilder()
      .overrides(bind[DatabaseConfigProvider].to[TestDatabase.testDbProvider])
      .injector()

  def inject[T : ClassTag]: T = injector.instanceOf[T]

  //needed for body.consumeData
  implicit val system = ActorSystem("storage-controller-spec")
  implicit val materializer = ActorMaterializer()

  protected val databaseHelper:DatabaseHelper = inject[DatabaseHelper]

  val logger: Logger = Logger(this.getClass)

  override def beforeAll(): Unit ={
    logger.warn(">>>> before all <<<<")
    val theFuture = databaseHelper.setUpDB().map({
      case Success(result)=>logger.info("DB setup successful")
      case Failure(error)=>logger.error(s"DB setup failed: $error")
    })

    Await.result(theFuture, 10.seconds)
  }

  override def afterAll(): Unit ={
    logger.warn("<<<< after all >>>>")
    Await.result(databaseHelper.teardownDB(), 10.seconds)
  }

  val componentName:String
  val uriRoot:String

  def testParsedJsonObject(checkdata:JsLookupResult,test_parsed_json:JsValue):Seq[MatchResult[Any]]

  val testGetId:Int
  val testGetDocument:String
  val testCreateDocument:String
  val testDeleteId:Int
  val testConflictId:Int
  val minimumNewRecordId:Int

  def bodyAsJsonFuture(response:Future[play.api.mvc.Result]) = response.flatMap(result=>
    result.body.consumeData.map(contentBytes=> {
      logger.debug(contentBytes.decodeString("UTF-8"))
      Json.parse(contentBytes.decodeString("UTF-8"))
    }
    )
  )

  componentName should {

    "return 400 on a bad request" in {
      logger.debug(s"$uriRoot/boum")
      val response = route(application,FakeRequest(GET, s"$uriRoot/boum")).get
      status(response) must equalTo(BAD_REQUEST)
    }

    "return valid data for a valid record" in  {
      logger.warn(s"Test URL is $uriRoot/1")
      val response:Future[play.api.mvc.Result] = route(application, FakeRequest(GET, s"$uriRoot/1")).get

      status(response) must equalTo(OK)
      val jsondata = Await.result(bodyAsJsonFuture(response), 5.seconds).as[JsValue]
      (jsondata \ "status").as[String] must equalTo("ok")
      (jsondata \ "result" \ "id").as[Int] must equalTo(1)
      testParsedJsonObject(jsondata \ "result", Json.parse(testGetDocument))
    }

    "accept new data to create a new record" in {
      val response = route(application, FakeRequest(
        method="PUT",
        uri=uriRoot,
        headers=FakeHeaders(Seq(("Content-Type", "application/json"))),
        body=testCreateDocument)
      ).get

      status(response) must equalTo(OK)
      val jsondata = Await.result(bodyAsJsonFuture(response), 5.seconds).as[JsValue]
      (jsondata \ "status").as[String] must equalTo("ok")
      (jsondata \ "detail").as[String] must equalTo("added")
      (jsondata \ "id").as[Int] must greaterThanOrEqualTo(minimumNewRecordId) //if we re-run the tests without blanking the database explicitly this goes up

      val newRecordId = (jsondata \ "id").as[Int]
      val checkResponse = route(application, FakeRequest(GET, s"$uriRoot/$newRecordId")).get
      val checkdata = Await.result(bodyAsJsonFuture(checkResponse), 5.seconds)


      (checkdata \ "status").as[String] must equalTo("ok")
      (checkdata \ "result" \ "id").as[Int] must equalTo(newRecordId)
      testParsedJsonObject(checkdata \ "result", Json.parse(testCreateDocument))
    }

    "delete a record" in {
      val response = route(application, FakeRequest(
        method="DELETE",
        uri=s"$uriRoot/$testDeleteId",
        headers=FakeHeaders(),
        body="")
      ).get

      status(response) must equalTo(OK)
      val jsondata = Await.result(bodyAsJsonFuture(response), 5.seconds).as[JsValue]
      (jsondata \ "status").as[String] must equalTo("ok")
      (jsondata \ "detail").as[String] must equalTo("deleted")
      (jsondata \ "id").as[Int] must equalTo(testDeleteId)
    }

    "return conflict (409) if attempting to delete something with sub-objects" in {
      val response = route(application, FakeRequest(
        method = "DELETE",
        uri = s"$uriRoot/$testConflictId",
        headers = FakeHeaders(),
        body = "")
      ).get

      status(response) must equalTo(CONFLICT)
      val jsondata = Await.result(bodyAsJsonFuture(response), 5.seconds).as[JsValue]
      (jsondata \ "status").as[String] must equalTo("error")
      (jsondata \ "detail").as[String] must equalTo("This is still referenced by sub-objects")
    }
  }
}

Помощник по базе данных:

class DatabaseHelper @Inject()(configuration: Configuration, dbConfigProvider: DatabaseConfigProvider) {

  private val dbConfig = dbConfigProvider.get[JdbcProfile]
  private val logger: Logger = Logger(this.getClass)

  def setUpDB():Future[Try[Unit]] = {
    logger.warn("In setUpDB")
    dbConfig.db.run(
      DBIO.seq(
        (TableQuery[FileAssociationRow].schema ++
          TableQuery[FileEntryRow].schema ++
          TableQuery[ProjectEntryRow].schema ++
          TableQuery[ProjectTemplateRow].schema ++
          TableQuery[ProjectTypeRow].schema ++
          TableQuery[StorageEntryRow].schema
        ).create,
        TableQuery[StorageEntryRow] += StorageEntry(None,None,"filesystem",Some("me"),None,None,None),
        TableQuery[StorageEntryRow] += StorageEntry(None,None,"omms",Some("you"),None,None,None),
        TableQuery[FileEntryRow] += FileEntry(None,"/path/to/a/video.mxf",1,"me",1,new Timestamp(12345678),new Timestamp(12345678),new Timestamp(12345678)),
        TableQuery[FileEntryRow] += FileEntry(None,"/path/to/secondtestfile",1,"tstuser",1,new Timestamp(123456789),new Timestamp(123456789),new Timestamp(123456789)),
        //"""{"name": "Premiere test template 1","projectTypeId": 1,"filepath", "storageId": 1}"""
        //"{"name":,"opensWith":"AdobePremierePro.app","targetVersion":"14.0"}"
        TableQuery[ProjectTypeRow] += ProjectType(None,"Premiere 2014 test","AdobePremierePro.app","14.0"),
        TableQuery[ProjectTypeRow] += ProjectType(None,"Cubase 7.0 test","Cubase.app","7.0"),
        TableQuery[ProjectTemplateRow] += ProjectTemplate(Some(1),"Premiere test template 1",1,"/srv/projectfiles/ProjectTemplatesDev/Premiere/premiere_template_2014.prproj",1)

      ).asTry
    )
  }

  def teardownDB():Future[Try[Unit]] = {
    logger.warn("In teardownDB")
    dbConfig.db.run(
      DBIO.seq(
        (
          TableQuery[FileAssociationRow].schema ++
            TableQuery[FileEntryRow].schema ++
            TableQuery[ProjectEntryRow].schema ++
            TableQuery[ProjectTemplateRow].schema ++
            TableQuery[ProjectTypeRow].schema ++
            TableQuery[StorageEntryRow].schema
        ).drop
      ).asTry
    )
  }
}

настройки build.sbt:

concurrentRestrictions in Global := Seq(
  Tags.limit(Tags.Test, 1),
  Tags.limitAll(1)
)

parallelExecution in Test := false

person Andy Gallagher    schedule 07.07.2017    source источник


Ответы (1)


исключение сообщает вам, что в вашем пуле соединений нет соединений.

Я вижу здесь две проблемы:

  1. Вы не закрываете соединение с базой данных при разрыве.
  2. Вы можете открывать множество соединений для каждого пула соединений, и, поскольку каждая из ваших спецификаций создает новый пул соединений, у вас заканчивается максимальное количество соединений, настроенное на вашем сервере postgres. Вы можете уменьшить количество соединений на пул соединений, уменьшив параметр "numThreads" вашего отличная конфигурация

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

person David Kaatz    schedule 07.07.2017
comment
Привет Дэвид, Большое спасибо за вашу помощь. Я добавил закрытие к демонтажу, и все заработало локально, но все еще были проблемы в CI. Я начал использовать драйверы H2DB, но переключился на Postgres, так как у меня возникли проблемы с тем, чтобы заставить H2 работать с Evolutions базы данных, но на данный момент я решил отказаться от эволюции и заставить приложение работать, а затем беспокоиться о переносе данных позже. Это действительно помогло улучшить мое понимание :) - person Andy Gallagher; 10.07.2017