Pantrix UI

Bir ekranı test etmek

Compose Multiplatform'da yazılmış bir ekranın testinin neden makineden makineye değiştiği, bir bileşenin otomasyon etiketiyle nasıl bulunduğu, platform hizmetlerinin yerine neyin konduğu ve aynı etiketlerin cihaz otomasyonuna (Maestro) nasıl ulaştığı. Sorun Pantrix'e özgü değildir: otomasyon etiketi, tema, pano, dosya seçici ya da saat kullanan her Compose Multiplatform projesinde aynı şekilde görülür.

Compose hakkında söylenen her şey Compose Multiplatform 1.12.2'nin ve compose ui-test 1.12.2'nin kaynaklarında okunmuş ya da bu depoda koşan bir testte görülmüştür; Maestro hakkında söylenenler Maestro 2.10.0 ile Android emülatörlerinde (API 34 tablet, Pixel 7 Pro API 33, Pixel 9 Pro Fold API 35), iPhone 18 Pro Max simülatöründe (iOS 27.0) ve Maestro'nun Chromium'unda görülmüştür. Hangisinin nerede görüldüğü 7. ve 8. bölümlerde yazılıdır.

1. Sorun

Belirti

Neden

2. Çözüm: beş parça

ParçaNe sağlar
1Etiketi bileşenin kuralıyla kurmak. Test bileşenin adını ve anahtarını yazar; etiketi bileşenin kullandığı fonksiyon kurarKural tek yerdedir; anahtar ekrandaki kodla aynı yazılır
2Sabit bir tema. Platform, dil, karanlık mod, yoğunluk, yazı ölçeği, hareket ve işletim sistemi testin seçimidirTest her makinede aynı ekranı görür
3Test çiftleri. Pano, dosya seçici, analitik, dokunsal geri bildirim ve saat testin kendisinindirMakineye dokunulmaz; ekranın söylediği kaydedilir
4Doğrulamalar. Tanılamanın bulguları, ad, dokunma hedefi, kısayol, yazıErişilebilirlik ve platform kuralları bir satırla sınanır
5Cihaz otomasyonu. Aynı etiketler Android'e ve iOS'a ulaşırBir Maestro akışı da ekrandaki kodun anahtarını arar; dil değişince kırılmaz

Birinci parça olmadan ötekiler kırılgandır: elle yazılmış her etiket bir kural kopyasıdır. İkinci ve üçüncü olmadan bir test makineye bağlıdır.

3. Pantrix ile

Test modülü ui/testing, com.pantrixui:testing olarak yayınlanır; paketi com.pantrixui.testing. Yalnızca :ui:foundation'a ve compose ui-test'e bağlıdır: bileşenler adlarıyla, metin olarak verilir. Foundation'da üç açılım onunla birlikte gelir: pantrixTestTagOf, LocalPantrixClock ve LocalFilePicker.

3.1. Kurulum

kotlin {
    sourceSets {
        commonTest.dependencies {
            implementation("com.pantrixui:testing:0.1.0-alpha03")
            implementation(kotlin("test"))
        }
        // runComposeUiTest masaüstünde gerçekten çizer: test JVM'i bu makinenin Skiko'sunu ister.
        jvmTest.dependencies { implementation(compose.desktop.currentOs) }
    }
}

3.2. Bir bileşeni bulmak

APINe yapar
hasPantrixTag(component, tagKey?, scope?)Bir SemanticsMatcher: bileşenin bu anahtarla ve bu bölümde taşıdığı etiket
onPantrixNode(component, tagKey?, scope?)O tek düğüm; yoksa ya da birden çoksa ona yapılan düşer
onAllPantrixNodes(component, tagKey?, scope?)Hepsi, ağacın sırasıyla
pantrixTags()Ekranın taşıdığı Pantrix etiketleri, ağacın sırasıyla
pantrixTestTagOf(component, tagKey, scope) (foundation)Etiketin metni, ekran kurulmadan
onPantrixNode("Button", "Save draft").performClick()            // Button_save_draft
onPantrixNode("Button", "pay", scope = "Checkout.Payment")      // Checkout.Payment.Button_pay
onAllPantrixNodes("Button", scope = "Checkout").assertCountEquals(3)
onPantrixNode("Segment", "newest", scope = "SegmentButton_sort") // bir kontrolün parçası

