Run Web Automation Tests at Scale with Agentic AI

Generate tests with AI agents or automate test suites with Selenium, Playwright and other frameworks. Run tests in parallel on TestMu AI scalable cloud grid.

Selenium JavaScriptAutomationJavaScript

13 JavaScript Testing Best Practices You Should Know [2026]

Thirteen JavaScript testing best practices, each with a Jest or Playwright test I ran and the output it printed, from Arrange, Act, Assert to mutation testing and cross-browser runs.

Last Updated on:

A Jest suite can run every line and branch of a shopping cart module and still pass after the discount rule is switched off. That happened when I ran Stryker, a mutation testing tool, against a weak suite for this guide: 7 of its 24 code changes went unnoticed, including the one that disabled the discount.

The 13 JavaScript testing best practices below each come with a test I ran and the output it printed, so you can see the mistake each one catches. The last practice runs one Playwright test on Chrome, Firefox, and WebKit on the TestMu AI grid.

Overview

JavaScript testing best practices are rules that keep tests readable, deterministic, and focused on behavior. The core ones are to structure every test as Arrange, Act, Assert, test public behavior instead of private state, mock only external boundaries such as the network and the clock, and judge a suite by its mutation score as well as its line coverage.

What Are the 13 JavaScript Testing Best Practices?

  • Arrange, Act, Assert: Write each test in three steps: build the state, call one behavior, then check the result. With one Act per test, a failing test points at a single action.
  • Three-part test names: Name the unit under test, the scenario, and the expected result. Jest joins nested describe and test names into the failure message, so a failure reads like a bug report.
  • Public behavior over private state: Assert on what callers can observe, such as return values, instead of private fields. Tests that read internal data structures fail after refactors that change no visible result.
  • Mocks at the boundary: Mock network calls, timers, and randomness, and let your own logic run, because every mocked function is code a test no longer checks. In Jest, resetMocks: true stops mock values leaking between tests.
  • toThrow instead of try/catch: A try/catch test passes when nothing is thrown, because its assertion never runs. Assert errors with expect(fn).toThrow(), or with rejects.toThrow() for promises.
  • Literal expected values: Write expected results as literals, for example in a test.each table, instead of recomputing them with the logic under test, which copies any bug in that logic into the test.
  • Seeded synthetic data: Generate test records with a seeded library such as Faker instead of copying production data, so personal data stays out of fixtures and each failure reproduces with the same record.
  • Isolated setup: Build each test's state inside the test or with a small factory function instead of shared beforeAll state. Built into Jest: Yes, the --randomize flag exposes tests that depend on order.
  • Right test level: Test logic with unit tests, real databases and APIs with integration tests, and user flows with end-to-end tests, keeping most tests at the fast unit level.
  • Failing test first: For new logic, write the test, watch it fail for the right reason, then write the least code that passes it. Writing boundary cases first forces a decision on edge values.
  • Mutation testing: Coverage shows which lines ran, not whether their results were checked. A mutation tool such as Stryker changes the code and reruns the tests, and changes no test catches reveal missing assertions. Built into Jest: No.
  • Fake timers instead of sleeps: Replace real clocks with jest.useFakeTimers() and fixed waits with assertions that retry, so time-dependent tests run in milliseconds and stop racing the code. Built into Jest: Yes.
  • Portable cross-browser runs: Define one Playwright project per browser and keep credentials in environment variables, so the same config runs on local browsers or on a cloud grid such as TestMu AI. Local browsers needed for grid runs: No.

13 JavaScript Testing Best Practices

In September 2026, npm served 195,220,808 downloads of jest.

In September 2026, vitest was downloaded 436,362,570 times.

Download counts include CI installs, so they track installs rather than teams, and every practice below applies to both runners. Vitest vs Jest covers how to choose between them.

The Vitest migration guide says Vitest "has been designed with a Jest compatible API" and lists the differences you may still hit, such as Jest enabling its globals by default while Vitest does not.

