DoesQA vs Playwright: the same test compared

DoesQA Flow with register and forgotten-password branches joining at Welcome

Same Start. Same End. Wildly Different Effort. One auth Flow with register and forgotten-password branches, side by side with a fair Playwright sketch.

Want to see the same test in DoesQA and Playwright?

Cool. Pick a journey real humans do: create an account with email verification, or reset a forgotten password with email. Same door in. Same "you're in" screen out. Two branches that start together and finish together.

In DoesQA this can be one Flow. Shared Open. Shared first clicks. Split. Join. Email inbox included.

In Playwright that is... well. Selectors. Page objects. A mail helper you wired up at 11pm. A remote runner you do not fully control. And a second copy of half of that when the forgotten password path "is basically the same" until it isn't.

If you want hundreds of lines of code, a huge amount of duplication, and flaky runs on third-party remote runners, the choice is clear.

If you want solid, easy-to-maintain quality assurance, check out DoesQA.

The journey

One product surface. Two paths:

  1. Register with email verification

  2. Forgotten password with email reset

Both open the app, hit Register, confirm the Login / Register screen, then split. Both end on Welcome. In DoesQA that shape is a later split: shared start, two branches, one join.


Auth Flow: register or forgotten password

That canvas is the whole point. Shared start. Branch labels. Mail steps on both sides. One Welcome check at the bottom. Two Test Cases from one Flow.

How-to detail lives in the docs: registration and email verification and forgotten password.

The same test in DoesQA

Rough map of the Flow above:

Stage

What happens

Shared start

Open https://example.com on Chrome. Touch Register. Check Login / Register is displayed.

Register branch

Set Email to testEmail. Set Password to NEW_PASSWORD. Touch Submit. Wait for new Test Case email. Open latest. Touch Verify email link.

Forgotten Password branch

Touch Already have an account. Set Email to testEmail. Touch Submit. Wait for new Test Case email. Open latest. Touch Reset password link. Set Password to NEW_PASSWORD. Touch Save new password.

Shared end

Check Welcome heading text contains Welcome.

No custom mail server in the test. No second framework for inbox. The Test Case inbox is a first-class step. Selectors live in shared Elements. Change Email once; both branches pick it up.

That is what "same start, same end" looks like when the product was built for journeys, not for assembling a lab around a browser API.

The same test in Playwright

Here is a fair, slightly-better-than-average sketch of that same Flow. Page objects. A small mail helper. Two specs that share the start and still duplicate the glue.

// auth.pages.ts
import { Page, expect } from '@playwright/test';

export class AuthPages {
  constructor(private readonly page: Page) {}

  async open() {
    await this.page.goto('https://example.com');
  }

  async touchRegister() {
    await this.page.getByRole('link', { name: 'Register' }).click();
  }

  async expectLoginOrRegister() {
    await expect(this.page.getByText('Login / Register')).toBeVisible();
  }

  async touchAlreadyHaveAnAccount() {
    await this.page.getByRole('link', { name: 'Already have an account' }).click();
  }

  async fillEmail(email: string) {
    await this.page.getByLabel('Email').fill(email);
  }

  async fillPassword(password: string) {
    await this.page.getByLabel('Password').fill(password);
  }

  async submit() {
    await this.page.getByRole('button', { name: 'Submit' }).click();
  }

  async saveNewPassword() {
    await this.page.getByRole('button', { name: 'Save new password' }).click();
  }

  async expectWelcome() {
    await expect(this.page.getByRole('heading', { name: /Welcome/i })).toBeVisible();
  }
}
// auth.pages.ts
import { Page, expect } from '@playwright/test';

export class AuthPages {
  constructor(private readonly page: Page) {}

  async open() {
    await this.page.goto('https://example.com');
  }

  async touchRegister() {
    await this.page.getByRole('link', { name: 'Register' }).click();
  }

  async expectLoginOrRegister() {
    await expect(this.page.getByText('Login / Register')).toBeVisible();
  }

  async touchAlreadyHaveAnAccount() {
    await this.page.getByRole('link', { name: 'Already have an account' }).click();
  }

  async fillEmail(email: string) {
    await this.page.getByLabel('Email').fill(email);
  }

  async fillPassword(password: string) {
    await this.page.getByLabel('Password').fill(password);
  }

  async submit() {
    await this.page.getByRole('button', { name: 'Submit' }).click();
  }

  async saveNewPassword() {
    await this.page.getByRole('button', { name: 'Save new password' }).click();
  }

