コンテンツにスキップ
  1. このファイルを統合テストの隣(たとえば integration_test/proxymail_qa.dart)に追加します。使うのは dart:io と 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. サインアップのテスト:

    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. API キー を指定して実行します:

    キーはこのテストビルドに組み込まれるため、ビルド自体は決して共有しないでください(TestFlight、Firebase App Distribution、ストアなど)。共有するビルドはキーなしで作ります。

    メールにウェブ用のリンクとアプリを開くリンク(myapp://…)の両方がある場合、qa.waitForLink(account.address, submitted, app: true) はアプリ用のリンクを取得します。

    flutter test integration_test --dart-define=PROXYMAIL_QA_API_KEY=$PROXYMAIL_QA_API_KEY

コードだけでなくメールもチェックする

Section titled “コードだけでなくメールもチェックする”
expect(await qa.countEmails(account.address, submitted), 1);
expect(await qa.checkEmail(account.address, submitted, expectHost: 'links.example.com'), isEmpty);

checkEmail は、メールが不合格になったチェックを返します。SPF、DKIM、DMARC、localhost や平文の http へのリンク、リンク先(expectHost はアプリのリンクが使うドメイン)、記載された有効期限、サイズなどです(各チェックの意味)。expectNoEmail(address, since) は何も届かない場合にのみ成功し、「配信停止したユーザーには何も届かない」ことを確認できます。category: 'marketing' を追加するとマーケティングメールだけを監視するため、領収書は届いてもかまいません。再送後は、waitForCode(address, submitted, unused: true) が使用済みのコードをスキップします。オファーに登録した後は、waitForCode(address, submitted, type: 'coupon') が代わりにクーポンやプロモーションコードを待ちます。チェックアウトで適用した後(ストアのサンドボックスやアプリのテストモードを使い、実際のカードは使わないでください)、recordCouponResult(address, 'accepted', message: shownText) がアプリの動作を記録し、coupon_result の判定 'pass'、'warn'、'fail' のいずれかを返します。1 人 1 回のオファーには oneTime: true を追加します。recordConsent(address, 'declined', prechecked: false) はアカウントが同意した内容を記録するため、checkEmail はペルソナが住む地域のルールに沿ってマーケティングを判定します。

createAddress(environment: 'staging', age: 'under18', country: 'GB') は、アカウントがどの環境用かを記録し、入力を求めるフォーム向けに、ペルソナに生年月日と国を設定します。

保存したアカウントでサインインする、または借りる

Section titled “保存したアカウントでサインインする、または借りる”

サインインのテストでは、signInAccount(tags: ['verified']) が保存済みのテスト用アカウントとそのパスワードを返し、合うものがなければ null を返します。テストを並列で実行する場合、checkOutAccount(['verified']) は他のテストが使っていないアカウントをパスワード付きで、返却されるまで貸し出します:

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

どちらも保存済みのパスワードを読むため、キーには Can read saved passwords 権限が必要です(Project settings → API keys)。アプリでの状態をアカウントにタグ付けするとプールを作れます。アカウントプール を参照してください。

ジョブで GitHub Action を使う場合は、アクションが開始した実行をキーと同じように渡し、各テストに名前を付けます。これで、各メールがどのテストから来たかがレポートに示されます:

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

ウィジェットテストでは、スマートフォンと同じ方法でリンクを開けません。確認リンクやマジックリンクをエンドツーエンドでテストするには、Maestro(openLink)を使うか、コンピューターから xcrun simctl openurl booted '<link>' または adb shell "am start -a android.intent.action.VIEW -d '<link>'" で開きます。Patrol でも Flutter テストからスマートフォンを操作できます。

このページについて質問がありますか?

質問はどの言語でもかまいません。ドキュメントからすぐに回答を得ることも、Proxy Mail QA チームに質問を送ることもできます。チームからの返信は英語です。