Aller au contenu
  1. Ajoutez ce fichier à côté de vos tests d’intégration, par exemple integration_test/proxymail_qa.dart. Il n’utilise que dart:io et dart:convert :

    proxymail_qa.dart
    // Disposable inboxes for Flutter integration tests: create a test address,
    // sign up with it in your app, then wait for the code or link from the email
    // it gets, and check the email itself. Guide: https://proxymail.qa/docs/flutter
    //
    // The API key comes from the test run: flutter test --dart-define=
    // PROXYMAIL_QA_API_KEY=... (never from the app's own configuration). Add
    // --dart-define=PROXYMAIL_QA_RUN=$PROXYMAIL_QA_RUN to add the calls to a run
    // (the GitHub Action sets it), and pass test: to name the test they belong to.
    import 'dart:convert';
    import 'dart:io';
    /// An account checked out of the pool: nobody else gets it until it's returned or the hold runs out.
    typedef CheckedOutAccount = ({String address, String? password, List<String> tags, String token});
    class ProxyMailQAException implements Exception {
    ProxyMailQAException(this.status, this.message);
    final int status;
    final String message;
    @override
    String toString() => message;
    }
    class ProxyMailQA {
    ProxyMailQA({String? apiKey, this.baseUrl = 'https://api.proxymail.qa/v1', String? run, this.test})
    : apiKey = apiKey ?? const String.fromEnvironment('PROXYMAIL_QA_API_KEY'),
    run = run ?? const String.fromEnvironment('PROXYMAIL_QA_RUN') {
    if (this.apiKey.isEmpty) {
    throw StateError('Pass --dart-define=PROXYMAIL_QA_API_KEY=... to flutter test.');
    }
    }
    final String apiKey;
    final String baseUrl;
    final String run;
    final String? test;
    final HttpClient _http = HttpClient();
    /// A new test address, with a password for the sign-up form. [environment] (such as 'staging')
    /// records where it's used; [age] ('adult', 'under18', 'under13'…) and [country] ('GB') give the
    /// persona a date of birth and a country.
    Future<({String address, String? password})> createAddress({String? environment, String? age, String? country}) async {
    final body = await _send('POST', '/addresses', body: {
    'password': true,
    'persona': true,
    if (environment != null) 'environment': environment,
    if (age != null) 'age': age,
    if (country != null) 'country': country,
    });
    return (address: body['address'] as String, password: body['password'] as String?);
    }
    /// The code from the newest email received since [since], waiting up to 30 seconds.
    /// [unused] true skips a code already used, for a second code. [type] 'coupon' waits for a
    /// coupon or promo code instead (after signing up for an offer), 'any' for either.
    Future<String> waitForCode(String address, DateTime since, {bool unused = false, String? type}) =>
    _waitFor('code', address, since, type: type, unused: unused);
    /// The link from the newest email received since [since]: verify, magic_signin, invite, reset or any.
    /// [app] true asks for the link that opens the app (myapp://…) when the email also has a web one.
    Future<Uri> waitForLink(String address, DateTime since, {String type = 'verify', bool? app}) async =>
    Uri.parse(await _waitFor('link', address, since, type: type, app: app));
    /// How many emails arrived since [since], to check the app sends exactly one.
    Future<int> countEmails(String address, DateTime since) async =>
    (await _send('GET', '/addresses/${Uri.encodeComponent(address)}/count?since=${_time(since)}'))['count'] as int;
    /// The checks the newest email since [since] fails: SPF, DKIM, DMARC, links to localhost or plain
    /// http, a stated expiry, its size and more. Empty when it passes. [expectHost] is the domain its
    /// link should open, such as your app's Universal Link or App Link domain.
    Future<List<String>> checkEmail(String address, DateTime since, {String? expectHost}) async {
    final host = expectHost == null ? '' : '&expect_host=${Uri.encodeQueryComponent(expectHost)}';
    final body = await _send('GET', '/addresses/${Uri.encodeComponent(address)}/checks?since=${_time(since)}$host');
    return (body['failed'] as List).cast<String>();
    }
    /// Completes if no email arrives within [timeout] seconds (at most 30), such as for an unsubscribed
    /// user; throws, saying what came, as soon as one does. [category] 'marketing' watches only for
    /// offers and newsletters, so receipts can still arrive.
    Future<void> expectNoEmail(String address, DateTime since, {int timeout = 30, String? category}) => _send('GET',
    '/addresses/${Uri.encodeComponent(address)}/none?since=${_time(since)}&timeout=$timeout${category == null ? '' : '&category=$category'}');
    /// A saved test account to sign in with, and its password: one with every tag given that no test
    /// has checked out, the one that got mail most recently first. null when none fits. Needs an API
    /// key allowed to read saved passwords.
    Future<({String address, String? password})?> signInAccount({List<String> tags = const [], String? environment}) async {
    try {
    final body = await _send('POST', '/addresses/sign-in', body: {'tags': tags, if (environment != null) 'environment': environment});
    return (address: body['address'] as String, password: body['password'] as String?);
    } on ProxyMailQAException catch (e) {
    if (e.status == 404) return null;
    rethrow;
    }
    }
    /// Borrows an account with every tag given (verified, premium…) that no other test has, for
    /// [minutes], with its saved password when the key may read it. Give it back with [returnAccount].
    Future<CheckedOutAccount> checkOutAccount(List<String> tags, {int minutes = 10}) async {
    final body = await _send('POST', '/addresses/checkout', body: {'tags': tags, 'minutes': minutes});
    final address = body['address'] as String;
    String? password;
    try {
    password = (await _send('GET', '/addresses/${Uri.encodeComponent(address)}/password'))['secret'] as String;
    } on ProxyMailQAException catch (e) {
    if (e.status != 403 && e.status != 404) rethrow;
    }
    final hold = body['checkout'] as Map<String, dynamic>;
    return (address: address, password: password, tags: (body['tags'] as List).cast<String>(), token: hold['token'] as String);
    }
    /// Gives a borrowed account back, recording what the test changed (tags to add and remove).
    Future<void> returnAccount(CheckedOutAccount account, {List<String> addTags = const [], List<String> removeTags = const []}) =>
    _send('POST', '/addresses/${Uri.encodeComponent(account.address)}/return',
    body: {'token': account.token, 'add_tags': addTags, 'remove_tags': removeTags});
    /// Records what the account agreed to at sign-up: [marketing] 'opted_in' or 'declined',
    /// [prechecked] when the box was ticked as the screen loaded, and [doubleOptIn] 'confirmed' or
    /// 'pending'. The rules where the persona lives then judge the marketing it gets.
    Future<void> recordConsent(String address, String marketing, {bool? prechecked, String? doubleOptIn}) =>
    _send('PUT', '/addresses/${Uri.encodeComponent(address)}/consent', body: {
    'marketing': marketing,
    if (prechecked != null) 'prechecked': prechecked,
    if (doubleOptIn != null) 'double_opt_in': doubleOptIn,
    });
    /// After applying a coupon at checkout in the app (the store's sandbox or the app's test mode, never
    /// a real card), records what happened: 'accepted', 'rejected' or 'error', with the app's message.
    /// [code] is the coupon applied (default: the newest at the address); [oneTime] marks a
    /// one-per-person offer. Returns the coupon_result verdict: 'pass', 'warn' or 'fail'.
    Future<String> recordCouponResult(String address, String result, {String? code, String? message, bool oneTime = false}) async {
    final recorded = await _send('POST', '/addresses/${Uri.encodeComponent(address)}/coupon-result', body: {
    'result': result,
    'one_time': oneTime,
    if (code != null) 'code': code,
    if (message != null) 'message': message,
    });
    return (recorded['check'] as Map<String, dynamic>?)?['status'] as String? ?? 'skip';
    }
    Future<String> _waitFor(String kind, String address, DateTime since, {String? type, bool? app, bool unused = false}) async {
    final query = 'since=${_time(since)}${type == null ? '' : '&type=$type'}${app == null ? '' : '&app=$app'}'
    '${unused ? '&unused=true' : ''}';
    final body = await _send('GET', '/addresses/${Uri.encodeComponent(address)}/$kind?$query');
    return body['value'] as String;
    }
    String _time(DateTime time) => Uri.encodeQueryComponent(time.toUtc().toIso8601String());
    Future<Map<String, dynamic>> _send(String method, String path, {Object? body}) async {
    final request = await _http.openUrl(method, Uri.parse('$baseUrl$path'));
    request.headers.set('Authorization', 'Bearer $apiKey');
    request.headers.contentType = ContentType.json;
    if (run.isNotEmpty) {
    request.headers.set('Proxy-Mail-QA-Run', run);
    // Percent-encoded: a test's name can hold characters headers can't.
    if (test != null) request.headers.set('Proxy-Mail-QA-Test', Uri.encodeComponent(test!));
    }
    if (body != null) request.write(jsonEncode(body));
    final response = await request.close().timeout(const Duration(seconds: 40));
    final json = jsonDecode(await response.transform(utf8.decoder).join()) as Map<String, dynamic>;
    if (response.statusCode >= 300) {
    // A wait that finds nothing says why: read `hint` before retrying.
    throw ProxyMailQAException(response.statusCode, [json['message'], json['hint']].whereType<String>().join(' '));
    }
    return json;
    }
    }
  2. Un test d’inscription :

    integration_test/sign_up_test.dart
    import 'package:flutter_test/flutter_test.dart';
    import 'package:integration_test/integration_test.dart';
    import 'package:my_app/main.dart' as app;
    import 'proxymail_qa.dart';
    void main() {
    IntegrationTestWidgetsFlutterBinding.ensureInitialized();
    testWidgets('sign-up sends a working code', (tester) async {
    final qa = ProxyMailQA();
    final account = await qa.createAddress();
    app.main();
    await tester.pumpAndSettle();
    final submitted = DateTime.now();
    await tester.enterText(find.byKey(const Key('email')), account.address);
    await tester.enterText(find.byKey(const Key('password')), account.password!);
    await tester.tap(find.text('Sign up'));
    await tester.pumpAndSettle();
    final code = await qa.waitForCode(account.address, submitted);
    await tester.enterText(find.byKey(const Key('code')), code);
    });
    }
  3. Exécutez-le avec votre clé d’API :

    La clé est compilée dans ce build de test, donc ne partagez jamais le build lui-même (TestFlight, Firebase App Distribution, une boutique). Les builds que vous partagez sont créés sans elle.

    Si l’e-mail contient à la fois un lien web et un lien qui ouvre l’app (myapp://…), qa.waitForLink(account.address, submitted, app: true) obtient celui de l’app.

    flutter test integration_test --dart-define=PROXYMAIL_QA_API_KEY=$PROXYMAIL_QA_API_KEY
expect(await qa.countEmails(account.address, submitted), 1);
expect(await qa.checkEmail(account.address, submitted, expectHost: 'links.example.com'), isEmpty);

checkEmail renvoie les vérifications que l’e-mail ne passe pas : SPF, DKIM et DMARC, liens vers localhost ou en http simple, destination de son lien (expectHost, le domaine qu’utilisent les liens de votre app), une expiration indiquée, sa taille et plus encore (signification de chaque vérification). expectNoEmail(address, since) ne réussit que si rien n’arrive, pour « un utilisateur désabonné ne reçoit rien » ; ajoutez category: 'marketing' pour ne surveiller que le marketing, afin que les reçus puissent encore arriver. Après un renvoi, waitForCode(address, submitted, unused: true) ignore le code déjà utilisé. Après l’inscription à une offre, waitForCode(address, submitted, type: 'coupon') attend plutôt son coupon ou code promo. Après l’avoir appliqué au paiement (la sandbox de la boutique ou le mode test de l’app, jamais une vraie carte), recordCouponResult(address, 'accepted', message: shownText) enregistre ce qu’a fait l’app et renvoie le verdict coupon_result, 'pass', 'warn' ou 'fail' ; ajoutez oneTime: true pour une offre limitée à une par personne. recordConsent(address, 'declined', prechecked: false) enregistre ce que le compte a accepté, pour que checkEmail juge son marketing selon les règles du lieu où vit le persona.

createAddress(environment: 'staging', age: 'under18', country: 'GB') enregistre l’environnement auquel le compte est destiné et donne à son persona une date de naissance et un pays, pour les formulaires qui les demandent.

Se connecter avec un compte enregistré, ou en emprunter un

Section intitulée « Se connecter avec un compte enregistré, ou en emprunter un »

Pour un test de connexion, signInAccount(tags: ['verified']) renvoie un compte de test enregistré et son mot de passe, ou null si aucun ne convient. Quand les tests tournent en parallèle, checkOutAccount(['verified']) en prête un qu’aucun autre test n’a, avec son mot de passe, jusqu’à ce que vous le rendiez :

final account = await qa.checkOutAccount(['verified']);
try {
// Sign in with account.address and account.password.
} finally {
await qa.returnAccount(account);
}

Les deux lisent des mots de passe enregistrés, donc la clé a besoin de l’autorisation Can read saved passwords (Project settings → API keys). Étiquetez les comptes selon leur état dans l’app pour créer des pools : voir un pool de comptes.

Avec l’Action GitHub dans le job, passez l’exécution qu’elle démarre comme la clé, et nommez chaque test, pour que le rapport indique de quel test vient chaque e-mail :

flutter test integration_test --dart-define=PROXYMAIL_QA_API_KEY=$PROXYMAIL_QA_API_KEY --dart-define=PROXYMAIL_QA_RUN=$PROXYMAIL_QA_RUN
final qa = ProxyMailQA(test: 'sign-up sends a working code');

Un test de widget ne peut pas ouvrir un lien comme le ferait le téléphone. Pour tester un lien de vérification ou magique de bout en bout, utilisez Maestro (openLink), ou ouvrez-le depuis votre ordinateur avec xcrun simctl openurl booted '<link>' ou adb shell "am start -a android.intent.action.VIEW -d '<link>'". Patrol peut aussi piloter le téléphone depuis un test Flutter.

Une question sur cette page ?

Posez votre question dans la langue de votre choix. La documentation peut y répondre tout de suite, ou vous pouvez l'envoyer à l'équipe Proxy Mail QA, qui répond en anglais.

Interroger la documentation