HOCO B2B
Mobile (Flutter)

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

LevelWhatToolsWhere it runsTarget
UnitPure 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 routingtest, mocktail, ProviderContainer with overrides, drift NativeDatabase.memory()Every PRhoco_core, hoco_offline, hoco_maps ≥ 80 % lines
WidgetEach screen in its states: loading, data, empty, error, offline, guest, pending validation; form validation; providers overridden with fakesflutter_test, ProviderScope(overrides:), fake repositoriesEvery PREvery RA/DA screen has at least its state test
Goldenhoco_ui components and a few key screens, fr and ar, text scale 1.0 and 1.3alchemist (design system)Every PREvery component of the inventory
IntegrationThe critical flows below, end to end in the real appintegration_test (+ Patrol for system dialogs: permissions, notifications)Nightly on an emulator; before each release on a physical phoneThe three flows green before acceptance
ManualDevice matrix, field test, accessibility passChecklists belowRelease candidates, MOB-13, MOB-17No 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

FlowScriptRuns against
Guest to signed-in resellerOpen 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 bannerFake server; staging with the test phones +213555000001…9 and the fixed staging OTP (Assumption of the auth page)
Order with live statusValidated 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 → reorderStaging
Visit day offlineDelegate 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 unblockedFake 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_plugin

release.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 dev and master: 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 (pub and github-actions) opens weekly PRs into dev.

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 through BusinessTime, 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 occurredAt and dependsOn; 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 build beyond 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.

BudgetTargetHow it is measured
Cold start to first frameUnder 1.5 sflutter 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 offlineStopwatch from launcher tap in an integration test with traceAction, five runs, median
Catalog search or filter change to resultsUnder 3 s on 4G (server p95 included); facet counts under 1 s after the productsIntegration test against staging
Scrolling the catalog grid and the tour list99 % of frames under 16 ms; no frame over 100 mswatchPerformance in integration tests; DevTools frame chart
Memory while browsing 200 productsUnder 300 MBDevTools memory view
APK size (per ABI, release)Under 35 MBflutter build apk --split-per-abi --analyze-size
Battery during a tourAssumption: at most 10 % over 8 hours attributable to the appField test (MOB-13)
Sync of a full offline dayUnder 2 minutes on 3G for 40 actions and 20 photosField test

A regression of more than 20 % on a budget blocks the release until explained.

Crash reporting

  • Sentry in staging and prod builds of both apps (architecture › observability): unhandled errors (FlutterError.onError, PlatformDispatcher.instance.onError, zones), ANRs, failed HTTP calls with requestId, 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 classExampleAndroidWhy
Reference mid-rangeSamsung Galaxy A15 / A25 or Xiaomi Redmi Note 1314–15Performance budgets; most resellers and delegates
Aggressive battery managementXiaomi, Oppo or Realme (MIUI/HyperOS, ColorOS)13–14Foreground service survival, push delivery when the app is closed
Low-end, low RAM2–3 GB RAM phone10–11Memory, cold start, image decoding
Oldest supportedAny7.0 (API 24) — smoke test onlyMinimum SDK
NewestPixel or current SamsungLatestTarget SDK behaviours: edge-to-edge, predictive back, notification and foreground-service rules
EmulatorsPixel profiles10, 13, latestCI 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.

On this page