Every example tests the same small cart module, shown below, which discounts subtotals of 100 or more. I ran the tests with Jest 30.5.2 on Node 24.14.0 under Windows 11, where Jest prints √ and × in place of the ✓ and ✕ it prints on macOS and Linux. If Jest is new to you, start with unit testing with Jest.

// cart.js
class Cart {
  constructor() { this._lines = []; }
  add({ sku, price, qty }) {
    if (!Number.isInteger(qty) || qty < 1) {
      throw new RangeError(`Invalid quantity for ${sku}: ${qty}`);
    }
    this._lines.push({ sku, price, qty });
  }
  subtotal() {
    return this._lines.reduce((sum, l) => sum + l.price * l.qty, 0);
  }
  total() {
    const subtotal = this.subtotal();
    return subtotal >= 100 ? subtotal * 0.9 : subtotal;
  }
}
module.exports = { Cart };

1. Structure Each Test as Arrange, Act, Assert

Arrange builds the state a test needs, Act calls the one behavior under test, and Assert checks the result. With one Act per test, a failure points at a single action, and every reader finds the setup, the call, and the check in the same place.

// cart.test.js
const { Cart } = require('./cart');

test('gives 10% off a cart that reaches 100', () => {
  // Arrange
  const cart = new Cart();
  cart.add({ sku: 'MUG', price: 60, qty: 1 });
  cart.add({ sku: 'TEE', price: 40, qty: 1 });

  // Act
  const total = cart.total();

  // Assert
  expect(total).toBe(90);
});

Once a team knows the pattern, blank lines between the steps are enough and the comments can go. If a test needs a second Act, split it into two tests.

2. Name Tests by Unit, Scenario, and Expected Result

A test name should say what is under test, in which situation, and what should happen. Jest joins the names of nested describe blocks and the test into one line of the failure message, so put the unit and the scenario in describe blocks and the expected result in the test name.

// cart.total.test.js
const { Cart } = require('./cart');

describe('Cart.total()', () => {
  describe('when the subtotal is exactly 100', () => {
    test('applies the 10% discount', () => {
      const cart = new Cart();
      cart.add({ sku: 'TEE', price: 50, qty: 2 });
      expect(cart.total()).toBe(90);
    });
  });
});

To check the payoff, I changed the discount condition in cart.js from "at least 100" to "more than 100" and re-ran the file. The failure message names the exact case that broke:

FAIL ./cart.total.test.js
  Cart.total()
    when the subtotal is exactly 100
      × applies the 10% discount (3 ms)

  ● Cart.total() › when the subtotal is exactly 100 › applies the 10% discount

    expect(received).toBe(expected) // Object.is equality

    Expected: 90
    Received: 100

Matchers such as toBe and toEqual already read as plain sentences, which is what behavior-driven development (BDD) style asks of a test. Keep Gherkin and Cucumber.js for scenarios that people outside engineering write or review; TDD vs BDD compares the two approaches.

3. Test Behavior Instead of Implementation Details

A test that reads private state breaks when the internals change, even when every result a caller sees stays the same. The first test below checks the _lines array directly, and the second checks the subtotal:

// behavior.test.js
const { Cart } = require('./cart');

test('stores the line in _lines (implementation detail)', () => {
  const cart = new Cart();
  cart.add({ sku: 'MUG', price: 60, qty: 2 });
  expect(cart._lines).toEqual([{ sku: 'MUG', price: 60, qty: 2 }]);
});

test('adds the line to the subtotal (behavior)', () => {
  const cart = new Cart();
  cart.add({ sku: 'MUG', price: 60, qty: 2 });
  expect(cart.subtotal()).toBe(120);
});

I then changed cart.js to keep its lines in a Map keyed by SKU, a refactor no caller can notice. One test failed and one passed:

FAIL ./behavior.test.js
  × stores the line in _lines (implementation detail) (4 ms)
  √ adds the line to the subtotal (behavior)

  ● stores the line in _lines (implementation detail)

    expect(received).toEqual(expected) // deep equality

    Expected: [{"price": 60, "qty": 2, "sku": "MUG"}]
    Received: Map {"MUG" => {"price": 60, "qty": 2, "sku": "MUG"}}

A failure like the first one sends someone to debug code that works. Test through the public methods, and delete tests that only restate a data structure.

4. Mock Only What You Do Not Control

Mock the boundaries a unit test cannot run, such as network calls, the clock, and randomness, and let your own logic run for real, since any code you mock goes untested.

In this VAT module, the rounding in vat.js is the logic worth testing, and rates-client.js holds the only network call:

// vat.js
const { fetchVatRate } = require('./rates-client');

async function grossPrice(net, country) {
  const rate = await fetchVatRate(country); // network call
  return Math.round(net * (1 + rate) * 100) / 100;
}

module.exports = { grossPrice };
// rates-client.js: the only module that talks to the tax API
async function fetchVatRate(country) {
  const res = await fetch(`${process.env.TAX_API_URL}/vat/${country}`);
  if (!res.ok) throw new Error(`VAT lookup failed: ${res.status}`);
  return (await res.json()).rate;
}

module.exports = { fetchVatRate };

The test replaces rates-client.js with jest.mock and leaves vat.js untouched, so grossPrice still does its own rounding: 49.99 at a rate of 0.2 comes back as 59.99.

// vat.test.js
jest.mock('./rates-client'); // mock the network boundary only
const { fetchVatRate } = require('./rates-client');
const { grossPrice } = require('./vat');

test('adds the country VAT rate to the net price', async () => {
  fetchVatRate.mockResolvedValue(0.2);
  await expect(grossPrice(49.99, 'GB')).resolves.toBe(59.99);
  expect(fetchVatRate).toHaveBeenCalledWith('GB');
});

test('surfaces a failed VAT lookup', async () => {
  fetchVatRate.mockRejectedValue(new Error('VAT lookup failed: 503'));
  await expect(grossPrice(10, 'GB')).rejects.toThrow('VAT lookup failed');
});

Reset mocks between tests so one test's mockResolvedValue cannot leak into the next. In Jest 30.5.2, restoreMocks: true left this automocked function returning the first test's value in the second test, while resetMocks: true, set in the jest.config.js shown in practice 11, cleared it.

5. Expect Errors Instead of Catching Them

When the code under test stops throwing, a try/catch test still passes, because the assertion inside catch never runs. Jest's toThrow matcher fails in exactly that case:

// errors.test.js
const { Cart } = require('./cart');

test('rejects a zero quantity (try/catch)', () => {
  const cart = new Cart();
  try {
    cart.add({ sku: 'MUG', price: 60, qty: 0 });
  } catch (err) {
    expect(err).toBeInstanceOf(RangeError);
  }
});

test('rejects a zero quantity (toThrow)', () => {
  const cart = new Cart();
  expect(() => cart.add({ sku: 'MUG', price: 60, qty: 0 }))
    .toThrow(RangeError);
});

I deleted the quantity guard from add() in cart.js and re-ran the file. The try/catch test still passed:

FAIL ./errors.test.js
  √ rejects a zero quantity (try/catch) (2 ms)
  × rejects a zero quantity (toThrow) (2 ms)

  ● rejects a zero quantity (toThrow)

    expect(received).toThrow(expected)

    Expected constructor: RangeError

    Received function did not throw

For async code, assert the rejection with await expect(promise).rejects.toThrow(), as the second test in vat.test.js does. If a test needs try/catch, call expect.assertions(1) first: with the guard deleted, Jest then failed the try/catch test with "Expected one assertion to be called but received zero assertion calls."

6. Use Literal Expected Values With test.each

An expected value computed with the same formula as the code repeats any bug in that formula. A test that set its expected total with the "more than 100" rule copied from the broken cart.js in practice 2 would have passed against it.

