Flutter'da Test Stratejileri: Unit, Widget ve Integration Tests

Flutter test piramidi üç katmandan oluşur: en hızlı ve en çok yazılan unit testler, widget render ve etkileşimini doğrulayan widget testler ve gerçek cihaz/emülatörde uçtan uca çalışan integration testler. Her katmanın farklı bir maliyeti ve güveni vardır.

Unit Test: İş Mantığını Test Etmek

Bağımlılıkları mocklamak için mockito ve build_runner kullanın:

// pubspec.yaml
// dev_dependencies:
//   flutter_test:
//     sdk: flutter
//   mockito: ^5.4.4
//   build_runner: ^2.4.8

// test/services/cart_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/repositories/product_repository.dart';
import 'package:my_app/services/cart_service.dart';

@GenerateMocks([ProductRepository])
import 'cart_service_test.mocks.dart';

void main() {
  late CartService cartService;
  late MockProductRepository mockRepo;

  setUp(() {
    mockRepo = MockProductRepository();
    cartService = CartService(repository: mockRepo);
  });

  group('CartService', () {
    test('sepete ürün ekleme toplam fiyatı günceller', () {
      // Arrange
      final product = Product(id: '1', name: 'Laptop', price: 25000);
      when(mockRepo.getById('1')).thenReturn(product);

      // Act
      cartService.addItem('1', quantity: 2);

      // Assert
      expect(cartService.totalPrice, equals(50000));
      expect(cartService.itemCount, equals(2));
      verify(mockRepo.getById('1')).called(1);
    });

    test('stok yetersizse hata fırlatır', () {
      when(mockRepo.getStock('1')).thenReturn(3);

      expect(
        () => cartService.addItem('1', quantity: 5),
        throwsA(isA<InsufficientStockException>()),
      );
    });

    test('aynı ürün tekrar eklenince miktar artar', () {
      final product = Product(id: '1', name: 'Kalem', price: 10);
      when(mockRepo.getById('1')).thenReturn(product);
      when(mockRepo.getStock('1')).thenReturn(100);

      cartService.addItem('1', quantity: 1);
      cartService.addItem('1', quantity: 1);

      expect(cartService.getQuantity('1'), equals(2));
      expect(cartService.items.length, equals(1));
    });
  });
}

// Async test
test('ürün listesi uzaktan yüklenir', () async {
  when(mockRepo.fetchProducts()).thenAnswer(
    (_) async => [
      Product(id: '1', name: 'A', price: 100),
      Product(id: '2', name: 'B', price: 200),
    ],
  );

  final products = await cartService.loadProducts();
  expect(products.length, equals(2));
});

Cubit/BLoC Unit Testi

// pubspec: bloc_test: ^9.1.7

import 'package:bloc_test/bloc_test.dart';

void main() {
  group('LoginCubit', () {
    late LoginCubit cubit;
    late MockAuthRepository mockAuth;

    setUp(() {
      mockAuth = MockAuthRepository();
      cubit = LoginCubit(authRepository: mockAuth);
    });

    tearDown(() => cubit.close());

    blocTest<LoginCubit, LoginState>(
      'geçerli kimlik bilgileriyle giriş başarılı',
      build: () {
        when(mockAuth.login(email: 'test@test.com', password: '123456'))
            .thenAnswer((_) async => User(id: '1', email: 'test@test.com'));
        return cubit;
      },
      act: (c) => c.login(email: 'test@test.com', password: '123456'),
      expect: () => [
        const LoginState(status: LoginStatus.loading),
        LoginState(status: LoginStatus.success,
            user: User(id: '1', email: 'test@test.com')),
      ],
    );

    blocTest<LoginCubit, LoginState>(
      'hatalı şifrede hata mesajı döner',
      build: () {
        when(mockAuth.login(email: anyNamed('email'), password: anyNamed('password')))
            .thenThrow(const AuthException('Geçersiz şifre'));
        return cubit;
      },
      act: (c) => c.login(email: 'a@b.com', password: 'yanlis'),
      expect: () => [
        const LoginState(status: LoginStatus.loading),
        const LoginState(status: LoginStatus.error, error: 'Geçersiz şifre'),
      ],
    );
  });
}

