NỘI DUNG BÀI HỌC

🌍 Chiến thuật "Tắc kè hoa" (Multi-Env):

🏗️ Kiến trúc "Siêu thị" (Scalable Catalog):

🏭 Cỗ máy tùy biến (Factory Logic):



Tại sao chúng ta cần "Test Data Factory"?

Trước khi vào code, hãy đặt vấn đề. Khi viết Automation Test, chúng ta thường gặp 3 cơn ác mộng:

  1. Hardcode: Viết cứng data trong file test (const user = "Tùng"). Khi user đổi tên, ta phải sửa 100 file test.

  2. Đa môi trường (Multi-Env): Test chạy ngon ở DEV, sang UAT chết ngắc vì User ID khác nhau.

  3. Dữ liệu bẩn (Test Pollution): Test Case A sửa dữ liệu, làm Test Case B chạy sau bị fail oan.

Giải pháp hôm nay chính là xây dựng một "Nhà máy dữ liệu" (Data Factory) thông minh, nơi cung cấp dữ liệu sạch, đúng môi trường và linh hoạt cho mọi test case.

 
Quản lý Đa môi trường (Multi-Environment)

Trước khi lấy dữ liệu, ta cần biết "đang ở đâu" (Dev hay Prod).

💻 Code:

function loadDataByEnv<T>(base: T, dev: T): T {

  // Lấy environment từ process.env (có sẵn trong Node.js/Playwright)

  // @ts-ignore - process.env có sẵn trong Node.js runtime

  const env = (process.env.NODE_ENV || process.env.TEST_ENV || 'dev').toLowerCase();

  switch (env) {

    case 'dev':

    case 'development':

      return dev;

    default:

      // Fallback: Dùng file schema (base) làm default

      return base;

  }

}

🕵️‍️ Phân tích:

Generics <T>: Đây là cái "khuôn". Dù bạn ném vào dữ liệu Customer hay Product, hàm này đều xử lý được và trả về đúng kiểu dữ liệu đó.

Cơ chế "Cầu dao điện":

Hàm tự động đọc biến môi trường NODE_ENV.

Nếu là 'dev' -> Trả về file customers-dev.json.

Nếu không (mặc định) -> Trả về file gốc customers.json.

Ý nghĩa: Test script không cần sửa đổi gì cả, chỉ cần đổi biến môi trường là tự động có data đúng.

 
Kho chứa & Định nghĩa (Catalog & Schema)

Đây là nơi chúng ta tổ chức dữ liệu.

💻 Code :

// 1. Schema tĩnh (Dùng cho TypeScript hiểu cấu trúc)

export const dataSchemas = {

  customers,

} as const;

export type DataSchemas = typeof dataSchemas;


// 2. Catalog động (Dùng cho lúc chạy - Runtime)

export const testDataCatalog = {

  customers: loadDataByEnv(customers, customersDev), // ✅ Tự động load theo môi trường

} as const;


export type TestDataCatalog = typeof testDataCatalog;

export type TestDataNamespace = keyof DataSchemas;

🕵️‍️ Phân tích:

dataSchemas (Bản vẽ): Dùng file gốc customers.json làm chuẩn. Giúp TypeScript gợi ý code (Intellisense).

testDataCatalog (Kho hàng): Nơi chứa dữ liệu thực tế. Nhờ hàm loadDataByEnv, kho hàng này sẽ tự động tráo đổi hàng hóa tùy theo môi trường.

as const: Biến object thành "Bất biến" (Read-only). Đảm bảo không ai vô tình xóa mất danh mục dữ liệu trong lúc code chạy

Đây không chỉ là kho chứa data Customers, mà nó là một Siêu thị dữ liệu. Hiện tại siêu thị mới mở 1 gian hàng (Customers), nhưng thiết kế của nó cho phép mở thêm hàng trăm gian hàng khác (Orders, Products...) chỉ bằng vài dòng code.

💻 Code :

Hãy tưởng tượng ngày mai dự án yêu cầu test thêm phần Đơn hàng (Orders).Chỉ cần làm như sau:

// BƯỚC 1: Import file mới (Chuẩn bị hàng hóa)

import customers from './customers.json';

// ... import customersDev ...

import orders from './orders.json';           // <--- Thêm dòng này

import ordersDev from './orders-dev.json';    // <--- Thêm dòng này



// BƯỚC 2: Định nghĩa Schema (Đăng ký tên gian hàng với TypeScript)