3.3. Sabit bir tema

PantrixTestTheme bir ekranı her makinede aynı gösterir. Varsayılanlar:

ParametreVarsayılanNeden
platformPlatformType.AndroidDokunma hedefleri, odak halkaları ve platformun yolları sabit
systemPlatformunki; masaüstünde ve web'de WindowsKısayolların neyle başladığı (⌘ mı Ctrl mi) makineye bağlı kalmaz
darkfalse
localePantrixLocale.EnglishYazının yönü de onunla gelir
densityPantrixDensity.Comfortable
fontScale1fYalnızca yazı büyür; ekranın pikselleri aynı kalır
windownull: testin çizdiği pencereVerilirse PantrixWindowFrame içinde: katlar ve katmanlar ona göre
hareketAzaltılmışHiçbir şey yarı yolda yakalanmaz; bir snackbar bir karede girer ve çıkar
findingsnull: tanılama açık, bulgular günlüğeVerilirse bulgular orada toplanır (3.5)
analytics, hapticsNoOpKaydedici verilince ekranın söyledikleri tutulur (3.4)
clipboardTemanın kendi TestClipboard'uMakinenin panosu hiç yazılmaz
filePickerTemanın kendi, hiç cevap vermeyen seçicisiSistemin hiçbir penceresi açılmaz
clocknull: cihazın saatiTarih ya da saat gösteren her test kendi TestClock'unu vermeli
@OptIn(ExperimentalTestApi::class)
@Test
fun pay_is_told_and_the_screen_has_no_findings() = runComposeUiTest {
    val findings = PantrixFindings()
    val analytics = RecordingAnalytics()
    setContent { PantrixTestTheme(findings = findings, analytics = analytics) { CheckoutScreen() } }
    onPantrixNode("Button", "pay").performClick()
    assertEquals(listOf("click"), analytics.eventsOf("Button").map { it.action })
    assertNoFindings(findings)
}

Her platformda: onEachPlatform(content, check) ekranı dört platformda sırayla, testin tek koşusunda kurar. Web'de bir koşu, testin geri verdiği bir söz (promise) olduğundan, bir döngüden başlatılan koşuları kimse beklemez; onlarda düşen hiçbir testi düşürmez.

onEachPlatform({ platform -> PantrixTestTheme(platform = platform) { SaveButton() } }) { platform ->
    onPantrixNode("Button", "save").assertTouchTargetOf(platform)
}

3.4. Test çiftleri

ÇiftNe tutar, nasıl cevap verir
TestClipboard(traits)Ekranın kopyaladığını, sırayla. text() ve texts() platformun okuduğu gibi okur: Android'de ClipData'nın ilk öğesi, masaüstünde Transferable'ın metni, iOS'ta düz metin, web'de text/plain. Web'de okumak tarayıcıyı bekler (suspend). traits ekrana platformun huyu olarak söylenir: ClipboardTraits(announcedBySystem = true) Android 13, ClipboardTraits(writable = false) düz http'den sunulan bir sayfa. Compose'un metin alanının sorduğu platform panosu da (nativeClipboard) testindir: masaüstünde son kopyayı tutan kendi AWT panosu, iOS'ta genel pasteboard'dan ayrı, kendi adlı bir pasteboard
RecordingAnalytics()Bileşenlerin bildirdiği olayları sırayla: events, eventsOf("Button")
RecordingHaptics()Çalınan dokunsal geri bildirimleri sırayla: played
TestFilePicker(files)Her seçime files ile cevap verir: yalnızca seçicinin gösterdiği türlerden, tek dosya istenmişse ilkiyle; hiçbiri uymuyorsa cevap vermez, seçiciyi kapatan bir kullanıcı gibi. requests her seçimin ne istediğini tutar
TestClock(now, utcOffset)Testin kurduğu anı gösterir; yalnızca test ilerletince ilerler (advanceBy, now = …). utcOffset verilmezse UTC'nin saatidir
TestFileSaver(result)Ekranın kaydettiği dosyaları sırayla tutar (files) ve her kaydetmeye result ile cevap verir: verilmezse kaydedildi. Tema her zaman kendisininkini verir, hiçbir kaydetme penceresi açılmaz; kaydedileni okuyan test kendisininkini temanın içinde, LocalFileSaver ile verir

