~/icsd.ir — bash
SYSTEM_ONLINE

تست در Flutter

تست بدون شک سرمایه‌گذاری است. در این فصل سه نوع اصلی test در Flutter را یاد می‌گیریم: Unit، Widget و Integration.

۱۲.۱ مقدمه

تست بدون شک سرمایه‌گذاری است. در این فصل سه نوع اصلی test در Flutter را یاد می‌گیریم: Unit، Widget و Integration.

۱۲.۲ هرم Test


       /         Integration  (کم - گران)
      /          - End-to-end
     /----
    /            Widget  (متوسط)
   /--------     - UI components
  /          
 /               Unit  (زیاد - ارزان)
/______________  - منطق، utilities
    

توصیه: ۷۰٪ Unit، ۲۰٪ Widget، ۱۰٪ Integration.

۱۲.۳ Unit Tests


flutter pub add --dev test mocktail
    

// test/calculator_test.dart
import "package:flutter_test/flutter_test.dart";
import "package:my_app/calculator.dart";

void main() {
  group("Calculator", () {
    late Calculator calc;
    
    setUp(() {
      calc = Calculator();
    });
    
    test("add returns correct sum", () {
      expect(calc.add(2, 3), equals(5));
    });
    
    test("divide throws on zero", () {
      expect(() => calc.divide(10, 0), throwsArgumentError);
    });
    
    test("supports negative numbers", () {
      expect(calc.add(-2, -3), -5);
      expect(calc.multiply(-2, 3), -6);
    });
  });
}

// اجرا
// flutter test
// flutter test --coverage  (با coverage)
    

Mocking با Mocktail


// test/auth_repository_test.dart
import "package:mocktail/mocktail.dart";
import "package:dio/dio.dart";

class MockDio extends Mock implements Dio {}
class MockTokenStorage extends Mock implements TokenStorage {}

void main() {
  late MockDio dio;
  late MockTokenStorage storage;
  late AuthRepository repo;
  
  setUp(() {
    dio = MockDio();
    storage = MockTokenStorage();
    repo = AuthRepository(dio, storage);
  });
  
  group("login", () {
    test("returns User on success", () async {
      when(() => dio.post(any(), data: any(named: "data")))
          .thenAnswer((_) async => Response(
                data: {
                  "access": "token123",
                  "refresh": "refresh123",
                  "user": {"id": 1, "email": "test@test.com"},
                },
                statusCode: 200,
                requestOptions: RequestOptions(path: ""),
              ));
      
      when(() => storage.saveTokens(access: any(named: "access"), refresh: any(named: "refresh")))
          .thenAnswer((_) async {});
      
      final user = await repo.login("test@test.com", "password");
      
      expect(user.email, "test@test.com");
      verify(() => storage.saveTokens(access: "token123", refresh: "refresh123")).called(1);
    });
    
    test("throws on 401", () async {
      when(() => dio.post(any(), data: any(named: "data")))
          .thenThrow(DioException(
            response: Response(
              statusCode: 401,
              requestOptions: RequestOptions(path: ""),
            ),
            requestOptions: RequestOptions(path: ""),
          ));
      
      expect(
        () => repo.login("test@test.com", "wrong"),
        throwsA(isA()),
      );
    });
  });
}
    

۱۲.۴ Widget Tests


// test/login_form_test.dart
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "package:my_app/login_form.dart";

void main() {
  group("LoginForm", () {
    testWidgets("displays email and password fields", (tester) async {
      await tester.pumpWidget(const MaterialApp(
        home: Scaffold(body: LoginForm()),
      ));
      
      expect(find.byType(TextFormField), findsNWidgets(2));
      expect(find.text("ایمیل"), findsOneWidget);
      expect(find.text("رمز عبور"), findsOneWidget);
      expect(find.text("ورود"), findsOneWidget);
    });
    
    testWidgets("shows error when email is empty", (tester) async {
      await tester.pumpWidget(const MaterialApp(
        home: Scaffold(body: LoginForm()),
      ));
      
      await tester.tap(find.text("ورود"));
      await tester.pump();
      
      expect(find.text("ایمیل لازم است"), findsOneWidget);
    });
    
    testWidgets("calls onSubmit when valid", (tester) async {
      String? capturedEmail;
      
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(
          body: LoginForm(
            onSubmit: (email, password) {
              capturedEmail = email;
            },
          ),
        ),
      ));
      
      await tester.enterText(find.byType(TextFormField).at(0), "test@test.com");
      await tester.enterText(find.byType(TextFormField).at(1), "password123");
      await tester.tap(find.text("ورود"));
      await tester.pumpAndSettle();
      
      expect(capturedEmail, "test@test.com");
    });
    
    testWidgets("shows loading indicator on submit", (tester) async {
      await tester.pumpWidget(const MaterialApp(
        home: Scaffold(body: LoginForm()),
      ));
      
      await tester.enterText(find.byType(TextFormField).at(0), "test@test.com");
      await tester.enterText(find.byType(TextFormField).at(1), "password");
      await tester.tap(find.text("ورود"));
      await tester.pump();  // یک frame
      
      expect(find.byType(CircularProgressIndicator), findsOneWidget);
    });
  });
}
    

Finders پرکاربرد


