Mobile Development

Mobile Testing Strategies

TOKEN

Strategi testing mobile — unit test, widget/UI test, integration test, E2E dengan Detox & Playwright, snapshot testing, dan test strategy

📋 Daftar Isi
  1. Testing Pyramid
  2. Unit Testing
  3. Widget / UI Testing
  4. E2E Testing
  5. Snapshot Testing
  6. Test Strategy
  7. Tips Testing
  8. Quiz Pemahaman

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
🔶
Integration (15-25%)
Multi-component
Widget tests
Unit (70-80%)
Fast, isolated
ViewModel, Utils
LevelKecepatanBiayaCakupanTool
UnitDetikRendahFungsi/ClassJUnit, XCTest, flutter_test
IntegrationMenitSedangMulti-componentEspresso, XCUITest, integration_test
E2EPuluhan menitTinggiFull flowDetox, Playwright, Appium
SnapshotDetikRendahVisual regressionPaparazzi, 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

StepCommandYang Terjadi
Record./gradlew recordPaparazziDebugSimpan baseline screenshots
Verify./gradlew verifyPaparazziDebugBandakan dengan baseline
Update./gradlew recordPaparazziDebugUpdate baseline jika perubahan disetujui

6. Test Strategy

LayerApa yang di-testTool
Model/EntityData class, serializationUnit test
ViewModel/BLoCBusiness logic, stateUnit test + mock
RepositoryData fetching, cachingIntegration test
UI ComponentsRendering, interactionWidget/UI test
Visual RegressionTampilan konsistenSnapshot test
User FlowEnd-to-end journeyE2E 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

TipsAlasan
Fokus pada ViewModel/Business LogicROI tertinggi, paling banyak bug
Mock external dependenciesIsolasi unit under test
Gunakan factory untuk test dataKonsisten, mudah diubah
Test edge cases (null, empty, error)Bug paling sering di edge cases
Snapshot test untuk visual regressionDeteksi perubahan UI yang tidak disengaja
Run E2E di CI (nightly build)E2E lambat, jangan di setiap commit
Measure test coverageTarget 70-80%, fokus area kritis

Quiz Pemahaman

Pertanyaan 1: Berapa persentase unit test yang ideal di testing pyramid?

a) 5-10%
b) 15-25%
c) 70-80%
d) 100%

Pertanyaan 2: Tool apa untuk E2E testing di React Native?

a) Jest
b) React Testing Library
c) Detox
d) Espresso

Pertanyaan 3: Apa yang dilakukan snapshot testing?

a) Test performa
b) Bandingkan screenshot dengan baseline
c) Test security
d) Test API

Pertanyaan 4: Framework apa untuk widget testing di Flutter?

a) flutter_test
b) Mockito
c) integration_test
d) dart:test

Pertanyaan 5: Apa yang harus di-mock saat unit testing ViewModel?

a) UI widgets
b) External dependencies (API, DB)
c) State management
d) Navigation
← SebelumnyaKembali ke Beranda Selanjutnya →Lihat Kategori
🔍 Zoom
100%
🎨 Tema