Skip to content

Instantly share code, notes, and snippets.

@PramodDutta
Created April 11, 2026 01:25
Show Gist options
  • Select an option

  • Save PramodDutta/14c62ab2b8bde5742b4eb08f19632fa3 to your computer and use it in GitHub Desktop.

Select an option

Save PramodDutta/14c62ab2b8bde5742b4eb08f19632fa3 to your computer and use it in GitHub Desktop.
TS_Topics_TheTestingAcademy

🟦 TypeScript-Only Features — TheTestingAcademy

Everything That EXISTS in TypeScript but NOT in JavaScript

For QA Engineers & SDETs



🟦 1. Type Annotations (:string, :number)

Type annotations let you tell TypeScript EXACTLY what type a variable, parameter, or return value should be. If you assign the wrong type, TypeScript catches it BEFORE you run the code — at compile time.

In JavaScript, let timeout = "five" is perfectly fine when you meant 5 — JS doesn't care. In TypeScript, if you write let timeout: number = "five", it throws an error immediately. You catch the bug before it ever reaches your test pipeline.

TypeScript has basic types: string, number, boolean, null, undefined, any, void, never, unknown. You can also create custom types.

Real QA use: When you write Playwright tests in TypeScript, if an API returns {status: 200} and you accidentally treat status as a string, TypeScript catches it. In JavaScript, you'd only discover this bug when the test fails at runtime — maybe in CI at 2 AM.

// Test configuration variables with types
let testName: string = "Login Test";
let maxRetries: number = 3;
let isHeadless: boolean = true;
let baseURL: string = "https://staging.api.com";
let timeout: number = 5000;

console.log("Test:", testName);
console.log("Retries:", maxRetries);
console.log("Headless:", isHeadless);
console.log("URL:", baseURL);
console.log("Timeout:", timeout + "ms");

Output:

Test: Login Test
Retries: 3
Headless: true
URL: https://staging.api.com
Timeout: 5000ms

// Function annotations for test utilities
function buildEndpoint(base: string, path: string): string {
    return base + path;
}

function isSuccessCode(code: number): boolean {
    return code >= 200 && code < 300;
}

function logTestStep(step: string): void {
    console.log("[STEP] " + step);
}

console.log(buildEndpoint("https://api.com", "/users"));
console.log("200 is success:", isSuccessCode(200));
console.log("404 is success:", isSuccessCode(404));
logTestStep("Navigate to login page");

Output:

https://api.com/users
200 is success: true
404 is success: false
[STEP] Navigate to login page

// Array and object type annotations for test data
let statusCodes: number[] = [200, 201, 404, 500];
let testSuites: string[] = ["Smoke", "Regression", "Sanity"];

console.log("Status codes:", statusCodes);
console.log("Suites:", testSuites);

let testResult: { name: string; status: string; duration: number } = {
    name: "Login Test",
    status: "PASS",
    duration: 1200
};

console.log(testResult.name + " → " + testResult.status + " (" + testResult.duration + "ms)");

Output:

Status codes: [ 200, 201, 404, 500 ]
Suites: [ 'Smoke', 'Regression', 'Sanity' ]
Login Test → PASS (1200ms)

Exercise 1: Annotate Test Config Variables

let browser: string = "Chrome";
let timeout: number = 5000;
let headless: boolean = false;
let retries: number = 2;

function getConfigSummary(browser: string, timeout: number, headless: boolean): string {
    return browser + " | " + timeout + "ms | headless: " + headless;
}

console.log(getConfigSummary(browser, timeout, headless));
console.log("Max retries:", retries);

Output:

Chrome | 5000ms | headless: false
Max retries: 2

Exercise 2: Filter Failed API Responses

let responseCodes: number[] = [200, 201, 404, 500, 302, 403];

function getFailedCodes(codes: number[]): number[] {
    return codes.filter(function (code: number): boolean {
        return code >= 400;
    });
}

console.log("All codes:", responseCodes);
console.log("Failed codes:", getFailedCodes(responseCodes));

Output:

All codes: [ 200, 201, 404, 500, 302, 403 ]
Failed codes: [ 404, 500, 403 ]

Exercise 3: Typed Bug Report Object

let bug: { id: number; title: string; severity: string; assignee: string } = {
    id: 101,
    title: "Login button unresponsive on mobile",
    severity: "Critical",
    assignee: "Dev"
};

function formatBug(b: { id: number; title: string; severity: string; assignee: string }): string {
    return "BUG-" + b.id + " [" + b.severity + "] " + b.title + " (Assigned: " + b.assignee + ")";
}

console.log(formatBug(bug));

Output:

BUG-101 [Critical] Login button unresponsive on mobile (Assigned: Dev)

Exercise 4: Void vs Return Type in Test Utilities

function countPassedTests(results: string[]): number {
    let count: number = 0;
    for (let i = 0; i < results.length; i++) {
        if (results[i] === "PASS") count++;
    }
    return count;
}

function printTestSummary(total: number, passed: number): void {
    console.log("Passed: " + passed + "/" + total);
    console.log("Failed: " + (total - passed) + "/" + total);
}

let results: string[] = ["PASS", "FAIL", "PASS", "PASS", "FAIL"];
let passed: number = countPassedTests(results);
printTestSummary(results.length, passed);

Output:

Passed: 3/5
Failed: 2/5


🟦 2. Interfaces

An interface defines the SHAPE of an object — what properties it must have and what types those properties must be. It's like a contract. If an object says "I follow this interface," it MUST have all the required properties.

Interfaces don't generate any JavaScript code. They exist ONLY at compile time for type checking. After compilation, interfaces completely disappear from the output.

Interfaces can be extended (one interface can inherit from another), and a class can implement multiple interfaces — unlike classes where you can only extend ONE parent.

Real QA use: In Playwright TypeScript projects, you define interfaces for API response shapes. If the backend changes a field name from userName to username, TypeScript catches every place in your tests that uses the old name — instantly.

