Programming Basics for Test Automation

Automated tests are small programs. A test file imports a runner and a page object, loops over test data, creates an object, waits for browser actions and checks a result. The lesson JavaScript Basics for Testers covered values, functions, promises and stack traces; this lesson adds the five language features almost every test file is built from: control flow, modules, classes, asynchronous code with error handling, and basic types.

The examples are TypeScript: JavaScript plus type annotations such as email: string, which the TypeScript compiler (tsc) and your editor check and which are removed before the code runs. Playwright runs TypeScript test files without extra setup. The goal is to open any spec file on your team, say what each line does, and write a new test in the same shape.

Why it matters

A tester who cannot read test code cannot tell whether a failing test found a defect in the product or has one of its own, and cannot review a colleague's change. Worse, some test defects make a test pass while the product is broken: a missing await lets the assertion run before the page has changed, and a try/catch that logs and continues hides a failed check from the runner. A suite of such tests reports green and finds nothing.

Test code also runs on every change, so its structure decides what a small change in the user interface costs: one edit in one file, or the same edit in every test.

Control flow: if, for...of and arrow functions

Test code uses the same decisions and loops as any program, but sparingly. if/else chooses a path, for example to dismiss a cookie banner only when it is shown. for...of runs the same steps once per item of an array, and in test code the array holds test-data objects: one per case, with the inputs and the expected result. The loop creates one named test per object, so a failure names the case.

An arrow function, (a, b) => a + b, is a function written as a value. Runners take functions as arguments, so the body of each test is a function passed as a callback, usually an arrow function: test('name', async () => { ... }). The runner stores the callback and calls it later; the body does not run while the file loads. In async ({ page }) => { ... } below, the runner passes the callback an object, and { page } takes out its page property (this is called destructuring): the browser page for this test.

  • Write the expected message into the data object; do not compute it with if inside the test, or the test can agree with a bug.
  • for...of iterates values, for...in iterates the keys of an object; in test code you almost always want for...of.
const cases = [
  { email: 'sara@example.com', password: 'Correct-1', message: 'Welcome, Sara' },
  { email: 'sara@example.com', password: 'wrong', message: 'Invalid login' },
];

for (const c of cases) {
  test('login: ' + c.message, async ({ page }) => {   // one test per data object
    // the steps use c.email, c.password and c.message
  });
}

Try it: Fake User Generator Try it: CSV to JSON

Modules: export and import

A module is a file that states what it shares. export class LoginPage makes a name available; import { LoginPage } from './pages/login-page' brings it into another file. Nothing is shared by accident: an unexported name stays private to its file, and a file that needs something must import it.

So a spec file opens with two kinds of import: test and expect from the runner package, the functions that register a test and check a result, and your own page objects and data. The spec then reads as a scenario, and the locators (the expressions that find an element, such as getByLabel('Email')) live in one place: when the login form changes, you edit the page object and every spec that imports it is fixed.

  • Relative paths start with ./ or ../; package names such as @playwright/test do not.
  • import type { Page } imports a type only; the compiler erases it from the output.
// pages/login-page.ts
export class LoginPage { /* ... */ }

// login.spec.ts
import { test, expect } from '@playwright/test';   // the runner
import { LoginPage } from './pages/login-page';     // your page object

Classes and instances: the shape of a page object

A class is a template for objects that share fields (data) and methods (functions). new LoginPage(page) creates an instance: it runs the constructor, which stores the browser page in a field, and returns an object whose methods reach that field through this. Each test creates its own instance, so no two tests share a page object.

A page object is exactly this: one field for the page, a constructor that receives it, and one method per thing a user can do on the screen, such as open() and signIn(email, password). The methods hide locators and clicks; the test calls them by name. The Selenium documentation adds a rule that holds in every language: page objects make no assertions, because the test decides what is correct; the one check it allows inside a page object is that the page has loaded correctly.

  • private readonly page means only code inside the class can use the field and nothing reassigns it after the constructor; a test that reaches for login.page gets a compiler error.
import type { Page } from '@playwright/test';

export class LoginPage {
  private readonly page: Page;              // a field

