📋 Daftar Isi
1. Testing Pyramid
Testing di mobile development mengikuti piramida: banyak unit test di bawah, sedikit integration test di tengah, dan beberapa E2E test di puncak.
Mobile Testing Pyramid
E2E (5-10%)
Full user journey
Detox, Playwright
Detox, Playwright
→
Integration (15-25%)
Multi-component
Widget tests
Widget tests
→
Unit (70-80%)
Fast, isolated
ViewModel, Utils
ViewModel, Utils
| Level | Kecepatan | Biaya | Cakupan | Tool |
|---|---|---|---|---|
| Unit | Detik | Rendah | Fungsi/Class | JUnit, XCTest, flutter_test |
| Integration | Menit | Sedang | Multi-component | Espresso, XCUITest, integration_test |
| E2E | Puluhan menit | Tinggi | Full flow | Detox, Playwright, Appium |
| Snapshot | Detik | Rendah | Visual regression | Paparazzi, swift-snapshot-testing |
2. Unit Testing
Kotlin — ViewModel Unit Test
@RunWith(MockitoJUnitRunner::class)
class ProductViewModelTest {
@Mock lateinit var repository: ProductRepository
@Mock lateinit var analytics: AnalyticsService
private lateinit var viewModel: ProductViewModel
@Before
fun setup() {
viewModel = ProductViewModel(repository, analytics)
}
@Test
fun `load products updates state to success`() = runTest {
// Arrange
val products = listOf(
Product("1", "Laptop", 15_000_000),
Product("2", "Phone", 8_000_000)
)
whenever(repository.getProducts()).thenReturn(products)
// Act
viewModel.loadProducts()
// Assert
val state = viewModel.uiState.value
assertTrue(state is UiState.Success)
assertEquals(2, (state as UiState.Success).products.size)
}
@Test
fun `load products updates state to error on failure`() = runTest {
whenever(repository.getProducts()).thenThrow(RuntimeException("Network error"))
viewModel.loadProducts()
val state = viewModel.uiState.value
assertTrue(state is UiState.Error)
assertEquals("Network error", (state as UiState.Error).message)
}
@Test
fun `search filters products by query`() = runTest {
val products = listOf(
Product("1", "Laptop ASUS", 15_000_000),
Product("2", "Phone Samsung", 8_000_000),
Product("3", "Laptop Dell", 12_000_000)
)
whenever(repository.getProducts()).thenReturn(products)
viewModel.loadProducts()
viewModel.search("Laptop")
val state = viewModel.uiState.value as UiState.Success
assertEquals(2, state.products.size)
}
}
Dart — Flutter Unit Test
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MockApiService extends Mock implements ApiService {}
void main() {
late ProductViewModel viewModel;
late MockApiService mockApi;
setUp(() {
mockApi = MockApiService();
viewModel = ProductViewModel(api: mockApi);
});
group('ProductViewModel', () {
test('loadProducts updates state to success', () async {
when(mockApi.getProducts()).thenAnswer(
(_) async => [Product(id: '1', name: 'Laptop')],
);
await viewModel.loadProducts();
expect(viewModel.state, isA());
expect(viewModel.products.length, 1);
expect(viewModel.products.first.name, 'Laptop');
});
test('loadProducts updates state to error on failure', () async {
when(mockApi.getProducts()).thenThrow(Exception('Network error'));
await viewModel.loadProducts();
expect(viewModel.state, isA());
});
test('search filters products', () async {
when(mockApi.getProducts()).thenAnswer(
(_) async => [
Product(id: '1', name: 'Laptop ASUS'),
Product(id: '2', name: 'Phone Samsung'),
Product(id: '3', name: 'Laptop Dell'),
],
);
await viewModel.loadProducts();
viewModel.search('Laptop');
expect(viewModel.filteredProducts.length, 2);
});
});
}
3. Widget / UI Testing
Dart — Flutter Widget Test
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
void main() {
group('SearchBar Widget', () {
testWidgets('displays initial query', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SearchBar(
query: 'Hello',
onQueryChange: (_) {},
onSearch: () {},
),
),
));
expect(find.text('Hello'), findsOneWidget);
});
testWidgets('calls onQueryChange when text entered', (tester) async {
String changedQuery = '';
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SearchBar(
query: '',
onQueryChange: (q) => changedQuery = q,
onSearch: () {},
),
),
));
await tester.enterText(find.byType(TextField), 'Test');
expect(changedQuery, 'Test');
});
testWidgets('calls onSearch when button tapped', (tester) async {
bool searched = false;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SearchBar(
query: 'Hello',
onQueryChange: (_) {},
onSearch: () => searched = true,
),
),
));
await tester.tap(find.byIcon(Icons.search));
expect(searched, true);
});
});
}
Kotlin — Espresso Test
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Test
fun loginWithValidCredentials_showsHome() {
onView(withId(R.id.emailInput))
.perform(typeText("user@test.com"), closeSoftKeyboard())
onView(withId(R.id.passwordInput))
.perform(typeText("password123"), closeSoftKeyboard())
onView(withId(R.id.loginButton)).perform(click())
onView(withId(R.id.homeTitle))
.check(matches(isDisplayed()))
}
@Test
fun loginWithInvalidCredentials_showsError() {
onView(withId(R.id.emailInput))
.perform(typeText("wrong@email.com"), closeSoftKeyboard())
onView(withId(R.id.passwordInput))
.perform(typeText("wrongpass"), closeSoftKeyboard())
onView(withId(R.id.loginButton)).perform(click())
onView(withText("Email atau password salah"))
.check(matches(isDisplayed()))
}
}
4. E2E Testing
4.1 Detox (React Native)
JavaScript — Detox E2E
// e2e/login.test.js
describe('Login Flow', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should login successfully', async () => {
await element(by.id('email-input')).typeText('user@test.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
await expect(element(by.id('home-screen'))).toBeVisible();
await expect(element(by.text('Selamat datang!'))).toBeVisible();
});
it('should show error for invalid credentials', async () => {
await element(by.id('email-input')).typeText('wrong@email.com');
await element(by.id('password-input')).typeText('wrong');
await element(by.id('login-button')).tap();
await expect(element(by.text('Login gagal'))).toBeVisible();
});
it('should navigate to product detail', async () => {
// Login first
await element(by.id('email-input')).typeText('user@test.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
// Navigate
await element(by.id('product-list')).scrollTo('bottom');
await element(by.id('product-item-0')).tap();
await expect(element(by.id('product-detail'))).toBeVisible();
await expect(element(by.id('product-name'))).toBeVisible();
});
});
4.2 Playwright (Flutter Web / Mobile)
JavaScript — Playwright E2E
// tests/app.spec.js
const { test, expect } = require('@playwright/test');
test.describe('App E2E Tests', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:8080');
});
test('homepage loads correctly', async ({ page }) => {
await expect(page.locator('h1')).toHaveText('Beranda');
await expect(page.locator('.product-card')).toHaveCount(10);
});
test('search filters products', async ({ page }) => {
await page.fill('[data-testid="search-input"]', 'Laptop');
await page.click('[data-testid="search-button"]');
const products = page.locator('.product-card');
await expect(products).toHaveCount(3);
await expect(products.first().locator('.name')).toContainText('Laptop');
});
test('product detail shows info', async ({ page }) => {
await page.click('.product-card:first-child');
await expect(page.locator('.product-name')).toBeVisible();
await expect(page.locator('.product-price')).toBeVisible();
await expect(page.locator('.product-description')).toBeVisible();
});
test('add to cart works', async ({ page }) => {
await page.click('.product-card:first-child');
await page.click('[data-testid="add-to-cart"]');
await expect(page.locator('.cart-count')).toHaveText('1');
});
});
5. Snapshot Testing
Kotlin — Paparazzi Snapshot
class ProductCardSnapshotTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5,
renderingMode = SessionParams.RenderingMode.NORMAL,
)
@Test
fun productCard_default() {
paparazzi.snapshot {
MaterialTheme {
ProductCard(
product = Product(
name = "Laptop ASUS ROG",
price = 15_000_000,
imageUrl = "",
rating = 4.5f,
)
)
}
}
}
@Test
fun productCard_loading() {
paparazzi.snapshot {
MaterialTheme {
ProductCardLoading()
}
}
}
@Test
fun productCard_error() {
paparazzi.snapshot {
MaterialTheme {
ProductCardError(message = "Gagal memuat data")
}
}
}
}
5.1 Snapshot Workflow
| Step | Command | Yang Terjadi |
|---|---|---|
| Record | ./gradlew recordPaparazziDebug | Simpan baseline screenshots |
| Verify | ./gradlew verifyPaparazziDebug | Bandakan dengan baseline |
| Update | ./gradlew recordPaparazziDebug | Update baseline jika perubahan disetujui |
6. Test Strategy
| Layer | Apa yang di-test | Tool |
|---|---|---|
| Model/Entity | Data class, serialization | Unit test |
| ViewModel/BLoC | Business logic, state | Unit test + mock |
| Repository | Data fetching, caching | Integration test |
| UI Components | Rendering, interaction | Widget/UI test |
| Visual Regression | Tampilan konsisten | Snapshot test |
| User Flow | End-to-end journey | E2E test |
💡 Testing Tips
Tulis test SEBELUM atau BERSAMAAN dengan kode (TDD). Test yang ditulis setelah production sering ditulis dengan asumsi yang sama seperti kode — sehingga tidak mendeteksi bug.
7. Tips Testing Efektif
| Tips | Alasan |
|---|---|
| Fokus pada ViewModel/Business Logic | ROI tertinggi, paling banyak bug |
| Mock external dependencies | Isolasi unit under test |
| Gunakan factory untuk test data | Konsisten, mudah diubah |
| Test edge cases (null, empty, error) | Bug paling sering di edge cases |
| Snapshot test untuk visual regression | Deteksi perubahan UI yang tidak disengaja |
| Run E2E di CI (nightly build) | E2E lambat, jangan di setiap commit |
| Measure test coverage | Target 70-80%, fokus area kritis |