Python (pytest, Selenium, Playwright)
Automatisch aus dem Englischen übersetzt.
Auf Englisch lesenHelper hinzufügen
Abschnitt betitelt „Helper hinzufügen“Kopiere diesen Helper in deinen Testordner. Er nutzt nur die Standardbibliothek, du musst also nichts installieren, und liest den API-Schlüssel aus
PROXYMAIL_QA_API_KEY:proxymail_qa.py """Disposable inboxes for Python tests (pytest with Selenium or Playwright):create a test address, sign up with it in your app, then wait for the code orlink from the email it gets. Guide: https://proxymail.qa/docs/pythonThe API key comes from the test run's environment (PROXYMAIL_QA_API_KEY).When PROXYMAIL_QA_RUN is set (start_run, or the GitHub Action), every calladds what it saw to that run. Standard library only; Python 3.9 or later."""import contextlibimport jsonimport mathimport osimport timeimport urllib.errorimport urllib.parseimport urllib.requestfrom datetime import datetime, timezoneBASE_URL = os.environ.get("PROXYMAIL_QA_API_URL", "https://api.proxymail.qa/v1")# Sent with every call. Some networks and firewalls turn away Python's# default user agent (Python-urllib), so the helper names itself.USER_AGENT = "proxymail-qa-python/1"class ProxyMailQAError(Exception):"""An API error. When a wait finds nothing, hint says why (an emailprovider in sandbox mode, say), and the message includes it."""def __init__(self, status, message, hint=None):super().__init__(f"{message} {hint}" if hint else message)self.status = statusself.hint = hintdef _request(method, path, body=None, retry=False):key = os.environ.get("PROXYMAIL_QA_API_KEY")if not key:raise RuntimeError("Set PROXYMAIL_QA_API_KEY for the test run.")headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", "User-Agent": USER_AGENT}run = os.environ.get("PROXYMAIL_QA_RUN")if run:headers["Proxy-Mail-QA-Run"] = run# A wait that will be asked again: its "nothing arrived" isn't evidence yet.if retry:headers["Proxy-Mail-QA-Run-Retry"] = "true"# The test this call belongs to (set_test, or the pytest hooks below).if _test["name"]:headers["Proxy-Mail-QA-Test"] = urllib.parse.quote(_test["name"], safe="")request = urllib.request.Request(BASE_URL + path,method=method,data=None if body is None else json.dumps(body).encode(),headers=headers,)try:with urllib.request.urlopen(request, timeout=40) as response:raw = response.read()if response.headers.get("Content-Type", "").startswith("text/markdown"):return raw.decode()return json.loads(raw) if raw else Noneexcept urllib.error.HTTPError as error:try:payload = json.loads(error.read() or b"{}")except ValueError:# Not the API's answer: a wrong address, or a proxy or firewall on the way.payload = {"message": f"HTTP {error.code} from {BASE_URL}, but not from the Proxy Mail QA API. Check PROXYMAIL_QA_API_URL, and whether a proxy or firewall turned the call away."}raise ProxyMailQAError(error.code, payload.get("message", str(error)), payload.get("hint")) from Nonedef create_address(**options):"""A new test address with a persona and a password for the sign-up form:{"address": ..., "password": ..., "persona": {...}}. Options includelabel, lifetime_days, domain, age ("adult", "under13", ...), country, andwhere it's used: site (a host or page URL) and environment ("staging")."""return _request("POST", "/addresses", {"persona": True, "password": True, **options})def form_values(fields, address=None, **options):"""What to type or pick in each field of a sign-up form. fields lists whatthe page says about each one: {"id", "type", "name", "label","placeholder", "autocomplete", "pattern", "min_length", "max_length","options": [{"value", "label"}]}. Returns {"address", "password","values": [{"id", "kind", "value", "note"}, ...]}, from a new address madefor the form (options as for create_address, such as age) or the onegiven. You fill in the form."""body = {"fields": fields, **options}if address:body["address"] = addressreturn _request("POST", "/form-values", body)def find_addresses(tags=None, country=None, min_age=None, max_age=None, has_password=None, search=None, limit=None, site=None, environment=None):"""Test accounts made earlier, newest first, where every filter givenmatches: find_addresses(tags=["age-gate"], country="GB", max_age=17)finds under-18s in GB. Ages are whole years today, from the persona'sdate of birth; search is part of the address, label, notes, name orusername; site and environment are where it's used."""query = [("tag", tag) for tag in tags or []]filters = (("country", country), ("min_age", min_age), ("max_age", max_age), ("search", search), ("limit", limit), ("site", site), ("environment", environment))for name, value in filters:if value is not None:query.append((name, value))if has_password is not None:query.append(("has_password", "true" if has_password else "false"))return _request("GET", "/addresses" + ("?" + urllib.parse.urlencode(query) if query else ""))def check_out_account(tags=None, country=None, min_age=None, max_age=None, has_password=None, search=None, minutes=None, site=None, environment=None):"""An account from the pool: one matching every filter given that nobodyelse has checked out (the least recently used first), held for `minutes`(default 10). Raises ProxyMailQAError 409 when every match is checkedout, 404 when none match. Tag accounts with their state (verified,premium, ...) to make pools."""filters = {"tags": tags, "country": country, "min_age": min_age, "max_age": max_age, "has_password": has_password, "search": search, "minutes": minutes}filters.update(site=site, environment=environment)return _request("POST", "/addresses/checkout", {name: value for name, value in filters.items() if value is not None})def sign_in_account(site=None, environment=None, tags=None):"""The saved test account to sign in with, and its password, in one call:one with a saved password and every tag given, used on this site first,then the one that got mail most recently. None when none fits. Needs akey created with "Can read saved passwords"; every reveal is logged."""body = {name: value for name, value in (("site", site), ("environment", environment), ("tags", tags)) if value is not None}try:return _request("POST", "/addresses/sign-in", body)except ProxyMailQAError as error:if error.status == 404:return Noneraisedef mark_dev_host(host):"""Marks a host ("localhost:3000") as a dev environment of the project:links to it then pass check_email's local-links check."""return _request("POST", "/project/dev-hosts", {"host": host})["dev_hosts"]def update_project(name=None, website=None, environments=None, email_prefix=None, mail_domain=None):"""Changes the project, as in Project settings: its name, website,environments, email prefix or default mail domain. Only the arguments givenchange; website="" or email_prefix="" removes one. Needs the project's lead(or an organisation owner or admin), and is in the project's activity log."""fields = {"name": name, "website": website, "environments": environments, "email_prefix": email_prefix, "mail_domain": mail_domain}return _request("PATCH", "/project", {k: v for k, v in fields.items() if v is not None})def extend_checkout(account, minutes=None):"""Holds a checked-out account longer: `minutes` from now (default 10,at most 60)."""body = {"token": account["checkout"]["token"]}if minutes is not None:body["minutes"] = minutesreturn _request("POST", f"/addresses/{urllib.parse.quote(account['address'])}/extend", body)def return_account(account, add_tags=None, remove_tags=None):"""Frees a checked-out account for the next test, recording the stateit was left in (tags to add and remove)."""body = {"token": account["checkout"]["token"], "add_tags": add_tags, "remove_tags": remove_tags}return _request("POST", f"/addresses/{urllib.parse.quote(account['address'])}/return", body)@contextlib.contextmanagerdef checked_out_account(create=None, check=None, reset=None, new_account=None, attempts=3, **filters):"""with checked_out_account(tags=["premium"]) as account: ... checks anaccount out and always returns it.Hooks make it an account in the state the test needs. Your test code runsthem, with your own access to the app; Proxy Mail QA never signs in to it.create(account) makes a new one when none is free: sign up withaccount["address"] and account["password"], verify it and set its state(new_account holds create_address options such as age or label).check(account) confirms the state: return False or raise when it isn't,and it's reset and checked again, or another account is tried; one thatstill fails loses the filter's tags and is tagged check-failed.reset(account) restores the state afterwards; if it fails, the accountloses the filter's tags and is tagged needs-reset."""account = _prepare_account(create, check, reset, new_account or {}, attempts, filters)try:yield accountexcept BaseException:with contextlib.suppress(Exception):_restore_account(account, reset, filters)raise_restore_account(account, reset, filters)def _prepare_account(create, check, reset, new_account, attempts, filters):tried, last = [], ""for _ in range(attempts):try:account = check_out_account(**filters)except ProxyMailQAError as error:if error.status not in (404, 409):raise# None matches, or every match is busy: make one, if the test can.if create:return _new_account(create, check, new_account, filters)if tried: # accounts that failed their check have left the poolbreakraiseif account["address"] in tried: # every match has been triedwith contextlib.suppress(Exception):return_account(account)breaktried.append(account["address"])problem = _failed_check(check, account)if problem is None:return accountif reset:try:reset(account)problem = _failed_check(check, account)except Exception as error:problem = f"reset failed: {error}"if problem is None:return accountlast = problem_note(f"Test account failed its check ({problem}); it loses its tags and is tagged check-failed.", account["address"])with contextlib.suppress(Exception):return_account(account, add_tags=["check-failed"], remove_tags=filters.get("tags"))if create:return _new_account(create, check, new_account, filters)tried_text = f": tried {', '.join(tried)}" + (f" (last: {last})" if last else "") if tried else ""raise RuntimeError(f"No test account passed the check{tried_text}. Pass create to make one.")def _new_account(create, check, new_account, filters):"""Makes an account with the create hook, held from the start so noparallel test takes it half made, then tags and checks it."""tags = [tag.lower() for tag in filters.get("tags") or []]min_age, max_age = filters.get("min_age"), filters.get("max_age")if (min_age is not None or max_age is not None) and not (new_account.get("age") or new_account.get("date_of_birth")):raise ValueError("Give new_account an age or date_of_birth, so new accounts match min_age and max_age.")options = {**new_account}if filters.get("country") and "country" not in options:options["country"] = filters["country"]created = create_address(**options)held = check_out_account(search=created["address"], minutes=filters.get("minutes"))if held["address"] != created["address"]:with contextlib.suppress(Exception):return_account(held)raise RuntimeError(f"Couldn't hold the new account {created['address']}.")try:create({**held, "password": created["password"]})tagged = _set_tags(created["address"], sorted(set(held["tags"]) | set(tags)))account = {**tagged, "checkout": held["checkout"]}age = _age((account.get("persona") or {}).get("date_of_birth"))if age is not None and ((min_age is not None and age < min_age) or (max_age is not None and age > max_age)):raise ValueError(f"The new account is {age}, outside min_age and max_age: give new_account a matching age.")problem = _failed_check(check, account)if problem is not None:raise RuntimeError(f"The new account {created['address']} failed its check after create: {problem}")_note("Made a new test account" + (f" tagged {', '.join(tags)}." if tags else "."), created["address"])return accountexcept BaseException:# Untagged, it stays out of the pool; it expires with its address.with contextlib.suppress(Exception):_set_tags(created["address"], held["tags"])with contextlib.suppress(Exception):return_account(held)raisedef _restore_account(account, reset, filters):"""Runs reset, then returns the account. If reset fails, the accountleaves the pool's state (needs-reset) and the error is raised."""if reset:try:reset(account)except Exception as error:_note(f"Couldn't reset the test account ({error}); it loses its tags and is tagged needs-reset.", account["address"])with contextlib.suppress(Exception):return_account(account, add_tags=["needs-reset"], remove_tags=filters.get("tags"))raisewith contextlib.suppress(Exception):return_account(account)def _failed_check(check, account):"""None when the account passes (or there's no check), or why it doesn't."""if check is None:return Nonetry:return "check returned False" if check(account) is False else Noneexcept Exception as error: # such as an AssertionErrorreturn str(error) or type(error).__name__def _set_tags(address, tags):return _request("PATCH", f"/addresses/{urllib.parse.quote(address)}", {"tags": tags})def _age(date_of_birth):"""Whole years from a YYYY-MM-DD date of birth, or None without one."""if not date_of_birth:return Noneborn, today = datetime.strptime(date_of_birth, "%Y-%m-%d").date(), datetime.now(timezone.utc).date()return today.year - born.year - ((today.month, today.day) < (born.month, born.day))def _note(text, address):"""Adds a line to the current run, if there is one, without failing the test."""if os.environ.get("PROXYMAIL_QA_RUN"):with contextlib.suppress(Exception):add_run_note(text, address=address)def _wait(kind, address, since, timeout, extra):deadline = time.monotonic() + timeoutsince = since.astimezone(timezone.utc).isoformat()while True:left = max(0, min(30, int(deadline - time.monotonic())))query = urllib.parse.urlencode({"since": since, "timeout": left, **extra})more = time.monotonic() + left < deadline - 0.5try:return _request("GET", f"/addresses/{urllib.parse.quote(address)}/{kind}?{query}", retry=more)except ProxyMailQAError as error:# Only a wait that found nothing is repeated, until the timeout.if error.status != 404 or not str(error).startswith(f"No {kind} arrived") or time.monotonic() >= deadline - 0.5:raisedef wait_for_code(address, since, timeout=30, unused=False, type=None):"""The code from the newest email received since `since` (a datetime),waiting up to `timeout` seconds. unused=True skips codes already used(returned by an earlier wait, or copied or filled in the app or ProxyMail QA for Chrome). type="coupon" waits for a coupon or promo codeinstead, "any" for either."""extra = {"unused": "true"} if unused else {}if type and type != "otp":extra["type"] = typereturn _wait("code", address, since, timeout, extra)["value"]def wait_for_coupon(address, since, timeout=300, unused=False):"""The coupon or promo code from the newest offer email received since`since`, waiting up to `timeout` seconds (offers often come minuteslater): {"code", "discount", "valid_until", "after_seconds"}, wherediscount is our "20% off" and valid_until a date, when the email statesthem."""match = _wait("code", address, since, timeout, {"type": "coupon", **({"unused": "true"} if unused else {})})return {"code": match["value"], "discount": match.get("discount"), "valid_until": match.get("valid_until"), "after_seconds": match.get("after_seconds")}def wait_for_link(address, since, type="verify", app=None, timeout=30, unused=False):"""The link from the newest email received since `since`: verify,magic_signin, invite, reset or any; or unsubscribe (the email's ownunsubscribe link) or list_unsubscribe (the List-Unsubscribe header's).app=True asks for the link that opens a mobile app (myapp://...) when theemail also has a web one; unused=True skips links already used."""extra = {"type": type, **({} if app is None else {"app": "true" if app else "false"}), **({"unused": "true"} if unused else {})}return _wait("link", address, since, timeout, extra)["value"]def _count_query(since, type, subtype, service, category=None, **extra):query = {key: value for key, value in extra.items()}if category:query["category"] = categoryif since is not None:query["since"] = since if isinstance(since, str) else since.astimezone(timezone.utc).isoformat()if service:query["service"] = serviceif type:query["type"] = typeif subtype:query["subtype"] = subtype if isinstance(subtype, str) else ",".join(subtype)return "?" + urllib.parse.urlencode(query) if query else ""def count_emails(address, since=None, type=None, subtype=None, service=None, category=None):"""How many emails reached the address, each summarised without itscontent: {"count": ..., "emails": [{"received_at", "service", "type","subtype", "category", "gap_seconds", "attachments"}, ...]}. type is"code" or "link"; category is "auth", "transactional" or "marketing";without since, every email the address has received. Count after waitingfor the first email, so a duplicate has had time to arrive."""query = _count_query(since, type, subtype, service, category)return _request("GET", f"/addresses/{urllib.parse.quote(address)}/count{query}")def check_email(address, since=None, type=None, subtype=None, service=None, email_id=None, expect_host=None, expect_text=None,expect_language=None, expect_within=None, category=None, expect_attachment=None,):"""Checks one email the address received (the newest that matches, oremail_id): SPF, DKIM and DMARC, local and plain http links, where its linkgoes (expect_host), a stated expiry, a plain-text part, size, image-onlyemails, phrases it should contain (expect_text, a list) and its language(expect_language, such as "de"), and how soon after since it arrived(expect_within, such as "5m"), and the attachments it should have(expect_attachment, a list: "application/pdf", ".ics", "invoice-*.pdf").Returns {"passed", "failed", "warnings","checks": [{"check", "status", "detail"}, ...]}, never the email's content.Wait for the email first."""query = _count_query(since, type, subtype, service, category)extra = [(name, value) for name, value in (("email_id", email_id), ("expect_host", expect_host), ("expect_language", expect_language), ("expect_within", expect_within)) if value]extra += [("expect_text", phrase) for phrase in expect_text or []]extra += [("expect_attachment", pattern) for pattern in expect_attachment or []]if extra:query += ("&" if query else "?") + urllib.parse.urlencode(extra)return _request("GET", f"/addresses/{urllib.parse.quote(address)}/checks{query}")def expect_no_email(address, since=None, timeout=30, type=None, subtype=None, service=None, category=None):"""Returns when no matching email arrives within `timeout` seconds, forchecks such as "an unsubscribed user gets nothing" (category="marketing",so receipts can still arrive); raisesProxyMailQAError (status 409) as soon as one does. Pass since as the timeof the action, so an email that came before the call still counts;without it, from now."""deadline = time.monotonic() + timeoutwhile True:left = max(0, min(30, math.ceil(deadline - time.monotonic())))query = _count_query(since, type, subtype, service, category, timeout=left)more = time.monotonic() + left < deadline - 0.5result = _request("GET", f"/addresses/{urllib.parse.quote(address)}/none{query}", retry=more)# The server's since, so later requests watch from the same moment.since = result["since"]if time.monotonic() >= deadline - 0.5:return resultdef mark_unsubscribed(address, email_id=None, service=None, at=None, grace=None):"""Records that the address unsubscribed from a sender, after the test didit in its own browser (Proxy Mail QA never opens unsubscribe links). Anylater marketing email from that sender fails after_unsubscribe, howeverlong after. Name the sender with email_id or service (a name or domain);with neither, the newest marketing email decides. at backdates it, andgrace ("2h", "1d") lets marketing already on its way through."""if at is not None and not isinstance(at, str):at = at.astimezone(timezone.utc).isoformat()body = {"email_id": email_id, "service": service, "at": at, "grace": grace}return _request("POST", f"/addresses/{urllib.parse.quote(address)}/unsubscribed", {k: v for k, v in body.items() if v is not None})def mark_resubscribed(address, service=None, email_id=None):"""The address subscribed again: ends the watch for that sender (the onlyone, when there's one)."""query = urllib.parse.urlencode({k: v for k, v in {"service": service, "email_id": email_id}.items() if v is not None})return _request("DELETE", f"/addresses/{urllib.parse.quote(address)}/unsubscribed" + (f"?{query}" if query else ""))def check_unsubscribe(address):"""Each unsubscribe and the marketing emails from that sender that cameafter it, by id and how long after: {"passed", "watches": [...]}."""return _request("GET", f"/addresses/{urllib.parse.quote(address)}/unsubscribed")def offers(address):"""Each sender's coupons and which version of the address each went to(plain, capitals or +tag). Mail to all of them reaches one inbox, sopassed is False when an offer reached two or more: one person got ittwice."""return _request("GET", f"/addresses/{urllib.parse.quote(address)}/offers")def check_sequence(address, steps, since=None):"""Whether a welcome series or drip campaign came in order and on time:each step is a dict with any of subject (a phrase), category, type ("code"or "link"), after ("1d") and within ("2h"). Each answers pass, early,late, missing or waiting; "done" is False while one is still waiting."""if since is not None and not isinstance(since, str):since = since.astimezone(timezone.utc).isoformat()body = {"steps": steps, **({"since": since} if since else {})}return _request("POST", f"/addresses/{urllib.parse.quote(address)}/sequence", body)def record_coupon_result(address, result, code=None, email_id=None, message=None, one_time=False, discount_applied=None):"""After applying a coupon at checkout (on staging with test payments,never real card details), records what the app did: "accepted","rejected" or "error", with its message. The answer's "check" is thecoupon_result verdict: it fails a coupon accepted after its end date, foran offer already given to another version of the address, or twice whenone_time."""body = {"result": result, "code": code, "email_id": email_id, "message": message, "one_time": one_time, "discount_applied": discount_applied}return _request("POST", f"/addresses/{urllib.parse.quote(address)}/coupon-result", {k: v for k, v in body.items() if v is not None})_KEEP = object()def record_consent(address, marketing=_KEEP, prechecked=_KEEP, double_opt_in=_KEEP):"""Records what the address agreed to at sign-up: marketing ("opted_in"or "declined"), prechecked (the box was ticked when the form loaded) anddouble_opt_in ("confirmed" or "pending"). Only what's given changes.check_email then judges marketing email by the rules where the personalives (a test aid, not legal advice)."""given = {"marketing": marketing, "prechecked": prechecked, "double_opt_in": double_opt_in}return _request("PUT", f"/addresses/{urllib.parse.quote(address)}/consent", {k: v for k, v in given.items() if v is not _KEEP})def consent(address):"""What the address agreed to, as recorded."""return _request("GET", f"/addresses/{urllib.parse.quote(address)}/consent")def start_run(name, source=None, meta=None):"""Starts a run: later calls (from this process and any it starts) addwhat they saw, for a report on the pull request. Returns {"id", "url",...}. In GitHub Actions, the Proxy Mail QA action does this for you."""run = _request("POST", "/runs", {"name": name, "meta": meta or {}, **({"source": source} if source else {})})os.environ["PROXYMAIL_QA_RUN"] = run["id"]return rundef add_run_note(text, failed=False, address=None):"""A line of your own in the run; failed=True fails it."""_request("POST", f"/runs/{os.environ['PROXYMAIL_QA_RUN']}/notes", {"text": text, "failed": failed, "address": address})def finish_run(failed=False):"""Finishes the run: passed unless an email was missing or came when noneshould have, an email check, a test, or an unsubscribe, offer or couponcheck failed, a note failed it, or failed=True says the tests failed."""return _request("POST", f"/runs/{os.environ.pop('PROXYMAIL_QA_RUN')}/finish", {"failed": failed})def set_test(name):"""Names the test the following calls belong to (None for none), so therun's report says which test each email came from. The pytest hooks atthe end of this file do it for you."""_test["name"] = " ".join(str(name).split())[:200] if name else Nonedef record_test(name, status, duration_ms=None, error=None):"""A test's result in the run: status is "passed", "failed", "flaky" or"skipped", and a failed test fails the run."""body = {"name": name, "status": status, "duration_ms": duration_ms, "error": error}_request("POST", f"/runs/{os.environ['PROXYMAIL_QA_RUN']}/tests", {key: value for key, value in body.items() if value is not None})def record_test_totals(passed, failed, skipped=0, flaky=0):"""How the suite went, for the top of the run's report."""totals = {"passed": passed, "failed": failed, "skipped": skipped, "flaky": flaky}_request("POST", f"/runs/{os.environ['PROXYMAIL_QA_RUN']}/tests", {"totals": totals})def run_report(run_id=None):"""The run's report in Markdown, for a pull request or job summary."""return _request("GET", f"/runs/{run_id or os.environ['PROXYMAIL_QA_RUN']}?format=markdown")def update_address(address, **changes):"""Changes a test account's details, such as after the app changed them:first_name, last_name, username, date_of_birth, country, label, notes ortags. None clears one."""return _request("PATCH", f"/addresses/{urllib.parse.quote(address)}", changes)def set_password(address, password=None, label="password"):"""Saves the password a test account uses now, after the app changed orreset it. Without one, a strong password is generated and returned once,as "password"."""body = {"label": label}if password is not None:body["password"] = passwordreturn _request("PUT", f"/addresses/{urllib.parse.quote(address)}/password", body)def variants(address):"""Versions of the address for testing a sign-up form: "delivered" ones(capitals, plus tags with special characters) whose mail reaches itsinbox, "undeliverable" ones, and "invalid" ones it should reject."""return _request("GET", f"/addresses/{urllib.parse.quote(address)}/variants")def name_variants(field=None, max_length=None):"""Awkward names for a form's name field ("first_name", "last_name","full_name" or "display_name"): "accept" (apostrophes, accents, otherscripts, one letter, exactly max_length), "reject" (over max_length,empty, only spaces), "store_as_text" (HTML or SQL the app must store astyped, never run) and "decide" (digits, symbols, emoji, spaces around,very long ones: note what the app did)."""query = {k: v for k, v in {"field": field, "max_length": max_length}.items() if v is not None}return _request("GET", "/variants/names" + ("?" + urllib.parse.urlencode(query) if query else ""))def password_variants(min_length=None, max_length=None, symbols=None):"""Awkward passwords within the field's rules: "accept" (the minimum andmaximum, each symbol class, spaces, non-ASCII), "reject" (one under orover, a symbol where none are allowed) and "decide" (only digits, onlylower case, a space at the start or end, emoji, composed and decomposedaccents, bcrypt's 72 bytes). Fresh each call."""query = {k: v for k, v in {"min_length": min_length, "max_length": max_length}.items() if v is not None}if symbols is not None:query["symbols"] = "true" if symbols else "false"return _request("GET", "/variants/passwords" + ("?" + urllib.parse.urlencode(query) if query else ""))def delete_address(address):_request("DELETE", f"/addresses/{urllib.parse.quote(address)}")def now():"""The time to pass as `since`: call it just before submitting the form."""return datetime.now(timezone.utc)# pytest: in a run (PROXYMAIL_QA_RUN), the report says which test each email# came from, records each failed test with the start of its error, and shows# the totals. Turn it on by importing the hooks into your conftest.py:## from proxymail_qa import pytest_runtest_setup, pytest_runtest_teardown, pytest_runtest_logreport, pytest_sessionfinish_test = {"name": None}_totals = {"passed": 0, "failed": 0, "skipped": 0, "flaky": 0}def _test_name(nodeid):"""tests/test_signup.py::TestSignUp::test_code[firefox] reads asTestSignUp › test_code[firefox]."""return " › ".join(nodeid.split("::")[1:]) or nodeiddef pytest_runtest_setup(item):set_test(_test_name(item.nodeid))def pytest_runtest_teardown(item):set_test(None)def pytest_runtest_logreport(report):if not os.environ.get("PROXYMAIL_QA_RUN"):returnif report.skipped and report.when in ("setup", "call"):_totals["skipped"] += 1elif report.failed and report.when in ("setup", "call"):_totals["failed"] += 1# pytest's one-line summary of the failure, such as "AssertionError: …".crash = getattr(getattr(report.longrepr, "reprcrash", None), "message", None)error = " ".join((crash or str(report.longrepr).strip().splitlines()[-1]).split())[:300]with contextlib.suppress(Exception):record_test(_test_name(report.nodeid), "failed", round(report.duration * 1000), error)elif report.passed and report.when == "call":_totals["passed"] += 1def pytest_sessionfinish(session, exitstatus):if os.environ.get("PROXYMAIL_QA_RUN") and any(_totals.values()):with contextlib.suppress(Exception):record_test_totals(**_totals)Selenium
Abschnitt betitelt „Selenium“Ein Registrierungstest mit Selenium, der den Code eingibt.
test_signup.py from selenium.webdriver.common.by import Byfrom proxymail_qa import create_address, delete_address, now, wait_for_codedef test_sign_up_sends_a_working_code(driver): # your WebDriver fixtureaccount = create_address()driver.get("https://staging.example.com/signup")driver.find_element(By.NAME, "email").send_keys(account["address"])driver.find_element(By.NAME, "password").send_keys(account["password"])submitted = now()driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()code = wait_for_code(account["address"], since=submitted)driver.find_element(By.NAME, "code").send_keys(code)delete_address(account["address"])Playwright für Python
Abschnitt betitelt „Playwright für Python“Oder mit Playwright für Python, der den Bestätigungslink öffnet.
sincesorgt dafür, dass das Warten alles ignoriert, was vor dem Absenden des Formulars gesendet wurde. Gewartet wird bis zu 30 Sekunden; übergibtimeoutfür länger. Kommt nichts an, sagtProxyMailQAError, warum, soweit Proxy Mail QA das erkennen kann, zum Beispiel dass der E-Mail-Anbieter deiner App nur an verifizierte Adressen sendet.Für einen Code statt eines Links verwende
wait_for_code; für den Link, der eine mobile App öffnet,wait_for_link(address, since, app=True).test_signup.py import refrom playwright.sync_api import Page, expectfrom proxymail_qa import create_address, delete_address, now, wait_for_linkdef test_sign_up_verifies_the_email(page: Page):account = create_address()page.goto("/signup")page.get_by_label("Email").fill(account["address"])page.get_by_label("Password").fill(account["password"])submitted = now()page.get_by_role("button", name="Sign up").click()page.goto(wait_for_link(account["address"], since=submitted))expect(page).to_have_url(re.compile("verified"))delete_address(account["address"])
Werte für ein Registrierungsformular
Abschnitt betitelt „Werte für ein Registrierungsformular“form_values liefert, was du in jedes Feld eines Formulars eingeben oder auswählen sollst, aus einer dafür neu erstellten Adresse: Benutzername und Passwort, die den Regeln der Felder entsprechen, das Geburtsdatum im Format des Felds, ein Land, das die Auswahlliste anbietet. Beschreibe jedes Feld so, wie die Seite es tut, und fülle sie dann aus:
result = form_values([ {"id": "email", "type": "email", "label": "Email"}, {"id": "pw", "type": "password", "label": "Password", "min_length": 12}, {"id": "dob", "type": "text", "label": "Date of birth", "placeholder": "DD/MM/YYYY"},])for field in result["values"]: if field["value"] is not None: page.locator(f"#{field['id']}").fill(field["value"])age= und country= formen eine neue Adresse; address= verwendet eine früher erstellte. Mehr im Leitfaden REST API.
Prüfen, dass eine E-Mail nicht oder genau einmal ankam
Abschnitt betitelt „Prüfen, dass eine E-Mail nicht oder genau einmal ankam“Manche Tests bestehen, wenn nichts passiert: Ein abgemeldeter Nutzer bekommt keinen Newsletter, eine zweite Reset-Anfrage innerhalb einer Minute wird abgelehnt. expect_no_email beobachtet die Adresse und löst ProxyMailQAError (Status 409) aus, sobald eine passende E-Mail ankommt. count_emails zählt, was kam, für „genau eine Bestätigungs-E-Mail“:
import time
from proxymail_qa import count_emails, expect_no_email, now, wait_for_link
def test_unsubscribed_users_get_no_newsletter(app, unsubscribed): sent = now() app.send_newsletter() expect_no_email(unsubscribed["address"], since=sent, timeout=20)
def test_sign_up_sends_one_verification_email(page, account): submitted = now() page.get_by_role("button", name="Sign up").click() wait_for_link(account["address"], since=submitted) time.sleep(10) # give a duplicate time to arrive assert count_emails(account["address"], since=submitted, type="link")["count"] == 1Keine der beiden gibt den Inhalt der E-Mails zurück: Jede E-Mail wird zusammengefasst durch den Zeitpunkt ihrer Ankunft, den Absender und ob sie einen Code oder Link hatte. Filter: type ("code" oder "link"), subtype (etwa "verify" oder "reset"), service und category ("marketing", damit Belege weiterhin ankommen können).
Um das Abmelden selbst zu testen, melde dich im Browser des Tests ab und halte es dann fest: mark_unsubscribed(address, email_id=...) (oder service="acme.com", und grace="1d" für E-Mails, die schon unterwegs sind). Jede spätere Marketing-E-Mail von diesem Absender lässt after_unsubscribe fehlschlagen, und check_unsubscribe(address)["passed"] ist false, wenn eine kam; mark_resubscribed(address) beendet die Beobachtung. Alles Weitere steht im Leitfaden REST API, und In CI ausführen behandelt Schlüssel, parallele Läufe und Aufräumen.
Versand und Aufbau einer E-Mail prüfen
Abschnitt betitelt „Versand und Aufbau einer E-Mail prüfen“check_email führt Prüfungen an der angekommenen E-Mail durch: SPF, DKIM und DMARC, Links zu localhost oder reinem http, wohin ihr Link führt, ob sie sagt, wann ihr Anmeldecode oder -link abläuft, ein Nur-Text-Teil, Größe und reine Bild-E-Mails und, wenn du es verlangst, Formulierungen, die sie enthalten soll (expect_text=[...]), und ihre Sprache (expect_language="de"). Sie gibt Urteile zurück, nie den Inhalt der E-Mail:
from proxymail_qa import check_email, now, wait_for_link
def test_verification_email_is_well_built(page, account): submitted = now() page.get_by_role("button", name="Sign up").click() wait_for_link(account["address"], since=submitted) result = check_email(account["address"], since=submitted, type="link", expect_host="staging.example.com") assert result["passed"], [c for c in result["checks"] if c["status"] == "fail"]Was jede Prüfung bedeutet, steht im Leitfaden REST API. Testest du eine App, die auf deinem Rechner läuft? mark_dev_host("localhost:3000") einmal, und Links dorthin bestehen local_links.
Ein Konten-Pool für parallele Tests
Abschnitt betitelt „Ein Konten-Pool für parallele Tests“Tagge Konten mit ihrem Zustand und lass dann jeden Test eines ausleihen, das kein anderer Test hat: with checked_out_account(tags=["verified", "premium"]) as account: checkt eines aus und gibt es immer zurück. Um festzuhalten, was ein Test geändert hat, ruf return_account(account, add_tags=[...], remove_tags=[...]) selbst nach check_out_account(...) auf. Mehr im Leitfaden REST API.
Braucht ein Test ein Konto in einem bestimmten Zustand in deiner App (verifiziert, im Premium-Tarif, ohne Bestellungen), übergib drei Hooks. Dein Testcode führt sie mit seinem eigenen Zugriff auf deine App aus; Proxy Mail QA meldet sich nie dort an. Hier steht app für deinen eigenen Code, der mit deiner App spricht.
def create(account): # when none is free: sign up, verify, upgrade submitted = now() app.sign_up(account["address"], account["password"]) app.open(wait_for_link(account["address"], since=submitted)) app.admin.set_plan(account["address"], "premium")
def check(account): # still in that state? Return False or raise if not return app.admin.user(account["address"])["plan"] == "premium"
def reset(account): # after the test: put it back app.admin.reset_user(account["address"], plan="premium", orders=[])
with checked_out_account(tags=["verified", "premium"], create=create, check=check, reset=reset) as account: ...Ein Konto, das check nicht besteht, wird zurückgesetzt und erneut geprüft; eines, das weiterhin fehlschlägt, verliert die Tags des Pools, bekommt den Tag check-failed, und das nächste wird versucht (attempts, 3). create läuft, wenn keines passt oder alle ausgecheckt sind, auf einer neuen Adresse, die von Anfang an für diesen Test reserviert ist (new_account nimmt create_address-Optionen wie age). Schlägt reset nach dem Test fehl, bekommt das Konto den Tag needs-reset und verlässt den Pool. Der Node.js-Client funktioniert genauso.
Ein Bericht über den Run
Abschnitt betitelt „Ein Bericht über den Run“Wenn PROXYMAIL_QA_RUN gesetzt ist, fügt jeder Aufruf diesem Run hinzu, was er gesehen hat: die Adressen, welche E-Mails ankamen und wie schnell, die Codes, wohin jeder Link führt, Prüfungen und E-Mails, die nie kamen. In GitHub Actions setzt die Proxy Mail QA-Action es und postet den Bericht im Pull Request. Anderswo rufst du start_run("Sign-up tests") vor den Tests auf (etwa in einer Fixture mit Session-Scope) und finish_run(failed=...) danach; run_report() liefert den Bericht in Markdown.
Von welchem Test jede E-Mail kam
Abschnitt betitelt „Von welchem Test jede E-Mail kam“Importiere in pytest die Hooks des Helpers in conftest.py. In einem Run nennt dann jeder Aufruf seinen Test, sodass der Bericht zeigt, aus welchem Test jede E-Mail kam, und der Run hält jeden fehlgeschlagenen Test mit dem Anfang seines Fehlers sowie die Summen fest:
from proxymail_qa import pytest_runtest_setup, pytest_runtest_teardown, pytest_runtest_logreport, pytest_sessionfinishAußerhalb eines Runs tun sie nichts. Andere Runner können set_test(name), record_test(name, status, duration_ms, error) und record_test_totals(passed, failed, skipped, flaky) selbst aufrufen.
Ein Testkonto wiederfinden
Abschnitt betitelt „Ein Testkonto wiederfinden“find_addresses gibt früher erstellte Testkonten zurück, die neuesten zuerst, auf die jeder angegebene Filter passt: tags, country, min_age und max_age (ganze Jahre ab heute, aus dem Geburtsdatum der Persona), has_password, search (Teil von Adresse, Label, Notizen, Name oder Benutzername) sowie site und environment (wo es verwendet wird, gesetzt mit create_address(site=..., environment=...)).
from proxymail_qa import find_addresses
teen = find_addresses(tags=["age-gate"], country="GB", max_age=17, has_password=True)[0]sign_in_account(site=..., tags=[...]) geht einen Schritt weiter: das gespeicherte Testkonto zum Anmelden, zuerst das auf dieser Website verwendete, mit seinem Passwort, oder None, wenn keines passt. Es braucht einen Schlüssel mit „Darf gespeicherte Passwörter lesen“.
from proxymail_qa import sign_in_account
account = sign_in_account(site="staging.example.com", tags=["verified"])page.get_by_label("Email").fill(account["address"])page.get_by_label("Password").fill(account["password"])Ein Testkonto aktuell halten
Abschnitt betitelt „Ein Testkonto aktuell halten“Ändert ein Test das Konto in der App, halte es fest, damit die gespeicherten Daten weiterhin zur Anmeldung taugen: set_password(address, "the-new-password") nach einer Passwortänderung oder einem Reset (lass das Passwort weg, um eines zu erzeugen, das einmal zurückgegeben wird), und update_address(address, username="new_name") für einen neuen Namen, Benutzernamen, ein neues Geburtsdatum oder Land oder für Label, Notizen und Tags.
Um ein Registrierungsformular mit ungewöhnlichen, aber gültigen Adressen zu testen, listet variants(address) Varianten auf, deren E-Mails trotzdem im Posteingang der Adresse ankommen, und ungültige, die das Formular ablehnen sollte (mehr). Für die anderen Felder liefern name_variants(field="first_name", max_length=50) und password_variants(min_length=8, max_length=64, symbols=True) Namen und Passwörter zum Eingeben, gruppiert danach, was die App mit jedem tun soll: accept, reject, store_as_text (HTML oder SQL, das sie nie ausführen darf) und decide (mehr). Ein Test, der ein Pool-Konto länger als geplant hält, kann extend_checkout(account, minutes=20) aufrufen.
Gutscheine und Angebote
Abschnitt betitelt „Gutscheine und Angebote“offers, record_coupon_result, check_sequence, record_consent, consent, mark_unsubscribed, mark_resubscribed und check_unsubscribe gehören zu Marketing- und Angebots-E-Mails, einer Labs-Funktion, die die Person, die den Schlüssel erstellt hat, in der App unter Account settings → Labs einschaltet; bis dahin lösen sie einen 403 mit einem entsprechenden Hinweis aus.
Nach der Anmeldung für ein Angebot wartet wait_for_coupon auf den Gutschein (standardmäßig fünf Minuten, da Angebote oft eine Weile dauern) und gibt den Code mit dem Rabatt und dem Gültigkeitsdatum aus der E-Mail zurück, sowie wie lange nach since er kam:
from proxymail_qa import check_email, now, wait_for_coupon
signed_up = now()page.get_by_role("button", name="Get 20% off").click()offer = wait_for_coupon(account["address"], since=signed_up)assert offer["discount"] == "20% off"assert check_email(account["address"], since=signed_up, type="code", subtype="coupon", expect_within="5m")["passed"]Um zu prüfen, dass ein Willkommensangebot jede Person nur einmal erreicht, melde dich erneut mit einer Variante derselben Adresse an (variants(address)["delivered"] listet sie auf, etwa name+promo1@…); dann ist offers(address)["passed"] False, wenn das Angebot an mehr als eine Variante ging.
Nachdem du den Gutschein an der Kasse eingelöst hast (auf Staging mit Testzahlungen), hält record_coupon_result(address, "accepted", message=...) fest, was die App getan hat; sein ["check"] ist das coupon_result-Urteil. Es schlägt fehl, wenn ein Gutschein nach dem Enddatum akzeptiert wurde, für ein wiederverwendetes Angebot oder zweimal mit one_time=True.
Bei einer Willkommensserie oder Drip-Kampagne sagt check_sequence(address, [{"subject": "Welcome"}, {"subject": "tips", "after": "1d"}], since=signed_up), ob jede E-Mail in der richtigen Reihenfolge und rechtzeitig kam.
Für die Marketingregeln je Land erstellst du die Adresse mit country, dann record_consent(address, marketing="declined", prechecked=False) nach dem Anmeldeformular; check_email(address, category="marketing") lässt dann marketing_consent für Marketing fehlschlagen, das nicht hätte gesendet werden dürfen. consent(address) liest es zurück.
wait_for_code(address, since, type="coupon") gibt nur den Code zurück.
Eine Frage zu dieser Seite?
Frag in jeder Sprache. Die Doku kann dir sofort antworten oder deine Frage an das Team von Proxy Mail QA schicken, das auf Englisch antwortet.