Quality and testing
How the Flutter apps are tested and kept healthy: the test pyramid, integration tests of the critical flows, the GitHub Actions pipeline, the review checklist, performance budgets, crash reporting and the manual device matrix.
Quality work is spread over the plan: tests are written with each feature task, then MOB-12 (staging integration), MOB-13 (field test), MOB-15 (test automation and hardening) and MOB-17 (design QA and accessibility) close the gaps before acceptance (QA-01, QA-02). The acceptance scenarios of the cahier (§12) are the yardstick (acceptance scenarios).
Test pyramid
| Level | What | Tools | Where it runs | Target |
|---|---|---|---|---|
| Unit | Pure Dart: money, phone, date and distance helpers; error-envelope parsing; idempotency key holder; catalog query codec; outbox ordering, coalescing, batch building, result handling, merge rules; tolerance and sampling policies; notification routing | test, mocktail, ProviderContainer with overrides, drift NativeDatabase.memory() | Every PR | hoco_core, hoco_offline, hoco_maps ≥ 80 % lines |
| Widget | Each screen in its states: loading, data, empty, error, offline, guest, pending validation; form validation; providers overridden with fakes | flutter_test, ProviderScope(overrides:), fake repositories | Every PR | Every RA/DA screen has at least its state test |
| Golden | hoco_ui components and a few key screens, fr and ar, text scale 1.0 and 1.3 | alchemist (design system) | Every PR | Every component of the inventory |
| Integration | The critical flows below, end to end in the real app | integration_test (+ Patrol for system dialogs: permissions, notifications) | Nightly on an emulator; before each release on a physical phone | The three flows green before acceptance |
| Manual | Device matrix, field test, accessibility pass | Checklists below | Release candidates, MOB-13, MOB-17 | No blocking defect open |
Fakes beat mocks: each repository interface has an in-memory fake (FakeCartRepository, FakeSyncServer) used by widget tests, integration tests and the dev flavor's offline mode. A fake that drifts from the contract is caught by running the same integration tests against Prism and staging.
Critical flows
| Flow | Script | Runs against |
|---|---|---|
| Guest to signed-in reseller | Open the app as a guest → catalog shows the price lock → filter by category and an attribute (counts update) → tap Panier → sign-up sheet → phone, OTP, store, customer type Demi-gros, password → pending screen → catalog now shows prices → checkout disabled with the validation banner | Fake server; staging with the test phones +213555000001…9 and the fixed staging OTP (Assumption of the auth page) |
| Order with live status | Validated reseller → search → product → add 3 → cart (minimum-amount bar) → checkout (address, payment, note) → confirmation Envoyée → back-office confirms the order (staging script or Anis) → order detail switches to Validée without refresh (SSE) and the push arrives when backgrounded → reorder | Staging |
| Visit day offline | Delegate signs in → today's tour downloaded → airplane mode → start tour → two visits with check-in, checklist, note, photos, report, end → one not done with reason → new prospect with façade photo → airplane mode off → sync centre reaches Synchronisé → back-office shows visits, photos and prospect; closing unblocked | Fake sync server (deterministic); staging by hand; the field test for real |
Network loss is simulated in integration tests by a dio interceptor that throws connection errors (deterministic), and on devices by airplane mode. Clock, location and connectivity are injected providers, so a test can move time past 48 hours or place the phone 400 m from a store.
CI pipeline
Three workflows in .github/workflows/. Builds run on Ubuntu runners only (macOS minutes cost ten times more and iOS comes after the pilot).
# ci.yml — every pull request into dev or master
name: ci
on:
pull_request:
branches: [dev, master]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Flutter version from .fvmrc
run: echo "FLUTTER_VERSION=$(jq -r .flutter .fvmrc)" >> "$GITHUB_ENV"
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- run: dart pub global activate melos
- run: flutter pub get
- run: melos run format:check
- name: Generated code is up to date
run: melos run gen && melos run l10n && git diff --exit-code
- run: melos run analyze
- run: melos run test:coverage
- name: RTL guard
run: "! grep -rnE 'EdgeInsets\\.only\\((left|right):|TextAlign\\.(left|right)|Alignment\\.center(Left|Right)' apps packages --include=*.dart --exclude=*.g.dart"# build.yml — every push to dev: staging APKs to testers
name: build
on:
push:
branches: [dev]
jobs:
apk:
runs-on: ubuntu-latest
timeout-minutes: 40
strategy:
matrix:
app: [reseller, delegate]
steps:
- uses: actions/checkout@v4
- run: echo "FLUTTER_VERSION=$(jq -r .flutter .fvmrc)" >> "$GITHUB_ENV"
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "17" }
- uses: subosito/flutter-action@v2
with: { flutter-version: "${{ env.FLUTTER_VERSION }}", channel: stable, cache: true }
- name: Signing and secrets files
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/upload.jks"
printf "storeFile=%s\nstorePassword=%s\nkeyAlias=%s\nkeyPassword=%s\n" "$RUNNER_TEMP/upload.jks" \
"${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" "${{ secrets.ANDROID_KEY_ALIAS }}" "${{ secrets.ANDROID_KEY_PASSWORD }}" \
> apps/${{ matrix.app }}/android/key.properties
echo "MAPS_API_KEY_STAGING=${{ secrets.MAPS_API_KEY_STAGING }}" > apps/${{ matrix.app }}/android/secrets.properties
- run: flutter pub get
- name: Build
working-directory: apps/${{ matrix.app }}
run: >
flutter build apk --flavor staging --release --dart-define-from-file=env/staging.json
--build-number=${{ github.run_number }} --obfuscate --split-debug-info=build/symbols
- name: Firebase App Distribution
working-directory: apps/${{ matrix.app }}
env:
GOOGLE_APPLICATION_CREDENTIALS_JSON: ${{ secrets.FIREBASE_SA_STAGING }}
run: |
echo "$GOOGLE_APPLICATION_CREDENTIALS_JSON" > "$RUNNER_TEMP/sa.json"
export GOOGLE_APPLICATION_CREDENTIALS="$RUNNER_TEMP/sa.json"
npx --yes firebase-tools appdistribution:distribute \
build/app/outputs/flutter-apk/app-staging-release.apk \
--app "${{ matrix.app == 'reseller' && vars.FIREBASE_APP_RESELLER_STAGING || vars.FIREBASE_APP_DELEGATE_STAGING }}" \
--groups "team" --release-notes "$(git log -1 --pretty=%s)"
- name: Upload symbols to Sentry
working-directory: apps/${{ matrix.app }}
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: dart run sentry_dart_pluginrelease.yml runs on v* tags: the same steps with the prod flavor and flutter build appbundle, then uploads the AAB to the Play internal track with a Play service account (build and release).
- Required checks on
devandmaster:ci / check. The build workflow is not required on PRs (minutes budget: an Android release build takes 8–15 minutes; GitHub Pro includes 3 000 minutes a month). - A nightly workflow runs the integration tests on an Android emulator (
reactivecircus/android-emulator-runner) against the fake server, and the golden suite on its own job. - Dependabot (
pubandgithub-actions) opens weekly PRs intodev.
Review checklist
Every mobile PR is checked against this list (the PR template copies it; rules for who reviews what are in pull requests and reviews):
- The PR title names the task (
feat(reseller): checkout [MRA-04]) and the description links the screens (RA-18) with screenshots or a short video. - Generated code (
hoco_api, build_runner, l10n, tokens) regenerated by the scripts, not edited. - Every screen state handled: loading, empty, error with
requestId, offline, guest (reseller), pending validation, locked (delegate). - No hard-coded user-facing string; new ARB keys have a description.
- Directional layout only (
EdgeInsetsDirectional…); text holds at 130 %. - Money as
Money(integer centimes), dates throughBusinessTime, phones through the E.164 helpers; no stock quantity displayed anywhere. - One idempotency key per logical action; retries reuse it; a changed payload gets a new one.
- Delegate mutations go through the outbox with
occurredAtanddependsOn; nothing writes to the server directly that could be offline. - Permissions requested in context, with the rationale screen.
- No token, phone number, name or coordinate in logs or Sentry events.
- Lists are lazy; images sized; no work in
buildbeyond layout. - Tests added: unit for logic, widget for states, golden for new components; drift migration test when the schema changed.
- Analytics events, if any, are in the catalogue and carry no personal data.
Performance budgets
Cahier §9: « Temps d'ouverture cible inférieur à 3 secondes sur une connexion mobile normale »; §5.1: search and filters in under 3 seconds (NFR-01, CAT-07). Measured on the reference mid-range phone (a current Samsung Galaxy A-series or Xiaomi Redmi Note, Android 14 or later, 4–6 GB RAM) with a profile or release build.
| Budget | Target | How it is measured |
|---|---|---|
| Cold start to first frame | Under 1.5 s | flutter run --profile --trace-startup (timeToFirstFrameMicros) |
| Cold start to usable home (cached content) | Under 3 s on 4G; delegate home from the local database under 2 s offline | Stopwatch from launcher tap in an integration test with traceAction, five runs, median |
| Catalog search or filter change to results | Under 3 s on 4G (server p95 included); facet counts under 1 s after the products | Integration test against staging |
| Scrolling the catalog grid and the tour list | 99 % of frames under 16 ms; no frame over 100 ms | watchPerformance in integration tests; DevTools frame chart |
| Memory while browsing 200 products | Under 300 MB | DevTools memory view |
| APK size (per ABI, release) | Under 35 MB | flutter build apk --split-per-abi --analyze-size |
| Battery during a tour | Assumption: at most 10 % over 8 hours attributable to the app | Field test (MOB-13) |
| Sync of a full offline day | Under 2 minutes on 3G for 40 actions and 20 photos | Field test |
A regression of more than 20 % on a budget blocks the release until explained.
Crash reporting
- Sentry in
stagingandprodbuilds of both apps (architecture › observability): unhandled errors (FlutterError.onError,PlatformDispatcher.instance.onError, zones), ANRs, failed HTTP calls withrequestId, release health (crash-free sessions). - Play Console vitals for the production and closed tracks: user-perceived crash rate and ANR rate. Google's bad-behaviour thresholds (about 1.09 % crashes, 0.47 % ANRs) must never be approached — they reduce store visibility.
- Targets: crash-free sessions ≥ 99.5 % during the pilot (LCH-03B) and ≥ 99.8 % at generalisation.
- Alerts to Imad (and Anis for API errors): a new issue in
prod, any issue above 10 users, crash-free under 99 % for an hour. - Triage during the pilot: daily with Anis; a crash affecting orders or the outbox is a severity-1 hotfix (build and release › hotfix).
Device matrix
| Device class | Example | Android | Why |
|---|---|---|---|
| Reference mid-range | Samsung Galaxy A15 / A25 or Xiaomi Redmi Note 13 | 14–15 | Performance budgets; most resellers and delegates |
| Aggressive battery management | Xiaomi, Oppo or Realme (MIUI/HyperOS, ColorOS) | 13–14 | Foreground service survival, push delivery when the app is closed |
| Low-end, low RAM | 2–3 GB RAM phone | 10–11 | Memory, cold start, image decoding |
| Oldest supported | Any | 7.0 (API 24) — smoke test only | Minimum SDK |
| Newest | Pixel or current Samsung | Latest | Target SDK behaviours: edge-to-edge, predictive back, notification and foreground-service rules |
| Emulators | Pixel profiles | 10, 13, latest | CI and quick checks |
Conditions checked on release candidates: airplane mode and weak 2G/3G (throttled), Wi-Fi without internet, battery saver on, storage nearly full, largest font size, Arabic system language (layout only — French copy), dark system theme (the app stays light), notification permission denied, location approximate only.
Definition of done (mobile)
A mobile task is done when its acceptance criteria are demonstrated on a physical phone against the mock or staging, the review checklist passes, tests are merged, Akram has seen the gallery or the screens for UI work, Sentry shows no new issue from the staging build, and the task's screens are reachable through their routes. The shared rules are in the definition of done.
Design system in Flutter
How hoco_ui implements Akram's design system: token JSON to generated Dart, ThemeData and ThemeExtensions, the Flutter mapping of the mobile component inventory, the Widgetbook gallery for design QA, RTL readiness, text scaling, accessibility and golden tests.
Build and release
Versions, release trains aligned with the plan, the Play Console setup, tracks and staged rollouts, store assets, data-safety answers and declarations, signing, forced updates, the pilot hotfix procedure and the post-pilot iOS path.