Flutter
Traducido automáticamente del inglés.
Leer en inglésAñade el helper
Sección titulada «Añade el helper»Añade este archivo junto a tus pruebas de integración, por ejemplo
integration_test/proxymail_qa.dart. Solo usadart:ioydart: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;}}Escribe una prueba de registro
Sección titulada «Escribe una prueba de registro»Una prueba de registro:
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);});}Ejecútalo con tu clave
Sección titulada «Ejecútalo con tu clave»Ejecútalo con tu clave de API:
La clave se compila en esta build de prueba, así que nunca compartas la build en sí (TestFlight, Firebase App Distribution, una tienda). Las builds que compartes se generan sin ella.
Si el correo tiene tanto un enlace web como uno que abre la app (
myapp://…),qa.waitForLink(account.address, submitted, app: true)obtiene el de la app.flutter test integration_test --dart-define=PROXYMAIL_QA_API_KEY=$PROXYMAIL_QA_API_KEY
Comprueba el correo, no solo el código
Sección titulada «Comprueba el correo, no solo el código»expect(await qa.countEmails(account.address, submitted), 1);expect(await qa.checkEmail(account.address, submitted, expectHost: 'links.example.com'), isEmpty);checkEmail devuelve las comprobaciones que el correo no supera: SPF, DKIM y DMARC, enlaces a localhost o a http simple, adónde lleva su enlace (expectHost, el dominio que usan los enlaces de tu app), una caducidad indicada, su tamaño y más (qué significa cada comprobación). expectNoEmail(address, since) pasa solo si no llega nada, para “un usuario que canceló la suscripción no recibe nada”; añade category: 'marketing' para vigilar solo el marketing, de modo que los recibos puedan seguir llegando. Después de un reenvío, waitForCode(address, submitted, unused: true) omite el código ya usado. Después de registrarte en una oferta, waitForCode(address, submitted, type: 'coupon') espera su cupón o código promocional. Después de aplicarlo en el pago (el sandbox de la tienda o el modo de prueba de la app, nunca una tarjeta real), recordCouponResult(address, 'accepted', message: shownText) registra lo que hizo la app y devuelve el resultado coupon_result, 'pass', 'warn' o 'fail'; añade oneTime: true para una oferta de una por persona. recordConsent(address, 'declined', prechecked: false) registra lo que aceptó la cuenta, para que checkEmail juzgue su marketing según las normas del lugar donde vive la persona.
createAddress(environment: 'staging', age: 'under18', country: 'GB') registra para qué entorno es la cuenta y da a su persona una fecha de nacimiento y un país, para los formularios que lo piden.
Inicia sesión con una cuenta guardada o toma una prestada
Sección titulada «Inicia sesión con una cuenta guardada o toma una prestada»Para una prueba de inicio de sesión, signInAccount(tags: ['verified']) devuelve una cuenta de prueba guardada y su contraseña, o null si ninguna encaja. Cuando las pruebas se ejecutan en paralelo, checkOutAccount(['verified']) presta una que ninguna otra prueba tiene, con su contraseña, hasta que la devuelvas:
final account = await qa.checkOutAccount(['verified']);try { // Sign in with account.address and account.password.} finally { await qa.returnAccount(account);}Ambas leen contraseñas guardadas, así que la clave necesita el permiso Can read saved passwords (Project settings → API keys). Etiqueta las cuentas con su estado en la app para crear grupos: consulta un grupo de cuentas.
Un informe en el pull request
Sección titulada «Un informe en el pull request»Con la Action de GitHub en el job, pasa la ejecución que inicia igual que la clave y ponle nombre a cada prueba, para que el informe indique de qué prueba vino cada correo:
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');Abrir un enlace
Sección titulada «Abrir un enlace»Una prueba de widget no puede abrir un enlace como lo haría el teléfono. Para probar un enlace de verificación o magic link de principio a fin, usa Maestro (openLink), o ábrelo desde tu computadora con xcrun simctl openurl booted '<link>' o adb shell "am start -a android.intent.action.VIEW -d '<link>'". Patrol también puede controlar el teléfono desde una prueba de Flutter.
¿Tienes una pregunta sobre esta página?
Pregunta en cualquier idioma. La documentación puede responderte de inmediato, o puedes enviar tu pregunta al equipo de Proxy Mail QA, que responde en inglés.