تست در 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
۱۲.۹ بهترین تجربیات
- هرم تست را رعایت کنید. ۷۰/۲۰/۱۰.
- Test-driven development. اول test، بعد کد.
- Coverage > 70%. هدف معقول.
- Mock dependency ها.
- setUp و tearDown. برای cleanup.
- Group های مرتب.
- اسامی توصیفی. “should X when Y”.
- Test یک چیز در هر test.
- CI از روز اول.
- 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.