Kotlin: شرح اختبار كوتلن باستخدام JUnit 5 و MockK

آخر تحديث: 2026-08-26

عقيدة Bob: الكود غير المُختبر هو افتراضات غير مُتحقَقة. JUnit 5 + MockK هو الثنائي الذهبي لاختبار كوتلن — صُمم MockK أصلاً لكوتلن (يدعم محاكاة الكوروتينات)، وJUnit 5 يوفر إطار اختبار حديث.

1. ما ستتعلمه


2. قصة قائد ضمان الجودة الحقيقي

(1) المشكلة: عدم توافق Mockito مع كوتلن

واجه Bob أخطاء الفئات النهائية (Final class) بشكل متكرر عند محاكاة فئات كوتلن باستخدام Mockito (فئات كوتلن نهائية افتراضيًا)، مما يتطلب تهيئة إضافة mock-maker-inline. وكانت دوال suspend مستحيلة المحاكاة تمامًا.

(2) حل MockK

KOTLIN
// Mockito: مشاكل مع الفئات النهائية ودوال suspend
when(repo.findById("ORD-001")).thenReturn(order)  // يفشل مع الفئات النهائية!

// MockK: أصلي لكوتلن، يدعم كل شيء
every { repo.findById("ORD-001") } returns order  // يعمل مع أي فئة!
coEvery { repo.fetchOrderAsync("ORD-001") } returns order  // دوال suspend!

MockK صُمم أصلاً لكوتلن — لا حاجة لتهيئة إضافية، مع دعم كامل للفئات النهائية والدوال الممتدة والكوروتينات.


3. أساسيات JUnit 5

(1) تهيئة التبعيات

KOTLIN
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.1")
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}

tasks.test {
    useJUnitPlatform()
}

(2) الاختبارات الأساسية

KOTLIN
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class OrderTest {
    @Test
    fun `should create order with correct id`() {
        val order = Order("ORD-001", 299.99, "PENDING")
        assertEquals("ORD-001", order.id)
        assertEquals(299.99, order.total)
        assertEquals("PENDING", order.status)
    }

    @Test
    fun `should throw on negative total`() {
        assertThrows(IllegalArgumentException::class.java) {
            Order("ORD-001", -50.0, "PENDING")
        }
    }
}

(3) الاختبارات المُمعَلَمة

KOTLIN
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource

class OrderPriorityTest {
    @ParameterizedTest
    @CsvSource(
        "100.0, LOW",
        "1500.0, MEDIUM",
        "15000.0, HIGH"
    )
    fun `should determine priority based on total`(total: Double, expected: String) {
        val priority = when {
            total > 10_000 -> "HIGH"
            total > 1_000 -> "MEDIUM"
            else -> "LOW"
        }
        assertEquals(expected, priority)
    }
}

(4) الاختبارات المتداخلة

KOTLIN
class OrderProcessorTest {
    @Nested
    inner class OrderCreation {
        @Test
        fun `should create pending order`() { ... }

        @Test
        fun `should reject negative total`() { ... }
    }

    @Nested
    inner class OrderProcessing {
        @Test
        fun `should confirm pending order`() { ... }

        @Test
        fun `should reject confirming shipped order`() { ... }
    }
}

4. أساسيات MockK

(1) إنشاء المحاكاة وتعريف السلوك

KOTLIN
import io.mockk.*

class OrderServiceTest {
    // إنشاء محاكاة
    val repo = mockk<OrderRepository>()
    val service = OrderService(repo)

    @Test
    fun `should find order by id`() {
        // تعريف السلوك
        every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING")
        every { repo.findById(any()) } returns null

        // تنفيذ
        val order = service.findOrder("ORD-001")

        // تأكيد
        assertEquals(299.99, order?.total)

        // تحقق: فحص التفاعل
        verify(exactly = 1) { repo.findById("ORD-001") }
    }
}

(2) واجهة برمجة MockK الشائعة

واجهة برمجة الغرض مثال
every { } returns تعريف قيمة الإرجاع every { repo.findById(any()) } returns order
every { } throws تعريف الاستثناء every { repo.save(any()) } throws SQLException()
every { } answers حساب ديناميكي every { repo.findById(any()) } answers { ... }
verify { } التحقق من حدوث الاستدعاء verify { repo.save(order) }
verifySequence { } التحقق من ترتيب الاستدعاء verifySequence { f1(); f2() }
confirmVerified(repo) تأكيد عدم وجود استدعاءات غير مُتحقَقة confirmVerified(repo)
coEvery { } returns تعريف كوروتين coEvery { repo.fetchAsync(any()) } returns order
coVerify { } تحقق من كوروتين coVerify { repo.fetchAsync(id) }

