Flutter
本页由英文自动翻译。
阅读英文版添加 helper
Section titled “添加 helper”把这个文件放在集成测试旁边,例如
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;@overrideString 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;}}编写注册测试
Section titled “编写注册测试”一个注册测试:
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);});}用你的密钥运行
Section titled “用你的密钥运行”用你的 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)。按账号在应用中的状态给它们打标签,就能组成账号池:参见账号池。
pull request 上的报告
Section titled “pull request 上的报告”如果任务中用了 GitHub Action,像传密钥一样传入它启动的运行,并给每个测试命名,这样报告就能说明每封邮件来自哪个测试:
flutter test integration_test --dart-define=PROXYMAIL_QA_API_KEY=$PROXYMAIL_QA_API_KEY --dart-define=PROXYMAIL_QA_RUN=$PROXYMAIL_QA_RUNfinal 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 团队,他们会用英文回复。