interface TestCase {
    id: number;
    name: string;
    status: string;
    duration: number;
}

let test1: TestCase = {
    id: 1,
    name: "Login with valid credentials",
    status: "PASS",
    duration: 1500
};

let test2: TestCase = {
    id: 2,
    name: "Login with invalid password",
    status: "FAIL",
    duration: 3200
};

console.log("TC-" + test1.id + ": " + test1.name + " → " + test1.status);
console.log("TC-" + test2.id + ": " + test2.name + " → " + test2.status);

Output:

TC-1: Login with valid credentials → PASS
TC-2: Login with invalid password → FAIL

// Interface with optional and readonly for API response
interface APIResponse {
    readonly statusCode: number;
    body: string;
    headers?: object;
    responseTime?: number;
}

let response: APIResponse = {
    statusCode: 200,
    body: '{"user": "admin"}'
};

console.log("Status:", response.statusCode);
console.log("Body:", response.body);
console.log("Headers:", response.headers);

Output:

Status: 200
Body: {"user": "admin"}
Headers: undefined

// Interface for test hook functions
interface TestHook {
    (testName: string): void;
}

let beforeEachHook: TestHook = function (testName: string): void {
    console.log("[BEFORE] Setting up: " + testName);
};

let afterEachHook: TestHook = function (testName: string): void {
    console.log("[AFTER] Tearing down: " + testName);
};

beforeEachHook("Login Test");
afterEachHook("Login Test");

Output:

[BEFORE] Setting up: Login Test
[AFTER] Tearing down: Login Test

Exercise 1: Interface for Bug Report

interface BugReport {
    id: number;
    title: string;
    severity: string;
    stepsToReproduce: string[];
}

function logBug(bug: BugReport): void {
    console.log("BUG-" + bug.id + " [" + bug.severity + "] " + bug.title);
    bug.stepsToReproduce.forEach(function (step: string, i: number) {
        console.log("  " + (i + 1) + ". " + step);
    });
}

logBug({
    id: 42,
    title: "Checkout fails on empty cart",
    severity: "High",
    stepsToReproduce: ["Login", "Go to cart", "Click checkout", "See error"]
});

Output:

BUG-42 [High] Checkout fails on empty cart
  1. Login
  2. Go to cart
  3. Click checkout
  4. See error

Exercise 2: Interface with Optional Config Properties

interface TestConfig {
    browser: string;
    headless: boolean;
    baseURL: string;
    timeout?: number;
    retries?: number;
}

let ciConfig: TestConfig = {
    browser: "Chrome",
    headless: true,
    baseURL: "https://staging.app.com"
};

let localConfig: TestConfig = {
    browser: "Firefox",
    headless: false,
    baseURL: "http://localhost:3000",
    timeout: 10000,
    retries: 3
};

console.log("CI:", ciConfig.browser, "| timeout:", ciConfig.timeout);
console.log("Local:", localConfig.browser, "| timeout:", localConfig.timeout);

Output:

CI: Chrome | timeout: undefined
Local: Firefox | timeout: 10000

Exercise 3: Extending Interfaces — Page Object Model

interface BasePage {
    url: string;
    title: string;
}

interface LoginPage extends BasePage {
    usernameSelector: string;
    passwordSelector: string;
    loginButtonSelector: string;
}

let loginPage: LoginPage = {
    url: "/login",
    title: "Login Page",
    usernameSelector: "#username",
    passwordSelector: "#password",
    loginButtonSelector: "#login-btn"
};

console.log("URL:", loginPage.url);
console.log("Title:", loginPage.title);
console.log("Username field:", loginPage.usernameSelector);

Output:

URL: /login
Title: Login Page
Username field: #username

Exercise 4: Class Implementing an Interface — Test Runner

interface Executable {
    name: string;
    run(): void;
    getStatus(): string;
}

class TestCase implements Executable {
    name: string;
    private passed: boolean = false;

    constructor(name: string) {
        this.name = name;
    }

    run(): void {
        this.passed = true;
        console.log("[RUN] " + this.name);
    }

    getStatus(): string {
        return this.passed ? "PASS" : "PENDING";
    }
}

let tc: Executable = new TestCase("Verify login redirect");
console.log("Before:", tc.getStatus());
tc.run();
console.log("After:", tc.getStatus());

Output:

Before: PENDING
[RUN] Verify login redirect
After: PASS


🟦 3. Enums

An enum (enumeration) is a set of named constants. Instead of using magic strings like "pass", "fail", "skip" scattered throughout your test code, you define them ONCE in an enum and use them everywhere.

By default, enums are numeric — first value is 0, second is 1, and so on. But you can create string enums where each value is an explicit string. String enums are more common in QA because they're readable in test reports and logs.

Enums exist at runtime (unlike interfaces which disappear). TypeScript generates a JavaScript object for each enum, so you can use them in if conditions and switch statements.

Real QA use: Test statuses, priority levels, browser names, environment names — anything with a fixed set of possible values. Enums prevent typos. TestStatus.PSAS causes a compile error. The string "psas" would go undetected until the test runs.

enum TestStatus {
    Pass = "PASS",
    Fail = "FAIL",
    Skip = "SKIP",
    Pending = "PENDING",
    Blocked = "BLOCKED"
}

function logResult(testName: string, status: TestStatus): void {
    let icon = status === TestStatus.Pass ? "✅" : status === TestStatus.Fail ? "❌" : "⏭️";
    console.log(icon + " " + testName + " → " + status);
}

logResult("Login with valid creds", TestStatus.Pass);
logResult("Login with expired token", TestStatus.Fail);
logResult("Login with SSO", TestStatus.Skip);

Output:

✅ Login with valid creds → PASS
❌ Login with expired token → FAIL
⏭️ Login with SSO → SKIP

enum Severity {
    Low,
    Medium,
    High,
    Critical
}

