我来帮你实现一个Kotlin跨平台的自动化测试方案,支持Android和iOS。
项目结构
kotlin-multiplatform-test/
├── shared/
│ ├── src/
│ │ ├── commonMain/
│ │ ├── commonTest/
│ │ ├── androidMain/
│ │ ├── androidTest/
│ │ ├── iosMain/
│ │ └── iosTest/
│ └── build.gradle.kts
├── androidApp/
│ ├── src/
│ └── build.gradle.kts
├── iosApp/
│ └── iosApp.xcodeproj
└── build.gradle.kts
1. 根目录 build.gradle.kts
<kotlin>
buildscript { repositories { gradlePluginPortal() google() mavenCentral() } dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20") classpath("com.android.tools.build:gradle:8.1.0") }}allprojects { repositories { google() mavenCentral() }}
2. Shared模块 build.gradle.kts
<kotlin>
plugins { kotlin("multiplatform") kotlin("native.cocoapods") id("com.android.library")}kotlin { android { compilations.all { kotlinOptions { jvmTarget = "1.8" } } } iosX64() iosArm64() iosSimulatorArm64() cocoapods { summary = "Shared module for testing" homepage = "Link to the Shared Module homepage" ios.deploymentTarget = "14.1" framework { baseName = "shared" } } sourceSets { val commonMain by getting { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") } } val commonTest by getting { dependencies { implementation(kotlin("test")) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") } } val androidMain by getting { dependencies { implementation("androidx.test.espresso:espresso-core:3.5.1") implementation("androidx.test:runner:1.5.2") implementation("androidx.test:rules:1.5.0") implementation("androidx.test.uiautomator:uiautomator:2.2.0") } } val androidUnitTest by getting { dependencies { implementation("junit:junit:4.13.2") implementation("org.mockito:mockito-core:5.3.1") implementation("org.robolectric:robolectric:4.10") } } val iosMain by getting val iosTest by getting }}android { namespace = "com.example.shared" compileSdk = 34 defaultConfig { minSdk = 24 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }}
3. 跨平台测试接口
commonMain/kotlin/TestInterface.kt
<kotlin>
package com.example.testingexpect class PlatformTestRunner { fun runUITest(testCase: UITestCase) fun findElementById(id: String): TestElement? fun findElementByText(text: String): TestElement? fun takeScreenshot(name: String)}expect class TestElement { fun click() fun setText(text: String) fun getText(): String fun isDisplayed(): Boolean fun swipe(direction: SwipeDirection)}enum class SwipeDirection { UP, DOWN, LEFT, RIGHT}data class UITestCase( val name: String, val steps: List<TestStep>)sealed class TestStep { data class Click(val elementId: String) : TestStep() data class Input(val elementId: String, val text: String) : TestStep() data class Verify(val elementId: String, val expectedText: String) : TestStep() data class Wait(val seconds: Int) : TestStep() data class Swipe(val direction: SwipeDirection) : TestStep()}
4. Android实现
androidMain/kotlin/AndroidTestRunner.kt
<kotlin>
package com.example.testingimport androidx.test.espresso.Espressoimport androidx.test.espresso.action.ViewActionsimport androidx.test.espresso.assertion.ViewAssertionsimport androidx.test.espresso.matcher.ViewMatchersimport androidx.test.platform.app.InstrumentationRegistryimport androidx.test.uiautomator.UiDeviceimport androidx.test.uiautomator.UiSelectorimport android.view.Viewimport org.hamcrest.Matcherimport java.io.Fileactual class PlatformTestRunner { private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) actual fun runUITest(testCase: UITestCase) { testCase.steps.forEach { step -> when (step) { is TestStep.Click -> { findElementById(step.elementId)?.click() } is TestStep.Input -> { findElementById(step.elementId)?.setText(step.text) } is TestStep.Verify -> { val element = findElementById(step.elementId) assert(element?.getText() == step.expectedText) } is TestStep.Wait -> { Thread.sleep(step.seconds * 1000L) } is TestStep.Swipe -> { swipeScreen(step.direction) } } } } actual fun findElementById(id: String): TestElement? { return try { val element = device.findObject(UiSelector().resourceId(id)) if (element.exists()) AndroidTestElement(element) else null } catch (e: Exception) { null } } actual fun findElementByText(text: String): TestElement? { return try { val element = device.findObject(UiSelector().text(text)) if (element.exists()) AndroidTestElement(element) else null } catch (e: Exception) { null } } actual fun takeScreenshot(name: String) { val file = File( InstrumentationRegistry.getInstrumentation().targetContext.filesDir, "$name.png" ) device.takeScreenshot(file) } private fun swipeScreen(direction: SwipeDirection) { val width = device.displayWidth val height = device.displayHeight when (direction) { SwipeDirection.UP -> device.swipe(width / 2, height * 3 / 4, width / 2, height / 4, 10) SwipeDirection.DOWN -> device.swipe(width / 2, height / 4, width / 2, height * 3 / 4, 10) SwipeDirection.LEFT -> device.swipe(width * 3 / 4, height / 2, width / 4, height / 2, 10) SwipeDirection.RIGHT -> device.swipe(width / 4, height / 2, width * 3 / 4, height / 2, 10) } }}actual class TestElement(private val uiObject: androidx.test.uiautomator.UiObject) { actual fun click() { uiObject.click() } actual fun setText(text: String) { uiObject.setText(text) } actual fun getText(): String { return uiObject.text ?: "" } actual fun isDisplayed(): Boolean { return uiObject.exists() } actual fun swipe(direction: SwipeDirection) { when (direction) { SwipeDirection.UP -> uiObject.swipeUp(10) SwipeDirection.DOWN -> uiObject.swipeDown(10) SwipeDirection.LEFT -> uiObject.swipeLeft(10) SwipeDirection.RIGHT -> uiObject.swipeRight(10) } }}
5. iOS实现
iosMain/kotlin/IOSTestRunner.kt
<kotlin>
package com.example.testingimport platform.XCTest.*import platform.Foundation.*import kotlinx.cinterop.*actual class PlatformTestRunner { private val app = XCUIApplication() init { app.launch() } actual fun runUITest(testCase: UITestCase) { testCase.steps.forEach { step -> when (step) { is TestStep.Click -> { findElementById(step.elementId)?.click() } is TestStep.Input -> { findElementById(step.elementId)?.setText(step.text) } is TestStep.Verify -> { val element = findElementById(step.elementId) assert(element?.getText() == step.expectedText) } is TestStep.Wait -> { NSThread.sleepForTimeInterval(step.seconds.toDouble()) } is TestStep.Swipe -> { swipeScreen(step.direction) } } } } actual fun findElementById(id: String): TestElement? { val element = app.descendantsMatchingType(XCUIElementTypeAny) .matchingIdentifier(id) .firstMatch return if (element.exists()) IOSTestElement(element) else null } actual fun findElementByText(text: String): TestElement? { val predicate = NSPredicate.predicateWithFormat("label == %@", text) val element = app.descendantsMatchingType(XCUIElementTypeAny) .matchingPredicate(predicate) .firstMatch return if (element.exists()) IOSTestElement(element) else null } actual fun takeScreenshot(name: String) { val screenshot = XCUIScreen.mainScreen.screenshot() // Save screenshot logic } private fun swipeScreen(direction: SwipeDirection) { when (direction) { SwipeDirection.UP -> app.swipeUp() SwipeDirection.DOWN -> app.swipeDown() SwipeDirection.LEFT -> app.swipeLeft() SwipeDirection.RIGHT -> app.swipeRight() } }}actual class TestElement(private val xcuiElement: XCUIElement) { actual fun click() { xcuiElement.tap() } actual fun setText(text: String) { xcuiElement.typeText(text) } actual fun getText(): String { return xcuiElement.label ?: "" } actual fun isDisplayed(): Boolean { return xcuiElement.exists() && xcuiElement.hittable } actual fun swipe(direction: SwipeDirection) { when (direction) { SwipeDirection.UP -> xcuiElement.swipeUp() SwipeDirection.DOWN -> xcuiElement.swipeDown() SwipeDirection.LEFT -> xcuiElement.swipeLeft() SwipeDirection.RIGHT -> xcuiElement.swipeRight() } }}
6. 测试用例示例
commonTest/kotlin/LoginTest.kt
<kotlin>
package com.example.testingimport kotlin.test.Testclass LoginTest { private val testRunner = PlatformTestRunner() @Test fun testLoginFlow() { val loginTest = UITestCase( name = "Login Flow Test", steps = listOf( TestStep.Click("login_button"), TestStep.Input("username_field", "testuser@example.com"), TestStep.Input("password_field", "password123"), TestStep.Click("submit_button"), TestStep.Wait(2), TestStep.Verify("welcome_text", "Welcome, Test User!") ) ) testRunner.runUITest(loginTest) testRunner.takeScreenshot("login_success") } @Test fun testScrollAndClick() { val scrollTest = UITestCase( name = "Scroll Test", steps = listOf( TestStep.Swipe(SwipeDirection.UP), TestStep.Wait(1), TestStep.Click("bottom_element"), TestStep.Verify("result_text", "Clicked!") ) ) testRunner.runUITest(scrollTest) }}
7. 页面对象模式
commonMain/kotlin/PageObject.kt
<kotlin>
package com.example.testing.pagesabstract class BasePage(protected val testRunner: PlatformTestRunner) { abstract fun verify()}class LoginPage(testRunner: PlatformTestRunner) : BasePage(testRunner) { private val usernameField = "username_field" private val passwordField = "password_field" private val loginButton = "login_button" override fun verify() { assert(testRunner.findElementById(loginButton)?.isDisplayed() == true) } fun login(username: String, password: String): HomePage { testRunner.findElementById(usernameField)?.setText(username) testRunner.findElementById(passwordField)?.setText(password) testRunner.findElementById(loginButton)?.click() return HomePage(testRunner) }}class HomePage(testRunner: PlatformTestRunner) : BasePage(testRunner) { private val welcomeText = "welcome_text" override fun verify() { assert(testRunner.findElementById(welcomeText)?.isDisplayed() == true) } fun getWelcomeMessage(): String { return testRunner.findElementById(welcomeText)?.getText() ?: "" }}
8. 运行测试
Android
<bash>
./gradlew :androidApp:connectedAndroidTest
iOS
<bash>
xcodebuild test -workspace iosApp.xcworkspace -scheme iosApp -destination 'platform=iOS Simulator,name=iPhone 14'
这个实现提供了:
跨平台的测试APIAndroid和iOS的具体实现页面对象模式支持基本的UI操作(点击、输入、滑动等)截图功能可扩展的测试框架
你可以根据具体需求进一步扩展功能。