Listelenen çiftler, TestFileSaver dışında, PantrixTestTheme'in parametreleridir. Tema kullanılmadan da verilebilirler, foundation'ın kendi yerlerinden:

// Bütün ekranın seçicisi: rememberFilePicker() ve PantrixFileDrop'un varsayılanı bunu alır.
CompositionLocalProvider(LocalFilePicker provides picker) { OfferScreen() }

// Ekranın kaydedicisi: rememberFileSaver() bunu verir. PantrixTestTheme'in içinde verilir.
CompositionLocalProvider(LocalFileSaver provides saver) { ThemeBuilderScreen() }

// Ekranın saati: rememberToday(), saat alanının seçicisi, elastic date picker ve grafiklerin anları bunu okur.
CompositionLocalProvider(LocalPantrixClock provides PantrixClock(clock) { 3.hours }) { BookingScreen() }

3.5. Doğrulamalar

APINe doğrular
assertNoFindings(findings)Tanılama hiçbir şey bulmadı: adsız kontrol, okunmayan renk, sıkışmış dokunma hedefi, iki düğümde bir etiket. Önce testin saatini bir buçuk saniyenin ötesine ilerletir, sonra bakar; düşerse her bulguyu adıyla söyler
assertHasName(name)Ekran okuyucunun düğüme verdiği ad: içerik açıklaması, yoksa yazısı
assertTouchTargetOf(platform)Parmağın basabileceği alan platformun istediği kadar: Android'de 48dp, iOS'ta 44pt, masaüstünde ve web'de fareyle 32dp. Ölçülen, basışı alan bölgedir, çizilen değil
performShortcut(shortcut, system)Kısayola o sistemin klavyesi gibi basar: Apple'ın sistemlerinde Command, ötekilerde Control ile. Ekranın da o sistemde yazıldığını PantrixTestTheme(system = …) söyler
readText()Düğümün gösterdiği yazı: bir metin alanının tuttuğu, yoksa yazıları ve birleştirdiklerinin yazıları, satır satır

Piksel ve renk yardımcıları modülde yoktur: bir rengi pikselde aramak bileşenin çizimine bağlıdır ve yalnızca Pantrix'in kendi testlerinde durur.

3.6. Ekran görüntüleri

Ekran görüntüsü testleri için modülde bir yardımcı yoktur ve modül Roborazzi'ye bağlanmaz: hangi platformda, hangi araçla ve nereye kaydedileceği uygulamanın seçimidir. Pantrix'in kendi kurulumu Roborazzi rehberindedir. Bir uygulama için özeti:

3.7. Cihaz otomasyonu: Maestro

