The Testing Academy · Class Notes Tuesday, 13 October (IST)
Live class · study guide

Data-driven testing in Playwright: JSON imports and the fs module, a for loop over rows, a CSV reader from scratch, then Excel, YAML, MySQL and Faker

What data-driven testing is, and why JSON is the format to reach for: its rules and data types, how it differs from a JavaScript object, and two ways to load it, import and the fs module. Then one login test run over many rows with a for loop, a CSV reader written from scratch, ready-made readers for Excel, YAML and MySQL, Faker for generated data, and Ponytail for trimming AI-written code. Files 279 to 290, pushed to the batch repo.

By Pramod Dutta, The Testing Academy. Study notes from the Playwright 3x class, built from the session recording, the batch repository (commit "feat: add data-driven testing module", files 279 to 290 and four readers, pushed as the class ended) and the Eraser notes deck. Every spec was run headless at the batch's 1920 × 1080 viewport with Playwright 1.63, and the MySQL spec against a local MySQL loaded with the class's seed file. Two specs fail as pushed: the CSV spec checks a column the CSV does not have, and the Faker spec lost its seed in the Ponytail trim. Each is shown as written in class, then with its fix.

01

What this class covered

  • What data-driven testing is: external data plus the application under test
  • JSON: what it is, its seven syntax rules and six data types
  • Four websites for reading JSON
  • A JavaScript object is not JSON; JSON.parse and JSON.stringify
  • Importing a JSON file straight into a spec
  • The fs and path modules: reading and writing a JSON file
  • The tsconfig change: resolveJsonModule and Node16
  • Why convert at all: change a copy, never the file
  • One test, many rows: a for loop over an array of logins
  • CSV: rows, columns and cells, and a CSV reader written from scratch
  • The same loop over a JSON file, and why JSON wins
  • Ready-made readers for Excel, YAML and MySQL, with specs written by AI
  • Faker, and the AI test-data agent still to come
  • Ponytail: asking AI for less code, and what the trim removed
  • Tasks: all four readers and every example up to 290 on GitHub
02

What data-driven testing is

The batch is in its endgame: data-driven testing today, then the page object model and fixtures, then building a framework and running it on Jenkins.

Data-driven testing is a way of testing where you have an external source of data and the application under test (the AUT), and you run the same test against the AUT many times, once for each piece of data. The class's example: a login page you want to try with the details of thousands of students. That data can sit in a JSON, CSV, Excel or YAML file, or a plain text file, which is avoided because it is hard to read.

Of those, JSON is the one to reach for with Playwright, which uses JavaScript and TypeScript and supports JSON natively. In the instructor's own work, more than 12,000 tests read their data from JSON, not CSV, because JSON is easier to read, lighter and simpler to use. CSV and Excel are supported, but nine times out of ten you will choose JSON.

ONLY THE READER CHANGES: THE LOOP AND THE TEST STAY THE SAME inline array login.json login-data.csv login-data.xlsx login-data.yaml MySQL table Faker READER rows from a source import (JSON) readCSV / readYAML readExcel / readDB ROWS array of objects { description, username, password, ... } TEST 1 valid credentials TEST 2 invalid password TEST N one per row for (const data of rows) the test body never changes, whichever source fed it
The class in one picture. Seven sources, one page under test. Swap the reader and the loop and the test body stay as they are, which is the point the class made about specs 287 to 289.
03

JSON: syntax rules and data types

JSON stands for JavaScript Object Notation: a lightweight format for storing data as key-value pairs and moving it between applications.

JSON
{
  "name": "Pramod",
  "role": "QA Engineer",
  "experience": 15
}

JSON is everywhere in testing: APIs, configuration files, test data, Playwright and Selenium frameworks, and the traffic between frontend and backend. The rules, from the class notes:

  1. Keys must use double quotes.
  2. String values must use double quotes.
  3. A key and its value are separated by :.
  4. Multiple properties are separated by ,.
  5. Objects use { }.
  6. Arrays use [ ].
  7. The last property must not have a comma.

JSON has six data types:

Data type Example
String "Pramod"
Number 15
Boolean true
Null null
Object { "city": "Bangalore" }
Array ["JavaScript", "Playwright"]

One object with all six:

JSON
{
  "name": "Pramod",
  "experience": 15,
  "isTrainer": true,
  "middleName": null,
  "skills": ["JavaScript", "Playwright", "Selenium"],
  "address": {
    "city": "Bangalore",
    "country": "India"
  }
}

To look at a big JSON file as a picture, paste it into JSON Crack or ToDiagram. To work out the path to a value, use JSONPath or JSON Path Finder. In a JSON path, $ stands for the whole document, so for the login file further down, $.validUser.email is the valid user's email. JSON paths get their own class later.

04

A JavaScript object is not JSON

This looks like JSON, and it is not:

JavaScript
const user = {
  username: "admin",
  password: "admin123"
};

console.log(user.username);
console.log(user.password);

It is a JavaScript object. The only visible difference is the keys: in JSON they must be in double quotes, in a JavaScript object they need not be. So remember the rule from the class notes: JS object != JSON. A JavaScript object can hold arrays, read by index (data.browsers[0] is "Chrome" in { browsers: ["Chrome", "Firefox", "Safari"] }), and so can JSON: an array of objects, which is very common in API testing, or an object inside an object.

Two built-in functions convert between them. JSON.parse turns a JSON string into a JavaScript object:

JavaScript
const jsonText = `{
  "name": "Pramod",
  "role": "QA Engineer"
}`;

const user = JSON.parse(jsonText);
console.log(user.name);
Text
Pramod

JSON.stringify goes the other way:

JavaScript
const user = {
  name: "Pramod",
  role: "QA Engineer"
};

const jsonText = JSON.stringify(user);

console.log(jsonText);
Text
{"name":"Pramod","role":"QA Engineer"}

Converting is how data travels, for example between a test and an API. And as the next sections show, it is what makes test data changeable.

05

Import a JSON file straight into a spec

The first practical: a JSON file with one user in it.

JSON
{
  "username": "admin",
  "password": "admin123"
}

Playwright can import it directly, with no library at all:

TypeScript
import { test, expect } from '@playwright/test';
import userData from './280_Users.json';

test('Verify DDT', async ({ page }) => {

    console.log(userData.username);
    console.log(userData.password);

});
Text
Running 1 test using 1 worker

admin
admin123
  ✓  1 [chromium] › tests/19_Data_Driven_Testing/279_DDT.spec.ts:4:5 › Verify DDT (40ms)

  1 passed (316ms)

userData is not a built-in library. It is a variable holding the whole JSON object, and you can name it anything you like, pramod included; the name only tells the reader what is inside. From there, userData.username reads a value, key by key.

The same idea with a real page: a test-data folder holding login.json, with a valid and an invalid user. These are made-up details for the practice page.

JSON
{
  "validUser": {
    "email": "admin@example.com",
    "password": "Password123"
  },
  "invalidUser": {
    "email": "wrong@example.com",
    "password": "wrongpassword"
  }
}
JavaScript
import { test, expect } from "@playwright/test";
import loginData from './test-data/login.json';

test.describe("Login - data driven", () => {
    test.beforeEach(async ({ page }) => {
        await page.goto("https://app.thetestingacademy.com/playwright/multiple_element_filter");
    });

    test("login with valid user", async ({ page }) => {
        await page.locator("#email").fill(loginData.validUser.email);
        await page.locator("#password").fill(loginData.validUser.password);
        await page.getByTestId("login-button").click();
    });

    test("login with invalid user", async ({ page }) => {
    await page.locator("#email").fill(loginData.invalidUser.email);
    await page.locator("#password").fill(loginData.invalidUser.password);
    await page.getByTestId("login-button").click();


  });
});
Text
Running 2 tests using 1 worker

  ✓  1 [chromium] › tests/19_Data_Driven_Testing/282_Login_DDT.spec.js:9:9 › Login - data driven › login with valid user (1.1s)
  ✓  2 [chromium] › tests/19_Data_Driven_Testing/282_Login_DDT.spec.js:15:9 › Login - data driven › login with invalid user (1.1s)

  2 passed (2.5s)

Both tests get their data straight from the import, the positive case and the negative one.

The tsconfig change. Importing a .json file from a TypeScript spec made the editor complain. The fix, found with AI's help, was in tsconfig.json: switch on resolveJsonModule, and move module resolution from Node 10 to Node16, TypeScript's setting for how Node 16 and later find modules. The lines that matter, from the repo's tsconfig.json:

JSON
"module": "Node16",
"moduleResolution": "Node16",
"resolveJsonModule": true,

The error was the editor's, not Playwright's. Playwright runs these JSON imports without any tsconfig: with tsconfig.json removed, 279 and 282 still pass. The setting is what stops VS Code underlining the import in red.

06

Reading and writing files: fs and path

Reading a file yourself needs two modules from Node.js: fs, the file system module, which reads and writes files, and path, which builds file paths. The class's file:

TypeScript
import fs from 'fs';
import path from 'path';

// Always build paths from __dirname. A bare './280_Users.json' resolves
// against the working directory, so it breaks when run from elsewhere.
const filePath = path.join(__dirname, '280_Users.json');
console.log('File path:', filePath);

const fileData = fs.readFileSync(filePath,'utf-8');
const userDataJSObject = JSON.parse(fileData);
console.log(userDataJSObject);

console.log(userDataJSObject.username);
console.log(userDataJSObject.password);

// Writing Data to a JSON File

const user = {
  name: "Pramod",
  role: "QA Trainer"
};

const jsonData = JSON.stringify(user);

fs.writeFileSync("output.json", jsonData);
console.log("JSON file created successfully");
Text
File path: .../LearningPlaywrightFundamentals3x/tests/19_Data_Driven_Testing/280_Users.json
{ username: 'admin', password: 'admin123' }
admin
admin123
JSON file created successfully

(Run here with ts-node. The first line is a full path, and how it starts depends on where you cloned the repo.)

  • path.join(__dirname, '280_Users.json') builds the full path to the file next to this script, so the read works whichever folder you run it from.
  • fs.readFileSync(filePath, 'utf-8') reads the file as text. UTF-8 is the standard encoding.
  • JSON.parse turns that text into a JavaScript object, so userDataJSObject.username works.
  • JSON.stringify plus fs.writeFileSync goes the other way and writes a new JSON file.

One thing to notice when you run it: the write uses a bare "output.json", so the file lands in whichever folder you ran the command from, the repo root in this case, which is exactly what the comment at the top warns about for reads. The repo's .gitignore already lists it.

If your data arrives as CSV, a converter such as csvjson.com turns it into JSON.

07

Why convert at all: change a copy, never the file

The question several people asked: if import works directly, why bother converting? The class's answer: you never modify the JSON file itself. It might hold 10,000 rows. When a test needs a different value, say a new invalid user created on the fly, you turn the JSON into a JavaScript object and change that object instead. That is also how dynamic data gets in: values from Faker can be dropped into the object, while the file stays as it is.

TWO WAYS TO LOAD THE SAME FILE, AND WHAT A CHANGE DOES import one object, shared by every test in the worker LOAD loaded once the same object for each test TEST ONE changes the email in memory only TEST TWO sees the change the file on disk is untouched fs.readFileSync + JSON.parse a new object on every call EVERY CALL a fresh copy parsed from the file CHANGE edit the copy add Faker values, tweak a row NEXT READ starts clean nothing leaks between tests
Both routes give you an ordinary object, and neither writes to the file unless you call fs.writeFileSync. What differs is sharing: checked by running two tests against login.json in one worker.

An imported JSON can be changed too. The class described a direct import as read-only. It is an ordinary object, and you can change it in memory, but it is loaded once and shared by every test in the worker: change the email in one test and the next test sees the new value. JSON.parse(fs.readFileSync(...)) gives you a fresh copy each time you call it, so the class's rule holds for a better reason: change a copy, never the file and never the shared import.

08

One test, many rows: a for loop

