در این پست قراره در مورد تست یکپارچه‌سازی با داکر صحبت کنیم و به شما بگیم که برای انجام این کار چه مراحلی لازمه انجام بشه.

اما قبل از اون لازمه با مفهوم تست یکپارچه‌سازی بیش‌تر آشنا بشین.   

تست یکپارچه‌سازی یا integration testing، یک تست‌نرم‌افزاریه که در اون تمام ماژول‌های برنامه به عنوان یک گروه ترکیب و تست میشن.

حالا انجام این کار چه فایده‌ای می‌تونه داشته باشه؟

هر ماژول برنامه توسط یک توسعه‌دهنده نوشته میشه که ممکنه درک و منطق متفاوتی با دیگر برنامه‌نویس‌ها داشته باشه.
به همین دلیل انجام تست یکپارچه‌سازی مهمه تا در تعامل بین ماژول‌های یکپارچه، مشکلات موجود در برنامه شناسایی بشه. 

حالا با توجه به این‌که هر برنامه باید با بخش‌های مختلفی مثل HTTP API، message broker، database و سرور SMTP ارتباط برقرار کنه، ایجاد محیط تست یکپارچه،‌ کار بسیار پیچیده‌ای به نظر می‌رسه. 

اما امروزه به لطف Testcontainer ها، از تمام توان داکر می‌شه استفاده کرد و تست یکپارچه‌سازی رو به راحتی انجام داد.  

در ادامه با ما همراه باشین تا با Testcontainer ها بیش‌تر آشنا بشین.

Testcontainer چیست؟ 

کانتینرها در تست‌های یکپارچه‌سازی بسیار مورد استفاده قرار می‌گیرن و در حال حاضر یک فریم‌ورک برای انجام این‌کار وجود داره به نام Testcontainer.

حالا ببینیم دقیقا چه کاری انجام میده؟

Testcontainer، یک کتابخانه‌ی جاواست که از تست‌های JUnit پشتیبانی می‌کنه.
نمونه‌های کم حجم از انواع پایگاه‌داده‌های رایج، وب‌بروزرهای Selenium یا هر چیز دیگه‌ای که می‌تونه در کانتینر داکر اجرا بشه رو ارائه می‌کنه. 

Testcontainer ها برای پشتیبانی از تست‌های یکپارچه‌سازی از پتانسیل بالایی برخوردار هستن.
این امکان رو فراهم می‌کنن تا در حین اجرای تست، کانتینرهای داکر رو به راحتی دستکاری و اجرا کنین.

علاوه بر این، برای خودکار سازی تنظیمات محیط تست، API هایی رو در اختیار شما میذاره.
با استفاده از این API ها، کانتینرهای داکر برای انجام تست شروع به کار می‌کنن و پس از اتمام اجرای تست از بین می‌رن. 

Testcontainer از docker-java برای ارتباط با Docker daemon استفاده می‌کنه.
با اکثر سیستم‌عامل‌ها و محیط‌ها کار می‌کنه و می‌تونین اون رو با Docker Toolbox نیز استفاده کنین.

در این لینک می‌تونین اطلاعات بیش‌تری راجع به Testcontainer ها پیدا کنین. 

وقتی کانتینری رو ایجاد می‌کنین، Testcontainer تلاش می‌کنه تا با استفاده از DOCKER_HOST و متغیرهای DOCKER_TLS_VERIFY و DOCKER_CERT_PATH، به Docker daemon متصل بشه.
(Docker daemon سرور داکر هست که به مدیریت مسائل کلیدی می‌پردازه)

خب در ادامه با مفاهیم مرتبط با Testcontainer بیشتر آشنا می‌شین و برای انجام تست یکپارچه‌سازی یک مثال رو با هم مرور می‌کنیم.

GenericContainer و ایجاد یک کانتینر

در تست‌ یکپارچه‌سازی با Testcontainer ها،‌ کلاس GenericContainer به طور مکرر مورد استفاده قرار می‌گیره و کانتینرها با شئ‌ای به نام GenericContainer مشخص می‌شن. 

روش‌های مختلفی برای ایجاد یک کانتینر وجود داره:

با استفاده از یک Dockerfile ،image یا حتی یک فایل Docker Compose، می‌تونین کانتینرها رو ایجاد کنین.