  constructor(page: Page) {                 // runs once, on new
    this.page = page;                       // this = the instance being built
  }

  async open(): Promise<void> {             // a method
    await this.page.goto('/login');         // relative to baseURL in the config
  }

  async signIn(email: string, password: string): Promise<void> {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Sign in' }).click();
  }
}

Async code and errors: await everything, swallow nothing

Every browser action (goto, fill, click) and every retrying assertion returns a promise, because the work happens in the browser and takes time. A function that waits is declared async; inside it, await pauses until the promise settles, then continues with the value or throws the error. The rule: await every action and every assertion that returns a promise.

The missing-await bug is one of the first things to look for when a JavaScript test is flaky. login.open() without await starts the navigation and runs the next line at once, so a check that reads the page once may read the old page; and when the un-awaited call is the last action, the test function can end before it has finished, so nothing in the test checks its outcome. The compiler does not flag a promise that nobody awaits; a linter (a tool that checks code for risky patterns, such as ESLint) does, with a rule such as @typescript-eslint/no-floating-promises, and a type can catch it too, as the worked example shows.

A thrown error is how a test fails: a failed expect throws, and the runner records the error as the result. try/catch/finally must respect that. Use finally for cleanup that must run either way, and catch only when the error is what you are testing, then assert on it. A catch that logs and continues turns a failed check into a pass. Real cleanup usually removes data the test created; the clearCookies call below only shows where cleanup goes, because Playwright already starts each test with fresh cookies.

// Wrong: the navigation is started but not awaited
login.open();
expect(await page.title()).toBe('Sign in');      // may read the previous page

// Wrong: the error is swallowed, so the runner records a pass
try {
  await expect(page.getByTestId('message')).toHaveText('Welcome, Sara');
} catch (error) {
  console.log('check failed', error);            // the test still passes
}

// Right: await every action, let a failed check throw, clean up in finally
await login.open();
try {
  await login.signIn('sara@example.com', 'Correct-1');
  await expect(page.getByTestId('message')).toHaveText('Welcome, Sara');
} finally {
  await page.context().clearCookies();           // runs on pass and on fail
}

Worked example: a test file in arrange-act-assert form

Below is login.spec.ts, using the LoginPage class from the classes section, with every feature of this lesson labelled. Each test follows arrange-act-assert: arrange the state (create the page object, open the page), act (sign in), assert (check the message). One act and one focused assertion per test keeps the report readable.

Annotations such as email: string, qty: number and Promise<void> are TypeScript types; Promise<T> reads as a promise that will give a value of type T, and void means no value. They change nothing at run time. The compiler checks them and reports every call that does not match: signIn(c.email, 123) because a number is not a string, login.signin(...) because the method does not exist, and const title = page.title(); title.includes('x') because title is a Promise<string>, which has no includes: a missing await caught by a type. Playwright removes the types without checking them and runs the tests anyway, so these errors show only in your editor and when you run npx tsc --noEmit (check only, write no files), which teams add as a step before the tests.

How does the file run? A test runner is a program you start from the command line (the next lesson). It finds files matching a pattern such as *.spec.ts and loads each one, which runs the top-level code: the loop calls test(...) and the runner stores the callbacks. It then calls each callback with a fresh page, waits for the returned promise, and records a pass when it fulfils, or a fail with the error and its stack trace when it rejects.

  • For Selenium users: import is Java import or Python from ... import; a class with fields and a constructor exists in both (__init__ and self in Python, a constructor named after the class and this in Java); the Java and Python bindings are synchronous, so there is no await: each call blocks until the driver answers, and the matching risk there is a missing explicit wait.
The login spec file, line by line: imports bring in the runner and the page object, a loop turns each data object into a test, an async arrow function awaits the page object's methods, and one assertion decides the result.

login.spec.ts: the test file

Part 1:import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';

Part 2:const cases = [
  { email: 'sara@example.com', password: 'Correct-1', message: 'Welcome, Sara' },
  { email: 'sara@example.com', password: 'wrong', message: 'Invalid login' },
];

