Zum Inhalt springen
  1. Füge diese Datei neben deinen Integrationstests hinzu, zum Beispiel integration_test/proxymail_qa.dart. Sie nutzt nur dart:io und 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. Ein Registrierungstest:

    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. Führe sie mit deinem API-Schlüssel aus:

    Der Schlüssel wird in diesen Test-Build kompiliert, also teile den Build selbst nie (TestFlight, Firebase App Distribution, ein Store). Builds, die du teilst, werden ohne ihn erstellt.

    Wenn die E-Mail sowohl einen Weblink als auch einen Link zum Öffnen der App hat (myapp://…), bekommt qa.waitForLink(account.address, submitted, app: true) den App-Link.

    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 gibt die Prüfungen zurück, die die E-Mail nicht besteht: SPF, DKIM und DMARC, Links auf localhost oder einfaches http, wohin ihr Link führt (expectHost, die Domain, die die Links deiner App nutzen), eine angegebene Ablaufzeit, ihre Größe und mehr (was jede Prüfung bedeutet). expectNoEmail(address, since) besteht nur, wenn nichts ankommt, für „ein abgemeldeter Nutzer bekommt nichts“; füge category: 'marketing' hinzu, um nur auf Marketing zu achten, damit Belege weiterhin ankommen können. Nach einem erneuten Senden überspringt waitForCode(address, submitted, unused: true) den bereits genutzten Code. Nach der Anmeldung für ein Angebot wartet waitForCode(address, submitted, type: 'coupon') stattdessen auf dessen Gutschein- oder Promo-Code. Nachdem du ihn im Checkout angewendet hast (in der Sandbox des Shops oder im Testmodus der App, nie mit einer echten Karte), erfasst recordCouponResult(address, 'accepted', message: shownText), was die App getan hat, und gibt das Ergebnis coupon_result, 'pass', 'warn' oder 'fail' zurück; füge oneTime: true für ein Angebot hinzu, das nur einmal pro Person gilt. recordConsent(address, 'declined', prechecked: false) erfasst, wozu das Konto zugestimmt hat, sodass checkEmail sein Marketing nach den Regeln am Wohnort der Persona bewertet.

createAddress(environment: 'staging', age: 'under18', country: 'GB') erfasst, für welche Umgebung das Konto ist, und gibt seiner Persona ein Geburtsdatum und ein Land, für Formulare, die danach fragen.

Mit einem gespeicherten Konto anmelden oder eines ausleihen

Abschnitt betitelt „Mit einem gespeicherten Konto anmelden oder eines ausleihen“

Für einen Anmeldetest gibt signInAccount(tags: ['verified']) ein gespeichertes Testkonto und sein Passwort zurück oder null, wenn keines passt. Wenn Tests parallel laufen, leiht checkOutAccount(['verified']) eines aus, das kein anderer Test hat, mit seinem Passwort, bis du es zurückgibst:

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

Beide lesen gespeicherte Passwörter, also braucht der Schlüssel die Berechtigung Can read saved passwords (Project settings → API keys). Markiere Konten in der App mit ihrem Zustand, um Pools zu bilden: siehe Konto-Pools.

Mit der GitHub Action im Job übergibst du den Lauf, den sie startet, wie den Schlüssel und benennst jeden Test, damit der Bericht sagt, von welchem Test jede E-Mail kam:

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');

Ein Widget-Test kann einen Link nicht so öffnen, wie das Telefon es tun würde. Um einen Bestätigungs- oder Magic Link durchgängig zu testen, nutze Maestro (openLink) oder öffne ihn von deinem Computer aus mit xcrun simctl openurl booted '<link>' oder adb shell "am start -a android.intent.action.VIEW -d '<link>'". Patrol kann das Telefon auch aus einem Flutter-Test steuern.

Eine Frage zu dieser Seite?

Frag in jeder Sprache. Die Doku kann dir sofort antworten oder deine Frage an das Team von Proxy Mail QA schicken, das auf Englisch antwortet.

Doku fragen