Kotlin: شرح اختبار كوتلن باستخدام JUnit 5 و MockK
آخر تحديث: 2026-08-26
عقيدة Bob: الكود غير المُختبر هو افتراضات غير مُتحقَقة. JUnit 5 + MockK هو الثنائي الذهبي لاختبار كوتلن — صُمم MockK أصلاً لكوتلن (يدعم محاكاة الكوروتينات)، وJUnit 5 يوفر إطار اختبار حديث.
1. ما ستتعلمه
- JUnit 5 + كوتلن: اختبارات
@Test،@ParameterizedTest،@Nested - MockK: إطار محاكاة أصلي لكوتلن
- اختبار الكوروتينات:
runTest/coEvery/coVerify - دورة حياة الاختبار:
@BeforeEach/@AfterEach - Charlie في الممارسة: تغطية كاملة لـ OrderProcessorTest
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. هرم الاختبار والتدفق
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). اختبارات الوحدة سريعة ومستقرة؛ اختبارات التكامل أبطأ لكن أكثر واقعية.
📖 ملخص
- JUnit 5 + MockK هو الثنائي الذهبي لاختبار كوتلن
- MockK يدعم كوتلن أصلاً: الفئات النهائية، الكوروتينات، الدوال الممتدة، الكائنات
every { } returnsللتعريف،verify { }للتحقق من الاستدعاءاتrunTestلاختبار الكوروتينات، يستخدم الوقت الافتراضي فلا حاجة للانتظار عندdelaycoEvery/coVerifyخصيصًا لمحاكاة دوال suspend والتحقق منها- الاختبارات المتداخلة (
@Nested) تنظم الاختبارات ذات الصلة؛ الاختبارات المُمعَلَمة (@ParameterizedTest) تقلل التكرار
📝 تمارين
- مبتدئ (⭐): اكتب اختبارات JUnit 5 لـ
Orderللتحقق من سلوكidوstatus. تلميح:@Test fun \should create order`() { ... }` - متوسط (⭐⭐): استخدم MockK لمحاكاة
OrderRepositoryواختبرOrderService.processOrder()لكل من المسار السليم ومسار الخطأ. تلميح:every { repo.findById(any()) } returns order - متقدم (⭐⭐⭐): استخدم
runTest+coEveryلاختبار دالة جلب طلب غير متزامنة، مع التحقق من توقيت استدعاءات الكوروتينات. تلميح:coEvery { repo.fetchOrderAsync(any()) } returns ...