协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
mobile-testing【开发与工程】Use this skill when the user asks about mobile testing strategies, unit tests, widget tests, component tests, integration tests, E2E, golden/snapshot tests, mocking, or CI test integration.
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Design comprehensive mobile test strategies following the test pyramid — unit, widget/component, integration, and E2E — with proper mocking, golden tests, and CI integration.
User request includes: mobile test, mobile testing, unit test mobile, widget test, component test, ui test mobile, e2e mobile, golden test, snapshot test mobile.
A markdown document containing:
No preamble. No postamble. No explanations.
4096 tokens
What are you testing?
├── Business logic (UseCase, calculator, validator)
│ └── Unit test — pure logic, no UI, fast, deterministic
├── ViewModel state management (state transitions, events)
│ └── Unit test with mocked dependencies — no UI rendering
├── UI component rendering (displays correct data in states)
│ ├── Flutter → Widget test (pumpWidget, find, expect)
│ ├── SwiftUI → ViewInspector / snapshot tests
│ ├── Jetpack Compose → compose-test with semijson tree
│ └── React Native → React Native Testing Library
├── Visual regression (design fidelity)
│ └── Golden/snapshot test — compare rendered UI to reference image
├── Feature flow (login → navigate → action)
│ ├── Integration test — multiple components, mocked API/DB
│ └── Avoid full E2E for speed
└── Critical user journey (checkout, signup, payment)
└── E2E test — real device/emulator, real API/staging
What needs mocking?
├── Network calls → Mock API responses (Mockito/mocktail/OHHTTPStubs)
├── Database/DAO → In-memory database or mock DAO
├── Platform channels (Flutter) → Mock channel handlers
├── System services (location, camera, biometrics)
│ ├── Mock per test (unit) OR fake service (integration)
│ └── Avoid mocking what you don't own (platform SDKs)
└── Time/date → Inject clock dependency, use fake timers
What CI stage?
├── Every PR (fast, <10min) → Unit + widget/component tests
├── Nightly (slow, <60min) → Integration tests + golden tests
├── Pre-release (manual trigger) → Full E2E suite on device farm
└── Post-release → Smoke tests on production (canary)
Apply the mobile test pyramid: many unit/widget tests, medium integration tests, few E2E critical journeys.
Cover business logic, use cases, repositories, and utilities with mocked dependencies and parameterized edge cases.
Test UI components with mock data, verify rendering, interactions, and state transitions.
Test feature flows combining multiple components, real API mocking, and database interactions.
Cover critical user journeys (login, purchase, order flow) on real devices or emulators.
Run unit + widget tests on every PR, integration nightly, E2E before release.
╱╲
╱E2E╲ Few — critical journeys only (5-10)
╱─────╲
╱Integration╲ Medium — feature flows (20-50)
╱─────────────╲
╱ Unit / Widget ╲ Many — every class, every widget state (200+)
╱─────────────────╲
class GetOrdersUseCaseTest {
private val repository = mockk<OrderRepository>()
private val useCase = GetOrdersUseCase(repository)
@Test
fun `returns orders from repository`() = runTest {
val orders = listOf(Order("1", "Test"))
coEvery { repository.getOrders() } returns Result.success(orders)
val result = useCase()
assertTrue(result.isSuccess)
assertEquals(orders, result.getOrNull())
}
@Test
fun `returns error when repository fails`() = runTest {
coEvery { repository.getOrders() } returns Result.failure(NetworkException("timeout"))
val result = useCase()
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is NetworkException)
}
@Test
fun `handles empty list`() = runTest {
coEvery { repository.getOrders() } returns Result.success(emptyList())
val result = useCase()
assertTrue(result.isSuccess)
assertTrue(result.getOrNull()!!.isEmpty())
}
}
import XCTest
@testable import MyApp
final class GetOrdersUseCaseTests: XCTestCase {
var useCase: GetOrdersUseCase!
var mockRepository: MockOrderRepository!
override func setUp() {
mockRepository = MockOrderRepository()
useCase = GetOrdersUseCase(repository: mockRepository)
}
func testReturnsOrders() async throws {
let expected = [Order(id: "1", title: "Test")]
mockRepository.stubbedOrders = expected
let orders = try await useCase()
XCTAssertEqual(orders, expected)
}
func testThrowsOnNetworkError() async {
mockRepository.stubbedError = NetworkError.timeout
await XCTAssertThrowsError(try await useCase())
}
}
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockOrderRepo extends Mock implements OrderRepository {}
void main() {
late GetOrdersUseCase useCase;
late MockOrderRepo repo;
setUp(() {
repo = MockOrderRepo();
useCase = GetOrdersUseCase(repo);
});
test('returns orders', () async {
final orders = [Order(id: '1', title: 'Test')];
when(() => repo.getOrders()).thenAnswer((_) async => orders);
final result = await useCase();
expect(result, orders);
});
test('throws on error', () async {
when(() => repo.getOrders()).thenThrow(NetworkException('timeout'));
expect(() => useCase(), throwsA(isA<NetworkException>()));
});
}
import { renderHook, act } from '@testing-library/react-hooks';
import { useOrderStore } from './orderStore';
jest.mock('../api/orderApi');
describe('useOrderStore', () => {
it('loads orders', async () => {
(orderApi.getOrders as jest.Mock).mockResolvedValue(mockOrders);
const { result } = renderHook(() => useOrderStore());
await act(async () => {
await result.current.loadOrders();
});
expect(result.current.orders).toEqual(mockOrders);
expect(result.current.isLoading).toBe(false);
});
it('handles error state', async () => {
(orderApi.getOrders as jest.Mock).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useOrderStore());
await act(async () => {
await result.current.loadOrders();
});
expect(result.current.error).toBe('Network error');
expect(result.current.isLoading).toBe(false);
});
});
@OptIn(ExperimentalCoroutinesApi::class)
class OrderViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val useCase = mockk<GetOrdersUseCase>()
private lateinit var vm: OrderViewModel
@Before
fun setup() {
vm = OrderViewModel(useCase)
}
@Test
fun `load orders sets loading then data`() = runTest {
val orders = listOf(Order("1", "Test"))
coEvery { useCase() } returns Result.success(orders)
vm.onAction(OrderAction.Refresh)
// Loading state
assertEquals(true, vm.state.value.isLoading)
// Advance past loading
advanceUntilIdle()
// Data state
assertEquals(orders, vm.state.value.orders)
assertEquals(false, vm.state.value.isLoading)
}
@Test
fun `load orders sets error on failure`() = runTest {
coEvery { useCase() } returns Result.failure(NetworkException("timeout"))
vm.onAction(OrderAction.Refresh)
advanceUntilIdle()
assertNotNull(vm.state.value.error)
assertEquals(false, vm.state.value.isLoading)
}
}
@MainActor
final class OrderViewModelTests: XCTestCase {
var vm: OrderViewModel!
var mockUseCase: MockGetOrdersUseCase!
override func setUp() {
mockUseCase = MockGetOrdersUseCase()
vm = OrderViewModel(getOrdersUseCase: mockUseCase)
}
func testLoadSetsOrders() async {
let expected = [Order(id: "1", title: "Test")]
mockUseCase.stubbedResult = expected
await vm.loadOrders()
XCTAssertEqual(vm.orders, expected)
XCTAssertFalse(vm.isLoading)
}
func testLoadSetsError() async {
mockUseCase.stubbedError = NetworkError.timeout
await vm.loadOrders()
XCTAssertNotNil(vm.error)
XCTAssertFalse(vm.isLoading)
}
}
testWidgets('OrderCard displays all states', (tester) async {
// Data state
await tester.pumpWidget(MaterialApp(home: OrderCard(order: tOrder)));
expect(find.text('Alice'), findsOneWidget);
expect(find.text('\$50.00'), findsOneWidget);
// Loading state
await tester.pumpWidget(MaterialApp(home: OrderCard(isLoading: true)));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Error state
await tester.pumpWidget(MaterialApp(home: OrderCard(error: 'Failed to load')));
expect(find.text('Failed to load'), findsOneWidget);
// Empty state
await tester.pumpWidget(MaterialApp(home: OrderCard(order: null)));
expect(find.text('No order found'), findsOneWidget);
});
testWidgets('OrderCard tap triggers callback', (tester) async {
var tapped = false;
await tester.pumpWidget(MaterialApp(
home: OrderCard(order: tOrder, onTap: () => tapped = true),
));
await tester.tap(find.text('Alice'));
expect(tapped, isTrue);
});
import { render, fireEvent } from '@testing-library/react-native';
describe('OrderCard', () => {
it('renders order data', () => {
const { getByText } = render(<OrderCard order={mockOrder} />);
expect(getByText('Alice')).toBeOnTheScreen();
expect(getByText('$50.00')).toBeOnTheScreen();
});
it('shows loading spinner when loading', () => {
const { getByTestId } = render(<OrderCard isLoading />);
expect(getByTestId('loading-spinner')).toBeOnTheScreen();
});
it('calls onPress when tapped', () => {
const onPress = jest.fn();
const { getByText } = render(<OrderCard order={mockOrder} onPress={onPress} />);
fireEvent.press(getByText('Alice'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
@Test
fun orderCardDisplaysCustomerName() {
composeTestRule.setContent {
OrderCard(order = testOrder)
}
composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
composeTestRule.onNodeWithText("$50.00").assertIsDisplayed()
}
@Test
fun orderCardTapTriggersCallback() {
var tapped = false
composeTestRule.setContent {
OrderCard(order = testOrder, onTap = { tapped = true })
}
composeTestRule.onNodeWithText("Alice").performClick()
assertTrue(tapped)
}
testWidgets('OrderCard golden test', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 200));
await tester.pumpWidget(MaterialApp(home: OrderCard(order: tOrder)));
await expectLater(
find.byType(OrderCard),
matchesGoldenFile('goldens/order_card.png'),
);
});
func testOrderCardSnapshot() {
let card = OrderCard(order: testOrder)
let vc = UIHostingController(rootView: card)
vc.view.frame = CGRect(x: 0, y: 0, width: 400, height: 200)
FBSnapshotVerifyView(vc.view)
}
@Test
fun orderCardSnapshot() {
paparazzi.snapshot {
OrderCard(order = testOrder)
}
}
// test_driver/app_test.dart
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('create order flow', (tester) async {
await tester.pumpWidget(MyApp());
await tester.tap(find.byKey(const Key('add-order')));
await tester.enterText(find.byKey(const Key('name-input')), 'Alice');
await tester.tap(find.byKey(const Key('save')));
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
});
}
@Test
fun createOrderFlow() {
composeTestRule.setContent {
MyApp()
}
composeTestRule.onNodeWithTag("add-order").performClick()
composeTestRule.onNodeWithTag("name-input").performTextInput("Alice")
composeTestRule.onNodeWithTag("save").performClick()
composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
}
describe('Order Flow', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
it('creates order end-to-end', async () => {
await element(by.id('add-order')).tap();
await element(by.id('name-input')).typeText('Alice\n');
await element(by.id('save')).tap();
await expect(element(by.text('Alice'))).toBeVisible();
});
it('handles network error gracefully', async () => {
await device.setURLBlacklist(['.*api.example.com.*']);
await element(by.id('add-order')).tap();
await element(by.id('save')).tap();
await expect(element(by.text('Network error'))).toBeVisible();
});
});
final class OrderFlowTests: XCTestCase {
let app = XCUIApplication()
override func setUp() {
continueAfterFailure = false
app.launch()
}
func testCreateOrder() {
app.buttons["add-order"].tap()
let nameField = app.textFields["name-input"]
nameField.tap()
nameField.typeText("Alice")
app.buttons["save"].tap()
XCTAssertTrue(app.staticTexts["Alice"].waitForExistence(timeout: 5))
}
func testEmptyNameShowsError() {
app.buttons["add-order"].tap()
app.buttons["save"].tap()
XCTAssertTrue(app.staticTexts["Name is required"].exists)
}
}
@Test
fun createOrderE2E() {
composeTestRule.onNodeWithTag("add-order").performClick()
composeTestRule.onNodeWithTag("name-input").performTextInput("Alice")
composeTestRule.onNodeWithTag("save").performClick()
composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
}
# .maestro/create-order.yaml
appId: com.example.app
---
- tapOn: "add-order"
- inputText: "Alice"
- tapOn: "save"
- assertVisible: "Alice"
name: Tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.x'
- run: flutter pub get
- run: flutter analyze
- run: flutter test --coverage --machine > report.log
- uses: VeryGoodOpenSource/very_good_coverage@v2
with:
path: ./coverage/lcov.info
min_coverage: 80
name: Android Tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- run: ./gradlew testDebugUnitTest
- run: ./gradlew ktlintCheck
- uses: actions/upload-artifact@v4
with:
path: app/build/reports/tests
name: iOS Tests
on: [pull_request]
jobs:
test:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- run: xcodebuild test -scheme App -destination 'platform=iOS Simulator,name=iPhone 16'
- run: swiftlint
name: RN Tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm test -- --coverage
- run: npx eslint .
// Android — build.gradle.kts
android {
testOptions {
unitTests.all {
jacoco {
includeNoLocationClasses = true
}
}
}
}
tasks.register("jacocoTestReport", JacocoReport::class) {
dependsOn("testDebugUnitTest")
reports { xml.required.set(true) }
}
// iOS — scheme configuration
// Edit Scheme > Test > Options > Code Coverage
// Or xccov:
// xcrun xccov view --report --json DerivedData/*.xcresult
# Flutter: very_good_coverage
min_coverage: 80
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "**/generated/**"
setUp/tearDown for clean statewaitFor instead of sleep. Never use fixed delays// Android — Macrobenchmark for startup/scrolling
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD,
) {
startActivityAndWait()
}
}
// iOS — XCTMetric for performance
func testScrollPerformance() {
let metric = XCTOSSignpostMetric.scrolling
measure(metrics: [metric]) {
app.tables.element.swipeUp()
}
}
testWidgets('OrderCard has accessibility labels', (tester) async {
await tester.pumpWidget(MaterialApp(home: OrderCard(order: tOrder)));
expect(find.bySemanticsLabel('Order for Alice'), findsOneWidget);
});
func testAccessibilityLabels() {
XCTAssertTrue(app.buttons["add-order"].exists)
XCTAssertTrue(app.staticTexts["order-status"].label == "Pending")
}
references/integration-testing.md — Mobile Integration Testingreferences/manual-testing.md — Manual Testing — Mobilereferences/mobile-testing-strategies.md — Mobile Testing Strategiesreferences/test-automation-frameworks.md — Test Automation Frameworksreferences/ui-testing.md — Mobile UI Testingreferences/unit-testing.md — Mobile Unit TestingAfter testing setup, hand off to:
mobile/universal/performance — Performance benchmarking testsmobile/universal/security — Security testing, penetration testingmobile/universal/deployment — CI/CD pipeline integration for testsmobile/universal/analytics — Test analytics for flaky detectionmobile/android — Espresso, Robolectric, Compose testmobile/ios — XCUITest, XCTest, snapshot testingmobile/flutter — flutter_test, integration_testmobile/react-native — Jest, Detox, React Native Testing Library