Pull request'in açıklaması kısa: "Booking module'üne unit test eklendi." Diff'te kırk yeni test, hepsi yeşil, kapsam raporu yüzde otuzlardan yetmişlere çıkmış. Yazan bir agent, onaylayan iki kişi, toplam inceleme süresi bir kahve molası. Üç hafta sonra destek ekibinden bir kayıt gelir: kalkışa tam 48 saat kala iptal eden yolcuya yarım iade yapılmış. İade hesabının üstünde beş test vardır. Biri bu davranışı açıkça doğrulamaktadır.
Bizim pozisyonumuz şu: AI test yazma işinin mekanik kısmını çok iyi yapıyor, neyin doğru olduğuna karar verme kısmını ise yapamıyor, çünkü elindeki tek bilgi kaynağı test edeceği kodun kendisi. Beklenen davranışı bir insan ya da ticket söylemediği sürece üretilen test, kodun bugünkü hâlini onaylayan bir imzadan ibarettir. Bu yazıda o imzayı nasıl tanıyacağınızı, yerine ne isteyeceğinizi ve testlerin gerçekten bir şey yakaladığını nasıl kontrol edeceğinizi anlatıyoruz. Örnek, hayali bir seyahat rezervasyon uygulamasının iptal ve iade akışı.
Kapsam bir satırın çalıştığını söyler, doğrulandığını söylemez
Kapsam aracı tek bir şeyi ölçer: test sırasında hangi satırlar çalıştı. O satırların sonucuna bakan bir assertion olup olmadığını bilmez. Bir fonksiyonu çağırıp dönüş değerini çöpe atan test ile bütün sınır değerlerini sınayan test, kapsam raporunda aynı yeşil satırı üretir.
İnsan elle test yazarken bu fark kendiliğinden sınırlı kalır, çünkü anlamsız test yazmak da emek ister. Agent için maliyet sıfırdır. "Kapsamı yüzde seksene çıkar" dediğinizde hedefi tutturmanın en kısa yolunu bulur: her public fonksiyonu bir kez çağırır, ne dönüyorsa onu bekler. Yanlış hedefi verdiniz, doğru şekilde tutturuldu.
Üretilmiş testin tanıdık hâlleri
Önce test edilecek kod ve onu doğuran ticket:
BOOK-1423 İptal iadesi kuralları
- Esnek bilet, kalkışa 48 saat ya da daha fazla varken iptal: tam iade
- Esnek bilet, 24 saat (dahil) ile 48 saat arası: yarı iade
- Esnek bilet, 24 saatten az kala ya da kalkıştan sonra: iade yok
- İade edilemez bilet: her durumda iade yok
enum Fare: Sendable { case flexible, nonRefundable }
struct Booking: Sendable {
let id: String
let price: Decimal
let departure: Date
let fare: Fare
}
struct RefundPolicy: Sendable {
func refund(for booking: Booking, cancelledAt now: Date) -> Decimal {
guard booking.fare == .flexible else { return 0 }
let hoursLeft = booking.departure.timeIntervalSince(now) / 3600
if hoursLeft > 48 { return booking.price } // ticket "48 ya da daha fazla" diyor
if hoursLeft >= 24 { return booking.price / 2 }
return 0
}
}
Kodda bir sınır hatası var: >= olması gereken yerde > yazılmış. Agent'a ticket'ı vermeden "bu dosya için test yaz" dediğinizde gelen testler aşağı yukarı şöyledir:
import Testing
import Foundation
struct RefundPolicyGeneratedTests {
let policy = RefundPolicy()
let departure = Date(timeIntervalSince1970: 1_800_000_000)
// 1) Implementasyonun aynası: formül testin içinde bir kez daha yazılmış
@Test func refundMatchesFormula() {
let booking = Booking(id: "B1", price: 200, departure: departure, fare: .flexible)
let now = departure.addingTimeInterval(-30 * 3600)
let hoursLeft = booking.departure.timeIntervalSince(now) / 3600
let expected: Decimal = hoursLeft > 48 ? booking.price
: hoursLeft >= 24 ? booking.price / 2 : 0
#expect(policy.refund(for: booking, cancelledAt: now) == expected)
}
// 2) Mevcut bug'ın fotoğrafı: kod ne dönüyorsa beklenen o
@Test func refundAtExactly48HoursIsHalf() {
let booking = Booking(id: "B1", price: 200, departure: departure, fare: .flexible)
let now = departure.addingTimeInterval(-48 * 3600)
#expect(policy.refund(for: booking, cancelledAt: now) == 100)
}
// 3) Kırmızıya dönemeyen test
@Test func refundIsNeverNegative() {
let booking = Booking(id: "B1", price: 200, departure: departure, fare: .flexible)
#expect(policy.refund(for: booking, cancelledAt: .now) >= 0)
}
}
Birinci test, hesabı üretim kodundan kopyalayıp sonucu kendisiyle karşılaştırıyor. Formüldeki hata iki tarafta da var, o yüzden test hatayla birlikte geçer. İkinci test en tehlikelisidir: adı ve gövdesi tutarlı, okuyan kişi "48 saatte yarım iade kuralı varmış" diye düşünür. Bug artık belgelenmiş bir davranıştır ve onu düzelten kişi kırmızı bir testle karşılaşır. Üçüncü test hangi gün çalıştırıldığına göre farklı bir dala girer ve hiçbirinde başarısız olamaz.
Dördüncü hâl aşırı mock kullanımıdır; onu birazdan Kotlin tarafında göstereceğiz. Dördünün ortak noktası aynı: beklenen değerin kaynağı koddur.
iOS: aynı kod için işe yarayan test
Beklenen değerler ticket'tan geliyor, test public arayüzü kullanıyor, sınırlar tabloda açıkça duruyor:
import Testing
import Foundation
struct RefundCase: Sendable, CustomTestStringConvertible {
let hoursLeft: Double
let fare: Fare
let expected: Decimal
var testDescription: String { "\(fare), kalkışa \(hoursLeft) saat kala -> \(expected)" }
}
@Suite("İptal iadesi (BOOK-1423)")
struct RefundPolicyTests {
static let departure = Date(timeIntervalSince1970: 1_800_000_000)
static let cases: [RefundCase] = [
RefundCase(hoursLeft: 72, fare: .flexible, expected: 200),
RefundCase(hoursLeft: 48, fare: .flexible, expected: 200), // sınır dahil
RefundCase(hoursLeft: 47.99, fare: .flexible, expected: 100),
RefundCase(hoursLeft: 24, fare: .flexible, expected: 100), // sınır dahil
RefundCase(hoursLeft: 23.99, fare: .flexible, expected: 0),
RefundCase(hoursLeft: -1, fare: .flexible, expected: 0), // kalkıştan sonra
RefundCase(hoursLeft: 72, fare: .nonRefundable, expected: 0),
]
@Test("İade tutarı bilet kuralına uyar", arguments: RefundPolicyTests.cases)
func refund(_ testCase: RefundCase) {
let booking = Booking(id: "B1", price: 200,
departure: Self.departure, fare: testCase.fare)
let cancelledAt = Self.departure.addingTimeInterval(-testCase.hoursLeft * 3600)
#expect(RefundPolicy().refund(for: booking, cancelledAt: cancelledAt) == testCase.expected)
}
}
Bu test bugünkü kodda ikinci satırda kırmızıya döner, ki istediğimiz de budur. Swift Testing parametreli testlerde her argümanı ayrı bir test case olarak raporlar; hangi satırın kırıldığını çıktıda görürsünüz. Tablonun kendisi de bir kazanımdır: ürün yöneticisine gösterip "eksik satır var mı" diye sorabileceğiniz bir belgedir.
Android: aynı tuzak, mock kılığında
Kotlin tarafında fiyat kuruş cinsinden Long olarak tutuluyor ve politika bir use case'in içinden çağrılıyor:
class RefundPolicy {
fun refundMinor(booking: Booking, cancelledAt: Instant): Long {
if (booking.fare != Fare.FLEXIBLE) return 0
val left = Duration.between(cancelledAt, booking.departure)
return when {
left > Duration.ofHours(48) -> booking.priceMinor
left >= Duration.ofHours(24) -> booking.priceMinor / 2
else -> 0
}
}
}
class CancelBookingUseCase(
private val policy: RefundPolicy,
private val api: BookingApi,
private val clock: Clock,
) {
suspend fun cancel(booking: Booking): CancellationResult {
val refund = policy.refundMinor(booking, clock.instant())
api.cancel(booking.id, refund)
return CancellationResult(refundMinor = refund)
}
}
Üretilmiş test (MockK ile):
@Test
fun `cancel calls policy and api`() = runTest {
val policy = mockk<RefundPolicy>()
val api = mockk<BookingApi>(relaxed = true)
val clock = mockk<Clock>()
val booking = mockk<Booking>(relaxed = true)
every { clock.instant() } returns Instant.EPOCH
every { policy.refundMinor(any(), any()) } returns 10_000L
val result = CancelBookingUseCase(policy, api, clock).cancel(booking)
verify(exactly = 1) { policy.refundMinor(any(), any()) }
coVerify(exactly = 1) { api.cancel(any(), any()) }
assertEquals(10_000L, result.refundMinor)
}
Test dört nesneyi mock'luyor, biri veri sınıfı. Doğruladığı şey "mock'a söylediğim sayı geri geldi" ve "iki fonksiyon çağrıldı". RefundPolicy'nin içini tamamen silseniz geçer. API'ye yanlış rezervasyon numarası gönderseniz yine geçer, çünkü any() her şeyi kabul eder. Üstüne, use case'in gövdesindeki satır sırasını değiştiren her refactoring'de kırılır. Yanlış alarm veren ve gerçek hatayı kaçıran bir test, hiç olmamasından pahalıdır.
Aynı use case için işe yarayan hâli gerçek politikayı, sabit bir saati ve sınırdaki tek dış bağımlılık için elle yazılmış bir fake'i kullanır:
class FakeBookingApi : BookingApi {
val cancellations = mutableListOf<Pair<String, Long>>()
override suspend fun cancel(bookingId: String, refundMinor: Long) {
cancellations += bookingId to refundMinor
}
}
fun aBooking(
id: String = "B1",
priceMinor: Long = 20_000,
departure: Instant = Instant.parse("2027-01-15T09:00:00Z"),
fare: Fare = Fare.FLEXIBLE,
) = Booking(id, priceMinor, departure, fare)
@Test
fun `kalkisa 30 saat kala iptal API ye yarim iade bildirir`() = runTest {
val departure = Instant.parse("2027-01-15T09:00:00Z")
val clock = Clock.fixed(departure.minus(Duration.ofHours(30)), ZoneOffset.UTC)
val api = FakeBookingApi()
val useCase = CancelBookingUseCase(RefundPolicy(), api, clock)
val result = useCase.cancel(aBooking(priceMinor = 20_000, departure = departure))
assertEquals(10_000L, result.refundMinor)
assertEquals(listOf("B1" to 10_000L), api.cancellations)
}
Kuralımız basit: mock, sürecin dışına çıkan sınırda (ağ, disk, saat, sistem servisi) durur. Kendi domain sınıflarınızı mock'layan bir test gördüğünüzde review'da geri çevirin.
Bunun yerine ne isteyeceksiniz
Agent'tan test isterken dört şeyi sabitliyoruz.
Davranış önce gelir ve koddan gelmez. Ticket'ın kabul kriterlerini, API sözleşmesini ya da kendi yazdığınız üç satırlık kural listesini prompt'a koyun. Elinizde böyle bir şey yoksa agent'a test yazdırmadan önce davranışı kendiniz yazmanız gerekir; bu, işin atlanamayan kısmıdır.
Sınır durumları koddan önce listelenir. Agent'tan önce yalnızca test senaryolarının listesini isteyin, kodu sonra. Liste beş dakikada okunur ve asıl review oradadır: 48 saat sınırı dahil mi, kalkıştan sonra iptal ne olur, tek kuruşluk fiyatın yarısı hangi yöne yuvarlanır. Son soru ticket'ta yok; agent onu sorarsa işini iyi yapmış demektir, sessizce bir cevap seçerse siz yakalamalısınız.
Test public arayüze yazılır. @testable import ile private yardımcıları sınayan ya da internal alanları okuyan testler implementasyona kilitlenir. Dışarıdan görülen girdi ve çıktı bir davranışı sınamaya yetmiyorsa sorunu sınıfın tasarımında arayın.
Bug için önce kırmızı test. Bir hatayı düzeltirken agent'a iki ayrı adım verin: önce hatayı yeniden üreten ve başarısız olan testi yazsın, çıktısını göstersin, sonra düzeltmeyi yapsın. Düzeltmeyle birlikte gelen ve hiç kırmızı görülmemiş bir testin hatayı yakaladığına dair elinizde kanıt yoktur.
Bunu her seferinde yazmak yerine bir kalıba bağlıyoruz:
Görev: RefundPolicy için test yaz.
Davranış kaynağı: aşağıdaki BOOK-1423 kabul kriterleri. Kod ile kriter çelişirse
kodu doğru kabul etme; çelişkiyi bana raporla ve o senaryoyu testte kriterin dediği gibi bekle.
Adım 1 (kod yazma): senaryo tablosunu çıkar: girdi, beklenen çıktı, hangi kriterden geldiği.
Sınır değerlerini ve kriterlerin cevaplamadığı soruları ayrıca listele. Onayımı bekle.
Adım 2: testleri yalnızca public arayüze karşı yaz. Domain tiplerini mock'lama.
Adım 3: her test için üretim kodunda onu kırmızıya döndüren tek satırlık değişikliği söyle,
uygula, testi çalıştır, çıktıyı göster, değişikliği geri al.
Test kırmızıya dönebiliyor mu
Üçüncü adım mutation testing düşüncesinin elle yapılan hâlidir ve bir test paketine güvenmek için bildiğimiz en ucuz kontroldür. Üretim kodunda küçük bir değişiklik yaparsınız (>= yerine >, && yerine ||, bir return değerini sabitlemek, bir yan etki çağrısını silmek), testleri çalıştırırsınız. En az bir test kırmızıya dönmüyorsa o satır çalıştırılıyor ama doğrulanmıyor demektir.
Bunu review sırasında elle yapmak beş dakika sürer ve agent'a da yaptırabilirsiniz; yalnız "kırmızıya döndü" beyanıyla yetinmeyin, yapıştırdığı test çıktısını okuyun. Değişikliği geri aldığını da diff'ten doğrulayın.
Hiç kırmızı görmediğiniz bir testin neyi koruduğunu bilmiyorsunuz.
iOS: Muter
Swift tarafında bu işi otomatikleştiren açık kaynak araç Muter. Yazı tarihinde deposu arşivlenmemiş ve 2026 içinde commit almış; ancak GitHub'daki son etiketli sürüm 2023 tarihli. Homebrew formülüyle ya da kaynaktan kurulabiliyor. Kendi Xcode ve Swift sürümünüzde çalıştığını küçük bir pakette denemeden CI'a bağlamayın.
brew install muter-mutation-testing/formulae/muter
cd Packages/BookingDomain
muter init # muter.conf.yml üretir: test komutu ve hariç tutulacak dosyalar
muter --files-to-mutate Sources/BookingDomain/RefundPolicy.swift
İki sınırını bilin. Muter her mutant için yapılandırdığınız test komutunu baştan çalıştırır; xcodebuild ile bütün uygulama hedefinde bu saatler sürer. Biz onu swift test ile hızlı çalışan domain paketlerinde ve yalnızca değişen dosyalarda kullanıyoruz. İkincisi operatör seti dardır: koşul operatörlerini tersine çevirir (>= olur <=, == olur !=), mantıksal bağlaçları değiştirir, yan etki çağrılarını siler, ternary dallarını takas eder. >= ile > arasındaki sınır mutasyonunu üretmez. Örneğimizdeki hata tam da bu türdendir, yani sınır kontrolü hâlâ elle ve tabloyla yapılır.
Android: PIT
JVM tarafında standart araç PIT (pitest), aktif biçimde sürüm alıyor ve varsayılan operatörleri arasında sınır mutasyonu (CONDITIONALS_BOUNDARY) var. Gradle için info.solidsoft.pitest plugin'i kullanılır; plugin'in kendi dokümantasyonu Android module'lerini doğrudan desteklemediğini söyler. Android için ayrı bir fork (pl.droidsonroids.pitest) var ve kendi README'sinde deneysel olarak tanımlanıyor.
Kotlin için bir uyarı daha: PIT bytecode üzerinde çalışır ve Kotlin derleyicisinin ürettiği ek bytecode anlamsız mutantlara yol açar. PIT'in sitesi Kotlin desteği için Arcmutate plugin'ini gösterir; bu plugin lisans gerektirir. Eski açık kaynak pitest-kotlin deposu arşivlenmiştir. Bizim tercihimiz PIT'i Android'e bağımlılığı olmayan saf Kotlin domain module'lerinde çalıştırmak ve raporu bir denetim aracı olarak okumak:
// booking/domain/build.gradle.kts (saf Kotlin/JVM module)
plugins {
kotlin("jvm")
id("info.solidsoft.pitest") version "1.19.0"
}
pitest {
pitestVersion.set("1.30.0")
junit5PluginVersion.set("1.2.3") // JUnit Platform sürümünüzle uyumunu plugin README'sinden kontrol edin
targetClasses.set(setOf("com.example.booking.domain.*"))
threads.set(4)
outputFormats.set(setOf("HTML"))
}
İki platformda da mutation skorunu CI kapısı yapmıyoruz. Skor hedef olduğu gün, kapsamın başına gelen onun da başına gelir. Ayda bir, en çok değişen üç dosyada çalıştırıp hayatta kalan mutantları okumak yeterince şey öğretir.
AI'ın güçlü olduğu işler
Beklenen davranış belli olduğunda geriye kalan iş büyük ölçüde yazı işidir ve agent bunu insandan hızlı, çoğu zaman da daha özenli yapar.
Refactoring öncesi characterization testleri bunların başında gelir. Sekiz yüz satırlık, kimsenin dokunmaya cesaret edemediği bir fiyat hesaplama sınıfını parçalamadan önce bugünkü davranışını kilitlemek istersiniz. Burada mevcut çıktıyı beklenen değer olarak kaydetmek bilinçli bir tercihtir. Yukarıdaki "bug'ın fotoğrafı" testinden farkı etikettedir: bu testlerin suite adında ve başlığında characterization oldukları yazar, spesifikasyon diye okunmazlar, refactoring bitince davranış testleriyle değiştirilirler. Agent girdi uzayını taramakta ve yüz satırlık tabloyu sabırla doldurmakta iyidir.
Tablo güdümlü testler ikinci güçlü alandır. Senaryo listesini onayladıktan sonra tabloya dökmek mekanik bir iştir. Saf Kotlin module'ünde JUnit Jupiter ile:
class RefundPolicyTest {
private val departure = Instant.parse("2027-01-15T09:00:00Z")
@ParameterizedTest(name = "kalkışa {0} dakika kala, {1} -> {2}")
@CsvSource(
"4320, FLEXIBLE, 20000",
"2880, FLEXIBLE, 20000", // 48 saat, sınır dahil
"2879, FLEXIBLE, 10000",
"1440, FLEXIBLE, 10000", // 24 saat, sınır dahil
"1439, FLEXIBLE, 0",
"-60, FLEXIBLE, 0", // kalkıştan sonra
"4320, NON_REFUNDABLE, 0",
)
fun `iade tutari bilet kuralina uyar`(minutesLeft: Long, fare: Fare, expectedMinor: Long) {
val booking = aBooking(priceMinor = 20_000, departure = departure, fare = fare)
val cancelledAt = departure.minus(Duration.ofMinutes(minutesLeft))
assertEquals(expectedMinor, RefundPolicy().refundMinor(booking, cancelledAt))
}
}
Fixture ve builder'lar da öyle. Yukarıdaki aBooking() gibi varsayılan değerli fabrika fonksiyonları, elle yazılmış fake'ler, JSON örneklerinden model üretimi: sıkıcı, hataya açık ve agent'ın iyi yaptığı işler. Tek uyarımız, fake'in davranışını sizin belirlemeniz.
Mekanik göçler dördüncü alandır. XCTest'ten Swift Testing'e geçiş bunun tipik örneği:
// Önce: XCTest
final class FareParserTests: XCTestCase {
var parser: FareParser!
override func setUp() { parser = FareParser(locale: Locale(identifier: "tr_TR")) }
func testParsesFlexibleFare() throws {
let fare = try XCTUnwrap(parser.parse("ESNEK"))
XCTAssertEqual(fare, .flexible)
}
func testRejectsUnknownCode() {
XCTAssertThrowsError(try parser.parseStrict("???"))
}
}
// Sonra: Swift Testing
struct FareParserTests {
let parser = FareParser(locale: Locale(identifier: "tr_TR"))
@Test func parsesFlexibleFare() throws {
let fare = try #require(parser.parse("ESNEK"))
#expect(fare == .flexible)
}
@Test func rejectsUnknownCode() {
#expect(throws: FareParser.UnknownCode.self) { try parser.parseStrict("???") }
}
}
Dönüşüm mekanik, review'ı öyle olmamalı. Apple'ın göç rehberindeki iki fark sessizce davranış değiştirir: XCTest senkron test metotlarını main actor'da çalıştırır, Swift Testing ise test fonksiyonlarını herhangi bir task'ta çalıştırır; main thread isteyen testlere @MainActor eklenmelidir. Swift Testing testleri varsayılan olarak paralel çalıştırır; paylaşılan state'e dayanan bir suite .serialized ister ya da, daha iyisi, o state'ten kurtulur. Göç PR'ında bakacağınız ilk sayı test adedidir: önce kaç test çalışıyordu, şimdi kaç. UI otomasyonu (XCUIApplication) ve performans testleri XCTest'te kalır; Apple'ın Swift Testing'i tanıttığı günden beri söylediği budur, yine de kullandığınız Xcode sürümünün notlarına bakın.
UI testi iskeleti beşinci alandır, bir şartla: element'ler görünen metinle bulunmaz, kalıcı kimlikle bulunur. Kimlikleri üretim koduna eklemek de agent'a verilebilecek iyi bir iştir.
// Üretim kodu (SwiftUI)
Text(viewModel.refundText)
.accessibilityIdentifier("cancellation.refundAmount")
Button("İptal et") { viewModel.confirm() }
.accessibilityIdentifier("cancellation.confirmButton")
// UI testi (XCTest)
final class CancellationUITests: XCTestCase {
@MainActor
func testFlexibleBookingShowsFullRefund() {
let app = XCUIApplication()
app.launchArguments = ["-uiTestScenario", "flexibleBooking72h"]
app.launch()
let refund = app.staticTexts["cancellation.refundAmount"]
XCTAssertTrue(refund.waitForExistence(timeout: 5))
XCTAssertEqual(refund.label, "₺200,00")
XCTAssertTrue(app.buttons["cancellation.confirmButton"].isEnabled)
}
}
Compose tarafında karşılığı Modifier.testTag ve onNodeWithTag:
import androidx.compose.ui.test.junit4.v2.createComposeRule
class CancellationScreenTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun confirmButtonReportsCancellation() {
var confirmed = false
composeRule.setContent {
CancellationScreen(
state = CancellationUiState(refundText = "₺200,00"),
onConfirm = { confirmed = true },
)
}
composeRule.onNodeWithTag("cancellation.refundAmount").assertTextEquals("₺200,00")
composeRule.onNodeWithTag("cancellation.confirmButton").performClick()
composeRule.runOnIdle { assertTrue(confirmed) }
}
}
Import satırına dikkat edin. Compose test API'lerinin v2 sürümü (ui-test-junit4 1.11 ile gelen junit4.v2 paketi) varsayılan olarak StandardTestDispatcher kullanır ve v1 deprecated edilmiştir. Agent'lar eğitim verisindeki ağırlık yüzünden hâlâ eski import'u yazar; v2'ye geçince coroutine'lerin hemen çalıştığını varsayan testler kırılır. Bunu talimat dosyanıza yazın.
Zayıf olduğu işler
Eşzamanlılık ve zamanlama
Agent'ın zamanla ilgili ilk refleksi beklemektir:
// Üretilmiş: gerçek zaman, gerçek dispatcher
@Test
fun `hold expires`() = runBlocking {
val hold = SeatHold(CoroutineScope(Dispatchers.Default), holdFor = 100.milliseconds)
hold.start()
delay(150)
assertEquals(HoldState.Expired, hold.state.value)
}
Bu test geliştiricinin makinesinde geçer, yüklü bir CI runner'ında arada bir kalır, üstelik üretimdeki on dakikalık süreyi hiç sınamaz: beklememek için parametreyi değiştirmiştir. kotlinx-coroutines-test ile aynı şey sanal zamanda ve deterministik yazılır; şartı, SeatHold'un scope'u dışarıdan almasıdır:
class SeatHold(
private val scope: CoroutineScope,
private val holdFor: Duration = 10.minutes,
) {
private val _state = MutableStateFlow(HoldState.Idle)
val state: StateFlow<HoldState> = _state.asStateFlow()
fun start() {
scope.launch {
_state.value = HoldState.Active
delay(holdFor)
_state.value = HoldState.Expired
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `koltuk on dakika tutulur, daha erken dusmez`() = runTest {
val hold = SeatHold(scope = backgroundScope)
hold.start()
runCurrent()
assertEquals(HoldState.Active, hold.state.value)
advanceTimeBy(9.minutes)
assertEquals(HoldState.Active, hold.state.value)
advanceTimeBy(1.minutes)
runCurrent() // advanceTimeBy tam o andaki görevi çalıştırmaz
assertEquals(HoldState.Expired, hold.state.value)
}
Son yorum satırı agent'ların düzenli olarak atladığı türden bir ayrıntıdır. runCurrent() olmadan test kırmızıdır; agent bunu "düzeltmek" için çoğu zaman advanceUntilIdle() yazar ve "daha erken düşmez" kontrolünü farkında olmadan siler.
Swift tarafında durum daha zordur. Üretilen test try await Task.sleep(for: .milliseconds(150)) ile bekler ve aynı şekilde kırılgandır. Çaresi tipe any Clock<Duration> enjekte etmektir, ancak standart kütüphanede elle ilerletilebilen bir test saati yoktur: küçük bir tane yazarsınız ya da bir kütüphaneden alırsınız. Swift Testing'in confirmation API'si de beklemeyi çözmez; rehber açıkça söyler, olayın confirmation kapanışı dönmeden gerçekleşmesi gerekir. Agent bu kısıtı bilmeden XCTestExpectation kalıbını birebir çevirir.
Yaşam döngüsü
Process death, arka plana geçiş, configuration change, scene yeniden bağlanması: bunların hiçbiri fonksiyon imzasında görünmez, agent da görmez. Ürettiği test viewDidLoad()'u ya da onCreate()'i elle çağırır ve sistemin gerçekte yaptığı sırayı taklit ettiğini varsayar. Bu senaryoları hangi araçla (cihaz üstü test, SavedStateHandle ile birim testi, elle prosedür) sınayacağınıza siz karar verirsiniz.
Kararsız UI testleri
Agent'a kararsız bir UI testini "düzelt" dediğinizde en sık gelen çözümler sleep eklemek, timeout'u büyütmek ve yeniden deneme sarmalayıcısıdır. Üçü de belirtiyi gizler. Sebep genellikle üç yerden birindedir: gerçek ağa çıkan test, kapatılmamış animasyon, testler arası taşan state. Bunları bulmak için uygulamayı çalıştırıp izlemek gerekir.
Neyin önemli olduğuna karar vermek
Agent her public fonksiyona eşit ilgi gösterir. İade hesabı ile bir description property'si onun gözünde aynı ağırlıktadır. Para, yetki, veri kaybı ve geri alınamayan işlemler için derin test, geri kalanı için ince bir katman isteyen risk sıralamasını siz yaparsınız.
Test edilebilir tasarım hâlâ her şeyi belirliyor
Yukarıdaki iyi testlerin hepsi tek bir şeye dayanıyor: RefundPolicy saati parametre olarak alıyor, CancelBookingUseCase bağımlılıklarını constructor'dan alıyor, SeatHold scope'unu dışarıdan alıyor. Aynı sınıflar Date() çağırsaydı, APIClient.shared'a uzansaydı, kendi CoroutineScope'unu yaratsaydı, agent'ın elinde iki yol kalırdı: global state'i testte ezmek ya da her şeyi mock'layan bir framework'e sarılmak. İkisi de yukarıda "işe yaramaz" dediğimiz testleri üretir.
Yani üretilen testlerin kalitesi, modelden çok kodunuzun şekline bağlıdır. Constructor injection ve küçük, tek işi olan module'ler agent'ın iyi test yazmasını mümkün kılar. Bu zemini kurmanın yolunu singleton'lar yazısında, container'ın bu zemini nasıl bozabildiğini de iOS ve Android DI anti-pattern yazılarında anlattık. Agent bir sınıf için yalnızca mock ormanı üretebiliyorsa bunu o sınıfın tasarımı hakkında bir bulgu olarak okuyun.
İş akışı: talimat dosyası, review listesi, CI
Talimat dosyasında test bölümü
Agent'lar depo kökündeki talimat dosyasını her oturumda okur. AGENTS.md birçok aracın desteklediği açık bir format; kullandığınız araç başka bir dosya bekliyorsa (CLAUDE.md, .github/copilot-instructions.md) aynı içerik oraya gider. Test bölümümüz aşağı yukarı şöyle:
## Testler
- Beklenen davranışın kaynağı ticket ya da görev açıklamasıdır. Kod ile çelişirse dur ve sor.
- Kod yazmadan önce senaryo tablosunu çıkar ve onay bekle.
- Yalnızca public arayüze karşı test yaz. Domain tiplerini mock'lama; sınırdaki
bağımlılıklar (ağ, disk, saat) için Tests/Fakes altındaki fake'leri kullan, yoksa ekle.
- Bug düzeltmesinde önce başarısız olan testi yaz ve çıktısını göster.
- Testlerde gerçek bekleme yok: sleep, Thread.sleep, gerçek dispatcher ile delay yasak.
- iOS: yeni birim testleri Swift Testing ile; UI testleri XCTest ile kalır.
- Android: coroutine testleri runTest ile; Compose testlerinde junit4.v2 API'leri.
- UI elementleri accessibilityIdentifier / testTag ile bulunur, görünen metinle bulunmaz.
- Mevcut bir testi silme, devre dışı bırakma ya da assertion'ını gevşetme. Gerekiyorsa
nedenini commit mesajına "Test-Change-Reason:" satırıyla yaz.
- Komutlar: swift test --package-path Packages/BookingDomain | ./gradlew :booking:domain:test
Üretilmiş testler için review listesi
Üretilmiş test kodunu, üretim kodunu okuduğunuz dikkatle okuyun; "sadece test" indirimi yapmayın. Baktığımız şeyler:
- Beklenen değer nereden geliyor: ticket'tan mı, kodun bugünkü çıktısından mı?
- Testin adı bir davranış mı söylüyor, yoksa bir fonksiyon adı mı tekrar ediyor?
- Üretim kodundaki hangi tek satırlık değişiklik bu testi kırmızıya döndürür? Cevap bulunamıyorsa test silinir.
- Mock'lanan şey sürecin sınırında mı, yoksa kendi domain tipimiz mi?
- Testin içinde üretim mantığının kopyası (aynı formül, aynı koşul) var mı?
- Gerçek saat, gerçek ağ,
sleep, rastgele değer, test sırasına bağımlılık var mı? - Diff'te silinen,
@Disabled/.disabledyapılan ya da beklenen değeri değiştirilen eski bir test var mı?
Son madde ayrı bir vurguyu hak ediyor. Agent'ın kırmızı bir testi yeşile çevirmenin en kısa yolu çoğu zaman testi değiştirmektir ve bunu "testi yeni davranışa göre güncelledim" diye raporlar.
CI kuralları
İki kural koyuyoruz. Birincisi: hiçbir test nedeni yazılmadan silinmez ya da zayıflatılmaz. Aşağıdaki betik kaba ama iş görür; test dosyalarında silinen assertion sayısı eklenenden fazlaysa ya da test dosyası silindiyse commit mesajında bir neden satırı arar:
#!/usr/bin/env bash
set -euo pipefail
base="${1:-origin/main}"
files=('*Tests.swift' '*Test.kt')
pattern='@Test|func test|#expect|#require|XCTAssert|assert(Equals|True|False|That)|verify'
count() { # $1: diff satırı ön eki (+ ya da -)
git diff "$base"...HEAD --unified=0 -- "${files[@]}" \
| grep -E "^\\$1[^$1]" | grep -cE "$pattern" || true
}
removed=$(count '-')
added=$(count '+')
deleted=$(git diff "$base"...HEAD --diff-filter=D --name-only -- "${files[@]}" | wc -l | tr -d ' ')
if { [ "$removed" -gt "$added" ] || [ "$deleted" -gt 0 ]; } \
&& ! git log "$base"..HEAD --format=%B | grep -q '^Test-Change-Reason:'; then
echo "Test silinmiş ya da zayıflatılmış (silinen: $removed, eklenen: $added, dosya: $deleted)."
echo "Commit mesajına 'Test-Change-Reason: ...' ekleyin."
exit 1
fi
İkincisi: kapsam bir tabandır. Bugünkü değerin biraz altına bir alt sınır koyarız ki büyük bir test silme işlemi fark edilsin; hedef olarak bir sayı ilan etmeyiz. Android'de Kover ile:
kover {
reports {
verify {
rule { minBound(60) } // bugünkü değerinizin birkaç puan altı: alarm eşiği
}
}
}
iOS'ta aynı kontrol, .xcresult içindeki kapsam verisini xcrun xccov view --report --json ile okuyan birkaç satırlık bir betikle yapılır. Sayıyı yükseltme görevini agent'a vermeyin; bu yazının ilk bölümüne geri dönersiniz.
Güvenin arttığını nasıl anlarsınız
Kapsam yüzdesi bu soruya cevap vermez. Bizim baktığımız göstergeler şunlar ve hepsini kendi verinizden çıkarabilirsiniz.
Kaçan hatalar: üretimde ya da QA'de bulunan her hata için iki soru sorun ve cevabı ticket'a yazın. Hatanın olduğu kodun üstünde test var mıydı? Varsa neden yakalamadı? Üç ay sonra bu notlar size testlerinizin hangi tür hatayı kaçırdığını söyler. "Test vardı, yakalamadı" oranı artıyorsa kapsamınız artmış, güveniniz artmamıştır.
Kararsız test oranı: CI'da ilk denemede kalıp yeniden çalıştırınca geçen test çalıştırmalarını sayın. Üretilmiş testler eklendikten sonra bu sayı yükseliyorsa zamanlama ve paylaşılan state sorunları içeri girmiştir. Ekip kırmızı build'i "yine o test" diye geçmeye başladığında paket değerini kaybetmiştir.
Bir hatayı yeniden üretme süresi: bir hata raporu geldiğinde onu başarısız bir teste dönüştürmek ne kadar sürüyor? Fixture'lar, fake'ler ve enjekte edilebilir saat yerindeyse dakikalar, değilse günler. Bu süre altyapınızın kalitesini kapsamdan çok daha iyi gösterir.
Öncesinde kırmızı görülmüş düzeltmelerin oranı: hata düzeltme PR'larının kaçında, düzeltmeden önce başarısız olan bir test var? PR şablonuna bir onay kutusu eklemek ölçmeye yeter.
Mutation örneklemesi: ayda bir, en çok değişen birkaç dosyada elle ya da araçla mutasyon yapın ve hayatta kalanları sayın. Trend yeterlidir.
Nerede durmalı
Üç ekranlı, iş kuralı sunucuda duran bir uygulamada senaryo tablosu onayı ve mutation aracı kurmak fazladır; orada birkaç UI akış testi ve review listesi yeter. Atılacak prototip kodu için hiç test üretmeyin. Bu yazıdaki disiplin, yanlış hesabın paraya, veriye ya da güvene mal olduğu kod için.
Kontrol listesi
- Agent'a verilen test görevlerinde beklenen davranış ticket'tan ya da yazılı bir kural listesinden geliyor.
- Kod yazılmadan önce senaryo tablosu ve sınır durumları onaylanıyor.
- Üretilmiş her test için onu kırmızıya döndüren tek satırlık değişiklik biliniyor ve en az bir kez denendi.
- Domain tipleri mock'lanmıyor; sınır bağımlılıkları için paylaşılan fake'ler var.
- Bug düzeltme PR'larında düzeltmeden önce başarısız olan test çıktısı var.
- Testlerde gerçek bekleme yok; Android'de
runTest, iOS'ta enjekte edilen saat kullanılıyor. - UI testleri element'leri
accessibilityIdentifier/testTagile buluyor. - Talimat dosyasında test bölümü var ve test komutlarını içeriyor.
- CI, nedeni yazılmadan silinen ya da zayıflatılan testte kalıyor.
- Kapsam alt sınır olarak tanımlı; kimseye hedef olarak verilmiyor.
- Kaçan hatalar, kararsız test oranı ve hata yeniden üretme süresi üç ayda bir gözden geçiriliyor.
Bu konuda desteğe mi ihtiyacınız var?
Uygulamanızda bu kontrollerin doğru kurulup kurulmadığını birlikte inceleyelim. Güvenlik denetimi ya da danışmanlıkla buradayız.
Kaynaklar
- Apple · Swift Testing
- Apple · Migrating a test from XCTest
- Android Developers · Testing Kotlin coroutines on Android
- Android Developers · Migrate to Compose testing v2 APIs
- JUnit · Parameterized classes and tests
- Muter · Mutation testing for Swift
- PIT · Mutation operators
- gradle-pitest-plugin · Documentation
- Kotlin · Kover Gradle plugin
- AGENTS.md · Open format for guiding coding agents
Yorumlar ve sorular
Katılmadığınız bir nokta ya da projenizde takıldığınız bir yer mi var? Yazın, konuşalım.