مثلا در نمونه کد زیر یک سرور Elasticsearch رو داریم که با استفاده از یک image ساخته شده:

برای آشنایی با Elasticsearch، این لینک رو مشاهده کنین.

GenericContainer container = new GenericContainer("docker.elastic.co/elasticsearch/elasticsearch:6.1.1")
.withEnv("discovery.type", "single-node")
.withExposedPorts(9200)
.waitingFor(
  Wait
  .forHttp("/_cat/health?v&pretty")
  .forStatusCode(200)  
  );
  • در کد بالا می‌بینین که فراهم کردن متغیرهای محیطی برای کانتینر با متد withEnv. بسیار ساده است. در این حالت، مقدار متغیر discovery.type برابر با single-node قرار داده شده.
  • در خط بعدی با ارسال درخواست HTTP در cat/ health_/ و دریافت پاسخ کد وضعیت ۲۰۰، اطمینان حاصل می‌شه که کانتینر بالا اومده و در حال کاره. 

توجه: روش‌های مختلف دیگه‌ای هم وجود دارن که اثبات می‌کنن کانتینر در حال اجراست:
Wait.forLogMessage: منتظر پیام Log بودن
Wait.forListeningPort: منتظر پورت listening بودن
Wait.forHealthcheck: استفاده از ویژگی healthcheck در داکر

  • در کد بالا برای نهایی کردن تنظیمات کانتینر، با استفاده از دستور withExposedPorts، پورت داخلی ۹۲۰۰ از کانتینر expose شده.
    این یعنی Testcontainer ها پورت کانتینر رو به یک پورت تصادفی map می‌کنن. بازیابی پورت map شده با استفاده از متد getMappedPort قابل انجامه.
  • حالا سرور Elasticsearch آماده‌ی استفاده است. برای شروع، فقط باید متد start رو اجرا کنین:
container.start();
  • در آخر با متد stop می‌تونین کانتینر رو متوقف کنین:
container.stop();

این کار باعث متوقف کردن کانتینر و volume مربوطه میشه. این کار فایده‌ی زیادی داره. چون از داشتن volume های غیر قابل استفاده جلوگیری می‌کنه. 

کانتینرهای از پیش تنظیم شده

مشابه داکر، اکوسیستم Testcontainer ها هم بسیار غنی است:

می‌تونین از انواع کانتینرهای از پیش تنظیم شده‌ مانند MySQL ،PostgreSQL، بانک اطلاعاتی Oracle،
Elasticserach،Neo4j،Kafka و ...، برای انجام تست یکپارچه‌سازی استفاده کنین.

@Rule
public KafkaContainer kafka = new KafkaContainer();

می‌تونین مستقیماً از ریپازیتوری maven به لیست کانتینرهای از پیش تنظیم شده، دسترسی داشته باشین. 

در طول فرآیند تست چه اتفاقی می‌افتد؟

یکی از قابلیت‌های مهم Testcontainer ها، ادغام آن با فریم‌ورک JUnit است. 

object های GenericContainer، همون rule های JUnit هستن، یعنی چرخه‌ی عمرشون مستقیماً به چرخه‌‌ی عمر تست محدود می‌شه.
بنابراین در بخشی که با علامت ClassRule@ یا Rule@ مشخص میشه، برای هر متد تست، یک کانتینر جدید شروع به کار می‌کنه.
بعد از اجرای همه‌ی متدها،‌ کانتینر از بین میره یا متوقف میشه.

@ClassRule
public static GenericContainer redis = new GenericContainer("redis:3.0.2")
 .withExposedPorts(6379);

Testcontainer از وابستگی JUnit 4 پشتیبانی می‌کرد و اگر تست‌های شما با JUnit 5 اجرا می‌شد، انجام تست دردسرساز بود. 

در حال حاضر JUnit مفهوم Rule رو با Extension جایگزین کرده.

ورژن ۱.۱۰.۰ JUnit در نوامبر ۲۰۱۸ منتشر شد و در حال حاضر Testcontainer از JUnit 5 هم پشتیبانی می‌کنه و می‌شه به کمک Testcontainers@ و Container@ (از کتابخانه‌ی اختصاصی junit-jupite)، از extension ها استفاده کرد:

<dependency>
 <groupId>testcontainers</groupId>
 <artifactId>junit-jupiter</artifactId>
 <version>1.10.2</version>
</dependency>