(3) MockK مقابل Mockito

البُعد Mockito MockK
الفئات النهائية في كوتلن تتطلب تهيئة إضافية دعم أصلي
محاكاة الكوروتينات غير مدعومة coEvery / coVerify
محاكاة الدوال الممتدة غير مدعومة mockkStatic
محاكاة الكائنات (object) صعبة mockkObject
بناء الجملة أسلوب Java أسلوب Kotlin DSL

5. اختبار الكوروتينات

(1) runTest

KOTLIN
import kotlinx.coroutines.test.runTest

class OrderServiceTest {
    val repo = mockk<OrderRepository>()
    val service = OrderService(repo)

    @Test
    fun `should fetch order asynchronously`() = runTest {
        // بفرض
        coEvery { repo.fetchOrder("ORD-001") } returns Order("ORD-001", 299.99, "PENDING")

        // عند
        val order = service.fetchOrderAsync("ORD-001")

        // إذن
        assertEquals(299.99, order.total)
        coVerify { repo.fetchOrder("ORD-001") }
    }
}

(2) أدوات اختبار الكوروتينات

الأداة الغرض
runTest نقطة دخول اختبار الكوروتينات (تحل محل runBlocking)
coEvery تعريف دوال الكوروتين
coVerify التحقق من استدعاءات دوال الكوروتين
TestDispatcher التحكم في جدولة الكوروتينات (وقت افتراضي)
advanceUntilIdle() تقديم جميع الكوروتينات المعلقة

6. دورة حياة الاختبار

KOTLIN
class OrderServiceTest {
    private lateinit var repo: OrderRepository
    private lateinit var service: OrderService

    @BeforeEach
    fun setup() {
        repo = mockk()
        service = OrderService(repo)
    }

    @AfterEach
    fun cleanup() {
        unmockkAll()  // مسح جميع المحاكيات
    }

    @Test
    fun `should process order`() { ... }
}

(1) توضيحات دورة الحياة

التوضيح وقت التنفيذ الغرض
@BeforeEach قبل كل اختبار تهيئة المحاكيات وكائنات الاختبار
@AfterEach بعد كل اختبار تنظيف الموارد
@BeforeAll قبل جميع الاختبارات (يحتاج companion object) تهيئة مكلفة لمرة واحدة
@AfterAll بعد جميع الاختبارات تنظيف عام

7. هرم الاختبار والتدفق

100%
flowchart TD
    A[اختبارات الوحدة<br/>MockK + JUnit5<br/>سريعة، معزولة] --> B[اختبارات التكامل<br/>Spring Boot Test<br/>قاعدة بيانات/HTTP حقيقية]
    B --> C[اختبارات شاملة<br/>TestContainers<br/>مكدس كامل]
    A --> D[runTest للكوروتينات]
    A --> E[MockK للتبعيات]
    D --> F[تحكم بالوقت الافتراضي]

8. مثال كامل: OrderProcessorTest

KOTLIN
// ============================================
// OrderProcessor - مجموعة اختبار
// الميزة: تغطية اختبار كاملة باستخدام JUnit5 + MockK
// ============================================

import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*
import io.mockk.*
import kotlinx.coroutines.test.runTest

// فئات النطاق
data class Order(val id: String, val total: Double, var status: String, val customerId: String)

interface OrderRepository {
    fun findById(id: String): Order?
    fun save(order: Order)
    suspend fun fetchOrderAsync(id: String): Order
}

class OrderService(private val repo: OrderRepository) {
    fun findOrder(id: String): Order = repo.findById(id) ?: throw NoSuchElementException("Order $id not found")

    fun processOrder(id: String): Order {
        val order = findOrder(id)
        if (order.status != "PENDING") throw IllegalStateException("Order $id is not pending")
        order.status = "CONFIRMED"
        repo.save(order)
        return order
    }

    suspend fun fetchOrderAsync(id: String): Order = repo.fetchOrderAsync(id)

    fun calculatePriority(order: Order): String = when {
        order.total > 10_000 -> "HIGH"
        order.total > 1_000 -> "MEDIUM"
        else -> "LOW"
    }
}

class OrderServiceTest {
    private lateinit var repo: OrderRepository
    private lateinit var service: OrderService

    @BeforeEach
    fun setup() {
        repo = mockk(relaxed = true)  // متساهل: يُرجع قيمًا افتراضية للدوال غير المُعرَّفة
        service = OrderService(repo)
    }

    @AfterEach
    fun cleanup() {
        unmockkAll()
    }