Part 3:for (const c of cases) {
  Part 4:test('login: ' + c.message, async ({ page }) => {
    const login = Part 5:new LoginPage(page);
    Part 6:await login.open();
    await login.signIn(c.email, c.password);
    Part 7:await expect(page.getByTestId('message')).toHaveText(c.message);
  });
}

pages/login-page.ts: the page object

import type { Page } from '@playwright/test';

Part 8:export class LoginPage {
  Part 9:private readonly page: Page;
  constructor(page: Page) { this.page = page; }

  async open(): Promise<void> { await this.page.goto('/login'); }

  Part 10:async signIn(email: string, password: string): Promise<void> {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Sign in' }).click();
  }
}
  1. 1Imports: two modulesThe runner package gives test and expect; your own file gives the page object. Nothing is global.
  2. 2Test data: an array of objectsOne object per case, with the inputs and the expected message.
  3. 3for...of: one test per objectRuns while the file loads and registers two tests, each named from its data.
  4. 4Async arrow function as callbackThe runner stores it and calls it later with a fresh page; async lets the body await.
  5. 5new: an instance of the classRuns the constructor; page is stored in the field. Each test gets its own instance.
  6. 6await on every browser actionWithout it, the next line runs before the page has changed: the missing-await bug.
  7. 7The assertionA failed expect throws, and the error is the test result. The test asserts the outcome, not the page object.
  8. 8export class: the page objectThe name the spec file imports; by convention one class per file, with the file named after the class.
  9. 9Field and constructorthis.page is set once on new and read by every method; private readonly stops anything else changing it.
  10. 10Typed async methodTwo string parameters and a Promise<void> result: the compiler rejects a number here, and the promise tells the caller to await the call.
Text version
// login.spec.ts
import { test, expect } from '@playwright/test';   // module: the runner
import { LoginPage } from './pages/login-page';     // module: our page object

const cases = [                                     // test data: one object per case
  { email: 'sara@example.com', password: 'Correct-1', message: 'Welcome, Sara' },
  { email: 'sara@example.com', password: 'wrong', message: 'Invalid login' },
];

for (const c of cases) {                            // control flow: one test per object
  test('login: ' + c.message, async ({ page }) => { // async arrow function as callback
    // Arrange
    const login = new LoginPage(page);              // class -> instance
    await login.open();                             // await: a browser action
    // Act
    await login.signIn(c.email, c.password);        // typed: (string, string)
    // Assert
    await expect(page.getByTestId('message')).toHaveText(c.message);
  });
}

Try it: JavaScript Formatter Try it: REST Request Generator

Common mistakes

  • Calling a browser action without await, then asserting on a page that has not changed yet; the test is flaky, or passes for the wrong reason.
  • Wrapping expect in a try/catch that logs the error and continues; the runner reports a pass while the product is broken.
  • Putting assertions inside page object methods, so the page object decides pass or fail and the test no longer says what it checks.
  • Copying locators into every spec file instead of importing one page object; a change to the login form then means twenty edits.
  • Silencing the compiler with any or // @ts-ignore, which switches off exactly the checks that catch wrong arguments and typos.

The QA perspective

On a team, test code is reviewed like product code. Before writing anything, read the existing suite: where spec files, page objects and test data live, which runner is imported, and which conventions the team follows for naming, async methods and cleanup. Ask whether a lint rule for un-awaited promises is on; if not, propose it, because it removes a whole class of flaky tests.

When a test fails, decide first what failed: the product, the test or the environment. The stack trace from the runner points at a line; if it is in a page object or a spec, read the code around it with the questions of this lesson: was every action awaited, did a catch hide something, did the data row say what the test expected? Record the answer in the defect report or in the change that fixes the test.

Practice exercise

Find the defects in a registration test

Your task

The spec file below registers two users and checks the message. It passes the type check (npx tsc --noEmit) only after one fix, and it has two more defects that make it unreliable or dishonest. Find all three, say what each one does to the result, and fix them on paper. Then label the arrange, act and assert parts of one test.

The page object RegisterPage has a constructor that takes page and declares open(): Promise<void>, fillForm(name: string, age: string): Promise<void> and submit(): Promise<void>.

