Flutter'da Test: Unit, Widget ve Integration Test, Fake Repository'ler ve Golden Test
Flutter'da testin omurgası fake repository'lerle yazılan unit ve widget testleridir; mock yalnız "bu çağrı yapıldı mı?" sorusu için, integration_test kritik akışlar için, golden test de tek platformda görsel değişiklikleri yakalamak için. Bookstore'un 86 testi, provider ve go_router'lı checkout akışı, mocktail'in registerFallbackValue tuzağı ve golden'ın platform farkıyla.
Sürüm notu: Bu yazıdaki örnekler Flutter 3.47.8 (Dart 3.13.7), flutter_test ve integration_test (Flutter SDK ile gelen), mocktail 1.0.5, provider 6.1.5+1 ve go_router 18.1.4 ile denenmiştir. Çıktılar flutter test koşularından. integration_test dosyası analyze'dan geçti ama cihaz, emulator ya da ChromeDriver gerektirdiği için cihazda çalıştırılmadı; aynı akış widget testi olarak çalıştı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) testlerinde mocktail ile bir TokenStore mock'u oluşturuyorsun ve hiçbir şey stub'lamadan await tokens.read() çağırıyorsun. read() bir Future<Tokens?> dönüyor, yani null geçerli bir sonuç. Ne olur: null mı gelir, test mi patlar? Moq'tan alışık olduğun "varsayılan değer döner" davranışı burada var mı?
Testlerin Haritası
Seri boyunca her yazı kendi testlerini ekledi; uygulama şu an 86 test içeriyor ve tamamı yaklaşık 10 saniyede koşuyor:
00:10 +85 ~1: All tests passed!
~1, on üçüncü yazıdaki canlı API testi: API_BASE_URL verilmeyince atlanıyor. Flutter'da üç test türü var ve her biri farklı bir soruya cevap veriyor:
| Tür | Neyi test eder | Nerede koşar | Bookstore'da örnek |
|---|---|---|---|
| Unit | Tek bir class: ViewModel, repository, service, fonksiyon | Dart VM, saniyenin altında | CartViewModel, ApiClient, authRedirect |
| Widget | Bir ekran ya da bütün uygulama, sahte ekran ve sahte saatle | Dart VM, platform yok | katalog sayfalama, adres formu, router, checkout akışı |
| Integration | Gerçek build, gerçek cihaz ya da emulator | Cihaz, dakikalar | integration_test/checkout_flow_test.dart |
Flutter'ın mimari önerilerinde test başlığı altında iki madde var ve ikisi de "Strongly recommend" etiketli: katmanları hem ayrı ayrı hem birlikte test etmek, ve "Make fakes for testing" (test için fake'ler yaz). Bu yazı ikisinin Bookstore'daki karşılığı.
Fake'ler: Girdi ve Çıktıya Odaklanmak
Fake, bir interface'in testte kullanılmak için yazılmış küçük ama çalışan implementasyonu. On birinci yazıdan beri kullandığımız FakeBookRepository gerçek bir katalog dönüyor, hangi sayfaların istendiğini kaydediyor ve istenirse hata veriyor:
/// Çalışan bir BookRepository: istenen sayfaları kaydeder, istenirse hata verir.
class FakeBookRepository implements BookRepository {
FakeBookRepository({SampleCatalog? catalog, this.failuresLeft = 0})
: _catalog = catalog ?? SampleCatalog(total: 60, pageSize: 20);
final SampleCatalog _catalog;
final calls = <int>[];
int failuresLeft;
Object error = Exception('timeout');
@override
Future<Result<List<Book>>> getPage(int page) async {
calls.add(page);
if (failuresLeft > 0) {
failuresLeft--;
return Result.failure(error);
}
return Result.ok(await _catalog.page(page));
}
Bu fake ile yazılan testler davranışı soruyor: "listenin sonuna gelince sonraki sayfa yükleniyor mu?", "hata sonrası tekrar dene çalışıyor mu?". Katalog ekranı testlerinin çıktısı:
after start: calls [0]
after scrolling to book 20: calls [0, 1]
after the last book: calls [0, 1, 2, 3]
after pull to refresh: calls [0, 0]
error then retry: calls [0, 0]
Önceki yazıda error alanını ekledik; CachedBookRepository'nin ağ hatası ile sunucu hatasını ayırdığını aynı fake'e NetworkException ya da ApiException(500, ...) verdirerek test ettik. InMemoryCartRepository, SampleOrderRepository ve SampleAuthRepository da aynı rolü oynuyor: üretim kodunda yalnız preview ve test için duruyorlar ve ViewModel testleri hiçbir mock kütüphanesi gerektirmiyor. Fake'in bir avantajı daha var: DriftCartRepository ile InMemoryCartRepository aynı sözleşmeyi uyguladığı için, ikisine aynı testleri koşturup davranışlarının aynı kaldığını görebilirsin.
mocktail: Etkileşimi Doğrulamak
Bazı testlerin sorusu "sonuç ne?" değil, "bu çağrı yapıldı mı?". Kullanıcı çıkış yaptığında token'ların silinmesi tam olarak böyle bir soru. Bunun için mocktail kullanıyoruz; mockito'nun aksine kod üretimi gerektirmiyor:
class MockTokenStore extends Mock implements TokenStore {}
void main() {
// any() parametre tipinde bir değer ister; record'lar da istisna değil
setUpAll(() => registerFallbackValue((accessToken: '', refreshToken: '')));
test('sign-out deletes the tokens exactly once', () async {
// Soru "ne saklandı?" değil, "clear() çağrıldı mı?":
// bu bir etkileşim, mock da bunun için var
final tokens = MockTokenStore();
when(tokens.clear).thenAnswer((_) async {});
final auth = ApiAuthRepository(
ApiClient(baseUrl: Uri.parse('http://127.0.0.1:52813'), tokens: tokens),
SharedPreferencesAsync(),
);
await auth.signOut();
verify(tokens.clear).called(1);
verifyNever(() => tokens.write(any()));
});
registerFallbackValue satırı olmadan test şu hatayla düştü:
Bad state: A test tried to use `any` or `captureAny` on a parameter of type `({String accessToken, String refreshToken})`, but
registerFallbackValue was not previously called to register a fallback value for `({String accessToken, String refreshToken})`.
any() argüman eşleyicisi null safety yüzünden parametre tipinde gerçek bir değere ihtiyaç duyuyor; kendi tiplerin ve record'lar için bunu bir kez kaydetmen gerekiyor. "Önce Tahmin Et"in cevabı da çıktıda:
_TypeError: type 'Null' is not a subtype of type 'Future<({String accessToken, String refreshToken})?>'
Stub'lanmamış bir method mock'ta null dönüyor; method'un dönüş tipi Future olduğu için null bir Future değil ve test tip hatasıyla patlıyor. Moq'un "loose" davranışı burada yok: async her method'u thenAnswer ile tanımlaman gerekiyor. Mock'ları etkileşim sorusu olan yerlerle sınırla; her repository'yi mock'lamak testleri implementasyona bağlar ve her refactor'da testleri de değiştirmek gerekir. Unit Test serisindeki "Mock'u Ne Zaman Kullanmamalı" yazısı aynı kararı C# tarafında anlatıyor.
Widget Testi: provider ve go_router ile Bütün Uygulama
Widget testi tek bir widget'la sınırlı değil. appProviders'a fake'leri verip BookstoreApp'i pump edersen gerçek router, gerçek redirect, gerçek ViewModel'ler ve gerçek ekranlarla bütün bir kullanıcı akışını emulator olmadan test edersin. Checkout akışını bir fonksiyon olarak yazdık:
/// Giriş yap, Dune'u sepete ekle, siparişi ver, siparişi gör. Aynı adımlar
/// widget testi (test/flows) ve cihazda (integration_test/) çalışır.
Future<void> checkoutFlow(WidgetTester tester) async {
final orders = SampleOrderRepository();
await tester.pumpWidget(
MultiProvider(
providers: appProviders(
bookRepository: FakeBookRepository(),
orderRepository: orders,
),
child: const BookstoreApp(),
),
);
await tester.pumpAndSettle();
// 1. Siparişler giriş ister: sekme giriş formuna yönlendirir
await tester.tap(find.text('Orders'));
await tester.pumpAndSettle();
Devamı kataloğa dönüp find.byTooltip('Add to cart') ile Dune'u ekliyor, sepet sekmesinde "Checkout"a basıyor, adres formunu dolduruyor ve sipariş sekmesine bakıyor. Bulucular kullanıcının gördüğü şeyleri hedefliyor (metin, tooltip, alan etiketi); bu, widget ağacının iç yapısı değiştiğinde testin kırılmamasını sağlıyor. Çıktı:
signed in, orders on screen: 2
snack bar: Order BK-1045 placed, cart empty: true
first order: BK-1045 · ₺489.00
Akış bir saniyenin altında bitti. Router'ın redirect'i, OrderRepository'nin bildirimiyle sipariş listesinin kendiliğinden tazelenmesi ve sepetin temizlenmesi aynı testte doğrulandı. Resmî mimari örneğinde router ayrıca MockGoRouter ile mock'lanıyor; ekranı tek başına test ederken bu işe yarıyor, ama navigation'ın kendisini test etmek istediğinde bizim yaptığımız gibi gerçek router'ı kurmak daha çok şey yakalıyor.
integration_test: Aynı Akış Cihazda
Widget testi sahte bir ekranda, platform olmadan koşuyor: gerçek klavye, gerçek kaydırma fiziği, plugin'lerin platform tarafı, Impeller ile çizim yok. integration_test paketi aynı WidgetTester API'siyle testi gerçek bir build içinde, cihazda ya da emulator'de çalıştırıyor:
void main() {
// Testi test VM'i yerine gerçek bir cihazda ya da emulator'de çalıştırır
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('checkout from sign-in to the order list', checkoutFlow);
}
Akışı ayrı bir fonksiyona koymanın kazancı burada: aynı adımlar test/flows'ta her commit'te saniyeler içinde, integration_test/'te ise cihazda koşuyor. Flutter dokümanındaki komutlar şunlar; bu yazıda cihaz ve emulator kullanmadığımız için hiçbirini çalıştırmadık:
flutter test integration_test/checkout_flow_test.dart -d <device-id>
flutter drive --driver=test_driver/integration_test.dart --target=integration_test/checkout_flow_test.dart -d web-server
İlki Android ya da iOS cihazda; -d vermezsen ve birden fazla cihaz bağlıysa Flutter hangisini kullanacağını soruyor. Web için ChromeDriver'ın çalışıyor olması ve integrationDriver()'ı çağıran küçük bir test_driver/integration_test.dart dosyası gerekiyor. Cihaz çeşitliliği için dokümanda Firebase Test Lab'e Android test APK'sı (Android uygulama paketi dosyası) yüklemenin adımları da var. Pratikte integration testleri sayıca az tutulur: kritik birkaç akış, gece ya da sürüm öncesi CI'da.
Golden Test ve Platform Farkı
Golden test, bir widget'ın görüntüsünü onaylanmış bir PNG (kayıpsız sıkıştırılmış resim formatı) ile piksel piksel karşılaştırıyor. BookCard için:
await expectLater(
find.byType(BookCard),
matchesGoldenFile('goldens/book_card_dune.png'),
);
İlk koşu flutter test --update-goldens ile referans resmi oluşturuyor. Sonra kartta resimle başlık arasındaki boşluğu 12'den 16 piksele çıkardık:
Golden "goldens/book_card_dune.png": Pixel test failed, 2.95%, 1382px diff detected.
Failure feedback can be found at
<work>/app/bookstore_app/test/ui/catalog/failures
failures/ klasörüne eski resim, yeni resim ve farkı gösteren iki resim yazılıyor; bu klasörü .gitignore'a ekledik. Oluşan golden'a bakınca ilk şaşırtan şey yazıların siyah kareler olması: flutter_test varsayılan olarak her karakteri bir kare olarak çizen bir test fontu kullanıyor (SDK dokümanı onu Ahem diye anıyor). Bunun nedeni platform farkı. Gerçek fontlarla çizilen metin, kenar yumuşatma ve font render'ı macOS, Linux ve Windows'ta birkaç piksel farklı çıkıyor ve aynı test bir makinede geçip diğerinde düşüyor. Test fontu bu farkı azaltıyor ama tamamen ortadan kaldırmıyor; bu yüzden golden testlerini etiketledik ve tek bir platformda (genelde CI'daki Linux) güncelleyip koşturuyoruz:
flutter test --exclude-tags golden,live
00:06 +84: All tests passed!
Golden test, layout'un ve renklerin istemeden değişmesini yakalamak için iyi bir araç; metnin kendisini ya da gerçek fontla görünümü doğrulamak için değil. Gerçek fontlarla golden üretmek için font dosyalarını testte yükleyen topluluk paketleri var; onları denemedik.
Üretimde Dikkat
Coverage'ı ölç ama hedef yapma. flutter test --coverage coverage/lcov.info yazıyor; üretilen .g.dart dosyalarını çıkarınca satır coverage'ı %89,4 çıktı. Katmanlara bakmak toplamdan daha faydalı: data/repositories %82,6, ui/book_details %76,4. Eksik satırların çoğu hata dalları; hangilerinin test edilmeye değer olduğuna satır satır bakarak karar ver.
Testleri hızlı tut. 86 testin 10 saniyede koşması her commit'te çalıştırılabilmelerinin nedeni. Gerçek ağa, gerçek zamana (Future.delayed ile bekleyen testler) ve gerçek cihaza bağlı testleri ayır ve etiketle; CI'da hızlı grup her pull request'te, yavaş grup gece çalışsın. GitHub Actions serisindeki workflow yapısı burada da aynı.
Zamanı enjekte et. OrdersScreen now alıyor, CachedBookRepository now fonksiyonu alıyor; böylece "35 dakika sonra" gibi senaryoları beklemeden test ettik. DateTime.now()'ı doğrudan çağıran kod, gece yarısı ya da ay sonunda düşen testler üretir.
Sık Düşülen Tuzaklar
Birinci tuzak, sonsuz animasyonlu bir ekranda pumpAndSettle() çağırmak. Dönen bir CircularProgressIndicator hiç "settle" olmadığı için test zaman aşımına uğrar; yükleniyor durumunu pump() ile kontrol et.
İkinci tuzak, widget testinde gerçek HTTP beklemek. testWidgets içindeki istekler 400 döner; ağı fake repository ya da MockClient ile değiştir.
Üçüncü tuzak, golden'ları her geliştiricinin kendi makinesinde güncellemesi. Bir hafta içinde golden'lar macOS ve Windows arasında gidip gelir ve kimse farkın gerçek olup olmadığını bilmez.
Dördüncü tuzak, integration_test'i bütün testlerin yerine koymak. Yavaş, cihaz gerektiren ve sık sık rastgele düşen bir test seti kısa sürede kimsenin çalıştırmadığı bir şeye dönüşür.
Küçük Bir Deneme
★☆☆ BookCard golden testine karanlık tema için ikinci bir golden ekle (AppTheme.dark) ve fiyat renginin değiştiğini görerek testi bir kez düşür.
★★☆ InMemoryCartRepository ve DriftCartRepository için tek bir sözleşme testi yaz: aynı test grubunu bir factory fonksiyonuyla iki implementasyona da koştur. Onuncu kopyadan sonra iki implementasyon da TooManyCopies dönüyor mu?
★★★ Checkout akışına giriş yapmamış bir kullanıcının "Checkout"a bastığı senaryoyu ekle. Adres sayfası redirect ile giriş sayfasına dönüşüyor; giriş sonrası kullanıcının adres formuna dönmesini sağla ve checkoutFlow testiyle doğrula.
Bir sonraki yazıda uygulamayı mağazalara taşıyoruz: Android App Bundle ve iOS build'i, imzalama, sürüm numaraları, dev/staging/prod için flavor'lar ve --dart-define-from-file, GitHub Actions'ta build ve imzalama adımları.