    @Nested
    @DisplayName("استرجاع الطلب")
    inner class OrderRetrieval {
        @Test
        fun `should find existing order`() {
            // بفرض
            every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")

            // عند
            val order = service.findOrder("ORD-001")

            // إذن
            assertEquals("ORD-001", order.id)
            assertEquals(299.99, order.total)
        }

        @Test
        fun `should throw when order not found`() {
            every { repo.findById(any()) } returns null
            assertThrows(NoSuchElementException::class.java) {
                service.findOrder("ORD-999")
            }
        }
    }

    @Nested
    @DisplayName("معالجة الطلب")
    inner class OrderProcessing {
        @Test
        fun `should confirm pending order`() {
            every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")
            every { repo.save(any()) } just Runs

            val processed = service.processOrder("ORD-001")

            assertEquals("CONFIRMED", processed.status)
            verify { repo.save(match { it.status == "CONFIRMED" }) }
        }

        @Test
        fun `should reject non-pending order`() {
            every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "SHIPPED", "CUST-001")

            assertThrows(IllegalStateException::class.java) {
                service.processOrder("ORD-001")
            }
            verify(exactly = 0) { repo.save(any()) }
        }
    }

    @Nested
    @DisplayName("حساب الأولوية")
    inner class PriorityCalculation {
        @Test
        fun `should assign HIGH priority for orders over 10000`() {
            val order = Order("ORD-001", 15_000.00, "PENDING", "CUST-001")
            assertEquals("HIGH", service.calculatePriority(order))
        }

        @Test
        fun `should assign MEDIUM priority for orders between 1000 and 10000`() {
            val order = Order("ORD-001", 2_500.00, "PENDING", "CUST-001")
            assertEquals("MEDIUM", service.calculatePriority(order))
        }

        @Test
        fun `should assign LOW priority for orders under 1000`() {
            val order = Order("ORD-001", 299.99, "PENDING", "CUST-001")
            assertEquals("LOW", service.calculatePriority(order))
        }
    }

    @Nested
    @DisplayName("العمليات غير المتزامنة")
    inner class AsyncOperations {
        @Test
        fun `should fetch order asynchronously`() = runTest {
            coEvery { repo.fetchOrderAsync("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")

            val order = service.fetchOrderAsync("ORD-001")

            assertEquals("ORD-001", order.id)
            coVerify { repo.fetchOrderAsync("ORD-001") }
        }
    }
}

الإخراج: جميع الاختبارات نجحت ✅


9. أمثلة عملية سريعة

▶ مثال: اختبارات JUnit 5 أساسية

KOTLIN
import org.junit.jupiter.api.*

class CalculatorTest {
    lateinit var calculator: Calculator

    @BeforeEach
    fun setUp() {
        calculator = Calculator()
    }

    @Test
    fun `addition should work correctly`() {
        val result = calculator.add(2, 3)
        assertEquals(5, result)
    }

    @Test
    fun `division by zero should throw`() {
        assertThrows<ArithmeticException> {
            calculator.divide(10, 0)
        }
    }

    @Test
    @Disabled("Not implemented yet")
    fun `complex operation is pending`() {
        // ...
    }

    @Nested
    inner class MultiplicationTests {
        @Test
        fun `should multiply positive numbers`() {
            assertEquals(6, calculator.multiply(2, 3))
        }
    }

    companion object {
        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("Starting calculator tests")
        }
    }
}

class Calculator {
    fun add(a: Int, b: Int) = a + b
    fun multiply(a: Int, b: Int) = a * b
    fun divide(a: Int, b: Int) = a / b
}

**الإخراج:

TEXT 📖 للعرض فقط
Starting calculator tests

▶ مثال: تأكيدات متنوعة

KOTLIN
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class AssertionsTest {
    @Test
    fun `various assertions`() {
        // Equality
        assertEquals(4, 2 + 2)
        assertNotEquals(5, 2 + 2)

        // Boolean
        assertTrue(10 > 5)
        assertFalse(10 < 5)

        // Null
        val value: String? = "Hello"
        assertNotNull(value)
        assertNull(null as String?)

        // Collections
        val list = listOf(1, 2, 3)
        assertTrue(list.contains(2))
        assertEquals(3, list.size)
        assertArrayEquals(arrayOf(1, 2, 3), list.toTypedArray())

        // Exceptions
        assertThrows<IllegalArgumentException> { error("Invalid!") }

        // Groups
        assertAll(
            { assertEquals(4, 2 + 2) },
            { assertTrue(true) }
        )
    }
}

**الإخراج:

TEXT 📖 للعرض فقط

▶ مثال: MockK للمحاكاة

KOTLIN
import io.mockk.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Assertions.*

class UserServiceTest {
    private lateinit var repository: UserRepository
    private lateinit var service: UserService

    @BeforeEach
    fun setUp() {
        repository = mockk()
        service = UserService(repository)
    }

    @AfterEach
    fun tearDown() {
        unmockkAll()
    }

    @Test
    fun `findById returns user when found`() {
        val user = User("1", "Alice")
        every { repository.findById("1") } returns user

        val result = service.findById("1")

        assertEquals(user, result)
        verify { repository.findById("1") }
    }

    @Test
    fun `findById throws when not found`() {
        every { repository.findById("999") } returns null

        assertThrows<UserNotFoundException> {
            service.findById("999")
        }
    }

    @Test
    fun `save calls repository once`() {
        val user = User("1", "Alice")
        every { repository.save(user) } returns Unit

        service.save(user)

        verify(exactly = 1) { repository.save(user) }
        confirmVerified(repository)
    }
}

interface UserRepository {
    fun findById(id: String): User?
    fun save(user: User): Unit
}

data class User(val id: String, val name: String)
class UserNotFoundException(id: String) : RuntimeException("User $id not found")

class UserService(private val repo: UserRepository) {
    fun findById(id: String): User = repo.findById(id) ?: throw UserNotFoundException(id)
    fun save(user: User) = repo.save(user)
}

**الإخراج:

TEXT 📖 للعرض فقط

▶ مثال: MockK relaxed و mockkObject

KOTLIN
import io.mockk.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.*

class OrderServiceTest {
    @AfterEach
    fun tearDown() {
        unmockkAll()
    }

    @Test
    fun `relaxed mock returns default values`() {
        val service = mockk<OrderService>(relaxed = true)

        val result = service.processOrder("ORD-001")
        assertNull(result)
    }

    @Test
    fun `verify call counts`() {
        val service = mockk<OrderService>(relaxed = true)

        repeat(3) { service.processOrder("ORD-$it") }

        verify(exactly = 3) { service.processOrder(match { it.startsWith("ORD-") }) }
        verify(exactly = 0) { service.processOrder("OTHER") }
    }

    @Test
    fun `mock companion object`() {
        mockkObject(Order.Companion)

        every { Order.Companion.create("ORD-001") } returns Order("ORD-001", 100.0)

        val order = Order.create("ORD-001")
        assertEquals("ORD-001", order.id)
        assertEquals(100.0, order.total)
    }
}

class Order(val id: String, val total: Double) {
    companion object {
        fun create(id: String): Order = Order(id, 0.0)
    }
}

abstract class OrderService {
    abstract fun processOrder(id: String): String?
}

**الإخراج:

TEXT 📖 للعرض فقط

▶ مثال: اختبار الكوروتينات

KOTLIN
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class AsyncServiceTest {
    @Test
    fun `test async with runTest`() = runTest {
        val service = AsyncService()

        val result = service.fetchData()

        assertEquals("Data loaded", result)
    }

    @Test
    fun `test with virtual time`() = runTest {
        val service = AsyncService()
        val startTime = currentTime

        val deferred = async { service.slowOperation() }
        advanceTimeBy(5000)

        val result = deferred.await()
        assertEquals("Done", result)
        assertEquals(5000L, currentTime - startTime)
    }
}

class AsyncService {
    suspend fun fetchData(): String {
        delay(1000)
        return "Data loaded"
    }

    suspend fun slowOperation(): String {
        delay(5000)
        return "Done"
    }
}

**الإخراج:

TEXT 📖 للعرض فقط

▶ مثال: اختبار تكامل مع قاعدة بيانات

KOTLIN
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*

class UserRepositoryIntegrationTest {
    private lateinit var repo: UserRepository

    @BeforeEach
    fun setUp() {
        repo = InMemoryUserRepository()
    }

    @Test
    fun `crud operations work correctly`() {
        val user = User("1", "Alice")
        repo.save(user)

        val found = repo.findById("1")
        assertEquals(user, found)

        val updated = User("1", "Alice Updated")
        repo.save(updated)
        assertEquals("Alice Updated", repo.findById("1")?.name)

        repo.delete("1")
        assertNull(repo.findById("1"))
    }

    @Test
    fun `findAll returns all users`() {
        repo.save(User("1", "Alice"))
        repo.save(User("2", "Bob"))
        repo.save(User("3", "Charlie"))

        val all = repo.findAll()
        assertEquals(3, all.size)
        assertTrue(all.any { it.name == "Alice" })
        assertTrue(all.any { it.name == "Bob" })
        assertTrue(all.any { it.name == "Charlie" })
    }