Material
import { test, expect } from '@playwright/test';
import { RegisterPage } from './pages/register-page';

const cases = [
  { name: 'Sara', age: 18, message: 'Account created' },
  { name: 'Omar', age: 17, message: 'You must be 18 or older' },
];

for (const c of cases) {
  test('register: ' + c.message, async ({ page }) => {
    const register = new RegisterPage(page);
    register.open();
    await register.fillForm(c.name, c.age);
    await register.submit();
    try {
      await expect(page.getByTestId('message')).toHaveText(c.message);
    } catch (error) {
      console.log('message check failed', error);
    }
  });
}
Steps
  1. Read each line and ask: does this action return a promise, and is it awaited?
  2. Look at every try/catch: what happens to the error?
  3. Compare each call with the method signature in the page object.
  4. Mark the arrange, act and assert lines of one test.
Show the model answer
Model answer

Compiler error: fillForm(c.name, c.age) passes a number where age: string is declared. Fix: String(c.age), or better, change the page object to accept age: number and convert inside it.

Missing await: register.open() starts the navigation and the test moves on at once, so fillForm races the page load: the result depends on timing, and an error from open() is no longer thrown at that line. Fix: await register.open().

Swallowed error: the catch logs the failed assertion and the test ends without an error, so the runner reports a pass for Omar even if the age rule is broken. Fix: remove the try/catch; if cleanup is needed, use finally without catch.

Arrange: new RegisterPage(page) and open(). Act: fillForm and submit. Assert: the expect line. The loop and the data array belong to no single test; they generate the two tests.

Frequently asked questions

Do I need TypeScript, or is JavaScript enough?

JavaScript is enough to run tests; the runner accepts both. TypeScript adds type annotations that the compiler checks, which catches wrong arguments, misspelled methods and some missing awaits before anything runs. Playwright runs TypeScript test files without extra setup but does not check their types, so read those errors in your editor or run npx tsc --noEmit. The other features of this lesson work the same in JavaScript.

Why did my test pass when I forgot await?

The un-awaited call returned a promise that nobody waited for, so the test function reached its end without an error and the runner recorded a pass. Whatever the action did afterwards was never checked by the test. Only an awaited promise reports its error at the line that caused it, so add the await and the test will fail or pass for the right reason.

Does every test need a page object?

No. A page you touch in one test can be driven directly. Create a page object when two tests repeat the same interaction, or when a screen changes often, so that locators live in one file. The page object model lesson later in this module covers the pattern in depth.

I know Java or Python. Should I learn TypeScript as well?

The concepts transfer: modules, classes, constructors and page objects look the same in Java and Python, and Selenium has official bindings for both. Learn the language your team's framework uses; the main difference to understand is that browser actions in Playwright and in Selenium's JavaScript binding are asynchronous and need await. Cypress, also written in JavaScript, queues its commands instead and is not used with await.

References

  • ECMAScript® Language Specification (ECMA-262)Ecma International / TC39The JavaScript language standard, including the RegExp grammar and semantics, JSON.parse/JSON.stringify and the Date object.
  • JavaScript — MDNMDN Web Docs (Mozilla)MDN's JavaScript guide and reference: the language's syntax, types, objects and built-ins as browsers implement them.
  • Playwright DocumentationMicrosoft (Playwright)Official documentation for Playwright, an end-to-end test framework that drives Chromium, WebKit and Firefox.
  • Page object models — SeleniumSelenium ProjectThe page object pattern: one class per page or component that hides locators and exposes the actions a test needs, reducing duplication.

Practise with Mutqan tools

Beautify JavaScript and TypeScript code with consistent indentation and spacing.

Formatting

Generate fake user records (name, username, email, password, avatar seed, role) for test accounts.

Test Data

Convert CSV or TSV data to a JSON array of objects.

JSON & Data

Generate ready-to-run REST API request code in cURL, JavaScript fetch, axios, Python requests, Java, C#, Go and PHP.

Web

Go deeper

Your progress on this topic

Part of the QA Roadmap