Pantrix'in etiketleri cihazın erişilebilirlik ağacına ulaşır: Android'de resource-id olarak (en dıştaki tema ve her modal ya da açılır katmanın kökü testTagsAsResourceId'yi açar), iOS'ta accessibilityIdentifier olarak (Compose testTag'i kendisi çevirir). Bir Maestro akışı bir bileşeni id: ile, ekrandaki kodun anahtarıyla bulur; uygulama başka bir dilde açılınca akış kırılmaz.

Uygulamanın kendi açtığı bir pencere, kendi Popup'ı ya da Dialog'u, Android'de bu köklerin dışındadır: gösterdiğinin kökü Modifier.pantrixAutomationRoot()'u kendisi taşır. iOS'ta, masaüstünde ve web'de bu bir şey değiştirmez.

Popup(onDismissRequest = onClose) {
    PantrixModalLayer {
        PantrixSurface(modifier = Modifier.pantrixTransientsRoot().pantrixAutomationRoot()) {
            PantrixButton(text = stringResource(Res.string.close), onClick = onClose, tagKey = "close_layer")
        }
    }
}
appId: ${APP_ID}
---
- launchApp:
    stopApp: true
- tapOn:
    id: "Button_browse"
- tapOn:
    id: "TextField_search"
- inputText: "PantrixSheet"
- tapOn:
    id: "Card_sheet"

Demo'nun akışları app/maestro/ altındadır; kataloğun her sayfasının bir akışı vardır:

Demo'nun MaestroFlowsTest'i (JVM) akışların kataloğun gerisinde kalmadığına bakar: her sayfanın bölümünün klasöründe bir akışı vardır, akış sayfayı bağlantısıyla açar ve sayfanın her örneğine sırasıyla uğrar. Bir sayfa ya da bir örnek eklenip akışı yazılmazsa test cihazsız düşer.

NasılKomut
ElleAndroid'in hata ayıklama derlemesinde maestro test -e APP_ID=com.pantrixui.cmp.demo.debug -e LINK_SCHEME=pantrixui-debug --exclude-tags fold app/maestro; iOS'ta bundle kimliği takım kimliğiyle (com.pantrixui.cmp.demo.PantrixUI<TEAM_ID>) ve LINK_SCHEME'siz
Android, Gradle./gradlew :app:androidApp:maestroTest: debug derlemesini yalnızca o cihaza kurar, akışları koşar
iOS, Gradle./gradlew :app:shared:maestroIosTest: uygulamayı Xcode ile açık simülatöre derler, kurar, akışları koşar
Bir bölüm./gradlew :app:androidApp:maestroTest -Pmaestro.flows=flows/form
Duman testi./gradlew :app:shared:maestroIosTest -Pmaestro.include=smoke

Ne zaman hangisi: Ekranın davranışı, erişilebilirliği ve platform kuralları ortak testlerde (runComposeUiTest), beş hedefte sınanır. Maestro gerçek bir cihazda, gerçek bir uygulamanın kurulduğunu ve etiketlerin sisteme ulaştığını gösterir; klavyeyi, sistem çubuklarını ve gezinmeyi de içerir.

4. Pantrix olmadan

Aşağıdakiler kütüphanesiz bir projeye kopyalanabilecek en kısa hâllerdir; Pantrix'in kodu (ui/testing/src/) aynı şeyi testleri ve kenar durumlarıyla yapar.

4.1. Etiketi tek yerden kurmak (commonMain ve commonTest)

// commonMain: bileşen etiketini bununla kurar.
fun testTagOf(component: String, key: String, scope: String? = null): String {
    val slug = key.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').take(32).trimEnd('_')
    return listOfNotNull(scope, "${component}_$slug").joinToString(".")
}

// commonTest: test de aynısını çağırır.
fun SemanticsNodeInteractionsProvider.onComponent(component: String, key: String, scope: String? = null) =
    onNode(hasTestTag(testTagOf(component, key, scope)))

4.2. Sabit bir tema ve testin panosu (commonTest)

class KeptClipboard : Clipboard {
    val entries = mutableListOf<ClipEntry?>()
    override suspend fun getClipEntry(): ClipEntry? = entries.lastOrNull()
    override suspend fun setClipEntry(clipEntry: ClipEntry?) { entries += clipEntry }
}

@Composable
fun FixedScreen(clipboard: Clipboard = remember { KeptClipboard() }, content: @Composable () -> Unit) {
    val drawn = LocalDensity.current
    CompositionLocalProvider(
        LocalDensity provides Density(drawn.density, fontScale = 1f),
        LocalLayoutDirection provides LayoutDirection.Ltr,
        LocalClipboard provides clipboard,
        content = content
    )
}

Temanın kendi varsayılanları da (dil, karanlık mod, hareket, işletim sistemi) aynı biçimde açıkça verilmelidir; hangileri olduğu temanın parametrelerinde okunur.

4.3. Etiketleri android'in otomasyonuna açmak (androidMain)

// Kökte bir kez; bir Dialog ya da Popup ayrı bir pencere olduğundan onun kökünde de.
Modifier.semantics { testTagsAsResourceId = true }

iOS'ta gerek yoktur: Compose testTag'i accessibilityIdentifier olarak verir.

5. Yardımcının yapmadıkları

6. Test etmek

Modülün kendi testleri çoğunlukla ortak koddadır ve beş hedefte koşar (JVM, iOS simülatörü, JS, Wasm; Android'inkiler ayrı ağaçta): bulmak, tema, çiftler ve doğrulamalar. Platformun panosunu masaüstünde ve iOS'ta kendi testleri sınar. Android'in host testi manifest'sizdir: modülün manifest'inin etkinliği bildirdiğini gösterir; etkinlik manifest'ten çıkarılınca "Unable to resolve activity" ile düşer.

Pantrix'in kendi testleri de modülü kullanır. Bileşen testlerinin TestTheme'i PantrixTestTheme'in üstünde ince bir sarmalayıcıdır: renkler, hareket ve tanılama onun içindeki bir temadan gelir. Olaylar ve dokunsal geri bildirim RecordingAnalytics ve RecordingHaptics'le tutulur, masaüstünün pano testleri TestClipboard'u kullanır. Foundation'ın ve bileşenlerin Android host testlerinin kendi manifest'i yoktur: etkinliği modülün manifest'i bildirir.

Her davranış bir kez bozulup testin kırmızıya döndüğü görüldü (8. bölüm). Saat, iki farkla okunur: bu makine UTC+3'te, tek farkla yazılmış bir test cihazın saatini okuyan bir bileşende de geçerdi.

7. Tuzaklar

8. Doğrulama

Testler

NeNerede
Bulmak: anahtar ve kesilmesi, bölüm zinciri, anahtarsız bulmak, parçalar, ekranın etiketleri ve birleştirilmiş ağaçui/testing/src/commonTest/.../PantrixTagsTest.kt (JVM, iOS, JS, Wasm)
Tema: varsayılanlar, verilenler, masaüstünde ve web'de Windows, pencere ve durumun korunması, pano ve onun üstünde odaklanan bir metin alanı, seçici, saat, tanılama, bekleyen bulgu, her platformPantrixTestThemeTest
Çiftler: analitik, dokunsal geri bildirim, seçicinin cevapları, saat, pano (zor metinler dahil) ve yazılamayan panoTestDoublesTest
Doğrulamalar: ad, yazı, dokunma hedefi, kısayolPantrixAssertionsTest
Masaüstünde platformun panosu: son kopyayı tutar, temizlenince boşalır, her testin panosu kendininui/testing/src/jvmTest/.../TestClipboardOnTheDesktopTest.kt
iOS'ta platformun panosu: aynıları, genel pasteboard'a hiç yazılmaması daui/testing/src/iosTest/.../TestClipboardOnIosTest.kt
Android: manifest'siz test, ClipData ve etiketi, seçiciui/testing/src/androidHostTest/.../TestingOnAndroidTest.kt
Etiketin metniui/foundation/src/commonTest/.../testing/TestTagTest.kt
Ekranın saati ve okuyanlarıPantrixClockTest, TimeTextFieldTest, ElasticDatePickerTest, ChartScreenClockTest
Ekranın seçicisiLocalFilePickerTest, FilesOnAndroidTest

Elle bozulan davranışlar

Elle bakılanlar

Doğrulanmayanlar

9. Bu depoda nerede

10. Açık kalanlar

docs/OPEN_ITEMS.md'de: web'de cihaz otomasyonu ve Android'de testin panosuyla açılan metin alanı menüsü.