Flutter
英語から自動翻訳されています。
英語で読むヘルパーを追加する
Section titled “ヘルパーを追加する”このファイルを統合テストの隣(たとえば
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') が代わりにクーポンやプロモーションコードを待ちます。チェックアウトで適用した後(ストアのサンドボックスやアプリのテストモードを使い、実際のカードは使わないでください)、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)。アプリでの状態をアカウントにタグ付けするとプールを作れます。アカウントプール を参照してください。
プルリクエストへのレポート
Section titled “プルリクエストへのレポート”ジョブで 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');リンクを開く
Section titled “リンクを開く”ウィジェットテストでは、スマートフォンと同じ方法でリンクを開けません。確認リンクやマジックリンクをエンドツーエンドでテストするには、Maestro(openLink)を使うか、コンピューターから xcrun simctl openurl booted '<link>' または adb shell "am start -a android.intent.action.VIEW -d '<link>'" で開きます。Patrol でも Flutter テストからスマートフォンを操作できます。
このページについて質問がありますか?
質問はどの言語でもかまいません。ドキュメントからすぐに回答を得ることも、Proxy Mail QA チームに質問を送ることもできます。チームからの返信は英語です。