List literal inputs and outputs instead. test.each turns each row into its own test, and the row at exactly 100 is the one that catches the boundary bug:

// discount.test.js
const { Cart } = require('./cart');

test.each([
  [99, 99],
  [100, 90],
  [250, 225],
])('a cart with a subtotal of %d totals %d', (price, expected) => {
  const cart = new Cart();
  cart.add({ sku: 'ITEM', price, qty: 1 });
  expect(cart.total()).toBe(expected);
});
PASS ./discount.test.js
  √ a cart with a subtotal of 99 totals 99 (2 ms)
  √ a cart with a subtotal of 100 totals 90
  √ a cart with a subtotal of 250 totals 225
Austin Siewert

Austin Siewert

Co-Founder, Steadfast Systems

Discovered @TestMu AI yesterday. Best browser testing tool I've found for my use case. Great pricing model for the limited testing I do 👏

3M+ Devs and QAs rely on TestMu AI

Deliver immersive digital experiences with Next-Generation Mobile Apps and Cross Browser Testing Cloud

7. Use Seeded Synthetic Data Instead of Production Records

GDPR Article 5 requires personal data to be "collected for specified, explicit and legitimate purposes" and "limited to what is necessary in relation to the purposes for which they are processed".

Production customer records copied into fixtures end up in test logs, screenshots, and CI artifacts, so generate synthetic customers instead. Faker produces realistic names, emails, and cities, and seeding it rebuilds the same record on every run, so a failure reproduces exactly:

// customer.test.js
const { faker } = require('@faker-js/faker');

function makeCustomer() {
  return {
    name: faker.person.fullName(),
    email: faker.internet.email(),
    city: faker.location.city(),
  };
}

test('the same seed rebuilds the same synthetic customer', () => {
  faker.seed(2026);
  const first = makeCustomer();
  faker.seed(2026);
  expect(makeCustomer()).toEqual(first);
  console.log(first);
});
  console.log
    {
      name: 'Ms. Vivienne Kris V',
      email: 'Eloise36@yahoo.com',
      city: 'Apple Valley'
    }

      at Object.<anonymous> (customer.test.js:17:11)

PASS ./customer.test.js
  √ the same seed rebuilds the same synthetic customer (23 ms)

@faker-js/faker 10 ships only as an ES module, and Jest 30.5.2 refused to require() it ("Must use import to load ES Module") until I set NODE_OPTIONS=--experimental-vm-modules. Faker 9.9.0 still ships a CommonJS build if you cannot set the flag.

Jest's 30.4.0 release notes confirm the flag: require(esm) is supported on Node 24.9 and later but still requires --experimental-vm-modules.

8. Keep Test Setup Visible and Isolated

A beforeAll hook that builds shared state hides what each test depends on, and a test that changes that state breaks whatever runs after it. In this file, the second test adds a tee to the cart the first test reads:

// shared-setup.test.js
const { Cart } = require('./cart');

let cart;
beforeAll(() => {
  cart = new Cart();
  cart.add({ sku: 'MUG', price: 60, qty: 1 });
});

test('a single mug is not discounted', () => {
  expect(cart.total()).toBe(60);
});

test('adding a tee reaches the discount', () => {
  cart.add({ sku: 'TEE', price: 40, qty: 1 });
  expect(cart.total()).toBe(90);
});

It passes in file order. Jest's --randomize flag shuffles test order, and npx jest shared-setup.test.js --randomize --seed=2 ran the second test first:

FAIL ./shared-setup.test.js
  √ adding a tee reaches the discount (4 ms)
  × a single mug is not discounted (4 ms)

  ● a single mug is not discounted

    expect(received).toBe(expected) // Object.is equality

    Expected: 60
    Received: 90

A small factory function keeps each test's setup inside the test. This version passed with the same seed:

// visible-setup.test.js
const { Cart } = require('./cart');

