Integration Test #
Integration test menjalankan aplikasi yang sesungguhnya di device atau emulator nyata, mensimulasikan alur pengguna dari awal sampai akhir. Flutter memiliki package integration_test bawaan, tapi ia memiliki keterbatasan fundamental: tidak bisa berinteraksi dengan komponen native seperti dialog izin lokasi/kamera, WebView untuk OAuth, atau notifikasi sistem. Patrol dari LeanCode hadir untuk mengatasi keterbatasan ini.
Patrol vs integration_test #
integration_test (bawaan Flutter):
✓ Tidak butuh setup tambahan
✓ Bisa test widget Flutter
✗ TIDAK bisa: dialog izin native (lokasi, kamera, notifikasi)
✗ TIDAK bisa: WebView (Google Sign-In, OAuth)
✗ TIDAK bisa: notifikasi sistem, settings OS
✗ TIDAK bisa: tekan tombol home/back hardware
Patrol (LeanCode):
✓ Semua kemampuan integration_test
✓ Interaksi dialog izin native
✓ Buka/tutup notifikasi, WebView
✓ Kontrol WiFi, dark mode, orientation
✓ Hot Restart -- test lebih cepat dikembangkan
✓ Finder yang lebih ringkas ($)
✓ Isolasi test yang lebih baik
Instalasi Patrol #
# pubspec.yaml
dev_dependencies:
patrol: ^3.14.0
integration_test:
sdk: flutter
# Install Patrol CLI (wajib untuk native automation)
dart pub global activate patrol_cli
# Verifikasi instalasi
patrol --version
Setup Android #
// android/app/src/androidTest/java/com/namapackage/MainActivityTest.java
package com.namapackage;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import pl.leancode.patrol.PatrolJUnitRunner;
@RunWith(Parameterized.class)
public class MainActivityTest {
@Parameterized.Parameters(name = "{0}")
public static Object[] testCases() {
PatrolJUnitRunner instrumentation =
(PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation();
instrumentation.setUp(MainActivity.class);
instrumentation.waitForPatrolAppService();
return instrumentation.listDartTests();
}
public MainActivityTest(String dartTestName) {
this.dartTestName = dartTestName;
}
private final String dartTestName;
@org.junit.Test
public void runDartTest() {
PatrolJUnitRunner instrumentation =
(PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation();
instrumentation.runDartTest(dartTestName);
}
}
// android/app/build.gradle -- tambahkan di defaultConfig
defaultConfig {
testInstrumentationRunner "pl.leancode.patrol.PatrolJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
// dan di android block
testOptions {
execution "ANDROIDX_TEST_ORCHESTRATOR"
}
// di dependencies
dependencies {
androidTestUtil "androidx.test:orchestrator:1.4.2"
}
Setup iOS #
# ios/Podfile -- pastikan minimum iOS 16
platform :ios, '16.0'
// ios/RunnerUITests/RunnerUITests.swift
import XCTest
final class RunnerUITests: XCTestCase {
func testRunner() throws {
let app = XCUIApplication()
app.launch()
}
}
Struktur File Test #
integration_test/
flows/
auth/
login_test.dart ← alur login
register_test.dart ← alur registrasi
produk/
browse_produk_test.dart ← browse dan filter produk
detail_produk_test.dart ← halaman detail
checkout/
checkout_flow_test.dart ← alur checkout lengkap
helpers/
app_runner.dart ← setup dan teardown app
mock_server.dart ← mock API untuk test
pages/ ← Page Object Model
login_page.dart
home_page.dart
produk_page.dart
patrolTest — Dasar Penulisan Test #
// integration_test/flows/auth/login_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';
import 'package:myapp/main.dart' as app;
void main() {
// patrolTest menggantikan testWidgets untuk integration test
patrolTest(
'pengguna bisa login dengan email dan password',
($) async {
// Jalankan aplikasi
await $.pumpWidgetAndSettle(app.MyApp());
// Patrol menggunakan $ sebagai shortcut untuk interaksi
// $('teks') setara dengan find.text('teks') tapi lebih ringkas
// Input email -- cari TextField berdasarkan key atau semantics
await $(#emailInput).enterText('[email protected]');
await $.pump();
// Input password
await $(#passwordInput).enterText('password123');
await $.pump();
// Tap tombol login
await $(#loginButton).tap();
await $.pumpAndSettle();
// Verifikasi berada di HomeScreen
expect($(#homeScreen), findsOneWidget);
expect($('Selamat datang, Budi!'), findsOneWidget);
},
);
patrolTest(
'login gagal menampilkan pesan error',
($) async {
await $.pumpWidgetAndSettle(app.MyApp());
await $(#emailInput).enterText('[email protected]');
await $(#passwordInput).enterText('passwordsalah');
await $(#loginButton).tap();
await $.pumpAndSettle();
// Masih di LoginScreen
expect($(#loginScreen), findsOneWidget);
// Error message muncul
expect($('Email atau password salah'), findsOneWidget);
},
);
}
Patrol Custom Finder — $ Operator #
// $ adalah PatrolTester yang disediakan oleh patrolTest
// Cara menggunakan $:
// Berdasarkan ValueKey (gunakan # untuk Symbol)
await $(#loginButton).tap(); // find.byKey(const ValueKey('loginButton'))
// Berdasarkan tipe widget
await $(ElevatedButton).tap(); // find.byType(ElevatedButton)
// Berdasarkan teks
await $('Submit').tap(); // find.text('Submit')
// Berdasarkan icon
await $(Icons.add).tap();
// Chaining -- cari di dalam parent
await $(#produkList).$(ListTile).tap(); // ListTile di dalam #produkList
// Menunggu sampai widget muncul (lebih andal dari pumpAndSettle untuk async)
await $(#homeScreen).waitUntilVisible();
await $(#loadingIndicator).waitUntilGone();
Interaksi Native — Izin dan OS #
Inilah keunggulan utama Patrol dibanding integration_test biasa:
patrolTest(
'app meminta izin lokasi dan menampilkan peta setelah diizinkan',
($) async {
await $.pumpWidgetAndSettle(app.MyApp());
// Navigasi ke fitur yang butuh izin lokasi
await $('Cari Toko Terdekat').tap();
await $.pumpAndSettle();
// Dialog izin native muncul secara otomatis
// Patrol bisa mengaksepnya!
if (await $.native.isPermissionDialogVisible()) {
await $.native.grantPermissionWhenInUse(); // atau denyPermission()
}
await $.pumpAndSettle();
// Setelah izin diberikan, peta seharusnya muncul
expect($(#mapView), findsOneWidget);
},
);
patrolTest(
'app meminta izin notifikasi saat onboarding',
($) async {
await $.pumpWidgetAndSettle(app.MyApp());
// Lewati onboarding
await $('Mulai').tap();
await $.pumpAndSettle();
// Patrol menerima dialog notifikasi
if (await $.native.isPermissionDialogVisible()) {
await $.native.grantPermissionWhenInUse();
}
// Interaksi dengan notifikasi native
await $.native.openNotifications();
// Verifikasi notifikasi dari app kita ada
expect(await $.native.containsNotificationWhere(
appName: 'MyApp',
titleContains: 'Selamat datang',
), isTrue);
await $.native.tapOnNotificationByIndex(0);
await $.pumpAndSettle();
// App terbuka ke halaman yang sesuai setelah tap notifikasi
expect($(#homeScreen), findsOneWidget);
},
);
patrolTest(
'app berfungsi saat WiFi dimatikan',
($) async {
await $.pumpWidgetAndSettle(app.MyApp());
await $.pumpAndSettle();
// Navigasi ke halaman produk
await $('Produk').tap();
await $.pumpAndSettle();
// Matikan WiFi
await $.native.disableWifi();
await $.pump(const Duration(seconds: 1));
// Refresh
await $(#refreshButton).tap();
await $.pumpAndSettle();
// Tampilkan error offline
expect($('Tidak ada koneksi internet'), findsOneWidget);
// Nyalakan kembali WiFi
await $.native.enableWifi();
await $.pumpAndSettle();
},
);
Page Object Model — Struktur Test yang Bersih #
Page Object Model (POM) memisahkan logika interaksi dari logika test, sehingga jika UI berubah hanya satu file yang perlu diupdate:
// integration_test/pages/login_page.dart
class LoginPage {
final PatrolTester $;
LoginPage(this.$);
// Finders
PatrolFinder get emailField => $(#emailInput);
PatrolFinder get passwordField => $(#passwordInput);
PatrolFinder get loginButton => $(#loginButton);
PatrolFinder get errorMessage => $(#errorMessage);
PatrolFinder get googleLoginButton => $(#googleLogin);
// Actions
Future<void> enterEmail(String email) async {
await emailField.enterText(email);
await $.pump();
}
Future<void> enterPassword(String password) async {
await passwordField.enterText(password);
await $.pump();
}
Future<void> tapLogin() async {
await loginButton.tap();
await $.pumpAndSettle();
}
Future<void> login(String email, String password) async {
await enterEmail(email);
await enterPassword(password);
await tapLogin();
}
// Assertions
void verifyOnLoginPage() {
expect($(#loginScreen), findsOneWidget);
}
void verifyErrorShown(String message) {
expect($(message), findsOneWidget);
}
}
// integration_test/pages/home_page.dart
class HomePage {
final PatrolTester $;
HomePage(this.$);
void verifyOnHomePage() {
expect($(#homeScreen), findsOneWidget);
}
Future<void> navigateToProduk() async {
await $('Produk').tap();
await $.pumpAndSettle();
}
}
// Penggunaan di test -- jauh lebih bersih dan mudah dibaca
patrolTest('alur login berhasil', ($) async {
await $.pumpWidgetAndSettle(app.MyApp());
final loginPage = LoginPage($);
final homePage = HomePage($);
await loginPage.login('[email protected]', 'password123');
homePage.verifyOnHomePage();
});
patrolTest('login dengan email kosong menampilkan error', ($) async {
await $.pumpWidgetAndSettle(app.MyApp());
final loginPage = LoginPage($);
await loginPage.login('', 'password123');
loginPage.verifyErrorShown('Email tidak boleh kosong');
loginPage.verifyOnLoginPage();
});
Menjalankan Patrol Test #
# Jalankan semua integration test
patrol test
# Jalankan file test tertentu
patrol test -t integration_test/flows/auth/login_test.dart
# Jalankan di device tertentu
patrol test --device emulator-5554
# Hot Restart mode -- untuk development test (lebih cepat)
patrol develop -t integration_test/flows/auth/login_test.dart
# Build tanpa jalankan (untuk upload ke test farm)
patrol build android
patrol build ios
Integrasi CI — GitHub Actions #
# .github/workflows/integration_test.yml
name: Integration Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
integration-test-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.x'
- name: Install Patrol CLI
run: dart pub global activate patrol_cli
- name: Enable KVM (untuk emulator Android)
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Start Android Emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
arch: x86_64
script: patrol test -t integration_test/flows/auth/login_test.dart
Ringkasan #
- Integration test menjalankan aplikasi sesungguhnya di device/emulator — paling lambat tapi paling mirip dengan pengguna nyata.
- Gunakan Patrol (bukan
integration_testbawaan) untuk bisa berinteraksi dengan dialog izin native, WebView, notifikasi sistem, dan kontrol OS.patrolTest()menggantikantestWidgets()untuk integration test. Parameter$adalahPatrolTesteryang menyediakan finder ringkas.$ operatoradalah finder ringkas Patrol:$(#keyName)untuk ValueKey,$(WidgetType)untuk tipe,$('teks')untuk teks.- Untuk interaksi native (izin, notifikasi):
$.native.grantPermissionWhenInUse(),$.native.openNotifications(),$.native.disableWifi().- Gunakan Page Object Model — pisahkan logika interaksi ke class terpisah agar perubahan UI hanya mempengaruhi satu file, bukan semua test.
- Integration test hanya untuk alur kritis (login, checkout, pembayaran) — jangan test setiap fitur dengan integration test, biaya dan lambatnya tidak sebanding.
- Gunakan
patrol developmode saat menulis test — Hot Restart membuat siklus development jauh lebih cepat.