Learn / Fundamentals

Test automation foundations for QA engineers

Moving from manual testing to automation is less about programming syntax than about five habits: choosing good locators, waiting on conditions, asserting clearly, isolating tests and reusing setup. This guide covers each with examples in Playwright.

Reading time about 15 minutes. Updated October 2026.

From judgment to precision

When you test by hand, you do a lot without noticing. A button has moved ten pixels and you click it anyway. A spinner turns, so you wait. An error appears in red and you recognize a failure.

An automated test has none of that judgment. It follows its instructions literally, so every one of those decisions has to be written down.

Manual mindsetAutomated mindset
"Fill in the email, submit, and check the welcome message appears.""Find the email field, type, wait until Submit is clickable, click, then wait until the heading is visible and reads 'Welcome, Sarah'. Clean up afterwards."
Exploratory and aware of context.Precise, repeatable and independent of who runs it.

Choosing locators

A locator is how a test finds an element. Many broken tests trace back to a locator that depended on something incidental, such as a styling class or the exact nesting of the page. Prefer locators that describe what the user sees or what the element is for.

locators.tsMost to least resilient
// Best: a role and accessible name, as a user would find it
page.getByRole('button', { name: 'Complete order' })

// Good: a dedicated test attribute
page.getByTestId('checkout-btn')

// Fine for static text, but breaks if the text is translated
page.getByText('Sign in with Google')

// Fragile: depends on styling classes
page.locator('button.bg-blue-600.px-4')

// Avoid: depends on the exact page structure
page.locator('/html/body/div[2]/div/form/div[4]/button[1]')

Accessible roles and names are a good default because they mirror how assistive technology finds elements, and they push the product toward better accessibility. Use data-testid where text changes often or is translated.

CertiHeal in CertiQA stores several ways of finding each recorded element and tries the alternatives when the primary one stops matching. It reduces breakage from cosmetic changes, and the repairs are recorded so you can review them.

Waiting and flaky tests

A test is flaky when it passes and fails on the same code. The most common cause is a race: the test acts before the page is ready. The cure is to wait for a condition rather than for a length of time.

Avoid: fixed sleepAnti-pattern
// Slow when the page is quick, flaky when it is slow
await page.click('#submit-btn');
await page.waitForTimeout(5000);
const text = await page.innerText('.result');
expect(text).toBe('Success');
Prefer: wait for a conditionWeb-first assertion
// Continues as soon as the condition is true
await page.getByTestId('submit-btn').click();
await expect(page.getByTestId('order-status'))
  .toHaveText('Success', { timeout: 5000 });

The first version wastes time when the system is fast and fails when it is slow. The second keeps checking and moves on the moment the condition holds, and fails with a clear message if it never does.

Assertions

A test without assertions is an automated clicker. Assertions are what make a run mean something, so choose them to match the risk you care about.

assertions.tsFour kinds
// State
await expect(button).toBeEnabled();
await expect(modal).toBeVisible();

// Text and values
await expect(badge).toHaveText(/Approved/i);
await expect(input).toHaveValue('[email protected]');

// Network
const res = await page.waitForResponse('/api/pay');
expect(res.status()).toBe(200);

// Visual
await expect(page).toHaveScreenshot('landing.png');

Prefer exact or pattern matches over loose ones, and assert on the outcome the user cares about rather than on an incidental detail of the page.

Test lifecycle and isolation

A reliable test is idempotent: it can run again, in any order, in parallel with others, and give the same result. Structure each test in four parts.

  1. Setup. Create unique data, such as a new user for this run, or seed state through an API.
  2. Act. Drive the journey through the browser or API, waiting on conditions.
  3. Assert. Check visible feedback, state changes and any records that should have been created.
  4. Teardown. Delete what you created and restore the environment, so later tests do not collide with it.

From manual case to code

Here is a typical manual test case, followed by the same check written in Playwright.

Manual test caseTest management tool
Title:          Verify promo code discount
Preconditions:  User signed in, 1 item ($100) in cart
Step 1:         Go to /checkout
Step 2:         Type SPRING20 into the Promo code field
Step 3:         Click "Apply code"
Expected:       Total changes from $100.00 to $80.00 and
                "20% discount applied" is shown
promo-code.spec.tsPlaywright TypeScript
import { test, expect } from '@playwright/test';

test('applies the promo code discount', async ({ page }) => {
  // Arrange: cart is seeded before the test starts
  await page.goto('/checkout');

  // Act
  await page.getByTestId('promo-code-input').fill('SPRING20');
  await page.getByRole('button', { name: 'Apply code' }).click();

  // Assert
  await expect(page.getByTestId('discount-alert'))
    .toHaveText('20% discount applied');
  await expect(page.getByTestId('order-total'))
    .toHaveText('$80.00');
});

Notice that "user signed in, one item in the cart" appears in the preconditions of many checkout tests. Rather than rewriting it each time, make it one reusable piece. In CertiQA that is a CertiBlock, such as a cart-with-standard-item block, which every case references and you fix in one place. See how reusable blocks work.

Key takeaways

  • Prefer role-based locators and dedicated test attributes over styling classes and absolute XPaths.
  • Wait on conditions, never on fixed sleeps.
  • Create unique data in setup and clean up in teardown so tests can run in parallel.
  • Turn repeated sequences into shared pieces instead of copying them.

FAQ

What is the biggest difference between manual and automated testing?

A person fills gaps with judgment: they notice a button has moved, wait for a spinner and recognize an error message. An automated test does exactly what it was told. It needs precise locators, waits that depend on conditions rather than time, explicit assertions and clean setup and teardown, and it fails if any of those are missing.

Why is sleep() or a fixed timeout an anti-pattern?

A fixed pause is either too long, which slows every run, or too short, which makes the test fail whenever the system is slower than usual. Modern tools such as Playwright wait until an element is actionable or an assertion is true, then continue immediately.

Which element locator is best in automated tests?

Prefer locators that reflect how users and assistive technology find things: accessible roles and names, labels and visible text. A dedicated test attribute such as data-testid is a good choice when text changes often or is translated. Avoid long CSS chains and absolute XPaths, because layout changes break them.

What does idempotent mean for a test?

A test is idempotent when it can run repeatedly, in any order, in parallel with others, and produce the same result. That usually means creating unique data in setup and cleaning up afterwards, so one test never depends on records left by another.

Should I automate every manual test case?

No. Automate the checks that are repetitive, important and stable, such as regression paths and critical journeys. Keep exploratory testing, usability judgment and one-off investigations manual, because that is where people add the most value.

Do I have to write code to automate tests?

Not necessarily. CertiQA lets you build web UI, API and agent tests as plain steps, and exports web UI tests to Playwright TypeScript. Learning to read that code is still worthwhile, because it shows how locators, waits and assertions work.

Web UI testing in CertiQA

Put the basics into practice.

Record a flow in the 14-day trial, then export it to Playwright and compare the code with what you just read.