function cartWith(...prices) {
  const cart = new Cart();
  prices.forEach((price, i) =>
    cart.add({ sku: `SKU-${i}`, price, qty: 1 }));
  return cart;
}

test('a single mug is not discounted', () => {
  expect(cartWith(60).total()).toBe(60);
});

test('adding a tee reaches the discount', () => {
  expect(cartWith(60, 40).total()).toBe(90);
});

Keep beforeEach for resets every test needs, and see how the Jest beforeEach function works for hook order. Adding --randomize to the CI command catches order-dependent tests before they show up as random failures.

9. Test Each Behavior at the Right Level

Unit tests check one module's logic with its boundaries mocked, integration tests check your code against real dependencies such as a database or an HTTP API, and end-to-end tests drive the whole application in a browser. The test pyramid puts most tests at the unit level because the levels differ in cost, as the runs for this guide show:

LevelWhat it checksExample in this guideMeasured run time
UnitOne module's logic, with its boundaries mocked17 tests across 10 files, from cart totals to the fake-timer reminder1.068 s for all 17 tests in Jest
IntegrationYour code against a real database, file system, or HTTP serverNot part of this guideNot measured
End-to-endA user flow in a real browserThe search test in practice 138.0 to 11.0 s per browser on the TestMu AI grid

A test that reads a file, writes to a database, or calls a real API is an integration test even when it sits in a unit test folder. Give those tests their own suite and environment, and see JavaScript unit testing for patterns at the unit level.

10. Write the Failing Test First for New Logic

Test-driven development writes the test before the code: run it, watch it fail for the right reason, then write the least code that makes it pass. Here a new rule says orders of 50 or more ship free:

// shipping.test.js
const { shippingFor } = require('./shipping');

test('charges 4.99 shipping below a 50 subtotal', () => {
  expect(shippingFor(49.99)).toBe(4.99);
});

test('ships free from a 50 subtotal', () => {
  expect(shippingFor(50)).toBe(0);
});

The first run failed because shipping.js did not exist yet:

FAIL ./shipping.test.js
  ● Test suite failed to run

    Cannot find module './shipping' from 'shipping.test.js'

Then the smallest implementation that passes:

// shipping.js
function shippingFor(subtotal) {
  return subtotal >= 50 ? 0 : 4.99;
}

module.exports = { shippingFor };
PASS ./shipping.test.js
  √ charges 4.99 shipping below a 50 subtotal (2 ms)
  √ ships free from a 50 subtotal

Writing the 49.99 and 50 cases first forces a decision about what happens at exactly 50 before any code exists.

11. Treat Coverage as a Floor and Check It With Mutation Testing

Code coverage counts the lines and branches a test runs and says nothing about whether the test checks their results; code coverage vs test coverage explains the difference. A coverageThreshold in jest.config.js makes npx jest --coverage fail when coverage drops below the floor you set:

// jest.config.js
module.exports = {
  resetMocks: true,
  testPathIgnorePatterns: ['/node_modules/', '/e2e/'],
  coverageThreshold: {
    global: { branches: 80, functions: 80, lines: 80 },
  },
};

To show the gap between running code and checking it, weak.test.js runs every line of cart.js but asserts almost nothing:

// weak.test.js: every line runs, almost nothing is checked
const { Cart } = require('./cart');

test('totals a large cart', () => {
  const cart = new Cart();
  cart.add({ sku: 'TV', price: 150, qty: 1 });
  expect(cart.total()).toBeGreaterThan(0);
});

test('totals a small cart', () => {
  const cart = new Cart();
  cart.add({ sku: 'MUG', price: 20, qty: 1 });
  expect(cart.total()).toBeDefined();
});

test('rejects a bad quantity', () => {
  expect(() => new Cart().add({ sku: 'X', price: 1, qty: 0 })).toThrow();
});

Jest reports full coverage for it:

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |                   
 cart.js  |     100 |      100 |     100 |     100 |                   