export const dataSchemas = {

  customers,

  orders, // <--- Thêm đúng 1 dòng này để có Auto Suggestion cho Orders

} as const;


export type DataSchemas = typeof dataSchemas;


// BƯỚC 3: Cập nhật Catalog (Bày hàng lên kệ thực tế)

export const testDataCatalog = {

  // Gian hàng Customers

  customers: loadDataByEnv(customers, customersDev),


  // Gian hàng Orders (Cắm vào là chạy ngay logic đa môi trường)

  orders: loadDataByEnv(orders, ordersDev), // <--- Thêm dòng này

} as const;

🕵️‍️ Phân tích kiến trúc "Plug & Play":

Tại sao lại tách riêng từng file? (Module hóa)

Vấn đề: Nếu nhét tất cả dữ liệu (User, Order, Product, Payment...) vào chung 1 file data.json khổng lồ -> File đó sẽ dài 10.000 dòng. Mở lên là lag máy, tìm không ra, sửa cực khó.

Giải pháp: Chúng ta chia nhỏ: customers.json chỉ chứa user, orders.json chỉ chứa đơn hàng.

Lợi ích: Quản lý dễ dàng, nhóm làm việc đông người không bị conflict code.


Sức mạnh của hàm loadDataByEnv (Tái sử dụng)

 Khi thêm orders, thầy KHÔNG CẦN viết lại hàm check môi trường.

Thầy chỉ cần gọi lại loadDataByEnv(orders, ordersDev). Cỗ máy logic đã viết 1 lần, dùng mãi mãi cho mọi loại file.

Type Safety tự động cập nhật

Ngay khi thêm dòng orders vào dataSchemas.