Two panels. Left, titled Copy-paste, with the sublabel One test per case: five different rubber stamps lie scattered at odd angles, one tall, one small, one with a chipped edge, one with a dark handle, above an untidy pile of stamped slips. Right, titled Data-driven, with the sublabel One test, many rows: a single clean stamp presses onto a neat stack of index cards with different squiggle lines, beside a tidy column of three cards, each with the same terracotta tick.
Copy a test once per case and the copies drift apart. Keep one test and feed it rows, and a change to the form is a change in one place.

Now the real thing. The class put five logins in an array, each a row with a description, a username and a password, and wrapped one test in a for loop:

TypeScript
import { test, expect } from '@playwright/test';


test.describe('DDT Simple', () => {

test.beforeEach(async ({ page }) => {
    await page.goto("https://app.thetestingacademy.com/playwright/multiple_element_filter");
  });

// TEST DATA - array of objects
    const loginData = [
        {
            description: "valid credentials",
            username: "admin@gmail.com",
            password: "admin123",
            expectedURL: /admin/,
            shouldPass: true
        },
        {
            description: "invalid password",
            username: "admin123@ymail.com",
            password: "wrongpass",
            expectedURL: /admin/,
            shouldPass: false
        },
        {
            description: "empty username",
            username: "",
            password: "admin123",
            expectedURL: /admin123/,
            shouldPass: false
        },
        {
            description: "empty password",
            username: "pramod@ppp.com",
            password: "",
            expectedURL: /admin/,
            shouldPass: false
        },
        {
            description: "both empty",
            username: "",
            password: "",
            expectedURL: /multiple_element_filter/,
            shouldPass: false
        }
    ];

    for(const data of loginData){
        test(`Login with : ${data.description}`, async ({ page }) => {
            await page.locator("#email").fill(data.username);
            await page.locator("#password").fill(data.password);
            await page.getByTestId("login-button").click();
        });

    }






});
Text
Running 5 tests using 1 worker

  ✓  1 [chromium] › tests/19_Data_Driven_Testing/284_DDT_JSON_ARRAY.spec.ts:50:13 › DDT Simple › Login with : valid credentials (1.3s)
  ✓  2 [chromium] › tests/19_Data_Driven_Testing/284_DDT_JSON_ARRAY.spec.ts:50:13 › DDT Simple › Login with : invalid password (1.2s)
  ✓  3 [chromium] › tests/19_Data_Driven_Testing/284_DDT_JSON_ARRAY.spec.ts:50:13 › DDT Simple › Login with : empty username (1.2s)
  ✓  4 [chromium] › tests/19_Data_Driven_Testing/284_DDT_JSON_ARRAY.spec.ts:50:13 › DDT Simple › Login with : empty password (1.0s)
  ✓  5 [chromium] › tests/19_Data_Driven_Testing/284_DDT_JSON_ARRAY.spec.ts:50:13 › DDT Simple › Login with : both empty (1.3s)

  5 passed (6.4s)

One test in the file, five in the run: valid credentials, invalid password, empty username, empty password and both empty. Each time round the loop, data is the next row, and the template literal gives each test its own title.

Five passes that check nothing. Each row carries expectedURL and shouldPass, but the test body never reads them, so a green run only proves the loop ran. The class left the checks for later. When you add them, know the page: this practice form signs in any password with a well-formed or empty email, and only an address with no @ is stopped, by the browser's own email check.

09

CSV: rows, columns, cells, and a reader from scratch

CSV is the older way. The class's file, in test-data:

Text
description,username,password,shouldPass,expectedError
valid credentials,admin@gamil.com,admin123,true,
invalid password,admin@gamil.com,wrongpass,false,Invalid credentials
empty username,,admin123@gamil.com,false,Username is required
empty password,admin@gamil.com,,false,Password is required
locked account,locked_user@gamil.com,pass123,false,Account is locked
special chars,admin@gamil.com,p@$$w0rd!,true,
SQL injection,admin' OR '1'='1,password,false,Invalid credentials

Open it in a grid viewer and you see the parts: rows run across, columns run down, the first row is the header, and the value where a row meets a column is a cell.

Unlike JSON, JavaScript cannot import a CSV file, so you need a utility to read it, a reader function kept in a util folder. The class wrote readCSV:

TypeScript
import * as fs from 'fs';
import * as path from "path";

export interface TestDataRow {
    [key: string]: string;
}

export function readCSV(filePath: string): TestDataRow[] {
    let fullPath = path.resolve(filePath);
    let content = fs.readFileSync(fullPath, 'utf-8');
    let lines = content.trim().split("\n");

    // First Line is headers
    let headers = lines[0].split(",");
      // Remaining Lines are data
    let data: TestDataRow[] = [];
    for(let i=1;i< lines.length;i++){
        let values = lines[i].split(',');
        let row: TestDataRow = {};
        for (let j = 0; j < headers.length; j++) {
            row[headers[j].trim()] = values[j]?.trim() || "";
        }
         data.push(row);
    }
        return data;

}

It is the double for loop from the matrix and triangle-pattern programs: rows on the outside, columns on the inside.

HOW THE CSV READER TURNS LINES INTO OBJECTS login-data.csv content.trim().split("\n") lines[0] description,username,password,... the header: its names become the keys lines[1] valid credentials,admin@gamil.com,... lines[2] invalid password,admin@gamil.com,... split(",") ROW 1 AS AN OBJECT { description: "valid credentials", username: "admin@gamil.com", password: "admin123", shouldPass: "true", expectedError: "" } OUTER LOOP: ROWS for (let i = 1; i < lines.length; i++) one pass per data line; lines[0] is skipped values = lines[i].split(",") INNER LOOP: COLUMNS for (let j = 0; j < headers.length; j++) row[headers[j]] = values[j]?.trim() || "" a missing cell becomes an empty string Every value arrives as a string: true in the file is "true" in the object.
util/csvReader.ts, the reader written in class, on the first data line of the CSV. Two loops, as in the matrix programs: rows on the outside, columns on the inside.
Step Code What you get
Resolve the path path.resolve(filePath) The file's full path
Read fs.readFileSync(fullPath, 'utf-8') The whole file as one string
Split into lines content.trim().split("\n") One string per line: 8 lines
Headers lines[0].split(",") description, username, password, shouldPass, expectedError
Each data line lines[i].split(',') for i from 1 The values of one row
Each column row[headers[j].trim()] = values[j]?.trim() || "" One key-value pair; an empty cell becomes ""
Result data.push(row) 7 row objects

Hard to digest the first time, and that is fine. Nobody writes these from scratch any more: CSV readers are free on npm, and interviews rarely ask for one. If an interviewer does, write this one.

The spec uses the reader where 284 had an inline array:

TypeScript
import { test, expect } from '@playwright/test';
import { readCSV } from './util/csvReader';
import path from 'path';

test.describe('DDT CSV', () => {
    const loginData = readCSV(path.join(__dirname, '/test-data/login-data.csv'));

    for (const data of loginData) {

        test(`Login with : ${data.description}`, async ({ page }) => {
            await page.goto('https://app.thetestingacademy.com/playwright/multiple_element_filter');

            let textboxEmailAddress = page.getByRole("textbox", { name: "Email Address" });
            let textboxPassword = page.getByRole("textbox", { name: "Password" }).or(page.locator("#password")).or(page.locator("[name=\"password\"]"));
            let buttonLoginToPracticeAccount = page.getByRole("button", { name: "Login to Practice Account" }).or(page.getByTestId("login-button")).or(page.getByText("Login to Practice Account"));
            await textboxEmailAddress.fill(data.username);
            await textboxPassword.fill(data.password);
            await buttonLoginToPracticeAccount.click();

            await expect(page).toHaveURL(data.expectedURL);

        });

    }


});

In class, all seven tests ran, and all seven failed. That was expected: the point was whether each row's values reached the form, and running in debug mode showed them arriving one row at a time. Run it now and every test fails the same way:

Text
Running 7 tests using 1 worker

  ✘  1 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : valid credentials (1.2s)
  ✘  2 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : invalid password (2.2s)
  ✘  3 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : empty username (1.3s)
  ✘  4 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : empty password (1.4s)
  ✘  5 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : locked account (1.3s)
  ✘  6 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : special chars (1.4s)
  ✘  7 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : SQL injection (1.1s)

  1) [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : valid credentials

    Error: expect(page).toHaveURL(expected) failed

    Error: expected value must be a string or regular expression
    Expected has value: undefined

      18 |             await buttonLoginToPracticeAccount.click();
      19 |
    > 20 |             await expect(page).toHaveURL(data.expectedURL);
         |                                ^
      21 |
      22 |         });
      23 |
        at tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:20:32

  ...

  7 failed

What the failures are really saying. The last line checks data.expectedURL, but the CSV has no expectedURL column, so every row hands toHaveURL the value undefined, and the assertion throws before any URL is compared. The failures say nothing about the login itself. The fix below checks what the class was after, that each row's values reached the form, and drops the check on a column that does not exist:

TypeScript
import { test, expect } from '@playwright/test';
import { readCSV } from './util/csvReader';
import path from 'path';

test.describe('DDT CSV', () => {
    const loginData = readCSV(path.join(__dirname, '/test-data/login-data.csv'));

    for (const data of loginData) {

        test(`Login with : ${data.description}`, async ({ page }) => {
            await page.goto('https://app.thetestingacademy.com/playwright/multiple_element_filter');

            let textboxEmailAddress = page.getByRole("textbox", { name: "Email Address" });
            let textboxPassword = page.getByRole("textbox", { name: "Password" }).or(page.locator("#password")).or(page.locator("[name=\"password\"]"));
            let buttonLoginToPracticeAccount = page.getByRole("button", { name: "Login to Practice Account" }).or(page.getByTestId("login-button")).or(page.getByText("Login to Practice Account"));
            await textboxEmailAddress.fill(data.username);
            await textboxPassword.fill(data.password);
            await expect(textboxEmailAddress).toHaveValue(data.username);
            await expect(textboxPassword).toHaveValue(data.password);
            await buttonLoginToPracticeAccount.click();

        });

    }


});
Text
Running 7 tests using 1 worker

  ✓  1 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : valid credentials (1.1s)
  ✓  2 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : invalid password (1.3s)
  ✓  3 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : empty username (1.3s)
  ✓  4 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : empty password (1.3s)
  ✓  5 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : locked account (1.3s)
  ✓  6 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : special chars (1.1s)
  ✓  7 [chromium] › tests/19_Data_Driven_Testing/285_DDT_CSV.spec.ts:10:13 › DDT CSV › Login with : SQL injection (984ms)

  7 passed (8.8s)

Import order does not matter. At one point the VS Code run button did not appear, and moving import path from 'path' to the last line was taken as the fix. Whatever hid the button, the order was not it: the same spec with import path first runs exactly the same.

10

The same loop over a JSON file, and why JSON wins

Now the same thing with JSON: five registration rows in test-data/registration-data.json (the first two shown here):

JSON
[
    {
        "description": "valid registration",
        "name": "Dev Sharma",
        "username": "dev@test.com",
        "password": "Strong@123",
        "confirmPassword": "Strong@123",
        "shouldPass": true,
        "expectedError": "Email already exists"
    },
    {
        "description": "password mismatch",
        "name": "Alice",
        "username": "alice@test.com",
        "password": "Strong@123",
        "confirmPassword": "Different@456",
        "shouldPass": false,
        "expectedError": "Passwords do not match"
    },
  ...
]
TypeScript
import { test, expect } from '@playwright/test';
import path from 'path';
import loginData from "./test-data/registration-data.json";

test.describe('DDT JSON', () => {

    test.beforeEach(async ({ page }) => {
        await page.goto('https://app.thetestingacademy.com/playwright/multiple_element_filter');
    });

    test.afterEach(async ({ }, testInfo) => {
        console.log(`afterEach: ${testInfo.title} - status: ${testInfo.status}`);
    });

    for (const data of loginData) {

        test(`Login with : ${data.description}`, async ({ page }) => {

            const textboxEmailAddress = page.getByRole("textbox", { name: "Email Address" });
            const textboxPassword = page.getByRole("textbox", { name: "Password" })
                .or(page.locator("#password"))
                .or(page.locator("[name=\"password\"]"));
            const buttonLogin = page.getByRole("button", { name: "Login to Practice Account" })
                .or(page.getByTestId("login-button"))
                .or(page.getByText("Login to Practice Account"));

            await textboxEmailAddress.fill(data.username);
            await textboxPassword.fill(data.password);
            await buttonLogin.click();

            // if (data.shouldPass === "true") {
            //     await expect(page).not.toHaveURL(/multiple_element_filter/);
            // } else {
            //     await expect(page.getByText(data.expectedError)).toBeVisible();
            // }
        });

    }

});
Text
Running 5 tests using 1 worker

afterEach: Login with : valid registration - status: passed
  ✓  1 [chromium] › tests/19_Data_Driven_Testing/286_JSON_DDT.spec.ts:17:13 › DDT JSON › Login with : valid registration (1.3s)
afterEach: Login with : password mismatch - status: passed
  ✓  2 [chromium] › tests/19_Data_Driven_Testing/286_JSON_DDT.spec.ts:17:13 › DDT JSON › Login with : password mismatch (1.2s)
afterEach: Login with : weak password - status: passed
  ✓  3 [chromium] › tests/19_Data_Driven_Testing/286_JSON_DDT.spec.ts:17:13 › DDT JSON › Login with : weak password (1.4s)
afterEach: Login with : duplicate email - status: passed
  ✓  4 [chromium] › tests/19_Data_Driven_Testing/286_JSON_DDT.spec.ts:17:13 › DDT JSON › Login with : duplicate email (1.3s)
afterEach: Login with : invalid email format - status: passed
  ✓  5 [chromium] › tests/19_Data_Driven_Testing/286_JSON_DDT.spec.ts:17:13 › DDT JSON › Login with : invalid email format (1.1s)

  5 passed (6.6s)

No reader to write: import the file, loop over it. That is the whole case for JSON. CSV needs a utility written first; JSON is supported natively, so it is import and a for loop. You can convert CSV to JSON, and you can ask your developers to share test data as JSON.

11

Excel, YAML and MySQL: the ready-made readers

For the other formats, the instructor brought readers he had already written, to share with the batch:

  • dbReader.ts connects to MySQL, runs a query, and returns the rows as plain objects.
  • excelReader.ts reads an .xlsx file with the ExcelJS library, for companies that insist on Excel.
  • yamlReader.ts reads YAML with the js-yaml library.
  • generateExcel.js creates a fake Excel file, for reference.

Then, rather than coding the specs, he had AI write 287, 288 and 289 from them. Nobody writes these utilities from scratch now: across the 40-plus companies the instructor consults for, nobody is doing it.

The point he made: only the line that fetches the rows changes. Every reader returns JavaScript objects, and the rest of the test stays the same. The YAML reader is the simplest:

TypeScript
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';

export interface LoginRow {
    description: string;
    username: string;
    password: string;
    shouldPass: boolean;
    expectedError: string;
}

/**
 * Reads a YAML file and returns it as a typed array.
 * Sync on purpose, so the data is ready at test-collection time
 * and we can create one test() per row.
 */
export function readYAML<T = LoginRow>(filePath: string): T[] {
    const fullPath = path.resolve(filePath);
    const content = fs.readFileSync(fullPath, 'utf-8');
    const data = yaml.load(content) as T[];

    if (!Array.isArray(data)) {
        throw new Error(`Expected a YAML list in ${fullPath}, got ${typeof data}`);
    }
    return data;
}
YAML
# Same seven rows as login-data.csv / login-data.xlsx / the MySQL seed.
# YAML is read synchronously, so these are available at test-collection time
# and each row becomes its own test(). See 288_DDT_YAML.spec.ts.

- description: valid credentials
  username: admin@gamil.com
  password: admin123
  shouldPass: true
  expectedError: ""
...
TypeScript
import { test, expect } from '@playwright/test';
import path from 'path';
import { readYAML, type LoginRow } from './util/yamlReader';

/**
 * Data-driven login, same page as 286, but the rows come from YAML.
 *
 * This is the closest match to 286. readYAML is synchronous, so the data is
 * available at test-collection time and each row becomes its own test(),
 * with its own entry in the report and its own retry.
 *
 * Compare with 287 (Excel) and 289 (MySQL), where the reader is async and the
 * rows have to be replayed as test.step()s inside one test instead.
 */
const YAML_PATH = path.join(__dirname, 'test-data', 'login-data.yaml');
const APP_URL = 'https://app.thetestingacademy.com/playwright/multiple_element_filter';

// Read at module load, before any test runs.
const loginData = readYAML<LoginRow>(YAML_PATH);

test.describe('DDT YAML', () => {

    test.beforeEach(async ({ page }) => {
        await page.goto(APP_URL);
    });

    test.afterEach(async ({ }, testInfo) => {
        console.log(`afterEach: ${testInfo.title} - status: ${testInfo.status}`);
    });

    for (const data of loginData) {

        test(`Login with : ${data.description}`, async ({ page }) => {

            const textboxEmailAddress = page.getByRole("textbox", { name: "Email Address" });
            const textboxPassword = page.getByRole("textbox", { name: "Password" })
                .or(page.locator("#password"))
                .or(page.locator("[name=\"password\"]"));
            const buttonLogin = page.getByRole("button", { name: "Login to Practice Account" })
                .or(page.getByTestId("login-button"))
                .or(page.getByText("Login to Practice Account"));

            await textboxEmailAddress.fill(data.username);
            await textboxPassword.fill(data.password);
            await buttonLogin.click();

            // YAML gives a real boolean, no "true"/"false" string parsing needed.
            expect(typeof data.shouldPass).toBe('boolean');
            console.log(`  ${data.description} -> shouldPass=${data.shouldPass}`);
        });

    }

});
Text
Running 7 tests using 1 worker

  valid credentials -> shouldPass=true
afterEach: Login with : valid credentials - status: passed
  ✓  1 [chromium] › tests/19_Data_Driven_Testing/288_DDT_YAML.spec.ts:33:13 › DDT YAML › Login with : valid credentials (1.2s)
  invalid password -> shouldPass=false
afterEach: Login with : invalid password - status: passed
  ✓  2 [chromium] › tests/19_Data_Driven_Testing/288_DDT_YAML.spec.ts:33:13 › DDT YAML › Login with : invalid password (1.3s)
  empty username -> shouldPass=false
afterEach: Login with : empty username - status: passed
  ✓  3 [chromium] › tests/19_Data_Driven_Testing/288_DDT_YAML.spec.ts:33:13 › DDT YAML › Login with : empty username (1.1s)
  empty password -> shouldPass=false
afterEach: Login with : empty password - status: passed
  ✓  4 [chromium] › tests/19_Data_Driven_Testing/288_DDT_YAML.spec.ts:33:13 › DDT YAML › Login with : empty password (1.2s)
  locked account -> shouldPass=false
afterEach: Login with : locked account - status: passed
  ✓  5 [chromium] › tests/19_Data_Driven_Testing/288_DDT_YAML.spec.ts:33:13 › DDT YAML › Login with : locked account (1.3s)
  special chars -> shouldPass=true
afterEach: Login with : special chars - status: passed
  ✓  6 [chromium] › tests/19_Data_Driven_Testing/288_DDT_YAML.spec.ts:33:13 › DDT YAML › Login with : special chars (1.1s)
  SQL injection attempt -> shouldPass=false
afterEach: Login with : SQL injection attempt - status: passed
  ✓  7 [chromium] › tests/19_Data_Driven_Testing/288_DDT_YAML.spec.ts:33:13 › DDT YAML › Login with : SQL injection attempt (894ms)

  7 passed (8.4s)

readYAML is synchronous, so the rows exist while Playwright is still collecting tests, and each row becomes its own test(). Excel and MySQL are different, and the repo shows how:

WHY THE EXCEL AND MYSQL SPECS HAVE A DIFFERENT SHAPE Sync reader JSON, CSV, YAML, Faker: the rows exist while tests are collected AT LOAD rows read at once readYAML(...) returns an array COLLECT one test() per row 7 tests are registered REPORT 7 tests each row retries on its own Async reader Excel, MySQL: a Promise, which collection cannot wait for COLLECT one test registered no rows yet HOOK beforeAll awaits rows they arrive at run time REPORT 1 test, 7 steps one retry covers every row
From the repo's README for this module: Playwright collects tests synchronously, so a reader that returns a Promise cannot create one test() per row. Specs 287 and 289 load in beforeAll and replay the rows as test.step().
TypeScript
import { test, expect } from '@playwright/test';
import path from 'path';
import { readLoginDataFromExcel } from './util/excelReader';
import type { LoginRow } from './util/yamlReader';

/**
 * Data-driven login, same page as 286, but the rows come from an .xlsx.
 *
 * Regenerate the spreadsheet with:
 *   node tests/19_Data_Driven_Testing/util/generateExcel.js
 *
 * WHY THIS LOOKS DIFFERENT FROM 286 AND 288
 * -----------------------------------------
 * exceljs is asynchronous, and Playwright collects tests *synchronously*.
 * There is no way to `await` at module level and still produce one test()
 * per row, so the rows are loaded in beforeAll and replayed as test.step()s
 * inside a single test. Each step still shows up separately in the report.
 *
 * If you need one real test() per row from an async source, pre-generate a
 * JSON file in globalSetup and import that synchronously instead.
 */
const XLSX_PATH = path.join(__dirname, 'test-data', 'login-data.xlsx');
const APP_URL = 'https://app.thetestingacademy.com/playwright/multiple_element_filter';

test.describe('DDT Excel', () => {

    let rows: LoginRow[] = [];

    test.beforeAll(async () => {
        rows = await readLoginDataFromExcel(XLSX_PATH);
        console.log(`Loaded ${rows.length} rows from ${path.basename(XLSX_PATH)}`);
    });

    test.afterEach(async ({ }, testInfo) => {
        console.log(`afterEach: ${testInfo.title} - status: ${testInfo.status}`);
    });

    test('login with every row from the spreadsheet', async ({ page }) => {
        expect(rows.length, 'no rows were read from the spreadsheet').toBeGreaterThan(0);

        for (const data of rows) {
            await test.step(`Login with : ${data.description}`, async () => {
                await page.goto(APP_URL);

                const textboxEmailAddress = page.getByRole("textbox", { name: "Email Address" });
                const textboxPassword = page.getByRole("textbox", { name: "Password" })
                    .or(page.locator("#password"))
                    .or(page.locator("[name=\"password\"]"));
                const buttonLogin = page.getByRole("button", { name: "Login to Practice Account" })
                    .or(page.getByTestId("login-button"))
                    .or(page.getByText("Login to Practice Account"));

                await textboxEmailAddress.fill(data.username);
                await textboxPassword.fill(data.password);
                await buttonLogin.click();

                console.log(`  ${data.description} -> shouldPass=${data.shouldPass}`);
            });
        }
    });

});

The Excel reader maps each spreadsheet row to the same LoginRow shape the YAML reader uses:

TypeScript
/** Same file, mapped to the shared LoginRow shape. */
export async function readLoginDataFromExcel(filePath: string): Promise<LoginRow[]> {
    const rows = await readExcel<Record<string, unknown>>(filePath, 'LoginData');

    return rows.map((r) => ({
        description: String(r.description ?? ''),
        username: String(r.username ?? ''),
        password: String(r.password ?? ''),
        // Excel gives a real boolean, but a "TRUE"/"true" string is also handled.
        shouldPass: r.shouldPass === true || String(r.shouldPass).toLowerCase() === 'true',
        expectedError: String(r.expectedError ?? ''),
    }));
}
Text
Running 1 test using 1 worker

Loaded 7 rows from login-data.xlsx
  valid credentials -> shouldPass=true
     1.1 [chromium] › tests/19_Data_Driven_Testing/287_DDT_EXCEL.spec.ts:38:9 › DDT Excel › login with every row from the spreadsheet › Login with : valid credentials (1.3s)
  invalid password -> shouldPass=false
     1.2 [chromium] › tests/19_Data_Driven_Testing/287_DDT_EXCEL.spec.ts:38:9 › DDT Excel › login with every row from the spreadsheet › Login with : invalid password (340ms)
  empty username -> shouldPass=false
     1.3 [chromium] › tests/19_Data_Driven_Testing/287_DDT_EXCEL.spec.ts:38:9 › DDT Excel › login with every row from the spreadsheet › Login with : empty username (308ms)
  empty password -> shouldPass=false
     1.4 [chromium] › tests/19_Data_Driven_Testing/287_DDT_EXCEL.spec.ts:38:9 › DDT Excel › login with every row from the spreadsheet › Login with : empty password (294ms)
  locked account -> shouldPass=false
     1.5 [chromium] › tests/19_Data_Driven_Testing/287_DDT_EXCEL.spec.ts:38:9 › DDT Excel › login with every row from the spreadsheet › Login with : locked account (295ms)
  special chars -> shouldPass=true
     1.6 [chromium] › tests/19_Data_Driven_Testing/287_DDT_EXCEL.spec.ts:38:9 › DDT Excel › login with every row from the spreadsheet › Login with : special chars (304ms)
  SQL injection attempt -> shouldPass=false
     1.7 [chromium] › tests/19_Data_Driven_Testing/287_DDT_EXCEL.spec.ts:38:9 › DDT Excel › login with every row from the spreadsheet › Login with : SQL injection attempt (78ms)
afterEach: login with every row from the spreadsheet - status: passed
  ✓  1 [chromium] › tests/19_Data_Driven_Testing/287_DDT_EXCEL.spec.ts:38:9 › DDT Excel › login with every row from the spreadsheet (3.0s)

  1 passed (3.5s)

One test, seven steps.

MySQL. Before you run the MySQL spec, the database must exist. The repo ships a seed file that creates it with the same seven rows, plus one inactive row the reader filters out:

Terminal
mysql -u root -p < tests/19_Data_Driven_Testing/test-data/seed.sql

Then set MYSQL_HOST and the other MYSQL_* values in .env, as listed in .env.example. The reader's query:

TypeScript
/** Reads login_data and maps snake_case columns to the shared LoginRow shape. */
export async function readLoginDataFromDB(): Promise<LoginRow[]> {
    const rows = await query<LoginDataRecord>(
        'SELECT description, username, password, should_pass, expected_error ' +
        'FROM login_data WHERE is_active = ? ORDER BY id',
        [1]
    );

    return rows.map((r) => ({
        description: r.description,
        username: r.username,
        password: r.password,
        shouldPass: r.should_pass === 1,
        expectedError: r.expected_error,
    }));
}

And the spec, after the Ponytail review described below:

TypeScript
import { test, expect } from '@playwright/test';
import { readLoginDataFromDB, isDbConfigured } from './util/dbReader';
import type { LoginRow } from './util/yamlReader';


test.describe('DDT MySQL', () => {

    test.skip(!isDbConfigured(), 'MYSQL_HOST is not set, see .env.example');

     let rows: LoginRow[] = [];

    test.beforeAll(async () => {
        rows = await readLoginDataFromDB();
    });

    test('login with every active row from the database', async ({ page }) => {
        expect(rows.length).toBeGreaterThan(0);

        for (const row of rows) {
            await test.step(`Login with : ${row.description}`, async () => {
                await page.goto('https://app.thetestingacademy.com/playwright/multiple_element_filter');
                await page.getByRole('textbox', { name: 'Email Address' }).fill(row.username);
                await page.getByRole('textbox', { name: 'Password' }).fill(row.password);
                await page.getByRole('button', { name: 'Login to Practice Account' }).click();
            });
        }
    });

});

Without a database configured, the suite skips instead of failing:

Text
Running 1 test using 1 worker

  -  1 [chromium] › tests/19_Data_Driven_Testing/289_DDT_MYSQL.spec.ts:16:9 › DDT MySQL › login with every active row from the database

  1 skipped

Against a MySQL loaded with the seed file:

Text
Running 1 test using 1 worker

     1.1 [chromium] › tests/19_Data_Driven_Testing/289_DDT_MYSQL.spec.ts:16:9 › DDT MySQL › login with every active row from the database › Login with : valid credentials (1.2s)
     1.2 [chromium] › tests/19_Data_Driven_Testing/289_DDT_MYSQL.spec.ts:16:9 › DDT MySQL › login with every active row from the database › Login with : invalid password (269ms)
     1.3 [chromium] › tests/19_Data_Driven_Testing/289_DDT_MYSQL.spec.ts:16:9 › DDT MySQL › login with every active row from the database › Login with : empty username (267ms)
     1.4 [chromium] › tests/19_Data_Driven_Testing/289_DDT_MYSQL.spec.ts:16:9 › DDT MySQL › login with every active row from the database › Login with : empty password (269ms)
     1.5 [chromium] › tests/19_Data_Driven_Testing/289_DDT_MYSQL.spec.ts:16:9 › DDT MySQL › login with every active row from the database › Login with : locked account (285ms)
     1.6 [chromium] › tests/19_Data_Driven_Testing/289_DDT_MYSQL.spec.ts:16:9 › DDT MySQL › login with every active row from the database › Login with : special chars (291ms)
     1.7 [chromium] › tests/19_Data_Driven_Testing/289_DDT_MYSQL.spec.ts:16:9 › DDT MySQL › login with every active row from the database › Login with : SQL injection attempt (77ms)
  ✓  1 [chromium] › tests/19_Data_Driven_Testing/289_DDT_MYSQL.spec.ts:16:9 › DDT MySQL › login with every active row from the database (2.7s)

  1 passed (3.1s)

To install everything this module uses, from the repo's README:

Terminal
npm i -D exceljs js-yaml @types/js-yaml mysql2 @faker-js/faker@9

The @9 matters: Faker 10 is ESM-only, and this repo is CommonJS.

Which to use? The JSON array, most of the time. Some teams use CSV. Excel, YAML and MySQL are rarely used in automation; you have the readers for when a project insists. In the instructor's team, it is JSON only.

12

Faker

Faker is a very well-known library that makes fake data look real. abc is not a real name, and a made-up email looks made up. Faker gives you a full name, an email and a password that look real but are not. The class's spec, as pushed after its Ponytail review:

TypeScript
import { test, expect } from '@playwright/test';
import { faker } from '@faker-js/faker';


const users = faker.helpers.multiple(() => ({
    name: faker.person.fullName(),
    email: faker.internet.email().toLowerCase(),
    password: faker.internet.password({ length: 12 }),
}), { count: 5 });

test.describe('DDT Faker', () => {

    test.beforeEach(async ({ page }) => {
        await page.goto('https://app.thetestingacademy.com/playwright/multiple_element_filter');
    });

    for (const user of users) {

        test(`Login with : ${user.name}`, async ({ page }) => {
            const email = page.getByRole('textbox', { name: 'Email Address' });
            const password = page.getByRole('textbox', { name: 'Password' });

            await email.fill(user.email);
            await password.fill(user.password);
            await expect(email).toHaveValue(user.email);

            await page.getByRole('button', { name: 'Login to Practice Account' }).click();
        });

    }

});
Text
Running 5 tests using 1 worker

  ✘  1 [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:19:13 › DDT Faker › Login with : Sabrina Dickinson DDS (0ms)
  ✘  2 [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:19:13 › DDT Faker › Login with : Donna Schaefer (0ms)
  ✘  3 [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:19:13 › DDT Faker › Login with : Eloise Gutmann (0ms)
  ✘  4 [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:19:13 › DDT Faker › Login with : Cesar Mraz (0ms)
  ✘  5 [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:19:13 › DDT Faker › Login with : Dr. Courtney Greenfelder (0ms)

  1) [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:19:13 › DDT Faker › Login with : Sabrina Dickinson DDS

    Test not found in the worker process. Make sure test title does not change.

  ...

  5 failed

Every test fails before it starts. The names are part of the test titles, and that is the problem:

PLAYWRIGHT LOADS A SPEC FILE TWICE, AND THE TITLES MUST MATCH As pushed no seed, after the Ponytail trim RUNNER collects 5 titles Sabrina Dickinson DDS, ... WORKER loads the file again Faker draws five new names RESULT Test not found in the worker process, 5 times With a seed faker.seed(123) before the names are drawn RUNNER collects 5 titles Edmond Lubowitz, ... WORKER loads the file again same seed, same five names RESULT 5 passed the same names on every run
Why spec 290 fails as pushed. The names are part of the test titles, and the worker re-runs the file before it looks a test up by title. Random names make the two passes disagree; a fixed seed makes them agree.

The repo's README for this module documents exactly this: the seed must be fixed, or every test is reported as "Test not found in the worker process". The fix is one line, before the names are drawn:

TypeScript
import { test, expect } from '@playwright/test';
import { faker } from '@faker-js/faker';

faker.seed(123);


const users = faker.helpers.multiple(() => ({
    name: faker.person.fullName(),
    email: faker.internet.email().toLowerCase(),
    password: faker.internet.password({ length: 12 }),
}), { count: 5 });

test.describe('DDT Faker', () => {

    test.beforeEach(async ({ page }) => {
        await page.goto('https://app.thetestingacademy.com/playwright/multiple_element_filter');
    });

    for (const user of users) {

        test(`Login with : ${user.name}`, async ({ page }) => {
            const email = page.getByRole('textbox', { name: 'Email Address' });
            const password = page.getByRole('textbox', { name: 'Password' });

            await email.fill(user.email);
            await password.fill(user.password);
            await expect(email).toHaveValue(user.email);

            await page.getByRole('button', { name: 'Login to Practice Account' }).click();
        });

    }

});
Text
Running 5 tests using 1 worker

  ✓  1 [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:21:13 › DDT Faker › Login with : Edmond Lubowitz (1.4s)
  ✓  2 [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:21:13 › DDT Faker › Login with : Maggie Kerluke (1.1s)
  ✓  3 [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:21:13 › DDT Faker › Login with : Mathew Muller (1.3s)
  ✓  4 [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:21:13 › DDT Faker › Login with : Miss Evelyn Purdy DDS (1.0s)
  ✓  5 [chromium] › tests/19_Data_Driven_Testing/290_DDT_FAKER.spec.ts:21:13 › DDT Faker › Login with : Willie Durgan (1.1s)

  5 passed (6.4s)

With the seed, Faker gives the same five names on every run. The README's version reads the seed from a FAKER_SEED variable, so you can run a different set of names and replay a failure exactly.

Faker will see little use in this batch, though. In the class after next, the instructor will show an AI agent that generates test data on the fly instead.

13

Ponytail: asking AI for less code

Ask AI to write code and it writes complex code: classes, helpers, method after method. People do not write code like that. Ponytail pushes the agent the other way, to the minimum code that does the job. The instructor's advice: if AI wrote it, a DB reader, a YAML or Excel reader, a Faker spec, run a Ponytail review. You can ask for one on any spec file. The prompt used in class:

can you do a ponytail review and make sure that the Faker JS file that we have created, which is 290, is the minimum one? Use the minimum code to implement this. Do not make the complex functions and everything.

It condensed 289 to the short spec above. On 290 the seed went too, the seeding part judged not required. As the Faker section shows, that was the one line the spec could not lose: the trimmed file fails every test. So keep the habit, and add one step to it: read what the trim removed, and run the tests after it.

14

Tasks and announcements

  • Today's task: add data-driven testing to your repository: all four readers, for CSV, Excel, YAML and MySQL, and every example up to 290. Practise them and push them to GitHub. Meeti posts the task with a screenshot.
  • Notes: the class notes on data-driven testing are shared: what it is, when to use it, inline arrays, generated test data, external files, TypeScript and best practices.
  • Doubts go in the data-driven doubt thread on the SDET Club, not the Zoom chat, so they can be answered properly, with screenshots.
  • Coming up: fixtures and the page object model, then the framework and Jenkins, and the AI agent that generates test data.

Task, data-driven testing. Implement the four readers and specs 279 to 290 yourself, then push them to your GitHub repository.