Chapitre 13
13 - Testing Mobile
13 - Testing Mobile
13 - Testing Mobile : Cours complet
Niveau : Intermédiaire → Avancé Durée estimée : 5 h Objectif : bâtir une stratégie de tests complète et durable pour une app mobile.
Table des matières
- La pyramide des tests mobile
- Tests unitaires
- Tests de composants / widgets
- Tests d'intégration
- Tests E2E
- Snapshot et golden tests
- Couverture de tests
- Intégration CI
- Stratégie de test par projet
1. La pyramide des tests mobile
1.1 Pourquoi tester ?
- Confiance : refactorer sans peur.
- Vitesse : les bugs détectés à l'écriture coûtent 10× moins cher qu'en production.
- Documentation vivante : un test exprime un comportement attendu.
- Qualité perçue : moins de crashs, plus de reviews.
1.2 La pyramide adaptée au mobile
Diagramme en cours de génération...
Ordre de valeur : l'unitaire et les composants couvrent la majorité du risque ; l'E2E ne couvre que les parcours critiques (login, achat, sync).
1.3 L'équilibre
| Niveau | Vitesse | Fiabilité | Coût | Couverture |
|---|---|---|---|---|
| Unitaire | ms | Élevée | Faible | Logique pure |
| Composant | s | Élevée | Moyen | UI/UX |
| Intégration | s-min | Moyenne | Moyen | Couches |
| E2E | min | Faible (flaky) | Élevé | Parcours critiques |
2. Tests unitaires
2.1 Qu'est-ce qu'un bon test unitaire ?
F.I.R.S.T.
- Fast — rapide
- Independent — indépendant (isolation totale)
- Repeatable — répétable (déterministe)
- Self-validating — auto-validant (booléen)
- Timely — écrit au bon moment (juste avant/avec le code)
2.2 Android — JUnit + MockK
class GetTicketsUseCaseTest {
private val repository: TicketRepository = mockk()
private val useCase = GetTicketsUseCase(repository)
@Test
fun `retourne les tickets en cas de succes`() = runTest {
val expected = listOf(Ticket("1", "A", false))
coEvery { repository.getTickets() } returns Result.success(expected)
val result = useCase()
assertTrue(result.isSuccess)
assertEquals(expected, result.getOrNull())
}
@Test
fun `propagate l erreur`() = runTest {
coEvery { repository.getTickets() } returns Result.failure(IOException("boom"))
val result = useCase()
assertTrue(result.isFailure)
assertIs<IOException>(result.exceptionOrNull())
}
}
Points clés :
runTest(kotlinx-coroutines-test) pour lessuspend.mockkpour les mocks ; Mockito pour les mocks JVM classiques.- Un test = un comportement (un seul assert meaningful).
2.3 iOS — XCTest
import XCTest
@testable import TaskFlow
final class GetTicketsUseCaseTests: XCTestCase {
func testSuccessReturnsTickets() async throws {
let repo = MockTicketRepository(result: .success([Ticket(id: "1", title: "A", done: false)]))
let useCase = GetTicketsUseCase(repository: repo)
let tickets = try await useCase.execute()
XCTAssertEqual(tickets.count, 1)
XCTAssertEqual(tickets.first?.id, "1")
}
func testFailureThrows() async {
let repo = MockTicketRepository(result: .failure(TestError.network))
let useCase = GetTicketsUseCase(repository: repo)
do {
_ = try await useCase.execute()
XCTFail("doit lever une erreur")
} catch {
// succès attendu
}
}
}
2.4 flutter_test (Dart)
test('getTickets retourne la liste', () async {
final repo = FakeTicketRepository();
final useCase = GetTicketsUseCase(repo);
final tickets = await useCase.call();
expect(tickets, hasLength(2));
});
2.5 Jest (React Native)
import { loadTickets } from './ticketsSlice';
import { api } from '../api';
jest.mock('../api', () => ({ api: { get: jest.fn() } }));
describe('loadTickets', () => {
it('dispatch les tickets quand l API répond', async () => {
(api.get as jest.Mock).mockResolvedValue({ data: [{ id: '1', title: 'A' }] });
const thunk = loadTickets();
const dispatch = jest.fn();
await thunk(dispatch, () => ({}), undefined);
const action = dispatch.mock.calls[1][0];
expect(action.type).toBe('tickets/load/fulfilled');
});
});
2.6 Le piège des mocks
Trop de mocks = les tests valident votre mock, pas votre code. Préférer :
- Fakes (implémentation en mémoire) quand c'est possible.
- Unités sans IO (use cases purs).
- Réserver les mocks aux frontières (réseau, plateforme).
3. Tests de composants / widgets
3.1 Jetpack Compose — UI tests
@RunWith(AndroidJUnit4::class)
class HomeScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `affiche la liste des tickets`() {
setContent { HomeScreen(state = HomeUiState.Success(tickets = listOf(t1, t2))) }
composeTestRule.onNodeWithText("A").assertIsDisplayed()
composeTestRule.onNodeWithText("B").assertIsDisplayed()
composeTestRule.onNodeWithTag("loading").assertDoesNotExist()
}
@Test
fun `affiche l erreur si le state est en echec`() {
setContent { HomeScreen(state = HomeUiState.Error("boom")) }
composeTestRule.onNodeWithText("boom").assertIsDisplayed()
}
}
Pattern : injecter le UiState directement dans le composable → tester les 3 états sans réseau.
3.2 XCUITest (iOS UI tests)
import XCTest
final class HomeScreenUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["-uiTestState", "success"] // état injecté
app.launch()
}
func testShowsTicketTitle() {
XCTAssertTrue(app.staticTexts["A"].waitForExistence(timeout: 5))
}
}
3.3 Widget tests (Flutter)
testWidgets('affiche la liste des tickets', (tester) async {
await tester.pumpWidget(MaterialApp(home: HomeScreen(state: successState)));
expect(find.text('Acheter du lait'), findsOneWidget);
});
3.4 React Native Testing Library
import { render, screen } from '@testing-library/react-native';
test('affiche les tickets', () => {
render(<HomeScreen tickets={[t1, t2]} />);
expect(screen.getByText('A')).toBeOnTheScreen();
});
3.5 Quoi tester dans un composant ?
- Les états (loading, vide, erreur, succès)
- La navigation (clicks → callbacks)
- L'accessibilité (labels, sémantique)
- Les interactions (swipe, drag)
Ne pas tester : les détails d'implémentation (testID internes), les animations.
4. Tests d'intégration
4.1 C'est quoi ?
Tester plusieurs couches ensemble : repository + Room + MockWebServer, ViewModel + use case réel, DI réelle.
4.2 Repository + Room en mémoire (Android)
@RunWith(AndroidJUnit4::class)
class TicketRepositoryTest {
private lateinit var db: AppDatabase
private lateinit var repository: TicketRepository
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).build()
repository = TicketRepositoryImpl(db.ticketDao(), FakeRemote())
}
@Test
fun `cree puis relit un ticket`() = runBlocking {
repository.createTicket("Nouveau")
val all = repository.observeTickets().first()
assertEquals(1, all.size)
}
}
4.3 Réseau simulé — MockWebServer
@Test
fun `le repository utilise le cache quand le reseau tombe`() = runBlocking {
// Étape 1 : réseau OK
server.enqueue(jsonResponse("""[{"id":"1","title":"A"}]"""))
repository.refresh()
// Étape 2 : réseau KO
server.enqueue(MockResponse().setResponseCode(503))
val tickets = repository.getTickets().getOrNull()
assertEquals(1, tickets.size) // données du cache
}
4.4 iOS — intégration
func testRepositoryWithInMemoryCoreData() async throws {
let container = try makeInMemoryContainer()
let repo = TicketRepositoryImpl(coreData: container, remote: StubRemote())
try await repo.importAndPersist()
let count = try container.viewContext.count(for: TicketEntity.fetchRequest())
XCTAssertEqual(count, 3)
}
5. Tests E2E
5.1 Principes
Un test E2E démarre l'app réelle, navigue comme un humain et vérifie les résultats. C'est lent et parfois flaky — on en garde peu et on cible les parcours critiques.
5.2 Detox (React Native)
// e2e/login.e2e.js
describe('Login', () => {
beforeAll(async () => {
await device.launchApp();
});
it('se connecte avec de bonnes identifiants', async () => {
await element(by.id('email')).typeText('user@taskflow.app');
await element(by.id('password')).typeText('S3cret!');
await element(by.id('loginButton')).tap();
await expect(element(by.id('homeTitle'))).toBeVisible();
});
});
5.3 Maestro (YAML — cross-platform)
appId: app.taskflow
---
- launchApp
- tapOn:
id: "email"
- inputText: "user@taskflow.app"
- tapOn:
id: "password"
- inputText: "S3cret!"
- tapOn:
id: "loginButton"
- assertVisible: "Bienvenue"
Maestro est devenu le standard 2024-2026 pour l'E2E mobile : YAML, cross-platform, streamable sur simulateur/device cloud.
5.4 Appium (toutes plateformes)
const { wd } = require('appium-client');
// driver au choix : uiautomator2 / xcuitest
const driver = await wd.promiseChainRemote({ host: 'localhost', port: 4723 });
await driver.init({ platformName: 'Android', automationName: 'UiAutomator2' });
const el = await driver.elementByAccessibilityId('email');
await el.sendKeys('user@taskflow.app');
5.5 Flutter — integration_test
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('parcours de login', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.enterText(find.byKey(Key('email')), 'user@taskflow.app');
await tester.tap(find.byKey(Key('loginButton')));
await tester.pumpAndSettle();
expect(find.text('Bienvenue'), findsOneWidget);
});
}
5.6 Réduire le flakiness E2E
- Id stables (testID/accessibility id) plutôt que du texte.
- Reset de l'état avant chaque test (DB, user).
- Timeouts généreux et waiters explicites.
- Lancer en parallèle par device, pas par test dans un device.
- Ne tester que les parcours critiques.
6. Snapshot et golden tests
6.1 Le principe
On capture l'image (ou la structure) d'un écran et on la compare à une référence committée. Toute différence = échec (à valider ou à mettre à jour).
| Outil | Plateforme | Type |
|---|---|---|
| Paparazzi | Android | Image (rendu sans émulateur) |
| Roborazzi | Android | Image (avec Layoutlib) |
| Swift Snapshot Testing (Point-Free) | iOS | Image |
| golden (Flutter) | Flutter | Image |
| Jest + react-test-renderer | RN | Structure sérialisée |
6.2 Paparazzi (Android)
@RunWith(Paparazzi::class)
class HomeScreenPaparazziTest {
@get:Rule val paparazzi = Paparazzi()
@Test
fun `rendu etat succes`() {
paparazzi.snapshot { HomeScreen(state = HomeUiState.Success(listOf(t1))) }
}
@Test
fun `rendu etat chargement`() {
paparazzi.snapshot { HomeScreen(state = HomeUiState.Loading) }
}
}
Paparazzi ne nécessite pas d'émulateur → idéal en CI.
6.3 Swift Snapshot Testing
import SnapshotTesting
func testHomeScreen() {
let view = HomeView(state: .success)
assertSnapshot(of: view, as: .image)
}
6.4 Golden (Flutter)
testWidgets('golden de HomeScreen', (tester) async {
await tester.pumpWidget(MaterialApp(home: HomeScreen()));
await expectLater(
find.byType(HomeScreen),
matchesGoldenFile('goldens/home_screen.png'),
);
});
6.5 Les pièges
- Polices et OS différents → rendus différents. Gel les fonts et la locale en CI.
- Un golden qui change à chaque run est inutile (tester sur un environnement stable).
- Mettre à jour les références consciemment (
--update-goldens), jamais à l'aveugle.
7. Couverture de tests
7.1 Mesurer
# Android — JaCoCo
./gradlew testDebugUnitTest jacocoTestReport
# iOS — Xcode
xcodebuild test -scheme App -enableCodeCoverage YES
# Flutter
flutter test --coverage && genhtml coverage/lcov.info
# React Native (Jest)
npx jest --coverage
7.2 Interpréter
- La couverture de lignes n'est pas la qualité. Une ligne testée peut être mal testée.
- Cibler la couverture par couche : Domain ≥ 90 %, Data ≥ 80 %, UI ~50-70 %.
- Se méfier des 100 % : le coût explose pour peu de valeur.
7.3 Objectifs raisonnables
| Couche | Objectif |
|---|---|
| Domain (use cases, entités) | 90-100 % |
| Data (repositories, mappings) | 80 % |
| Présentation (ViewModels) | 80 % |
| UI (composants) | 50-70 % |
| E2E | parcours critiques seulement |
7.4 Mutation testing (optionnel)
Pitest (JVM) modifie votre code (ex : > → >=) et vérifie que les tests échouent. Si un mutant survit, votre test ne couvre pas bien le comportement. Puissant, mais coûteux — à réserver aux modules critiques.
8. Intégration CI
8.1 Quoi lancer en CI ?
Diagramme en cours de génération...
8.2 GitHub Actions (exemple Android)
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Unit + Integration tests
run: ./gradlew testDebugUnitTest jacocoTestReport --stacktrace
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: app/build/reports
8.3 Cache et vitesse
- Android : cacher
.gradleet~/.gradle. - iOS : cacher les DerivedData et le cache SPM.
- Flutter : cacher le pub cache.
- RN : cacher node_modules + pods.
- Séparer les jobs (unitaires vs E2E) pour un feedback rapide.
8.4 E2E dans la CI
- Localement : un seul device (émulateur/simulateur) pour la PR.
- Nightly : suite complète sur device cloud (Firebase Test Lab, BrowserStack, Sauce Labs, Maestro Cloud).
- Paralléliser par shard pour rester sous 15 min.
8.5 Gate de merge
[ ] Lint passe
[ ] Unitaires passent
[ ] Coverage >= seuil
[ ] Snapshots à jour
[ ] E2E critiques passent
9. Stratégie de test par projet
9.1 Le compromis à arbitrer
| Situation | Stratégie |
|---|---|
| Prototype / MVP | Tests du Domain + 1 E2E de login |
| App de production | Pyramide complète + E2E critiques |
| App financière/médicale | Tout + mutation testing + tests de sécurité |
| Librairie/package | Tests unitaires + intégration + golden |
9.2 TDD : écrire les tests en premier
- Red (le test échoue) → Green (implémentation minimale) → Refactor.
- Idéal pour le Domain (use cases) et les repositories.
- Moins adapté pour l'UI exploratoire (design en mouvement).
9.3 La règle des 3 A
Tout test suit Arrange-Act-Assert :
// Arrange
val repo = mockk<TicketRepository>()
coEvery { repo.getTickets() } returns Result.success(listOf(t1))
val useCase = GetTicketsUseCase(repo)
// Act
val result = useCase()
// Assert
assertTrue(result.isSuccess)
10. Synthèse
Diagramme en cours de génération...
Points à retenir :
- La pyramide : beaucoup d'unitaires, peu d'E2E.
- Tester les états (loading/success/error), pas les détails d'implémentation.
- Fakes > mocks autant que possible.
- Les snapshots protègent les régressions visuelles.
- La CI doit être rapide pour être utilisée : unitaires en PR, E2E en nightly.
- La couverture de ligne ≠ qualité — viser les comportements.
Aller plus loin
- Chapitre 14 (Performance) : détecter les régressions de perf en CI.
- Chapitre 16 (DevOps) : Fastlane + device cloud.
- Chapitre 18 (Projet-Fil-Rouge) : la stratégie de test complète de TaskFlow.