Khi em gõ getTestDataSimple(, máy sẽ tự động gợi ý thêm:

Plaintext

👉 'customers'

👉 'orders'  <-- (Mới xuất hiện)


Cỗ máy Photocopy (Cloning)

Để tránh "Dữ liệu bẩn", ta không bao giờ dùng bản gốc.

💻 Code:

function cloneData<T>(data: T): T {

  // Cách 1: structuredClone (modern, nhanh, support nhiều types)

  if (typeof structuredClone !== 'undefined') {

    return structuredClone(data);

  }


  // Cách 2: JSON (fallback, chậm hơn, chỉ support JSON-serializable)

  return JSON.parse(JSON.stringify(data));

}

🕵️‍️ Phân tích:

Vấn đề: Trong JS, gán object là tham chiếu (A = B). Nếu Test 1 sửa A, thì B cũng bị sửa theo -> Hỏng dữ liệu gốc.

Giải pháp: Hàm này tạo ra một bản sao mới tinh, độc lập hoàn toàn ở vùng nhớ khác. Test case tha hồ "đập phá" bản sao này mà không ảnh hưởng đến bản gốc trong kho.


Dây chuyền sản xuất (Main Function)

Đây là hàm quan trọng nhất. 

💻 Code:

export function getTestDataSimple<N extends TestDataNamespace, K extends keyof DataSchemas[N]>(

  namespace: N,

  key: K,

  options?: {

    overrides?: Record<string, any>; // Override fields (chỉ cho object)

    transform?: (data: any) => any; // Transform data (cho mọi type)

  }

): any {

  // ───────────────────────────────────────────────────────

  // BƯỚC 1: Kiểm tra namespace có tồn tại không

  // ───────────────────────────────────────────────────────

  const namespaceData = testDataCatalog[namespace] as any;

  if (!namespaceData) {

    throw new Error(

      `Namespace "${String(namespace)}" không tồn tại trong catalog. ` +

        `Các namespace có sẵn: ${Object.keys(testDataCatalog).join(', ')}`

    );

  }

  // ───────────────────────────────────────────────────────

  // BƯỚC 2: Kiểm tra key có tồn tại trong namespace không

  // ───────────────────────────────────────────────────────

  const entry = namespaceData[key];

  if (!entry) {

    throw new Error(

      `Key "${String(key)}" không tồn tại trong namespace "${String(namespace)}". ` +

        `Các keys có sẵn: ${Object.keys(namespaceData).join(', ')}`

    );

  }

  // ───────────────────────────────────────────────────────

  // BƯỚC 3: Clone data (tránh thay đổi data gốc)

  // ───────────────────────────────────────────────────────

  const dataEntry = entry as unknown as DataEntry;

  let result = cloneData(dataEntry.data);

  // ───────────────────────────────────────────────────────

  // BƯỚC 4: Apply overrides (nếu có)

  // ───────────────────────────────────────────────────────

  if (options?.overrides) {

    // Kiểm tra an toàn: Cấm dùng overrides cho Array/Primitive

    if (Array.isArray(result)) {

      throw new Error(

        `Không thể dùng overrides cho array. ` +

          `Hãy dùng transform() thay thế: ${String(namespace)}.${String(key)}`

      );

    }
    if (typeof result !== 'object' || result === null) {

      throw new Error(

        `Không thể dùng overrides cho primitive (string, number, boolean). ` +

          `Hãy dùng transform() thay thế: ${String(namespace)}.${String(key)}`

      );

    }

    // Merge overrides vào result

    Object.assign(result, options.overrides);

  }

  // ───────────────────────────────────────────────────────

  // BƯỚC 5: Apply transform (nếu có)

  // ───────────────────────────────────────────────────────

  if (options?.transform) {

    result = options.transform(result);

  }

  // ───────────────────────────────────────────────────────

  // BƯỚC 6: Return data

  // ───────────────────────────────────────────────────────

  return result;

}

🕵️‍️ Phân tích chi tiết logic (Step-by-Step):

🛡️ Bước 1 & 2: Validation (Kiểm tra kho)

Mục đích: Đảm bảo người dùng không yêu cầu những thứ không có thật.

Hoạt động: Kiểm tra xem namespace (ví dụ: 'customers') và key (ví dụ: 'minimal') có tồn tại trong Catalog không.

Tại sao cần? TypeScript check lúc code, nhưng Runtime check lúc chạy. Bước này giúp báo lỗi rõ ràng dễ hiểu (tiếng Việt) thay vì lỗi undefined is not an object khó debug.

📠 Bước 3: Cloning (Photocopy)

Mục đích: Bảo vệ dữ liệu gốc (Immutability).

Hoạt động: Gọi hàm cloneData để tạo bản sao. Từ dòng này trở đi, mọi thay đổi result đều chỉ tác động lên bản sao.

✏️ Bước 4: Overrides (Dán nhãn đè - Chỉ Object)

Mục đích: Cho phép sửa nhanh các trường đơn giản (VD: Đổi tên, đổi giá tiền).

Hoạt động: Dùng Object.assign(result, overrides).

Cơ chế an toàn (Safety Check):

Hàm này CẤM dùng overrides cho Array. Vì Object.assign sẽ trộn mảng theo vị trí index (rất nguy hiểm, tạo ra dữ liệu lai tạp).

Nếu phát hiện là Array, hàm sẽ Throw Error để bắt học sinh dùng transform.

🛠️ Bước 5: Transform (Độ chế - Vạn năng)

Mục đích: Xử lý các thay đổi phức tạp mà overrides bó tay (Xử lý Mảng, Tính toán, Logic động).

Hoạt động:

Nhận vào một hàm callback.

Giao toàn bộ dữ liệu result cho hàm đó.

Nhận lại kết quả mới.

Ví dụ: Muốn lọc mảng, muốn nhân đôi giá tiền, muốn set ngày là "ngày mai"... đều làm ở đây.


Cách sử dụng & Sức mạnh của Auto Suggestion (Gợi ý code)

Đây là lý do tại sao chúng ta phải khổ sở định nghĩa Type, Generics, keyof, typeof ở các bước trước.

Khi viết code test, hệ thống sẽ thông minh như Google Search. Nó tự động gợi ý tên file, tên data, giúp em không bao giờ gõ sai chính tả.

Auto Suggestion hoạt động như thế nào?

Hãy tưởng tượng đang gõ lệnh trong VS Code:

Gợi ý Namespace (Chọn loại dữ liệu)

Khi gõ: getTestDataSimple(

VS Code sẽ ngay lập tức hiện ra danh sách:

Plaintext

👉 'customers'

   'orders'

   'products'
Không cần nhớ trong hệ thống có gì, máy sẽ nhắc chúng ta


Gợi ý Key (Chọn mẫu data)

Sau khi chọn 'customers' và gõ dấu phẩy: getTestDataSimple('customers',

Máy sẽ tự động nhìn vào file customers.json và gợi ý:

👉 'minimal'      (Dữ liệu tối thiểu)

   'full_flow'    (Dữ liệu đầy đủ)

   'error_case'   (Dữ liệu lỗi)

Báo lỗi ngay lập tức (Type Safety)

Nếu cố tình gõ sai: getTestDataSimple('customers', 'data_nay_khong_co')

🔴 Lỗi đỏ lòm sẽ hiện ra ngay:

Argument of type '"data_nay_khong_co"' is not assignable to parameter of type '"minimal" | "full_flow" ...'

(Dịch: "Này, trong kho làm gì có cái data nào tên thế này? Chọn lại đi!")


Các kịch bản sử dụng (Code Mẫu)

Dưới đây là 3 cách dùng từ cơ bản đến nâng cao. 

Level 1: "Mì ăn liền" (Lấy data chuẩn)

Dùng khi cần data chuẩn để test luồng chính (Happy Path).

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

import { getTestDataSimple } from './test-data-factory';

test('Đăng ký tài khoản thành công', async ({ page }) => {

    // 1. Máy tự gợi ý 'customers' và 'valid_register'

    const userData = getTestDataSimple('customers', 'valid_register');

   

    // 2. Sử dụng data (userData đã được clone, an toàn tuyệt đối)

    await page.fill('input[name="email"]', userData.email);

    await page.fill('input[name="password"]', userData.password);

    await page.click('#btn-register');

});

Level 2: "Xào nấu nhẹ" (Dùng Overrides)

Dùng khi muốn sửa nhanh 1 vài trường thông tin (VD: Test login sai password).

test('Đăng nhập thất bại do sai mật khẩu', async ({ page }) => {

    // Lấy user chuẩn, nhưng GHI ĐÈ mật khẩu thành sai

    const badUser = getTestDataSimple('customers', 'valid_login', {

        overrides: {

            password: 'wrong_password_123', // Ghi đè dòng này

            isLocked: true                  // Thêm trường mới

        }

    });


    await page.fill('#email', badUser.email); // Email vẫn chuẩn

    await page.fill('#password', badUser.password); // Password sai

   

    // Expect thấy lỗi

    await expect(page.locator('.error')).toHaveText('Sai mật khẩu');

});

Level 3: "Phẫu thuật thẩm mỹ" (Dùng Transform)

Dùng khi cần xử lý Mảng (Array), tính toán số học, hoặc Logic động.

test('Giỏ hàng tính tổng tiền đúng', async ({ page }) => {

    // Data gốc: items = [{ price: 100 }, { price: 200 }]


    const cartData = getTestDataSimple('orders', 'standard_cart', {

        transform: (cart) => {

            // 1. Nhân đôi số lượng item đầu tiên

            cart.items[0].qty = 2;


            // 2. Thêm một item mới vào mảng

            cart.items.push({ name: 'Quà tặng', price: 0 });


            // 3. Tính toán lại tổng tiền (Logic động)

            cart.total = cart.items.reduce((sum, item) => sum + item.price * (item.qty || 1), 0);

           

            return cart;

        }

    });

    console.log(cartData.total); // Data đã được tính toán lại chính xác

});

 

Deep Dive: Tại sao overrides "kỵ" Array và Primitive?

Trong hàm getTestDataSimple,  có đoạn code Chặn lỗi (Throw Error) nếu cố tình dùng overrides cho Mảng (Array) hoặc Số/Chuỗi (Primitive).

Tại sao lại khó tính như vậy? Câu trả lời nằm ở cách hàm Object.assign() hoạt động.

Cơ chế của Object.assign (Cái Bút Xóa)

Khi dùng Object.assign(goc, suaDoi) cho Object, nó hoạt động theo cơ chế "Điền vào chỗ trống" hoặc "Cập nhật hồ sơ". Đây chính xác là điều chúng ta muốn khi sửa dữ liệu test.

Hãy tưởng tượng tờ khai sơ yếu lý lịch:

Họ tên: Nguyễn Văn A

Địa chỉ: Hà Nội

SĐT: 0988...

Khi muốn báo đổi địa chỉ, em chỉ cần viết: "Địa chỉ mới: Sài Gòn".

👉 Kết quả: Hệ thống sẽ chỉ sửa dòng Địa chỉ, còn Tên và SĐT giữ nguyên.

Ví dụ Code minh họa:

// 1. Dữ liệu gốc (Hồ sơ nhân viên)

const staff = {

    name: "Tùng",

    role: "Staff",   // Chức vụ cũ

    salary: 1000,

    active: true

};


// 2. Overrides (Quyết định thăng chức)

// chỉ cần đưa vào những trường muốn thay đổi

const promotion = {

    role: "Manager", // Sửa chức vụ

    salary: 2000     // Sửa lương

};


// 3. Thực hiện Object.assign

Object.assign(staff, promotion);


console.log(staff);

Kết quả tuyệt vời:

{

    name: "Tùng",       // ✅ Giữ nguyên (Không bị mất)

    active: true,       // ✅ Giữ nguyên (Không bị mất)

    role: "Manager",    // ✏️ Đã cập nhật

    salary: 2000        // ✏️ Đã cập nhật

}

👉 Kết luận: Đây gọi là "Partial Update" (Cập nhật một phần). Nó không phải copy-paste lại toàn bộ object dài ngoằng chỉ để sửa 1 dòng. Đây là lý do tại sao overrides sinh ra để dành cho Object.


Thảm họa khi dùng với Array (Mảng)

Mảng trong JavaScript thực chất là Object với các key là số thứ tự (Index: 0, 1, 2...).

Khi dùng Object.assign lên mảng, nó sẽ ghi đè theo vị trí (Index) chứ không thay thế toàn bộ mảng. Điều này tạo ra những dữ liệu "quái thai" (Frankenstein Data).

Ví dụ minh họa:

// 1. Dữ liệu gốc (Giỏ hàng có 3 món)

const gioHangCu = ["Táo", "Cam", "Nho"];


// 2. Ý định của em (Chỉ muốn mua Dưa Hấu)

const overrides = ["Dưa Hấu"];

// 3. Nếu thầy KHÔNG chặn lỗi, điều gì xảy ra?

Object.assign(gioHangCu, overrides);

console.log(gioHangCu);

Kết quả kinh hoàng:

[

  "Dưa Hấu",  // Vị trí 0: "Táo" bị đè bởi "Dưa Hấu"

  "Cam",      // Vị trí 1: VẪN CÒN NGUYÊN !!!

  "Nho"       // Vị trí 2: VẪN CÒN NGUYÊN !!!

]

=> Hậu quả: muốn mua 1 món, hệ thống lại tính tiền 3 món (1 món mới + 2 món cũ rác). Đây là lỗi "Râu ông nọ cắm cằm bà kia".

👉 Giải pháp: Dùng transform.

// Thay thế hoàn toàn mảng cũ bằng mảng mới

transform: (arr) => ["Dưa Hấu"]


Sự vô nghĩa khi dùng với Primitive (Số, Chuỗi, Boolean)

Primitive (Nguyên thủy) là những viên gạch cơ bản, không thể "trộn" cái này vào cái kia được.

Ví dụ minh họa:

 

// Dữ liệu gốc

let luong = 1000;



// Ý định: Sửa thành 5000

const overrides = 5000;


// Lệnh này hoàn toàn vô dụng trong JS

Object.assign(luong, overrides);



console.log(luong); // Vẫn là 1000

Tại sao? Số 1000 không phải là một cái hộp để bỏ số 5000 vào. Chỉ có thể vứt số 1000 đi và thay bằng số 5000.

Hậu quả: Code chạy không báo lỗi, nhưng dữ liệu không thay đổi -> Test case sai logic mà không biết tại sao.

👉 Giải pháp: Dùng transform.

// Tính toán hoặc thay thế giá trị

transform: (val) => 5000

🎓 Tóm tắt :

Dùng overrides: Khi muốn sửa Chi tiết nhỏ trong một Object lớn (Ví dụ: Sửa email trong user).

Dùng transform: Khi muốn sửa Mảng (List), sửa Số/Chuỗi, hoặc muốn Tính toán logic (Cộng trừ nhân chia).


Deep Dive: Tại sao phải "Photocopy" (Clone) dữ liệu?

Trong hàm cloneData, tdùng 2 cách (structuredClone hoặc JSON.parse) để tạo ra một bản sao hoàn toàn mới. Tại sao không dùng dấu bằng (const data = originalData) cho nhanh?

Để hiểu điều này, cần hiểu về Tham Chiếu (Reference) trong lập trình.


Thảm họa "Dùng chung chìa khóa" (Gán bằng =)

Trong JavaScript, khi các tạo một Object, máy tính sẽ lưu Object đó vào một cái kho (Bộ nhớ Heap) và đưa cho biến một cái Chìa khóa (Địa chỉ bộ nhớ).

Ví dụ: const userGoc = { name: "Tùng" }

Máy tính tạo cái hộp chứa { name: "Tùng" }.

Biến userGoc giữ chìa khóa mở cái hộp đó (Ví dụ: Chìa khóa số #101).

Nếu làm thế này:

const userMoi = userGoc; // ❌ SAI LẦM CHẾT NGƯỜI

👉 Thực tế: KHÔNG tạo ra user mới. Chỉ đang đánh thêm một chìa khóa số #101 nữa và đưa cho biến userMoi.

Hậu quả:

userMoi mở hộp ra, sửa tên thành "Huy".

userGoc quay lại mở hộp (bằng chìa khóa cũ), thấy tên trong hộp đã biến thành "Huy".

Kết quả: Test Case sau làm hỏng dữ liệu của Test Case trước.

Code minh họa thảm họa:

const dataGoc = { price: 1000 };

const dataTest1 = dataGoc; // Gán tham chiếu (Dùng chung)


dataTest1.price = 0; // Test 1 sửa giá để test lỗi

console.log(dataGoc.price);

// 😱 KẾT QUẢ: 0

// (Dữ liệu gốc đã bị hỏng vĩnh viễn!)

 

Giải pháp: "Xây nhà mới" (Cloning)

Hàm cloneData hoạt động như một cỗ máy Photocopy 3D.

function cloneData<T>(data: T): T {

  // Cách 1: structuredClone (Công nghệ mới, copy siêu sâu)

  if (typeof structuredClone !== 'undefined') {

    return structuredClone(data);

  }

  // Cách 2: JSON (Công nghệ cũ, biến thành chữ rồi biến lại thành hình)

  return JSON.parse(JSON.stringify(data));

}

Cơ chế hoạt động:

Nó nhìn vào cái hộp cũ ({ name: "Tùng" }).

Nó xây một cái hộp MỚI TINH ở một địa chỉ khác (Ví dụ: Địa chỉ #999).

Nó copy nội dung "Tùng" bỏ vào hộp mới.

Nó trả về chìa khóa #999.

userMoi cầm khóa #999.

userGoc cầm khóa #101.

Hai người ở hai nhà khác nhau, làm gì cũng không ảnh hưởng tới nhau.

Code minh họa giải pháp:

const dataGoc = { price: 1000 };

// ✅ Dùng hàm cloneData

const dataTest1 = cloneData(dataGoc);

dataTest1.price = 0; // Test 1 sửa giá thoải mái

console.log(dataGoc.price);

// 🥳 KẾT QUẢ: 1000

// (Dữ liệu gốc vẫn an toàn tuyệt đối!)


Giải thích kỹ thuật bên trong hàm (Cho bạn nào tò mò)

structuredClone(data):

Đây là hàm xịn nhất hiện nay (Modern JS).

Nó không chỉ copy dữ liệu thường, mà copy được cả những thứ phức tạp như Date (Ngày tháng), Map, Set, RegExp.

Tốc độ cực nhanh vì được trình duyệt hỗ trợ tận gốc.

JSON.parse(JSON.stringify(data)):

Đây là "mẹo" (Hack) dùng từ ngày xưa khi chưa có structuredClone.

JSON.stringify: Biến Object thành chuỗi văn bản (String). Lúc này nó mất hết liên kết bộ nhớ.

JSON.parse: Tạo Object mới từ chuỗi văn bản đó.

Nhược điểm: Nó sẽ làm hỏng dữ liệu Date (biến thành chuỗi) hoặc undefined (bị xóa mất). Nhưng với file JSON test data thông thường thì cách này vẫn rất ổn.

 

Teacher

Teacher

Nguyên Hoàng

Automation Engineer

With 7+ years of hands-on experience across multiple languages and frameworks. I'm here to share knowledge, helping you turn complex processes into simple and effective solutions.

Cộng đồng Automation Testing Việt Nam:

🌱 Telegram Automation Testing:   Cộng đồng Automation Testing
🌱 
Facebook Group Automation: Cộng đồng Automation Testing Việt Nam
🌱 
Facebook Fanpage: Cộng đồng Automation Testing Việt Nam - Selenium
🌱 Telegram
Manual Testing:   Cộng đồng Manual Testing
🌱 
Facebook Group Manual: Cộng đồng Manual Testing Việt Nam

Chia sẻ khóa học lên trang

Bạn có thể đăng khóa học của chính bạn lên trang Anh Tester để kiếm tiền

Danh sách bài học