----------|---------|----------|---------|---------|-------------------

Mutation testing measures what coverage cannot. A tool such as Stryker makes one small change to the code at a time, such as flipping a comparison, reruns the tests, and records the change as a survived mutant when every test still passes. This config ran Stryker 10.0.0 against the weak suite:

{
  "testRunner": "jest",
  "mutate": ["cart.js"],
  "reporters": ["clear-text", "progress"],
  "jest": {
    "config": { "testMatch": ["**/weak.test.js"] }
  }
}

Stryker made 24 mutants of cart.js, and 7 survived the weak suite, including the boundary bug from practice 2 and a change that switched the discount off:

[Survived] ConditionalExpression
cart.js:15:12
-       return subtotal >= 100 ? subtotal * 0.9 : subtotal;
+       return false ? subtotal * 0.9 : subtotal;
Tests ran:
    totals a large cart
    totals a small cart


[Survived] EqualityOperator
cart.js:15:12
-       return subtotal >= 100 ? subtotal * 0.9 : subtotal;
+       return subtotal > 100 ? subtotal * 0.9 : subtotal;
Tests ran:
    totals a large cart
    totals a small cart

A second run, with the tests from practices 1, 2, 5, 6, 8, and 12 listed in testMatch instead, scored very differently on identical coverage:

SuiteLine coverageBranch coverageMutants killedSurvivedMutation score (%)
weak.test.js (3 tests)100%100%17 of 24770.83
Practices 1, 2, 5, 6, 8, and 12 (10 tests)100%100%23 of 24195.83

The one survivor was the error message: toThrow(RangeError) checks only the error type, so an empty message passed. Asserting the message with toThrow('Invalid quantity for MUG: 0') killed it on a re-run, 24 of 24. Mutation runs execute the tests once per mutant, so point Stryker at the modules where a wrong result costs the most, such as pricing and permissions.

12. Fix Flaky Tests at the Source

A test that waits on a real clock is slow and races the code it checks. Jest's fake timers replace setTimeout and Date with a clock the test advances itself, so this check of a 30-minute abandoned-cart reminder needs no waiting:

// reminder.js
const THIRTY_MINUTES = 30 * 60 * 1000;

function scheduleReminder(cart, notify, delay = THIRTY_MINUTES) {
  return setTimeout(() => {
    if (cart.subtotal() > 0) notify(cart);
  }, delay);
}

module.exports = { scheduleReminder };
// reminder.test.js
const { Cart } = require('./cart');
const { scheduleReminder } = require('./reminder');

test('sends the abandoned-cart reminder after 30 minutes', () => {
  jest.useFakeTimers();
  const notify = jest.fn();
  const cart = new Cart();
  cart.add({ sku: 'MUG', price: 60, qty: 1 });

  scheduleReminder(cart, notify);
  jest.advanceTimersByTime(29 * 60 * 1000);
  expect(notify).not.toHaveBeenCalled();
  jest.advanceTimersByTime(60 * 1000);
  expect(notify).toHaveBeenCalledWith(cart);

  jest.useRealTimers();
});
PASS ./reminder.test.js
  √ sends the abandoned-cart reminder after 30 minutes (5 ms)

The test took 5 ms. In browser tests the same rule means no fixed sleeps: Playwright's web-first assertions, like toBeVisible() in practice 13, retry until the page reaches the expected state. The guide to flaky tests covers the other common causes.

Some flakiness only shows up across many CI runs. TestMu AI's Test Intelligence flags tests whose results change across runs and ranks each one by flake rate, so the worst offenders surface first; fixing them is still your job. The flaky test analytics documentation describes the trend and severity views.

13. Run E2E Tests Across Browsers From One Portable Config

An end-to-end suite that passes only on its author's laptop fails in CI for reasons unrelated to the application. Keep credentials and environment details in environment variables, use one config on every machine, and choose browsers by what your users run.

This Playwright config defines one project per browser and connects each project to the TestMu AI grid when LT_USERNAME is set; without it, the same projects run on local browsers:

// playwright.config.js, part 1 of 2: the TestMu AI grid endpoint
const { defineConfig } = require('@playwright/test');
const { version } = require('@playwright/test/package.json');

function grid(browserName, platform) {
  const caps = encodeURIComponent(JSON.stringify({
    browserName,
    browserVersion: 'latest',
    'LT:Options': {
      platform,
      build: 'JavaScript testing best practices',
      user: process.env.LT_USERNAME,
      accessKey: process.env.LT_ACCESS_KEY,
      playwrightClientVersion: version,
    },
  }));
  return `wss://cdp.lambdatest.com/playwright?capabilities=${caps}`;
}
// playwright.config.js, part 2 of 2: one project per browser
const onGrid = Boolean(process.env.LT_USERNAME);
const target = (name, platform) =>
  onGrid ? { connectOptions: { wsEndpoint: grid(name, platform) } } : {};

module.exports = defineConfig({
  testDir: './e2e',
  use: { baseURL: 'https://ecommerce-playground.lambdatest.io' },
  projects: [
    { name: 'chrome', use: target('Chrome', 'Windows 11') },
    { name: 'firefox', use: {
      browserName: 'firefox', ...target('pw-firefox', 'Windows 11') } },
    { name: 'webkit', use: {
      browserName: 'webkit', ...target('pw-webkit', 'macOS Sequoia') } },
  ],
});

The test holds no environment details. Its afterEach hook reports pass or fail to the grid, and its toBeVisible() assertion waits for the results heading instead of sleeping:

// e2e/search.spec.js
const { test, expect } = require('@playwright/test');

test.afterEach(async ({ page }, testInfo) => {
  const status = testInfo.status === 'passed' ? 'passed' : 'failed';
  const action = { action: 'setTestStatus', arguments: { status } };
  await page.evaluate(() => {},
    `lambdatest_action: ${JSON.stringify(action)}`);
});

test('search lists matching products', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('textbox', { name: 'Search For Products' })
    .fill('iPhone');
  await page.getByRole('button', { name: 'Search' }).click();
  await expect(page.getByRole('heading', { name: 'Search - iPhone' }))
    .toBeVisible();
});

With Playwright 1.64.0 and my grid credentials exported, one command ran it on all three browsers. I ran only the grid path; the local path needs browsers from npx playwright install:

Running 3 tests using 3 workers

  ok 1 [chrome] › e2e\search.spec.js:11:1 › search lists matching products (8.0s)
  ok 3 [webkit] › e2e\search.spec.js:11:1 › search lists matching products (9.6s)
  ok 2 [firefox] › e2e\search.spec.js:11:1 › search lists matching products (11.0s)

  3 passed (1.1m)

The sessions landed in build 108345623 on the TestMu AI dashboard: Chrome 154 on Windows 11, Firefox on Windows 11, and WebKit on macOS Sequoia, each marked passed by the hook. The 1.1 minutes include starting three cloud browsers; the tests themselves took 8.0 to 11.0 seconds.

TestMu AI's Automation Cloud runs existing Selenium, Playwright, Cypress, and Puppeteer suites on 3,000+ browser and OS combinations and splits a suite across concurrent cloud sessions, so a new browser in the matrix is one more project in this config. The Playwright on TestMu AI documentation lists the capabilities each project can set.

Note

Note: Run the practice 13 config against your own app with a free TestMu AI account: export your username and access key, and the same three projects run on the grid. Try TestMu AI free

How Are AI Coding Agents Changing JavaScript Test Writing?

AI coding agents now write JavaScript tests and run them in the terminal, and the tooling around them has changed in ways that affect how you review their output:

  • Prompts that protect assertions - VS Code's guide to testing existing code with AI tells the agent to reuse the project's test framework, run the new tests with the existing test command, and "not change implementation code or weaken assertions to make the tests pass". It also asks the agent to "Keep expected results explicit rather than computing them with the function under test", which is practice 6.
  • Jest output trimmed for agents - Jest 30.3.0 added a reporter to "Reduce token usage when run through LLMs", according to its 30.3.0 release notes. It switches on when variables such as AI_AGENT or CLAUDECODE are set and prints only failing files and the summary, which is all Jest printed for passing files when I ran this guide's suite from Claude Code; unset those variables to see every test.
  • Browser tools that read the accessibility tree - The Playwright MCP server lets an LLM agent drive a browser, and Playwright's documentation says it "operates on the page's accessibility tree, not pixels", returning a structured snapshot of the page's elements, roles, and text, so the agent acts on page structure instead of screenshots.
  • Verification in a real browser - Kane CLI from TestMu AI is a deterministic browser agent that validates rendered UI in real Chrome from natural-language objectives, so a coding agent can confirm a feature works in the browser after its unit tests pass. Its exit codes separate a failed check (1) from a setup or authentication error (2) and a timeout (3), so CI can tell a broken feature from a broken environment.

Before merging a test an agent wrote, check it against practices 3 to 6 and run Stryker on the module it covers: each surviving mutant names an assertion the test is missing. The Playwright skill gives agents such as Claude Code the grid setup used in practice 13.

Let Claude Code write Playwright tests that actually pass.

Playwright

Conclusion

Pick the module your team changes most often, run Stryker against its tests, and add the assertions each surviving mutant points to before you write new tests. When the suite is solid, run it in parallel on HyperExecute, TestMu AI's test orchestration cloud, following the Jest on HyperExecute guide.

Testing from the terminal? Kane CLI by TestMu AI lets SDETs and developers plan, write, run and debug tests from the command line.

To run your first test, start free, or
for a full walkthrough of the product.

Author

...

Sri Harsha

Blogs: 8

  • Linkedin

Sri Harsha is Engineering Manager of the Open Source Program Office at TestMu AI, where he leads open-source engineering behind the Selenium and Appium automation grid and builds agentic AI systems for quality engineering. He is a member of the Selenium Technical Leadership Committee and a committer to WebdriverIO and Appium, and was recognized with the LambdaTest Delta Award 2023 for Best Contributor in open-source testing. He brings over 10 years of experience in software testing and automation, with earlier roles at EPAM Systems and ZenQ. Sri Harsha holds a B.Tech in Computer Science from Jawaharlal Nehru Technological University.

Reviewer

...

Parth Mistry

Reviewer

  • Linkedin

Parth Mistry is a Member of Technical Staff at TestMu AI, building SmartUI, the visual regression testing product. He developed and owns the SmartUI CLI, a modular TypeScript tool built on Playwright for multi-browser automation, and built the Storybook CLI for visual regression of UI components. He maintains cross-language SDKs in Python, Java, Ruby, C#, and Node.js, and engineered a Node-based visual rendering service on Kafka, Redis, MySQL, and S3. His migration of that service to an event-driven, KEDA-autoscaled architecture improved execution speed by 60% and cut annual infrastructure cost by $9,600. He also built the end-to-end SmartUI integration with KaneAI. Parth is a Google Summer of Code 2024 contributor and an alumnus of IIT Jodhpur.

Add to Google preferred sources

Summarise with AI

Copied to Clipboard!
...

3000+ Browsers. One Platform.

See exactly how your site performs everywhere.

Try it free
...

Write Tests in Plain English with KaneAI

Create, debug, and evolve tests using natural language.

Try for free

JavaScript Testing FAQs

Did you find this page helpful?

More Related Blogs

TestMu AI forEnterprise

Get access to solutions built on Enterprise
grade security, privacy, & compliance

  • Advanced access controls
  • Advanced data retention rules
  • Advanced Local Testing
  • Premium Support options
  • Early access to beta features
  • Private Slack Channel
  • Unlimited Manual Accessibility DevTools Tests