find.text("سلام")                 // متن
find.byType(ElevatedButton)       // type
find.byKey(const Key("submit"))   // key
find.byIcon(Icons.search)         // icon
find.byTooltip("جستجو")           // tooltip
find.descendant(of: ..., matching: ...)
    

Actions


await tester.tap(finder)               // کلیک
await tester.longPress(finder)         // long press
await tester.drag(finder, Offset(...))  // drag
await tester.enterText(finder, "text") // ورود متن
await tester.scrollUntilVisible(finder, 100)
await tester.pump()                    // یک frame
await tester.pumpAndSettle()           // تا انیمیشن تمام شود
    

۱۲.۵ Integration Tests

تست end-to-end که اپ را روی emulator یا device اجرا می‌کند.


flutter pub add --dev integration_test
    

// integration_test/login_flow_test.dart
import "package:flutter/material.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("App E2E", () {
    testWidgets("login → home → logout flow", (tester) async {
      app.main();
      await tester.pumpAndSettle();
      
      // 1. روی Login Page هستیم
      expect(find.text("ورود"), findsOneWidget);
      
      // 2. اطلاعات وارد می‌کنیم
      await tester.enterText(find.byKey(const Key("email")), "test@test.com");
      await tester.enterText(find.byKey(const Key("password")), "password123");
      
      // 3. کلیک Login
      await tester.tap(find.text("ورود"));
      await tester.pumpAndSettle(const Duration(seconds: 3));
      
      // 4. باید روی صفحه اصلی باشیم
      expect(find.text("صفحه اصلی"), findsOneWidget);
      expect(find.text("سلام، test"), findsOneWidget);
      
      // 5. روی منو کلیک
      await tester.tap(find.byIcon(Icons.menu));
      await tester.pumpAndSettle();
      
      // 6. خروج
      await tester.tap(find.text("خروج"));
      await tester.pumpAndSettle();
      
      // 7. باید روی Login باشیم
      expect(find.text("ورود"), findsOneWidget);
    });
  });
}

// اجرا:
// flutter test integration_test/login_flow_test.dart
    

۱۲.۶ Golden Tests

تست‌هایی که screenshot widget را با reference image مقایسه می‌کنند.


testWidgets("MyButton golden test", (tester) async {
  await tester.pumpWidget(const MaterialApp(
    home: Scaffold(
      body: Center(
        child: MyButton(label: "کلیک"),
      ),
    ),
  ));
  
  await expectLater(
    find.byType(MyButton),
    matchesGoldenFile("goldens/my_button.png"),
  );
});

// اجرا تست با generate
// flutter test --update-goldens
    

۱۲.۷ تست Riverpod Provider


import "package:flutter_riverpod/flutter_riverpod.dart";

void main() {
  test("CounterProvider increments", () {
    final container = ProviderContainer();
    
    expect(container.read(counterProvider), 0);
    
    container.read(counterProvider.notifier).increment();
    
    expect(container.read(counterProvider), 1);
  });
  
  test("ProductsProvider loads from API", () async {
    final container = ProviderContainer(
      overrides: [
        // mock API
        apiClientProvider.overrideWith((_) => MockApiClient()),
      ],
    );
    
    final products = await container.read(productsProvider.future);
    expect(products.length, 3);
  });
}
    

۱۲.۸ CI/CD با GitHub Actions


# .github/workflows/flutter.yml
name: Flutter CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true
      
      - run: flutter pub get
      - run: flutter analyze
      - run: dart format --set-exit-if-changed .
      - run: flutter test --coverage
      
      - uses: codecov/codecov-action@v3
        with:
          file: coverage/lcov.info
  
  build-android:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v3
        with:
          distribution: zulu
          java-version: 17
      - uses: subosito/flutter-action@v2
      
      - run: flutter pub get
      - run: flutter build apk --release
      
      - uses: actions/upload-artifact@v4
        with:
          name: app-release.apk
          path: build/app/outputs/flutter-apk/app-release.apk
  
  build-ios:
    needs: test
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
      
      - run: flutter pub get
      - run: flutter build ios --release --no-codesign
    

۱۲.۹ بهترین تجربیات

  1. هرم تست را رعایت کنید. ۷۰/۲۰/۱۰.
  2. Test-driven development. اول test، بعد کد.
  3. Coverage > 70%. هدف معقول.
  4. Mock dependency ها.
  5. setUp و tearDown. برای cleanup.
  6. Group های مرتب.
  7. اسامی توصیفی. “should X when Y”.
  8. Test یک چیز در هر test.
  9. CI از روز اول.
  10. Performance test برای هزاران آیتم.

۱۲.۱۰ خلاصه فصل

  • سه نوع تست: Unit، Widget، Integration
  • Mocktail برای mocking
  • WidgetTester با pump و pumpAndSettle
  • Finders و Actions
  • Integration test برای E2E
  • Golden tests برای UI consistency
  • تست Riverpod با ProviderContainer
  • CI/CD با GitHub Actions
در فصل بعد: انتشار اپ — Play Store، App Store، CodeMagic.

نمایش سایت

رنگ سایت
حالت نمایش
اندازهٔ متن
خوانایی

این تنظیمات فقط روی مرورگر شما ذخیره می‌شود.