بررسی یک مثال

در این قسمت نحوه‌ی استفاده از Testscontainer برای تست برنامه‌ی
Spring PetClinic رو با هم بررسی می‌کنیم.

در نوشتن این برنامه، از سه مولفه‌ی spring استفاده شده، یعنی:
Spring JPA ،Spring MVC و Spring Boot

هدف این برنامه، مدیریت کلینیک حیوانات خانگی با سه موجودیت pet owner،pet‌ و vet است.

با توجه به شکل بالا، لایه‌ی controller، امکان ایجاد و خواندن موجودیت‌ها رو فراهم می‌کنه.
سپس، لایه‌ی persist‌ با یک دیتابیس رابطه‌ای ارتباط برقرار می‌کنه.
حالا برنامه می‌تونه طوری تنظیم بشه که با پایگاه داده HSQLDB یا MySQL ارتباط برقرار کنه.

لایه‌ی persist با تست‌های یکپارچه‌سازی تست می‌شه و در این تست یکپارچه‌سازی، از in-memory database به نام HSQL استفاده می‌شه.
در حالی که خود لایه‌ی persist از دیتابیس MySQL استفاده می‌کنه.

نیازمندی‌های لازم در این مثال برای انجام تست یکپارچه‌سازی:

برای اجرای کانتینرها به داکر احتیاج دارین.

پس اول باید داکر رو روی دستگاهی که قراره تست‌ها رو اجرا کنه، نصب کنین تا اطمینان حاصل بشه که کانتینرهای داکر به درستی در محیط تست اجرا می‌شن. 

در مرحله‌ی بعد باید وابستگی مربوط به Testcontainer ها رو به پروژه اضافه کنین.

در این حالت، موارد زیر رو به فایل pom.xml اضافه کنین:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers</artifactId>
  <version>1.10.2</version>
  <scope>test</scope>
</dependency>

تنظیمات پایگاه داده

تنظیمات پیش فرض پایگاه داده در فایل application.properties، انجام می‌شه. کدهای زیر تنظیمات پیش‌فرض پروژه هستن:

database=hsqldb
spring.datasource.schema=classpath*:db/${database}/schema.sql
spring.datasource.data=classpath*:db/${database}/data.sql

همون طور که می‌بینین یک in-memory database داریم با نام HSQLDB، که با یک اسکیما از فایل schema.sql مقدار دهی شده. در خط بعدی، پایگاه داده با فایل data.sql همراه شده است. این‌ها پیکربندی پیش‌فرض پروژه هستن.

در مرحله‌ی بعدی برای تنظیم اتصال به پایگاه داده‌ی MySQL، باید فایل application-test.properties رو ایجاد کنین:

spring.datasource.url=jdbc:mysql://localhost/petclinic
spring.datasource.username=petclinic
spring.datasource.password=petclinic
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

در مرحله‌ی بعدی، کلاس ClinicServiceTests.java رو تست کنین. این کلاس شامل تمام تست‌های یکپارچه‌سازی برای لایه‌ی persist میشه. 
برای این‌که مطمئن بشین برنامه‌ی Spring از connection بانک اطلاعاتی موجود استفاده می‌کنه، باید تنظیمات تست برنامه‌ی Spring رو تغییر بدین:

@RunWith(SpringRunner.class)
@DataJpaTest(includeFilters = @ComponentScan.Filter(Service.class))
@TestPropertySource(locations="classpath:application-test.properties")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class ClinicServiceTests {
...
}

در دستور TestPropertySource، امکان بارگذاری فایل application-test.properties فراهم میشه و در خط AutoConfigureTestDatabase (با مقدار NONE)، از ایجاد پایگاه داده‌ی embedded جلوگیری می‌شه. 

تنظیم کانتینر MySQL

میخوایم یک پایگاه داده‌ی MySQL ایجاد کنیم که مطابق با شرایط تست‌ برنامه‌ باشه. 
در این مثال، از قابلیت Testcontainer ها استفاده می‌شه تا از Dockerfile ایجاد شده، ایمیج داکر ایجاد بشه.

- در مرحله‌ی اول، یک ایمیج MySQL رو از داکر هاب pull کنین و یک GenericContainer test rule رو با مشخص کردن نام ایمیج داکر بسازین:

@ClassRule
public static GenericContainer mysql = new GenericContainer(
  new ImageFromDockerfile("mysql-petclinic")
    .withDockerfileFromBuilder(dockerfileBuilder -> {
       dockerfileBuilder.from("mysql:5.7.8")
    }
);

- در ادامه‌ی کد بالا باید دیتابیس و user connection رو ایجاد کنین. این کار با استفاده از متغیرهای محیطی موجود درایمیج داکر انجام می‌شه:

@ClassRule
public static GenericContainer mysql = new GenericContainer(
  new ImageFromDockerfile("mysql-petclinic")
   .withDockerfileFromBuilder(dockerfileBuilder -> {
      dockerfileBuilder.from("mysql:5.7.8")
      // root password is mandatory
      .env("MYSQL_ROOT_PASSWORD", "root_password")
      .env("MYSQL_DATABASE", "petclinic")
      .env("MYSQL_USER", "petclinic")
      .env("MYSQL_PASSWORD", "petclinic")
 })

- در مرحله‌ی بعد، باید schema پایگاه داده رو ایجاد کنین و متغیرهای پایگاه داده رو مقداردهی کنین.

اگه به کد زیر نگاه کنین، دایرکتوری ایمیج رو مشاهده می‌کنین (docker-entrypoint-initdb.d/).

از بین دایرکتوری‌های ایمیج،‌ این دایرکتوری اسکن می‌شه و همه‌ی فایل‌ها با Extensionهای sh ، .sql. و .sql.gz اجرا می‌شن.
پس فقط کافیه فایل‌های schema.sql و data.sql رو در این دایرکتوری image قرار بدین:

@ClassRule
public static GenericContainer mysql = new GenericContainer(
  new ImageFromDockerfile("mysql-petclinic")
    .withDockerfileFromBuilder(dockerfileBuilder -> {
      dockerfileBuilder.from("mysql:5.7.8")
     .env("MYSQL_ROOT_PASSWORD", "root_password")
     .env("MYSQL_DATABASE", "petclinic")
     .env("MYSQL_USER", "petclinic")
     .env("MYSQL_PASSWORD", "petclinic")
     .add("a_schema.sql", "/docker-entrypoint-initdb.d")
     .add("b_data.sql", "/docker-entrypoint-initdb.d");
 })
 .withFileFromClasspath("a_schema.sql", "db/mysql/schema.sql")
 .withFileFromClasspath("b_data.sql", "db/mysql/data.sql"))

- در ادامه‌ی کد هم متد withClasspathResourceMapping اضافه شده.
با استفاده از این متد، فایل‌های schema.sql و data.sql به صورت یک volume در classpath درون کانتینر قرار می‌گیرن. در این صورت از طریق ساختار Dockerfile میشه بهش دسترسی پیدا کرد.

- و در آخر، باید پورت پیش فرض MySQL رو expose کنین: یعنی پورت ۳۳۰۶.

@ClassRule
public static GenericContainer mysql = new GenericContainer(
  new ImageFromDockerfile("mysql-petclinic")
    .withDockerfileFromBuilder(dockerfileBuilder -> {
      ....
    })
  .withExposedPorts(3306)
  .withCreateContainerCmdModifier(
    new Consumer<CreateContainerCmd>() {   
     @Override
     public void accept(CreateContainerCmd createContainerCmd) {
       createContainerCmd.withPortBindings(
         new PortBinding(Ports.Binding.bindPort(3306), new ExposedPort(3306))
       );
    }
 })
 .waitingFor(Wait.forListeningPort());

نتیجه‌گیری

Testcontainer این امکان رو فراهم می‌کنه تا با استفاده از کانتینرهای داکر، تست‌های یکپارچه‌سازی رو به راحتی انجام بدین.

تو این پست بررسی کردیم که چطوری با چند خط کد و به راحتی یک پایگاه داده‌ی MySQL رو برای تست‌های یکپارچه‌سازی تنظیم کنین، بدون این‌که بخوایین چرخه‌ی عمر کانتینر رو مدیریت کنین. 

سورس کدهای این پست در این بخش از github وجود داره و می‌تونین ازش استفاده کنین.

منابع:

Dockerize your integration tests
Integration tests with Docker
Testcontainers - Bring Your Integration Tests to a New Level
Docker Test Containers in Java Tests
Integration Testing
TestContainers: Making Java Integration Tests Easy