  async expectWelcome() {
    await expect(this.page.getByRole('heading', { name: /Welcome/i })).toBeVisible();
  }
}
// auth.pages.ts
import { Page, expect } from '@playwright/test';

export class AuthPages {
  constructor(private readonly page: Page) {}

  async open() {
    await this.page.goto('https://example.com');
  }

  async touchRegister() {
    await this.page.getByRole('link', { name: 'Register' }).click();
  }

  async expectLoginOrRegister() {
    await expect(this.page.getByText('Login / Register')).toBeVisible();
  }

  async touchAlreadyHaveAnAccount() {
    await this.page.getByRole('link', { name: 'Already have an account' }).click();
  }

  async fillEmail(email: string) {
    await this.page.getByLabel('Email').fill(email);
  }

  async fillPassword(password: string) {
    await this.page.getByLabel('Password').fill(password);
  }

  async submit() {
    await this.page.getByRole('button', { name: 'Submit' }).click();
  }

  async saveNewPassword() {
    await this.page.getByRole('button', { name: 'Save new password' }).click();
  }

  async expectWelcome() {
    await expect(this.page.getByRole('heading', { name: /Welcome/i })).toBeVisible();
  }
}
// mail.ts
// "Slightly better than average" still means you own this.
export async function waitForNewMessage(inboxId: string) {
  // poll your mail API / Mailosaur / custom inbox service
}

export async function openLatestAndClick(linkText: RegExp) {
  // fetch HTML, find anchor, return href for page.goto
}
// mail.ts
// "Slightly better than average" still means you own this.
export async function waitForNewMessage(inboxId: string) {
  // poll your mail API / Mailosaur / custom inbox service
}

export async function openLatestAndClick(linkText: RegExp) {
  // fetch HTML, find anchor, return href for page.goto
}
// mail.ts
// "Slightly better than average" still means you own this.
export async function waitForNewMessage(inboxId: string) {
  // poll your mail API / Mailosaur / custom inbox service
}

export async function openLatestAndClick(linkText: RegExp) {
  // fetch HTML, find anchor, return href for page.goto
}
// register.spec.ts
import { test } from '@playwright/test';
import { AuthPages } from './auth.pages';
import { waitForNewMessage, openLatestAndClick } from './mail';

test('register with email verification', async ({ page }) => {
  const auth = new AuthPages(page);
  const email = `user+${Date.now()}@mail.test`;
  const password = process.env.NEW_PASSWORD!;

  await auth.open();
  await auth.touchRegister();
  await auth.expectLoginOrRegister();

  await auth.fillEmail(email);
  await auth.fillPassword(password);
  await auth.submit();

  await waitForNewMessage(email);
  const verifyUrl = await openLatestAndClick(/verify|confirm/i);
  await page.goto(verifyUrl);
  await auth.expectWelcome();
});
// register.spec.ts
import { test } from '@playwright/test';
import { AuthPages } from './auth.pages';
import { waitForNewMessage, openLatestAndClick } from './mail';

test('register with email verification', async ({ page }) => {
  const auth = new AuthPages(page);
  const email = `user+${Date.now()}@mail.test`;
  const password = process.env.NEW_PASSWORD!;

  await auth.open();
  await auth.touchRegister();
  await auth.expectLoginOrRegister();

  await auth.fillEmail(email);
  await auth.fillPassword(password);
  await auth.submit();

  await waitForNewMessage(email);
  const verifyUrl = await openLatestAndClick(/verify|confirm/i);
  await page.goto(verifyUrl);
  await auth.expectWelcome();
});
// register.spec.ts
import { test } from '@playwright/test';
import { AuthPages } from './auth.pages';
import { waitForNewMessage, openLatestAndClick } from './mail';

test('register with email verification', async ({ page }) => {
  const auth = new AuthPages(page);
  const email = `user+${Date.now()}@mail.test`;
  const password = process.env.NEW_PASSWORD!;

  await auth.open();
  await auth.touchRegister();
  await auth.expectLoginOrRegister();

  await auth.fillEmail(email);
  await auth.fillPassword(password);
  await auth.submit();

  await waitForNewMessage(email);
  const verifyUrl = await openLatestAndClick(/verify|confirm/i);
  await page.goto(verifyUrl);
  await auth.expectWelcome();
});
// forgotten-password.spec.ts
import { test } from '@playwright/test';
import { AuthPages } from './auth.pages';
import { waitForNewMessage, openLatestAndClick } from './mail';

