Интеграционное тестирование микросервисов на Scala

О себе

  • Программирую с 2011 года
  • PHP
  • JavaScript
  • Java
  • Scala
  • 2GIS
  • Реклама
  • BigData
  • Crawler

Casino

  • 25+ сервисов на Scala
  • PostgreSQL
  • Kafka
  • ZeroMQ
  • Cassandra
  • Hadoop
  • Spark
  • Machine learning stuff
  • ...

Как тестировать?

Unit tests

Integration tests

CI env

CI env

deps

APP

push

config

jar

run

test

+ Это работает

- CI-окружение всегда включено  лишняя трата ресурсов

- Все зависимости также всегда включены

- Невозможно запустить тесты двух веток одновременно

- Очень тяжело настроить локально

- Невозможно тестировать старт/стоп/рестарт

Integration tests

+ Легко запускать локально

Gitlab CI + docker

CI env

CI env

deps

APP

push

docker

run

test

Принципиально не отличается от прошлой схемы, но появляется docker

docker-compose

docker-compose

deps

APP

push

run

test

docker-compose

+/- Проще настроить локально, но всё еще нужно согласовывать несколько мест

+ Не нужно держать отдельное окружение и зависимости

+ Можно тестировать разные ветки одновременно

- Всё еще невозможно тестировать старт/стоп/рестарт

- Сложности с запуском

Запуск docker-compose

docker-compose 2

version: '2.1'
services:
  web:
    build: .
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
  redis:
    image: redis
  db:
    image: db
    healthcheck:
      test: "some test here"

Запуск docker-compose

docker-compose 3

Запуск docker-compose

docker-compose 3

version: '3'
services:
  web:
    build: .
    depends_on: [ db, redis ]
  redis:
    image: redis
    command: [ "./wait-for-it.sh", ... ]
  db:
    image: redis
    command: [ "./wait-for-db.sh", ... ]

wait-for-it.sh

Запуск docker-compose

docker-compose 3

version: '3'
services:
  postgres:
    ...

  my_service:
    depends_on: [ postgres ]
    ...

  sbt:
    depends_on: [ my_service ]
    ...

docker-compose-run.sh

Запуск docker-compose

docker-compose 3

docker-compose up -d postgres

docker-compose-run.sh

docker-compose run sbt
down $?
docker-compose up -d my_service
wait_until 10 2 docker-compose exec -T \ 
  my_service sh -c "netstat -ntlp | grep 80 || exit 1"
wait_until 10 2 docker-compose exec -T \ 
  postgres psql postgresql://my_user:my_pass@postgres:5432/my_service \ 
  -c "SELECT PostGIS_version();"

Запуск docker-compose

docker-compose 3

function down {
    echo "Exiting with code $1"
    if [[ $1 -eq 0 ]]; then
        docker-compose down
        exit $1
    else
        docker-compose logs -t postgres my_service
        docker-compose down
        exit $1
    fi
}

docker-compose-run.sh

2. docker-it-scala

1. docker-java / spotify docker-client

3. Testcontainers

Testcontainers

  • Java-библиотека для запуска и тестирования
    docker-контейнеров
  • Есть Scala-фасад testcontainers-scala
  • Из коробки поддерживает ряд популярных сервисов: postgresql, mysql, nginx, kafka, selenium
  • Можно запускать любые другие контейнеры
  • Простое и гибкое API

Testcontainers

val pgContainer: PostgreSQLContainer = 
  PostgreSQLContainer("postgres:9.6")

Predefined containers

val pgUrl: String = pgContainer.jdbcUrl
val pgPort: Int = pgContainer.mappedPort(5432)
pgContainer.finished()
pgContainer.starting()

Testcontainers

class GenericContainer(
  imageName: String,
  exposedPorts: Seq[Int] = Seq(),
  env: Map[String, String] = Map(),
  command: Seq[String] = Seq(),
  classpathResourceMapping: Seq[(String, String, BindMode)] = Seq(),
  waitStrategy: Option[WaitStrategy] = None
) ...

Custom containers

Testcontainers

class PostgresqlSpec extends FlatSpec 
  with ForAllTestContainer {

Running tests

  override val container = PostgreSQLContainer()
  "PostgreSQL container" should "be started" in {
    Class.forName(container.driverClassName)
    val connection = DriverManager
      .getConnection(container.jdbcUrl, container.username, container.password)
    // test some stuff
  }
}

Testcontainers

val pgContainer = PostgreSQLContainer()
val myContainer = MyContainer()

override val container = 
  MultipleContainers(pgContainer, myContainer)

Running tests

Testcontainers

deps

APP

push

run

test

Testcontainers

+ Есть гибкие healthcheck'и

+ Наконец, можно тестировать старт/стоп/рестарт

+ Никаких *.sh-файликов в репозитории

+ Можно конфигурировать приложение и тесты как угодно гибко

+ Одинаково легко запускается на CI и локально

+ Не нужно держать отдельное окружение и зависимости

+ Можно тестировать разные ветки одновременно

Dependent containers

Exception encountered when invoking run on a nested suite - Mapped port can only be obtained after the container is started
class MySpec extends FlatSpec 
  with ForAllTestContainer {
  val pgCont = PostgreSQLContainer()
  override val container = MultipleContainers(appCont, pgCont)
  val appCont = 
    AppContainer(pgCont.jdbcUrl, pgCont.username, pgCont.password)
  // tests here
}
class MyTest extends FreeSpec 
  with BeforeAndAfterAll {

  lazy val pgCont = PostgreSQLContainer()
  lazy val appCont = 
    AppContainer(pgCont.jdbcUrl, pgCont.username, pgCont.password)

  override def beforeAll(): Unit = ??? // start all containers

  override def afterAll(): Unit = ??? // shutdown all containers

  // tests here
}
org.postgresql.util.PSQLException: Connection to localhost:32787 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.
@Override
public String getJdbcUrl() {
    return "jdbc:postgresql://" + 
        getContainerIpAddress() + 
        ":" + 
        getMappedPort(POSTGRESQL_PORT) + 
        "/" + 
        databaseName;
}

Рекомендация разработчиков testcontainers: создавать руками network для взаимодействия между контейнерами

class MyTest extends FreeSpec with BeforeAndAfterAll {

  val network: Network = Network.newNetwork()
  val dbName = "some_db"
  val pgContainerAlias = "postgres"
  val jdbcUrl = s"jdbc:postgresql://$pgContainerAlias:5432/$dbName"

  lazy val pgCont = {
    val c = PostgreSQLContainer("postgres:9.6")
    c.container.withNetwork(network)
    c.container.withNetworkAliases(pgContainerAlias)
    c.container.withDatabaseName(dbName)
    c
  }
  lazy val appCont = {
    val c = AppContainer(jdbcUrl, pgCont.username, pgCont.password)
    c.container.withNetwork(network)
    c
  }

  override def beforeAll(): Unit = ???
  override def afterAll(): Unit = ??? // shutdown containers + network

  // tests here
}
class MyTest extends FreeSpec 
  with ForAllTestContainer 
  with BeforeAndAfterAll {

  val network: Network = Network.newNetwork()
  val dbName = "some_db"
  val pgContainerAlias = "postgres"
  val jdbcUrl = s"jdbc:postgresql://$pgContainerAlias:5432/$dbName"

  lazy val pgCont = {
    val c = PostgreSQLContainer("postgres:9.6")
    c.container.withNetwork(network)
    c.container.withNetworkAliases(pgContainerAlias)
    c.container.withDatabaseName(dbName)
    c
  }
  lazy val appCont = {
    val c = AppContainer(jdbcUrl, pgCont.username, pgCont.password)
    c.container.withNetwork(network)
    c
  }

  override val container = MultipleContainers(pgCont, appCont)

  override def afterAll(): Unit = ??? // shutdown network

  // tests here
}

Mocks

val hostIp = ???

AppContainer(sparkJobServerMockHost = hostIp)

val sparkJobServerMock = new SparkJobServerMock()

sparkJobServerMock.init(someData)

val apiResult = appApi.callMethod()

assert(apiResult == someData)




val sparkJobServerMock = new SparkJobServerMock()




val sparkJobServerMock = new SparkJobServerMock()

sparkJobServerMock.init(someData)




val sparkJobServerMock = new SparkJobServerMock()

sparkJobServerMock.init(someData)

val apiResult = appApi.callMethod()




val sparkJobServerMock = new SparkJobServerMock()

sparkJobServerMock.init(someData)

val apiResult = appApi.callMethod()

assert(apiResult == someData)

Mocks

val client: com.github.dockerjava.api.DockerClient =
  DockerClientFactory
    .instance
    .client

Работает не везде!

val networkInfo: com.github.dockerjava.api.model.Network =
  client
    .inspectNetworkCmd()
    .withNetworkId(network.getId)
    .exec()
val hostIp: String = 
  networkInfo
    .getIpam
    .getConfig
    .get(0)
    .getGateway

https://github.com/rnorth/containercore

Итоги

  • Testcontainers  очень хорошая либа, которая решает наши проблемы
  • Сделать нормальные интеграционные тесты сложно
  • Докер помогает, но тянет за собой кучу "особенностей"
  • Но текущее API добавляет немного боли

Спасибо!

lmnet89@gmail.com

Бадальянц Юрий, 2018

Made with Slides.com