    @Test
    fun `save duplicate id overwrites`() {
        repo.save(User("1", "Alice"))
        repo.save(User("1", "Alice Updated"))

        val all = repo.findAll()
        assertEquals(1, all.size)
        assertEquals("Alice Updated", all[0].name)
    }
}

interface UserRepository {
    fun save(user: User)
    fun findById(id: String): User?
    fun findAll(): List<User>
    fun delete(id: String)
}

class InMemoryUserRepository : UserRepository {
    private val data = mutableMapOf<String, User>()

    override fun save(user: User) {
        data[user.id] = user
    }

    override fun findById(id: String): User? = data[id]
    override fun findAll(): List<User> = data.values.toList()
    override fun delete(id: String) { data.remove(id) }
}

**الإخراج:

TEXT 📖 للعرض فقط

▶ مثال: Parameterized tests

KOTLIN
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.api.Assertions.*

class ParameterizedTestExample {
    @ParameterizedTest
    @ValueSource(ints = [1, 2, 3, 5, 10, 100])
    fun `square of positive numbers should be positive`(n: Int) {
        assertTrue(n * n > 0)
    }

    @ParameterizedTest
    @CsvSource(
        "1, 1, 2",
        "2, 3, 5",
        "10, 20, 30",
        "0, 0, 0"
    )
    fun `addition works for multiple inputs`(a: Int, b: Int, expected: Int) {
        assertEquals(expected, a + b)
    }
}

**الإخراج:

TEXT 📖 للعرض فقط

❓ أسئلة شائعة

س ما الفرق بين المحاكاة المتساهلة والمحاكاة العادية في MockK؟
ج المحاكاة المتساهلة (mockk(relaxed = true)) تُرجع قيمًا افتراضية (0/null/false) للدوال غير المُعرَّفة؛ المحاكاة العادية تُلقي MockKException للدول غير المُعرَّفة. يُفضل استخدام المحاكيات العادية لتجنب إخفاء السلوك غير المكوّن.
س ما الفرق بين runTest و runBlocking؟
ج runTest يستخدم الوقت الافتراضي (يتجاوز delay)، لذلك تكتمل الاختبارات في أجزاء من الثانية؛ runBlocking يستخدم الوقت الحقيقي وينتظر المدة الفعلية للتأخير. يجب استخدام runTest لاختبارات الكوروتينات.
س كيف أحاكي كائنًا مرافقًا (companion object)؟
ج استخدم mockkObject(Order) لمحاكاة كائن المرافق بالكامل، ثم every { ... } returns. استعد الحالة باستخدام unmockkObject(Order) بعد الاختبار.
س كيف أحاكي الدوال الممتدة؟
ج استخدم mockkStatic("package.ClassNameKt") لمحاكاة دوال ممتدة محددة. لكن حاول تجنب ذلك — يمكن غالبًا إعادة هيكلة الدوال الممتدة إلى دوال عادية لقابلية اختبار أفضل.
س ما نسبة تغطية الاختبار الكافية؟
ج استهدف 80% تغطية الأسطر، لكن التغطية لا تعني الجودة. منطق الأعمال الحرج (المدفوعات، المخزون، آلات الحالات) يجب أن تكون تغطيته 100%؛ فئات البيانات البسيطة يمكن أن تبقى غير مُختبرة.
س كيف أميز بين اختبارات التكامل واختبارات الوحدة؟
ج اختبارات الوحدة تحاكي جميع التبعيات (اختبار فئة واحدة)؛ اختبارات التكامل تستخدم تبعيات حقيقية (قاعدة بيانات، HTTP). اختبارات الوحدة سريعة ومستقرة؛ اختبارات التكامل أبطأ لكن أكثر واقعية.

📖 ملخص


📝 تمارين

  1. مبتدئ (⭐): اكتب اختبارات JUnit 5 لـ Order للتحقق من سلوك id و status. تلميح: @Test fun \should create order`() { ... }`
  2. متوسط (⭐⭐): استخدم MockK لمحاكاة OrderRepository واختبر OrderService.processOrder() لكل من المسار السليم ومسار الخطأ. تلميح: every { repo.findById(any()) } returns order
  3. متقدم (⭐⭐⭐): استخدم runTest + coEvery لاختبار دالة جلب طلب غير متزامنة، مع التحقق من توقيت استدعاءات الكوروتينات. تلميح: coEvery { repo.fetchOrderAsync(any()) } returns ...

← السابق | التالي →

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%