function needsImmediateAttention(severity: Severity): boolean {
    return severity >= Severity.High;
}

console.log("Low urgent?", needsImmediateAttention(Severity.Low));
console.log("Critical urgent?", needsImmediateAttention(Severity.Critical));
console.log("Severity name:", Severity[2]);

Output:

Low urgent? false
Critical urgent? true
Severity name: High

Exercise 1: Enum for Test Environments

enum Environment {
    Dev = "https://dev.api.com",
    Staging = "https://staging.api.com",
    QA = "https://qa.api.com",
    Prod = "https://api.com"
}

function runSmokeTests(env: Environment): void {
    console.log("Running smoke tests on: " + env);
    console.log("GET " + env + "/health → 200 OK");
}

runSmokeTests(Environment.QA);
runSmokeTests(Environment.Staging);

Output:

Running smoke tests on: https://qa.api.com
GET https://qa.api.com/health → 200 OK
Running smoke tests on: https://staging.api.com
GET https://staging.api.com/health → 200 OK

Exercise 2: Enum for Browser Selection

enum Browser {
    Chrome = "chrome",
    Firefox = "firefox",
    Safari = "safari",
    Edge = "edge"
}

function launchBrowser(browser: Browser): void {
    switch (browser) {
        case Browser.Chrome:
            console.log("Launching Chromium (Chrome v120)");
            break;
        case Browser.Firefox:
            console.log("Launching Gecko (Firefox v115)");
            break;
        case Browser.Safari:
            console.log("Launching WebKit (Safari v17)");
            break;
        case Browser.Edge:
            console.log("Launching Chromium (Edge v120)");
            break;
    }
}

launchBrowser(Browser.Chrome);
launchBrowser(Browser.Safari);

Output:

Launching Chromium (Chrome v120)
Launching WebKit (Safari v17)

Exercise 3: Enum for HTTP Methods in API Testing

enum HTTPMethod {
    GET = "GET",
    POST = "POST",
    PUT = "PUT",
    DELETE = "DELETE"
}

function sendRequest(method: HTTPMethod, endpoint: string): void {
    console.log(method + " " + endpoint + " → 200 OK");
}

sendRequest(HTTPMethod.GET, "/api/users");
sendRequest(HTTPMethod.POST, "/api/users");
sendRequest(HTTPMethod.DELETE, "/api/users/1");

Output:

GET /api/users → 200 OK
POST /api/users → 200 OK
DELETE /api/users/1 → 200 OK


🟦 4. Generics ()

Generics let you write code that works with ANY type while still being type-safe. The <T> is a placeholder — "I don't know the type yet, but I'll use it consistently."

Without generics, you'd either use any (kills type safety) or write separate functions for every type (duplicates code). Generics give you BOTH flexibility AND safety.

The T stands for "Type" — it's a convention. You can use any letter: <T>, <K, V>, <E>. When you call the function, TypeScript replaces T with the actual type.

Real QA use: A generic API helper — parseResponse<UserData>(res) returns UserData, while parseResponse<OrderData>(res) returns OrderData. Same function, different types, full type safety and autocomplete.

function getFirstResult<T>(results: T[]): T {
    return results[0];
}

let firstCode = getFirstResult<number>([200, 404, 500]);
let firstTest = getFirstResult<string>(["Login", "Signup", "Cart"]);

console.log("First code:", firstCode);
console.log("First test:", firstTest);

Output:

First code: 200
First test: Login

class TestDataStore<T> {
    private items: T[] = [];

    add(item: T): void {
        this.items.push(item);
    }

    getAll(): T[] {
        return this.items;
    }

    getFirst(): T {
        return this.items[0];
    }

    count(): number {
        return this.items.length;
    }
}

let codeStore = new TestDataStore<number>();
codeStore.add(200);
codeStore.add(404);
codeStore.add(500);

let testStore = new TestDataStore<string>();
testStore.add("Login Test");
testStore.add("Checkout Test");

console.log("Codes:", codeStore.getAll());
console.log("First code:", codeStore.getFirst());
console.log("Tests:", testStore.getAll());
console.log("Test count:", testStore.count());

Output:

Codes: [ 200, 404, 500 ]
First code: 200
Tests: [ 'Login Test', 'Checkout Test' ]
Test count: 2

Exercise 1: Generic API Response Wrapper

function wrapResponse<T>(statusCode: number, data: T): { statusCode: number; data: T } {
    return { statusCode: statusCode, data: data };
}

let userResp = wrapResponse<string>(200, "admin");
let countResp = wrapResponse<number>(200, 42);
let flagResp = wrapResponse<boolean>(200, true);

console.log(userResp);
console.log(countResp);
console.log(flagResp);

Output:

{ statusCode: 200, data: 'admin' }
{ statusCode: 200, data: 42 }
{ statusCode: 200, data: true }

Exercise 2: Generic Key-Value for Test Config

function makeConfigEntry<K, V>(key: K, value: V): { key: K; value: V } {
    return { key: key, value: value };
}

let timeout = makeConfigEntry<string, number>("timeout", 5000);
let env = makeConfigEntry<string, string>("env", "staging");
let headless = makeConfigEntry<string, boolean>("headless", true);

console.log(timeout);
console.log(env);
console.log(headless);

Output:

{ key: 'timeout', value: 5000 }
{ key: 'env', value: 'staging' }
{ key: 'headless', value: true }

Exercise 3: Generic Interface for API Responses

interface APIResponse<T> {
    status: number;
    data: T;
    timestamp: string;
}

interface UserData {
    name: string;
    email: string;
    role: string;
}

interface OrderData {
    orderId: number;
    total: number;
}

let userResponse: APIResponse<UserData> = {
    status: 200,
    data: { name: "Dev", email: "dev@test.com", role: "admin" },
    timestamp: "2026-04-11"
};

let orderResponse: APIResponse<OrderData> = {
    status: 200,
    data: { orderId: 1001, total: 2499 },
    timestamp: "2026-04-11"
};