Widget Test: UI Render ve Etkileşim

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mockito/mockito.dart';

void main() {
  group('LoginPage widget testi', () {
    testWidgets('boş form gönderildiğinde hata mesajları gösterilir',
        (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          home: BlocProvider<LoginCubit>(
            create: (_) => LoginCubit(authRepository: MockAuthRepository()),
            child: const LoginPage(),
          ),
        ),
      );

      // Form elemanlarının varlığını kontrol et
      expect(find.byType(TextFormField), findsNWidgets(2));
      expect(find.text('Giriş Yap'), findsOneWidget);

      // Butona tıkla
      await tester.tap(find.widgetWithText(ElevatedButton, 'Giriş Yap'));
      await tester.pump(); // bir frame bekle

      // Doğrulama hatalarını kontrol et
      expect(find.text('E-posta zorunludur'), findsOneWidget);
      expect(find.text('Şifre zorunludur'), findsOneWidget);
    });

    testWidgets('geçerli form gönderildiğinde loading gösterilir',
        (tester) async {
      final mockRepo = MockAuthRepository();
      when(mockRepo.login(email: anyNamed('email'), password: anyNamed('password')))
          .thenAnswer((_) => Future.delayed(const Duration(seconds: 1),
              () => User(id: '1', email: 'test@test.com')));

      await tester.pumpWidget(
        MaterialApp(
          home: BlocProvider(
            create: (_) => LoginCubit(authRepository: mockRepo),
            child: const LoginPage(),
          ),
        ),
      );

      // Form doldur
      await tester.enterText(
        find.byKey(const Key('email_field')), 'test@test.com');
      await tester.enterText(
        find.byKey(const Key('password_field')), '123456');

      await tester.tap(find.widgetWithText(ElevatedButton, 'Giriş Yap'));
      await tester.pump(); // state değişimini işle

      expect(find.byType(CircularProgressIndicator), findsOneWidget);
    });
  });
}

Golden Test: UI Görsel Regresyon

Widget'ın görsel çıktısını bir referans dosyasıyla karşılaştırır. UI değişiklikleri kasıtsız olarak kırılmasın diye kullanılır:

testWidgets('ProductCard altın görüntüyle eşleşir', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      theme: AppTheme.light,
      home: Scaffold(
        body: ProductCard(
          product: Product(
            id: '1',
            title: 'Test Ürün',
            price: 299.99,
            imageUrl: 'https://via.placeholder.com/150',
          ),
        ),
      ),
    ),
  );

  await expectLater(
    find.byType(ProductCard),
    matchesGoldenFile('goldens/product_card.png'),
  );
});
// İlk çalıştırma: flutter test --update-goldens
// Sonraki çalıştırmalar: görsel farklılık varsa test başarısız olur

Integration Test: Uçtan Uca

// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Giriş akışı entegrasyon testi', () {
    testWidgets('kullanıcı giriş yapıp ana sayfaya ulaşır', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      // Giriş sayfasındayız
      expect(find.text('Hoş Geldiniz'), findsOneWidget);

      await tester.enterText(find.byKey(const Key('email_field')),
          'demo@example.com');
      await tester.enterText(find.byKey(const Key('password_field')),
          'Demo1234!');
      await tester.tap(find.widgetWithText(ElevatedButton, 'Giriş Yap'));

      await tester.pumpAndSettle(const Duration(seconds: 3));

      // Ana sayfadayız
      expect(find.text('Ana Sayfa'), findsOneWidget);
      expect(find.byType(BottomNavigationBar), findsOneWidget);
    });
  });
}
// Çalıştırma: flutter test integration_test/ -d emulator-5554

İyi test stratejisi demek çok sayıda unit test, daha az widget testi ve kritik akışlar için birkaç integration testi demektir. Tüm testleri CI/CD pipeline'a entegre ederek her push'ta otomatik çalıştırın.