MFormations
Modern Mobile Engineering

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

  1. La pyramide des tests mobile
  2. Tests unitaires
  3. Tests de composants / widgets
  4. Tests d'intégration
  5. Tests E2E
  6. Snapshot et golden tests
  7. Couverture de tests
  8. Intégration CI
  9. 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

NiveauVitesseFiabilitéCoûtCouverture
UnitairemsÉlevéeFaibleLogique pure
ComposantsÉlevéeMoyenUI/UX
Intégrations-minMoyenneMoyenCouches
E2EminFaible (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 les suspend.
  • mockk pour 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).

OutilPlateformeType
PaparazziAndroidImage (rendu sans émulateur)
RoborazziAndroidImage (avec Layoutlib)
Swift Snapshot Testing (Point-Free)iOSImage
golden (Flutter)FlutterImage
Jest + react-test-rendererRNStructure 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

CoucheObjectif
Domain (use cases, entités)90-100 %
Data (repositories, mappings)80 %
Présentation (ViewModels)80 %
UI (composants)50-70 %
E2Eparcours 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 .gradle et ~/.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

SituationStratégie
Prototype / MVPTests du Domain + 1 E2E de login
App de productionPyramide complète + E2E critiques
App financière/médicaleTout + mutation testing + tests de sécurité
Librairie/packageTests 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 :

  1. La pyramide : beaucoup d'unitaires, peu d'E2E.
  2. Tester les états (loading/success/error), pas les détails d'implémentation.
  3. Fakes > mocks autant que possible.
  4. Les snapshots protègent les régressions visuelles.
  5. La CI doit être rapide pour être utilisée : unitaires en PR, E2E en nightly.
  6. 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.