console.log(userResponse.data.name + " (" + userResponse.data.role + ")");
console.log("Order #" + orderResponse.data.orderId + " — ₹" + orderResponse.data.total);

Output:

Dev (admin)
Order #1001 — ₹2499


🟦 5. private / public / protected Keywords

TypeScript has three access modifiers that control WHO can access class members. JavaScript only has public (default) and #private (ES2022). TypeScript adds protected — which JavaScript completely lacks.

public means accessible everywhere — inside the class, in child classes, and from outside. This is the default.

private means accessible ONLY inside the class where it's defined. Not even child classes can access it. Enforced at COMPILE time only.

protected means accessible inside the class AND in child classes, but NOT from outside. Perfect for Page Object Model — BasePage can have protected methods that child pages use, but test files cannot access directly.

class APIClient {
    public baseURL: string;
    private apiKey: string;
    protected timeout: number;

    constructor(baseURL: string, apiKey: string, timeout: number) {
        this.baseURL = baseURL;
        this.apiKey = apiKey;
        this.timeout = timeout;
    }

    private getAuthHeader(): string {
        return "Bearer " + this.apiKey;
    }

    public sendRequest(path: string): void {
        console.log("GET " + this.baseURL + path);
        console.log("Auth: " + this.getAuthHeader());
        console.log("Timeout: " + this.timeout + "ms");
    }
}

class UserAPIClient extends APIClient {
    getUsers(): void {
        console.log("Fetching users (timeout: " + this.timeout + "ms)");
        console.log("URL: " + this.baseURL + "/users");
    }
}

let client = new APIClient("https://api.staging.com", "key_secret_123", 5000);
console.log("Base URL:", client.baseURL);
client.sendRequest("/health");

let userClient = new UserAPIClient("https://api.staging.com", "key_abc", 3000);
userClient.getUsers();

Output:

Base URL: https://api.staging.com
GET https://api.staging.com/health
Auth: Bearer key_secret_123
Timeout: 5000ms
Fetching users (timeout: 3000ms)
URL: https://api.staging.com/users

Exercise 1: Protected in Page Object Inheritance

class BasePage {
    protected baseURL: string;

    constructor(url: string) {
        this.baseURL = url;
    }

    protected navigate(path: string): void {
        console.log("Navigating to: " + this.baseURL + path);
    }
}

class LoginPage extends BasePage {
    constructor() {
        super("https://app.staging.com");
    }

    login(user: string): void {
        this.navigate("/login");
        console.log("Typing " + user + " into #username");
        console.log("Clicking #login-btn");
    }
}

let page = new LoginPage();
page.login("admin");

Output:

Navigating to: https://app.staging.com/login
Typing admin into #username
Clicking #login-btn

Exercise 2: Constructor Shorthand

class TestRun {
    constructor(
        public testName: string,
        private apiKey: string,
        protected environment: string
    ) {}

    showInfo(): void {
        console.log(this.testName + " on " + this.environment);
    }

    showKey(): void {
        console.log("Key: " + this.apiKey.slice(0, 4) + "****");
    }
}

let run = new TestRun("Smoke Suite", "key_abcdef123", "staging");
console.log("Test:", run.testName);
run.showInfo();
run.showKey();

Output:

Test: Smoke Suite
Smoke Suite on staging
Key: key_****


🟦 6. readonly

readonly makes a property UNCHANGEABLE after it's set. You can assign it in the declaration or constructor, but nowhere else.

It's different from const. const is for variables. readonly is for class properties and interface fields.

readonly works with all access modifiers — public readonly, private readonly, protected readonly.

Real QA use: Test configs like baseURL, apiKey, or timeout that should be set once and never accidentally changed during test execution.

class PlaywrightConfig {
    readonly baseURL: string;
    readonly timeout: number;
    readonly retries: number;

    constructor(url: string, timeout: number, retries: number) {
        this.baseURL = url;
        this.timeout = timeout;
        this.retries = retries;
    }

    showConfig(): void {
        console.log("URL: " + this.baseURL);
        console.log("Timeout: " + this.timeout + "ms");
        console.log("Retries: " + this.retries);
    }
}

let config = new PlaywrightConfig("https://staging.app.com", 30000, 2);
config.showConfig();

// config.baseURL = "https://other.com";  → ❌ Error! Cannot assign to readonly

Output:

URL: https://staging.app.com
Timeout: 30000ms
Retries: 2

Exercise 1: readonly in Interface for Test Result

interface TestResult {
    readonly testId: number;
    readonly testName: string;
    status: string;
}

let result: TestResult = {
    testId: 101,
    testName: "Verify login redirect",
    status: "PENDING"
};

console.log("TC-" + result.testId + ": " + result.testName);

result.status = "PASS";
console.log("Status:", result.status);

// result.testId = 999;  → ❌ Error! readonly

Output:

TC-101: Verify login redirect
Status: PASS

Exercise 2: readonly Array of Test Steps

class TestScenario {
    readonly steps: readonly string[];
    readonly name: string;

    constructor(name: string, steps: string[]) {
        this.name = name;
        this.steps = steps;
    }

    display(): void {
        console.log("Scenario: " + this.name);
        this.steps.forEach(function (step: string, i: number) {
            console.log("  " + (i + 1) + ". " + step);
        });
    }
}

let scenario = new TestScenario("User Login", [
    "Open browser",
    "Navigate to /login",
    "Enter credentials",
    "Click login",
    "Verify dashboard"
]);

scenario.display();

Output:

Scenario: User Login
  1. Open browser
  2. Navigate to /login
  3. Enter credentials
  4. Click login
  5. Verify dashboard


🟦 7. Abstract Classes

An abstract class CANNOT be instantiated directly. You cannot write new AbstractClass(). It exists ONLY to be extended by child classes.

