跳转到内容
  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') 会改为等待它的优惠券或促销码。在结账时使用后(使用商店的 sandbox 或应用的测试模式,绝不要用真实银行卡),recordCouponResult(address, 'accepted', message: shownText) 会记录应用的处理结果,并返回 coupon_result 结论:'pass'、'warn' 或 'fail';对于每人限用一次的优惠,加上 oneTime: true。recordConsent(address, 'declined', prechecked: false) 记录账号同意了哪些内容,这样 checkEmail 就会按 persona 所在地的规则判断它的营销邮件。

createAddress(environment: 'staging', age: 'under18', country: 'GB') 记录账号用于哪个环境,并给它的 persona 设定出生日期和国家,供需要填写这些信息的表单使用。

以已保存的账号登录,或借用一个

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

widget 测试无法像手机那样打开链接。要端到端测试验证链接或魔法链接,请使用 Maestro(openLink),或者在电脑上用 xcrun simctl openurl booted '<link>' 或 adb shell "am start -a android.intent.action.VIEW -d '<link>'" 打开它。Patrol 也可以在 Flutter 测试中操控手机。

对本页有疑问?

可以用任何语言提问。文档可以立即回答,你也可以把问题发给 Proxy Mail QA 团队,他们会用英文回复。