Skip to content

Flutter

Add this file next to your integration tests, for example integration_test/proxymail_qa.dart. It uses only dart:io and 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.
Future<String> waitForCode(String address, DateTime since, {bool unused = false}) => _waitFor('code', address, since, 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.
Future<void> expectNoEmail(String address, DateTime since, {int timeout = 30}) =>
_send('GET', '/addresses/${Uri.encodeComponent(address)}/none?since=${_time(since)}&timeout=$timeout');
/// 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});
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;
}
}

A sign-up test:

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

Run it with your API key:

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

If the email has both a web link and one that opens the app (myapp://…), qa.waitForLink(account.address, submitted, app: true) gets the app one.

expect(await qa.countEmails(account.address, submitted), 1);
expect(await qa.checkEmail(account.address, submitted, expectHost: 'links.example.com'), isEmpty);

checkEmail returns the checks the email fails: SPF, DKIM and DMARC, links to localhost or plain http, where its link goes (expectHost, the domain your app’s links use), a stated expiry, its size and more (what each check means). expectNoEmail(address, since) passes only if nothing arrives, for “an unsubscribed user gets nothing”. After a resend, waitForCode(address, submitted, unused: true) skips the code already used.

createAddress(environment: 'staging', age: 'under18', country: 'GB') records which environment the account is for and gives its persona a date of birth and a country, for forms that ask.

For a sign-in test, signInAccount(tags: ['verified']) returns a saved test account and its password, or null when none fits. When tests run in parallel, checkOutAccount(['verified']) lends one no other test has, with its password, until you give it back:

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

Both read saved passwords, so the key needs Can read saved passwords (Project settings → API keys). Tag accounts with their state in the app to make pools: see an account pool.

With the GitHub Action in the job, pass the run it starts like the key, and name each test, so the report says which test each email came from:

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

A widget test can’t open a link the way the phone would. To test a verify or magic link end to end, use Maestro (openLink), or open it from your computer with xcrun simctl openurl booted '<link>' or adb shell "am start -a android.intent.action.VIEW -d '<link>'". Patrol can also drive the phone from a Flutter test.