Abstract methods have NO body — just the signature. Every child class MUST provide its own implementation. If a child forgets, TypeScript throws a compile error.

Abstract classes CAN have regular methods with implementations — unlike interfaces which only define signatures. This makes abstract classes a mix between interfaces and regular classes.

Real QA use: BaseTest defines abstract setup() and abstract teardown() — forcing every test type to implement their own. But BaseTest also provides a concrete run() method that orchestrates setup → execute → teardown. Enforcement AND shared logic.

abstract class BaseTest {
    protected testName: string;

    constructor(testName: string) {
        this.testName = testName;
    }

    abstract setup(): void;
    abstract execute(): void;
    abstract teardown(): void;

    run(): void {
        console.log("=== " + this.testName + " ===");
        this.setup();
        this.execute();
        this.teardown();
        console.log("=== DONE ===");
    }
}

class UITest extends BaseTest {
    setup(): void { console.log("  Setup: launch browser"); }
    execute(): void { console.log("  Execute: click buttons, fill forms"); }
    teardown(): void { console.log("  Teardown: close browser"); }
}

class APITest extends BaseTest {
    setup(): void { console.log("  Setup: create HTTP client"); }
    execute(): void { console.log("  Execute: send requests, validate responses"); }
    teardown(): void { console.log("  Teardown: clear tokens"); }
}

let uiTest = new UITest("Login UI Test");
let apiTest = new APITest("User API Test");

uiTest.run();
console.log("");
apiTest.run();

Output:

=== Login UI Test ===
  Setup: launch browser
  Execute: click buttons, fill forms
  Teardown: close browser
=== DONE ===

=== User API Test ===
  Setup: create HTTP client
  Execute: send requests, validate responses
  Teardown: clear tokens
=== DONE ===

Exercise 1: Abstract Report Generator

abstract class TestReporter {
    protected results: string[] = [];

    addResult(result: string): void {
        this.results.push(result);
    }

    abstract generate(): void;
}

class ConsoleReporter extends TestReporter {
    generate(): void {
        console.log("--- Console Report ---");
        this.results.forEach(function (r: string) {
            console.log("  " + r);
        });
    }
}

class JSONReporter extends TestReporter {
    generate(): void {
        console.log("--- JSON Report ---");
        console.log(JSON.stringify({ results: this.results }, null, 2));
    }
}

let consoleReport = new ConsoleReporter();
consoleReport.addResult("Login: PASS");
consoleReport.addResult("Cart: FAIL");
consoleReport.generate();

console.log("");

let jsonReport = new JSONReporter();
jsonReport.addResult("Login: PASS");
jsonReport.addResult("Cart: FAIL");
jsonReport.generate();

Output:

--- Console Report ---
  Login: PASS
  Cart: FAIL

--- JSON Report ---
{
  "results": [
    "Login: PASS",
    "Cart: FAIL"
  ]
}

Exercise 2: Abstract Page with Concrete Navigation

abstract class BasePage {
    protected url: string;

    constructor(url: string) {
        this.url = url;
    }

    navigate(): void {
        console.log("Navigating to: " + this.url);
    }

    abstract verify(): void;
}

class LoginPage extends BasePage {
    constructor() { super("/login"); }

    verify(): void {
        console.log("Verify: username field exists");
        console.log("Verify: login button is visible");
    }
}

class DashboardPage extends BasePage {
    constructor() { super("/dashboard"); }

    verify(): void {
        console.log("Verify: welcome message displayed");
        console.log("Verify: sidebar menu loaded");
    }
}

let pages: BasePage[] = [new LoginPage(), new DashboardPage()];

pages.forEach(function (page: BasePage) {
    page.navigate();
    page.verify();
    console.log("---");
});

Output:

Navigating to: /login
Verify: username field exists
Verify: login button is visible
---
Navigating to: /dashboard
Verify: welcome message displayed
Verify: sidebar menu loaded
---


🟦 8. Type Assertions (as)

Type assertions tell TypeScript "I know the type — trust me." It doesn't change data at runtime. It just tells the compiler to treat a value as a specific type.

Two syntaxes: value as Type (recommended) and <Type>value (older). Always use as.

Type assertions are NOT type casting — they don't convert data. Use only when you genuinely KNOW the type is correct.

Real QA use: When Playwright returns element attributes as unknown, or when JSON.parse() returns any for an API response body, you assert the type for autocomplete and type safety.

let rawResponse: unknown = {
    status: 200,
    body: { user: "admin", role: "tester" }
};

interface UserResponse {
    status: number;
    body: { user: string; role: string };
}

let response = rawResponse as UserResponse;
console.log("Status:", response.status);
console.log("User:", response.body.user);
console.log("Role:", response.body.role);

Output:

Status: 200
User: admin
Role: tester

Exercise 1: Asserting DOM Element Properties

let element: unknown = {
    tagName: "BUTTON",
    textContent: "Submit",
    id: "submit-btn",
    disabled: false
};

let button = element as { tagName: string; textContent: string; id: string; disabled: boolean };

console.log("Tag:", button.tagName);
console.log("Text:", button.textContent);
console.log("ID:", button.id);
console.log("Disabled:", button.disabled);

Output:

Tag: BUTTON
Text: Submit
ID: submit-btn
Disabled: false

Exercise 2: Asserting Parsed Test Config

function loadConfig(): unknown {
    return { browser: "Chrome", headless: true, baseURL: "https://qa.app.com", retries: 3 };
}

interface TestConfig {
    browser: string;
    headless: boolean;
    baseURL: string;
    retries: number;
}

let config = loadConfig() as TestConfig;
console.log("Browser:", config.browser);
console.log("Headless:", config.headless);
console.log("URL:", config.baseURL);
console.log("Retries:", config.retries);

Output:

Browser: Chrome
Headless: true
URL: https://qa.app.com
Retries: 3


🟦 9. override Keyword