test('forgotten password with email', async ({ page }) => {
  const auth = new AuthPages(page);
  const email = process.env.EXISTING_USER_EMAIL!;
  const password = process.env.NEW_PASSWORD!;

  await auth.open();
  await auth.touchRegister();
  await auth.expectLoginOrRegister();

  await auth.touchAlreadyHaveAnAccount();
  await auth.fillEmail(email);
  await auth.submit();

  await waitForNewMessage(email);
  const resetUrl = await openLatestAndClick(/reset|password/i);
  await page.goto(resetUrl);
  await auth.fillPassword(password);
  await auth.saveNewPassword();
  await auth.expectWelcome();
});
// forgotten-password.spec.ts
import { test } from '@playwright/test';
import { AuthPages } from './auth.pages';
import { waitForNewMessage, openLatestAndClick } from './mail';

test('forgotten password with email', async ({ page }) => {
  const auth = new AuthPages(page);
  const email = process.env.EXISTING_USER_EMAIL!;
  const password = process.env.NEW_PASSWORD!;

  await auth.open();
  await auth.touchRegister();
  await auth.expectLoginOrRegister();

  await auth.touchAlreadyHaveAnAccount();
  await auth.fillEmail(email);
  await auth.submit();

  await waitForNewMessage(email);
  const resetUrl = await openLatestAndClick(/reset|password/i);
  await page.goto(resetUrl);
  await auth.fillPassword(password);
  await auth.saveNewPassword();
  await auth.expectWelcome();
});
// forgotten-password.spec.ts
import { test } from '@playwright/test';
import { AuthPages } from './auth.pages';
import { waitForNewMessage, openLatestAndClick } from './mail';

test('forgotten password with email', async ({ page }) => {
  const auth = new AuthPages(page);
  const email = process.env.EXISTING_USER_EMAIL!;
  const password = process.env.NEW_PASSWORD!;

  await auth.open();
  await auth.touchRegister();
  await auth.expectLoginOrRegister();

  await auth.touchAlreadyHaveAnAccount();
  await auth.fillEmail(email);
  await auth.submit();

  await waitForNewMessage(email);
  const resetUrl = await openLatestAndClick(/reset|password/i);
  await page.goto(resetUrl);
  await auth.fillPassword(password);
  await auth.saveNewPassword();
  await auth.expectWelcome();
});

That is the polite version of the canvas you just looked at. You still have not counted CI config, parallel workers, the browser cloud bill, reporting, flake retries, and the meeting where someone asks why register is green while reset is "flaky again".

How long does this take?

Rough estimate, from scratch, for a very good Playwright developer who already knows the tooling: a day to a day and a half.

That is project setup, page objects, standing up a mail provider, wiring the helper, both paths green once, and the usual "why is the inbox empty" diversion. Not CI. Not a runner farm. Not the second week of flakes.

The same Flow in DoesQA, from a newly created blank account: about 10 minutes.

Open. Touch. Check. Branch. Mail steps. Welcome. Done. Hosted runners and Results are already there.

Playwright is good at driving a single browser. It is not a mail product, a runner product, a results product, or a maintenance product. Those arrive as extra code and extra vendors. See The Real Cost of Playwright and DoesQA compared.

Where DoesQA is better (and we're not shy about it)

Same journey. Look at the maintenance story.

Change

DoesQA

Playwright sketch above

Email field selector moves

Update one Element

Hunt locators across pages and specs

Verify link copy changes

Update one Element

Touch mail helper and both specs

Shared start changes (Open / Register / Login screen)

Edit once on the Flow

Edit both specs and hope the page object still matches

Add a third auth path

Another branch on the same Flow

Another spec file, more shared-but-not-shared glue

Debug a fail

Step timeline, screenshot, video on the Run

Logs, your reporter, the runner vendor's UI

DoesQA ships authoring, shared Steps, hosted runners, and Results as one platform. When something is wrong, it is DoesQA's problem to fix the platform. That is the difference between a Flow and a second development repo that needs to be maintained.

For more on the benefits of Codeless versus code: Codeless vs coded.

So... which one is the same test?

Both prove register and forgotten password.

Only one keeps the shared start and shared end as a single maintained object without creating a second engineering team.

If you want hundreds of lines of code, a huge amount of duplication, and flaky runs on third-party remote runners, Playwright is free. Help yourself.

If you want solid, easy-to-maintain quality assurance, start a DoesQA trial.