The override keyword explicitly marks that a child method is intentionally overriding a parent method. Without it, overriding is implicit — you use the same name and hope it matches.

If you misspell the method name, without override, TypeScript thinks you're creating a NEW method — no error. With override, TypeScript checks the parent actually HAS that method. Typo = compile error.

Enable "noImplicitOverride": true in tsconfig.json to make override required.

Real QA use: In large POM hierarchies with 20+ page classes, misspelling verifyPageLoaded as verfiyPageLoaded silently creates a new method — parent's version runs instead. With override, TypeScript catches the typo immediately.

class BaseTest {
    setup(): void {
        console.log("[BASE] Open browser");
    }

    teardown(): void {
        console.log("[BASE] Close browser");
    }
}

class LoginTest extends BaseTest {
    override setup(): void {
        super.setup();
        console.log("[LOGIN] Navigate to /login");
    }

    override teardown(): void {
        console.log("[LOGIN] Clear session cookies");
        super.teardown();
    }
}

let test = new LoginTest();
test.setup();
console.log("--- running test ---");
test.teardown();

Output:

[BASE] Open browser
[LOGIN] Navigate to /login
--- running test ---
[LOGIN] Clear session cookies
[BASE] Close browser

Exercise 1: Override Page Verification

class BasePage {
    verify(): void {
        console.log("Verifying page loaded");
    }
}

class CheckoutPage extends BasePage {
    override verify(): void {
        super.verify();
        console.log("Verifying cart items displayed");
        console.log("Verifying total price calculated");
        console.log("Verifying payment button enabled");
    }
}

new CheckoutPage().verify();

Output:

Verifying page loaded
Verifying cart items displayed
Verifying total price calculated
Verifying payment button enabled


🟦 10. Decorators

Decorators are special functions that ATTACH to classes, methods, or properties using the @ symbol. They modify behavior without changing original code.

Decorators run at CLASS DEFINITION time, not when you create objects. Used heavily in Angular and NestJS.

Enable "experimentalDecorators": true in tsconfig.json.

Real QA use: Angular tests use @Component, @Injectable. Some test frameworks use @Test, @BeforeEach, @Timeout(5000). Understanding decorators helps debug Angular component tests.

// Decorator that auto-logs every test step
function LogStep(target: any, methodName: string, descriptor: PropertyDescriptor) {
    let original = descriptor.value;

    descriptor.value = function (...args: any[]) {
        console.log("[STEP] " + methodName + "(" + args.join(", ") + ")");
        return original.apply(this, args);
    };
}

class LoginPage {
    @LogStep
    enterUsername(user: string): void {
        console.log("  Typed: " + user);
    }

    @LogStep
    enterPassword(pass: string): void {
        console.log("  Typed: ***");
    }

    @LogStep
    clickLogin(): void {
        console.log("  Button clicked");
    }
}

let page = new LoginPage();
page.enterUsername("admin");
page.enterPassword("secret");
page.clickLogin();

Output:

[STEP] enterUsername(admin)
  Typed: admin
[STEP] enterPassword(secret)
  Typed: ***
[STEP] clickLogin()
  Button clicked

Exercise 1: Timing Decorator for Performance Testing

function MeasureTime(target: any, methodName: string, descriptor: PropertyDescriptor) {
    let original = descriptor.value;

    descriptor.value = function (...args: any[]) {
        let start = Date.now();
        let result = original.apply(this, args);
        let end = Date.now();
        console.log("[TIMER] " + methodName + " took " + (end - start) + "ms");
        return result;
    };
}

class APITest {
    @MeasureTime
    validateResponse(): void {
        let sum = 0;
        for (let i = 0; i < 1000000; i++) sum += i;
        console.log("Response validated");
    }
}

let test = new APITest();
test.validateResponse();

Output:

Response validated
[TIMER] validateResponse took 5ms


🟦 11. Namespaces

Namespaces organize code into logical groups and prevent name collisions. They wrap related functions and classes under a single name.

Before ES6 modules, namespaces were the primary code organization tool. Now ES6 modules are preferred, but namespaces still appear in older codebases and .d.ts files.

Accessed using dot notation: NamespaceName.ClassName. Use export inside a namespace to make things accessible.

Real QA use: TestUtils.Logger, TestUtils.Reporter, TestUtils.DataGenerator — organizing utilities under one umbrella. Modern Playwright/Cypress projects prefer ES6 modules instead.

namespace TestUtils {
    export function formatTestName(name: string): string {
        return "TC_" + name.toUpperCase().replace(/ /g, "_");
    }

    export function getTimestamp(): string {
        return new Date().toISOString().slice(0, 10);
    }

    export class StepLogger {
        private stepCount: number = 0;

        log(step: string): void {
            this.stepCount++;
            console.log("[Step " + this.stepCount + "] " + step);
        }
    }
}

console.log(TestUtils.formatTestName("login test"));
console.log("Date:", TestUtils.getTimestamp());

let logger = new TestUtils.StepLogger();
logger.log("Open browser");
logger.log("Navigate to /login");
logger.log("Enter credentials");

Output:

TC_LOGIN_TEST
Date: 2026-04-11
[Step 1] Open browser
[Step 2] Navigate to /login
[Step 3] Enter credentials

Exercise 1: Nested Namespaces for API and UI Testing

namespace QAFramework {
    export namespace API {
        export function get(endpoint: string): void {
            console.log("[API] GET " + endpoint + " → 200 OK");
        }

        export function post(endpoint: string): void {
            console.log("[API] POST " + endpoint + " → 201 Created");
        }
    }

    export namespace UI {
        export function click(selector: string): void {
            console.log("[UI] Click " + selector);
        }

        export function type(selector: string, text: string): void {
            console.log("[UI] Type '" + text + "' into " + selector);
        }
    }
}

QAFramework.API.get("/api/users");
QAFramework.API.post("/api/users");
QAFramework.UI.click("#login-btn");
QAFramework.UI.type("#username", "admin");

Output:

[API] GET /api/users → 200 OK
[API] POST /api/users → 201 Created
[UI] Click #login-btn
[UI] Type 'admin' into #username


🟦 12. Tuple Types

A tuple is a fixed-length array where EACH position has a specific type. Regular arrays have one type for ALL elements. Tuples have different types at different positions.

Tuples enforce both ORDER and COUNT. [string, number] means first MUST be string, second MUST be number.

Tuples are useful when a function returns multiple values of different types — lightweight alternative to creating an object.

Real QA use: Test results as [testName, status, duration]. API health checks as [endpoint, statusCode, responseTime]. Lightweight and type-safe.

let result1: [string, string, number] = ["Login Test", "PASS", 1200];
let result2: [string, string, number] = ["Cart Test", "FAIL", 3400];

console.log(result1[0] + " → " + result1[1] + " (" + result1[2] + "ms)");
console.log(result2[0] + " → " + result2[1] + " (" + result2[2] + "ms)");

Output:

Login Test → PASS (1200ms)
Cart Test → FAIL (3400ms)

function validateAPIResponse(status: number, body: string): [boolean, string] {
    if (status >= 200 && status < 300) {
        return [true, "Response OK: " + body];
    } else {
        return [false, "Failed with status " + status];
    }
}

let [passed1, msg1] = validateAPIResponse(200, '{"user": "admin"}');
let [passed2, msg2] = validateAPIResponse(500, "Server Error");

console.log("Passed:", passed1, "→", msg1);
console.log("Passed:", passed2, "→", msg2);

Output:

Passed: true → Response OK: {"user": "admin"}
Passed: false → Failed with status 500

Exercise 1: Tuple Array for Test Report

let testReport: [string, string, number][] = [
    ["Login Test", "PASS", 1200],
    ["Signup Test", "FAIL", 3400],
    ["Cart Test", "PASS", 800],
    ["Checkout Test", "PASS", 2100]
];

let passCount: number = 0;

testReport.forEach(function (entry: [string, string, number]) {
    let icon = entry[1] === "PASS" ? "✅" : "❌";
    console.log(icon + " " + entry[0] + " (" + entry[2] + "ms)");
    if (entry[1] === "PASS") passCount++;
});

console.log("---");
console.log("Passed: " + passCount + "/" + testReport.length);

Output:

✅ Login Test (1200ms)
❌ Signup Test (3400ms)
✅ Cart Test (800ms)
✅ Checkout Test (2100ms)
---
Passed: 3/4

Exercise 2: Named Tuple for API Health Check

type HealthCheckResult = [endpoint: string, status: number, responseTime: number];

let results: HealthCheckResult[] = [
    ["/api/auth", 200, 45],
    ["/api/users", 200, 120],
    ["/api/orders", 503, 5000],
    ["/api/products", 200, 88]
];

results.forEach(function (r: HealthCheckResult) {
    let icon = r[1] === 200 ? "✅" : "❌";
    console.log(icon + " " + r[0] + " → " + r[1] + " (" + r[2] + "ms)");
});

Output:

✅ /api/auth → 200 (45ms)
✅ /api/users → 200 (120ms)
❌ /api/orders → 503 (5000ms)
✅ /api/products → 200 (88ms)

Exercise 3: Optional Tuple for Test Logs

type TestLog = [level: string, message: string, details?: string];

let logs: TestLog[] = [
    ["INFO", "Test suite started"],
    ["PASS", "Login test passed"],
    ["FAIL", "Cart test failed", "Element #cart-total not found"],
    ["WARN", "Slow response", "GET /api/orders took 4500ms"],
    ["INFO", "Test suite finished"]
];

logs.forEach(function (log: TestLog) {
    let output = "[" + log[0] + "] " + log[1];
    if (log[2]) {
        output += " — " + log[2];
    }
    console.log(output);
});

Output:

[INFO] Test suite started
[PASS] Login test passed
[FAIL] Cart test failed — Element #cart-total not found
[WARN] Slow response — GET /api/orders took 4500ms
[INFO] Test suite finished


🟦 COMPARISON TABLE

# Feature QA Use Case JS Equivalent
1 Type Annotations Catch wrong types in test configs, API responses None
2 Interfaces Define API response shapes, page object contracts Duck typing
3 Enums Test statuses, environments, browsers, severity Object.freeze()
4 Generics <T> Reusable API helpers for any response type any (unsafe)
5 private/public/protected Control access in page objects and utilities # private only
6 readonly Lock test configs (baseURL, API key, timeout) Object.freeze()
7 Abstract Classes Force child tests to implement setup/teardown None
8 Type Assertions (as) Type parsed JSON, DOM elements, API responses Not needed
9 override keyword Catch typos in 20+ page class hierarchies Implicit
10 Decorators (@) Auto-log steps, measure performance, Angular tests Stage 3 JS
11 Namespaces Organize test utilities under one umbrella ES6 modules
12 Tuple Types Return [testName, status, duration] type-safely Regular arrays


🟦 INTERVIEW QUESTIONS — 25 Questions for QA/SDET


Q1. What is the difference between JavaScript and TypeScript?

TypeScript is a SUPERSET of JavaScript. Every valid JavaScript code is also valid TypeScript, but TypeScript adds static typing, interfaces, enums, generics, access modifiers, and other features. TypeScript must be compiled to JavaScript before running. The main QA benefit is catching type-related bugs at compile time — wrong API response types, misspelled properties, incorrect function arguments — instead of discovering them when tests fail at runtime.


Q2. What are type annotations? Give a QA example.

Type annotations declare what type a variable, parameter, or return value should be. Example: let timeout: number = 5000 ensures timeout is always a number. If someone writes timeout = "five seconds", TypeScript catches it before the test runs. In Playwright, response.status() returns number — TypeScript validates you use it correctly.


Q3. What is the difference between interface and type?

Both define object shapes. Interfaces can be extended and implemented by classes. Types support unions (string | number) and intersections. Interfaces can be merged (re-declared), types cannot. For API response shapes and page object contracts, prefer interfaces. For union types like type Status = "pass" | "fail", use type.


Q4. Can a test class implement multiple interfaces?

Yes. class LoginTest implements Runnable, Reportable { }. The class must provide all properties and methods from every interface. Useful when a test class needs to be Executable (has run()), Loggable (has log()), AND Retryable (has retry()).


Q5. What is the difference between TypeScript private and JavaScript #?

TypeScript private is enforced at COMPILE TIME only — compiled JavaScript has no restriction. JavaScript # is enforced at RUNTIME — truly private. For test utilities handling API keys, use # even in TypeScript for maximum protection.


Q6. What is protected and how is it used in Page Object Model?

protected means accessible inside the class AND child classes, but NOT from outside. In POM, BasePage has protected baseURL and protected navigate() — child pages like LoginPage can use them, but test spec files cannot. Keeps internal wiring hidden.


Q7. What are enums? Give 3 QA examples.

Enums are named constants. Examples: (1) enum TestStatus { Pass, Fail, Skip } for results. (2) enum Environment { Dev, Staging, QA, Prod } for targets. (3) enum Browser { Chrome, Firefox, Safari } for cross-browser testing. Enums prevent typos — TestStatus.PSAS causes a compile error.


Q8. Numeric vs string enums — which is better for QA?

String enums (Pass="PASS") are better for QA because they're readable in test reports and CI logs. Seeing "PASS" in a log is clear. Seeing 0 tells you nothing.


Q9. How would you use generics in API testing?

function parseResponse<T>(response: Response): T — call as parseResponse<UserData>(res) for user data, parseResponse<OrderData>(res) for order data. Same function, different types, full autocomplete and type checking.


Q10. What is the difference between any, unknown, and never?

any disables all type checking. unknown is safe — must check/assert type before using. never represents values that never occur (function that always throws). In test code, avoid any for API responses; prefer unknown and assert to the correct interface.


Q11. When would you use type assertions in Playwright?

JSON.parse(await response.text()) returns any. Assert it: let data = JSON.parse(body) as UserResponse. Now TypeScript provides autocomplete for data.name, data.email. Without assertion, no type safety on parsed responses.


Q12. Abstract class vs interface — when to use which?

Abstract classes can have BOTH abstract methods and concrete implementations. Interfaces only define signatures. Use abstract classes for BaseTest — abstract setup() that children must implement, plus concrete run() that orchestrates lifecycle. Use interfaces for pure contracts like API response shapes.


Q13. How does readonly help in test configuration?

readonly baseURL: string ensures no test accidentally modifies the base URL mid-execution. Prevents a common bug where one test modifies shared config and breaks subsequent tests.


Q14. Why does override matter in large POM projects?

Without override, misspelling verifyPageLoaded as verfiyPageLoaded silently creates a new method — parent's version runs instead, causing mysterious failures. With override, TypeScript catches the typo immediately.


Q15. Where do QA engineers encounter decorators?

Angular component tests (@Component, @Injectable), NestJS API testing (@Controller, @Get), and some test frameworks (@Test, @BeforeEach). Understanding decorators helps debug decorator-heavy framework tests.


Q16. How are tuples useful in test reporting?

A result tuple ["Login Test", "PASS", 1200] — position 0 is always test name (string), position 1 is status (string), position 2 is duration (number). TypeScript enforces this structure. Regular arrays have no per-position type enforcement.


Q17. Should you use namespaces in a new Playwright project?

No. Use ES6 modules (import/export) — the modern standard that works natively with Playwright's test runner. Namespaces still appear in older codebases and .d.ts declaration files.


Q18. What is Partial<T> and how does it help API testing?

Partial<T> makes ALL properties optional. For PATCH endpoint testing — if User has name, email, age, rolePartial<User> allows { name: "New" } without requiring all fields. Perfect for partial update tests.


Q19. Why is TypeScript preferred for Playwright over JavaScript?

TypeScript catches bugs at compile time — wrong selector types, misspelled properties, incorrect API response access. Playwright's TypeScript definitions provide autocomplete for every method. When Playwright releases breaking changes, TypeScript shows exactly where tests need updating.


Q20. What happens to TypeScript features after compilation?

Type annotations, interfaces, generics, access modifiers, readonly, and override are REMOVED. Output is plain JavaScript. Enums generate real JavaScript objects. Decorators also generate code. Runtime behavior is pure JavaScript.


Q21. What is Required<T>?

Required<T> makes ALL properties mandatory. If TestConfig has optional timeout? and retries?, Required<TestConfig> forces every property to be provided. Use for production config validation — all values must be explicit, no defaults.


Q22. What is the constructor shorthand?

Adding public, private, or protected in constructor parameters auto-creates and assigns properties: constructor(public name: string, private key: string). JavaScript requires manual this.name = name. Reduces boilerplate in page objects.


Q23. Can you use TypeScript in Cypress?

Yes. Rename .js to .ts, add tsconfig.json. Cypress provides type definitions for cy.get(), cy.visit(), cy.request(). Use interfaces for fixture types, enums for test constants, generics for custom commands.


Q24. What is Pick<T, K> and Omit<T, K>?

Pick<User, "name" | "email"> creates a type with ONLY name and email from User. Omit<User, "password"> creates a type with everything EXCEPT password. Useful in API testing — pick only the fields you're validating, omit sensitive fields from response logging.


Q25. How do you configure TypeScript for a QA project?

Create tsconfig.json: "strict": true (all strict checks), "target": "ES6", "module": "commonjs" or "ESNext", "outDir": "./dist", "noImplicitOverride": true, "experimentalDecorators": true, "sourceMap": true. For Playwright, extend @playwright/test/tsconfig.json. For Cypress, cypress.config.ts handles most settings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment