# Jest: Learning by Example

# สารบัญ

| Level | หัวข้อ                                                                | ความยาก    |
| ----- | --------------------------------------------------------------------- | ---------- |
| 1     | [Test แรกของคุณ](#level-1-test-แรกของคุณ)                             | ⭐         |
| 2     | [Matchers พื้นฐาน](#level-2-matchers-พื้นฐาน)                         | ⭐         |
| 3     | [Matchers ขั้นกลาง](#level-3-matchers-ขั้นกลาง)                       | ⭐⭐       |
| 4     | [จัดกลุ่ม Test ด้วย describe](#level-4-จัดกลุ่ม-test-ด้วย-describe)   | ⭐⭐       |
| 5     | [Setup และ Teardown](#level-5-setup-และ-teardown)                     | ⭐⭐       |
| 6     | [ทดสอบ Function จริง](#level-6-ทดสอบ-function-จริง)                   | ⭐⭐⭐     |
| 7     | [ทดสอบ Class และ Object](#level-7-ทดสอบ-class-และ-object)             | ⭐⭐⭐     |
| 8     | [ทดสอบ Error และ Exception](#level-8-ทดสอบ-error-และ-exception)       | ⭐⭐⭐     |
| 9     | [Async/Await Testing](#level-9-asyncawait-testing)                    | ⭐⭐⭐⭐   |
| 10    | [Mocking พื้นฐาน](#level-10-mocking-พื้นฐาน)                          | ⭐⭐⭐⭐   |
| 11    | [Mocking ขั้นสูง](#level-11-mocking-ขั้นสูง)                          | ⭐⭐⭐⭐⭐ |
| 12    | [Test Driven Development](#level-12-test-driven-development)          | ⭐⭐⭐⭐⭐ |
| 13    | [โปรเจกต์จริง: Library System](#level-13-โปรเจกต์จริง-library-system) | ⭐⭐⭐⭐⭐ |

---

# เริ่มต้น: ติดตั้ง Jest

```bash
# สร้างโฟลเดอร์โปรเจกต์
mkdir jest-tutorial
cd jest-tutorial

# สร้าง package.json
npm init -y

# ติดตั้ง Jest
npm install --save-dev jest

# แก้ไข package.json เพิ่ม test script
```

แก้ไข `package.json`:

```json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}
```

**โครงสร้างโฟลเดอร์:**

```
jest-tutorial/
├── src/
│   └── (โค้ดของเรา)
├── tests/
│   └── (ไฟล์ test)
├── package.json
└── jest.config.js (optional)
```

---

# Level 1: Test แรกของคุณ

> ⭐ ความยาก: ง่ายมาก

## 🎯 เป้าหมาย

เขียน test แรกให้ผ่าน (และไม่ผ่าน)

## 📝 ตัวอย่าง 1.1: Hello Jest

สร้างไฟล์ `tests/first.test.js`:

```javascript
// Test ที่ง่ายที่สุดในโลก
test("สองบวกสองเท่ากับสี่", () => {
  expect(2 + 2).toBe(4);
});
```

**รัน:**

```bash
npm test
```

**ผลลัพธ์:**

```
 PASS  tests/first.test.js
  ✓ สองบวกสองเท่ากับสี่ (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
```

## 📝 ตัวอย่าง 1.2: Test ที่ไม่ผ่าน

```javascript
test("ทดสอบที่จะไม่ผ่าน", () => {
  expect(2 + 2).toBe(5); // ❌ ผิด!
});
```

**ผลลัพธ์:**

```
 FAIL  tests/first.test.js
  ✕ ทดสอบที่จะไม่ผ่าน (3 ms)

  Expected: 5
  Received: 4
```

## 📝 ตัวอย่าง 1.3: หลาย Test ในไฟล์เดียว

```javascript
test("บวกเลข", () => {
  expect(1 + 1).toBe(2);
});

test("ลบเลข", () => {
  expect(5 - 3).toBe(2);
});

test("คูณเลข", () => {
  expect(3 * 4).toBe(12);
});

test("หารเลข", () => {
  expect(10 / 2).toBe(5);
});
```

## 💡 สิ่งที่เรียนรู้

| คำสั่ง                   | ความหมาย                 |
| ------------------------ | ------------------------ |
| `test('ชื่อ', () => {})` | สร้าง test case ใหม่     |
| `expect(ค่าจริง)`        | ค่าที่ต้องการทดสอบ       |
| `.toBe(ค่าที่คาดหวัง)`   | เปรียบเทียบว่าเท่ากันไหม |

---

# Level 2: Matchers พื้นฐาน

> ⭐ ความยาก: ง่าย

## 🎯 เป้าหมาย

เรียนรู้ Matchers ที่ใช้บ่อยที่สุด

## 📝 ตัวอย่าง 2.1: toBe vs toEqual

```javascript
// toBe ใช้กับ primitive (number, string, boolean)
test("toBe สำหรับ primitive", () => {
  const a = 5;
  expect(a).toBe(5);

  const name = "Jest";
  expect(name).toBe("Jest");

  const isActive = true;
  expect(isActive).toBe(true);
});

// toEqual ใช้กับ object และ array
test("toEqual สำหรับ object", () => {
  const user = { name: "John", age: 25 };
  expect(user).toEqual({ name: "John", age: 25 });
});

test("toEqual สำหรับ array", () => {
  const numbers = [1, 2, 3];
  expect(numbers).toEqual([1, 2, 3]);
});
```

## 📝 ตัวอย่าง 2.2: ทำไม toBe ใช้กับ Object ไม่ได้

```javascript
test("toBe กับ object - จะ FAIL!", () => {
  const obj1 = { a: 1 };
  const obj2 = { a: 1 };

  // ❌ FAIL - เพราะเป็นคนละ reference
  // expect(obj1).toBe(obj2);

  // ✅ PASS - ใช้ toEqual แทน
  expect(obj1).toEqual(obj2);
});

test("toBe กับ object เดียวกัน - PASS", () => {
  const obj1 = { a: 1 };
  const obj2 = obj1; // reference เดียวกัน

  expect(obj1).toBe(obj2); // ✅ PASS
});
```

## 📝 ตัวอย่าง 2.3: Truthiness Matchers

```javascript
test("ทดสอบค่า null", () => {
  const value = null;
  expect(value).toBeNull();
  expect(value).toBeDefined();
  expect(value).not.toBeUndefined();
});

test("ทดสอบค่า undefined", () => {
  let value;
  expect(value).toBeUndefined();
  expect(value).not.toBeDefined();
});

test("ทดสอบ truthy/falsy", () => {
  // Truthy values
  expect(1).toBeTruthy();
  expect("hello").toBeTruthy();
  expect([]).toBeTruthy();
  expect({}).toBeTruthy();

  // Falsy values
  expect(0).toBeFalsy();
  expect("").toBeFalsy();
  expect(null).toBeFalsy();
  expect(undefined).toBeFalsy();
});
```

## 📝 ตัวอย่าง 2.4: not - กลับความหมาย

```javascript
test("ใช้ not กลับความหมาย", () => {
  expect(5).not.toBe(3);
  expect("hello").not.toBe("world");
  expect([1, 2]).not.toEqual([1, 2, 3]);
  expect(null).not.toBeUndefined();
});
```

## 💡 สรุป Matchers พื้นฐาน

| Matcher           | ใช้ทำอะไร                | ตัวอย่าง                            |
| ----------------- | ------------------------ | ----------------------------------- |
| `toBe(x)`         | เท่ากับ x (primitive)    | `expect(5).toBe(5)`                 |
| `toEqual(x)`      | เท่ากับ x (object/array) | `expect({a:1}).toEqual({a:1})`      |
| `toBeNull()`      | เป็น null                | `expect(null).toBeNull()`           |
| `toBeUndefined()` | เป็น undefined           | `expect(undefined).toBeUndefined()` |
| `toBeDefined()`   | ไม่ใช่ undefined         | `expect(5).toBeDefined()`           |
| `toBeTruthy()`    | เป็นค่า truthy           | `expect(1).toBeTruthy()`            |
| `toBeFalsy()`     | เป็นค่า falsy            | `expect(0).toBeFalsy()`             |
| `not`             | กลับความหมาย             | `expect(5).not.toBe(3)`             |

---

# Level 3: Matchers ขั้นกลาง

> ⭐⭐ ความยาก: ปานกลาง

## 📝 ตัวอย่าง 3.1: Number Matchers

```javascript
test("เปรียบเทียบตัวเลข", () => {
  const value = 10;

  expect(value).toBeGreaterThan(5); // > 5
  expect(value).toBeGreaterThanOrEqual(10); // >= 10
  expect(value).toBeLessThan(20); // < 20
  expect(value).toBeLessThanOrEqual(10); // <= 10
});

test("ทศนิยม - ใช้ toBeCloseTo", () => {
  // ❌ อย่าใช้ toBe กับทศนิยม!
  // expect(0.1 + 0.2).toBe(0.3); // FAIL เพราะ floating point error

  // ✅ ใช้ toBeCloseTo แทน
  expect(0.1 + 0.2).toBeCloseTo(0.3);
  expect(0.1 + 0.2).toBeCloseTo(0.3, 5); // 5 ทศนิยม
});
```

## 📝 ตัวอย่าง 3.2: String Matchers

```javascript
test("ทดสอบ string", () => {
  const message = "Hello, World!";

  // ตรงกับ regex
  expect(message).toMatch(/Hello/);
  expect(message).toMatch(/world/i); // i = case insensitive

  // มีคำว่า
  expect(message).toMatch("World");

  // ไม่มีคำว่า
  expect(message).not.toMatch("Goodbye");
});

test("ทดสอบ email format", () => {
  const email = "test@example.com";
  expect(email).toMatch(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/);
});
```

## 📝 ตัวอย่าง 3.3: Array Matchers

```javascript
test("ทดสอบ array", () => {
  const fruits = ["apple", "banana", "orange"];

  // มี item อยู่ใน array
  expect(fruits).toContain("banana");
  expect(fruits).not.toContain("grape");

  // ความยาว array
  expect(fruits).toHaveLength(3);
});

test("array ของ object", () => {
  const users = [
    { name: "John", age: 25 },
    { name: "Jane", age: 30 },
  ];

  // ใช้ toContainEqual สำหรับ object
  expect(users).toContainEqual({ name: "John", age: 25 });
});
```

## 📝 ตัวอย่าง 3.4: Object Matchers

```javascript
test("ทดสอบ object", () => {
  const user = {
    name: "John",
    age: 25,
    email: "john@example.com",
    address: {
      city: "Bangkok",
      country: "Thailand",
    },
  };

  // มี property
  expect(user).toHaveProperty("name");
  expect(user).toHaveProperty("age", 25); // มี property และค่าตรง

  // nested property
  expect(user).toHaveProperty("address.city");
  expect(user).toHaveProperty("address.city", "Bangkok");

  // ตรวจสอบบางส่วนของ object
  expect(user).toMatchObject({
    name: "John",
    age: 25,
  });
});
```

## 📝 ตัวอย่าง 3.5: รวมทุก Matcher

```javascript
test("ตัวอย่างรวม: ข้อมูลหนังสือ", () => {
  const book = {
    title: "JavaScript Testing",
    author: "John Doe",
    pages: 350,
    price: 29.99,
    tags: ["javascript", "testing", "jest"],
    publisher: {
      name: "Tech Books",
      year: 2024,
    },
  };

  // String
  expect(book.title).toMatch(/JavaScript/);

  // Number
  expect(book.pages).toBeGreaterThan(300);
  expect(book.price).toBeCloseTo(30, 0);

  // Array
  expect(book.tags).toContain("jest");
  expect(book.tags).toHaveLength(3);

  // Object
  expect(book).toHaveProperty("publisher.year", 2024);
  expect(book).toMatchObject({
    author: "John Doe",
    pages: 350,
  });
});
```

## 💡 สรุป Matchers ขั้นกลาง

| Matcher               | ใช้กับ          | ตัวอย่าง                                 |
| --------------------- | --------------- | ---------------------------------------- |
| `toBeGreaterThan(n)`  | Number          | `expect(10).toBeGreaterThan(5)`          |
| `toBeLessThan(n)`     | Number          | `expect(5).toBeLessThan(10)`             |
| `toBeCloseTo(n, d)`   | Float           | `expect(0.3).toBeCloseTo(0.1+0.2)`       |
| `toMatch(regex)`      | String          | `expect('hi').toMatch(/h/)`              |
| `toContain(item)`     | Array           | `expect([1,2]).toContain(1)`             |
| `toContainEqual(obj)` | Array of Object | `expect([{a:1}]).toContainEqual({a:1})`  |
| `toHaveLength(n)`     | Array/String    | `expect([1,2,3]).toHaveLength(3)`        |
| `toHaveProperty(key)` | Object          | `expect({a:1}).toHaveProperty('a')`      |
| `toMatchObject(obj)`  | Object          | `expect({a:1,b:2}).toMatchObject({a:1})` |

---

# Level 4: จัดกลุ่ม Test ด้วย describe

> ⭐⭐ ความยาก: ปานกลาง

## 🎯 เป้าหมาย

จัดระเบียบ test ให้อ่านง่ายและดูแลง่าย

## 📝 ตัวอย่าง 4.1: describe พื้นฐาน

```javascript
describe("Calculator", () => {
  test("บวกเลขได้ถูกต้อง", () => {
    expect(1 + 2).toBe(3);
  });

  test("ลบเลขได้ถูกต้อง", () => {
    expect(5 - 3).toBe(2);
  });

  test("คูณเลขได้ถูกต้อง", () => {
    expect(4 * 5).toBe(20);
  });

  test("หารเลขได้ถูกต้อง", () => {
    expect(10 / 2).toBe(5);
  });
});
```

**ผลลัพธ์:**

```
 PASS  tests/calculator.test.js
  Calculator
    ✓ บวกเลขได้ถูกต้อง (1 ms)
    ✓ ลบเลขได้ถูกต้อง
    ✓ คูณเลขได้ถูกต้อง (1 ms)
    ✓ หารเลขได้ถูกต้อง
```

## 📝 ตัวอย่าง 4.2: describe ซ้อนกัน (Nested)

```javascript
describe("User Module", () => {
  describe("Registration", () => {
    test("สมัครสมาชิกสำเร็จ", () => {
      expect(true).toBe(true);
    });

    test("แจ้งเตือนเมื่อ email ซ้ำ", () => {
      expect(true).toBe(true);
    });
  });

  describe("Login", () => {
    test("login สำเร็จ", () => {
      expect(true).toBe(true);
    });

    test("แจ้งเตือนเมื่อ password ผิด", () => {
      expect(true).toBe(true);
    });
  });

  describe("Profile", () => {
    test("ดูข้อมูลส่วนตัวได้", () => {
      expect(true).toBe(true);
    });

    test("แก้ไขข้อมูลได้", () => {
      expect(true).toBe(true);
    });
  });
});
```

**ผลลัพธ์:**

```
 PASS  tests/user.test.js
  User Module
    Registration
      ✓ สมัครสมาชิกสำเร็จ
      ✓ แจ้งเตือนเมื่อ email ซ้ำ
    Login
      ✓ login สำเร็จ
      ✓ แจ้งเตือนเมื่อ password ผิด
    Profile
      ✓ ดูข้อมูลส่วนตัวได้
      ✓ แก้ไขข้อมูลได้
```

## 📝 ตัวอย่าง 4.3: ใช้ it แทน test

```javascript
// test และ it ทำงานเหมือนกัน
// it อ่านเป็นภาษาอังกฤษได้ลื่นกว่า

describe("Array", () => {
  it("should start empty", () => {
    const arr = [];
    expect(arr).toHaveLength(0);
  });

  it("should add items", () => {
    const arr = [];
    arr.push(1);
    expect(arr).toContain(1);
  });

  it("should remove items", () => {
    const arr = [1, 2, 3];
    arr.pop();
    expect(arr).toEqual([1, 2]);
  });
});
```

## 📝 ตัวอย่าง 4.4: Skip และ Only

```javascript
describe("Feature Tests", () => {
  // ข้าม test นี้ไปก่อน
  test.skip("ฟีเจอร์ที่ยังไม่เสร็จ", () => {
    expect(true).toBe(false);
  });

  // รันเฉพาะ test นี้
  // test.only('รันแค่อันนี้', () => {
  //   expect(true).toBe(true);
  // });

  test("ทดสอบปกติ", () => {
    expect(1 + 1).toBe(2);
  });
});

// ข้ามทั้ง describe
describe.skip("Module ที่ยังไม่พร้อม", () => {
  test("test 1", () => {});
  test("test 2", () => {});
});
```

## 📝 ตัวอย่าง 4.5: โครงสร้างแนะนำ

```javascript
// tests/book.test.js

describe("Book", () => {
  describe("constructor", () => {
    it("should create a book with title and author", () => {
      // ...
    });

    it("should throw error if title is empty", () => {
      // ...
    });
  });

  describe("methods", () => {
    describe("borrow()", () => {
      it("should decrease available copies", () => {
        // ...
      });

      it("should throw error if no copies available", () => {
        // ...
      });
    });

    describe("return()", () => {
      it("should increase available copies", () => {
        // ...
      });
    });
  });
});
```

---

# Level 5: Setup และ Teardown

> ⭐⭐ ความยาก: ปานกลาง

## 🎯 เป้าหมาย

เตรียมข้อมูลก่อน test และทำความสะอาดหลัง test

## 📝 ตัวอย่าง 5.1: beforeEach และ afterEach

```javascript
describe("Shopping Cart", () => {
  let cart;

  // รันก่อนทุก test
  beforeEach(() => {
    cart = [];
    console.log("🛒 สร้างตะกร้าใหม่");
  });

  // รันหลังทุก test
  afterEach(() => {
    cart = null;
    console.log("🗑️ ล้างตะกร้า");
  });

  test("เพิ่มสินค้าลงตะกร้า", () => {
    cart.push({ name: "iPhone", price: 30000 });
    expect(cart).toHaveLength(1);
  });

  test("ตะกร้าว่างเปล่าเสมอตอนเริ่มต้น", () => {
    // cart ถูก reset แล้วใน beforeEach
    expect(cart).toHaveLength(0);
  });

  test("เพิ่มหลายสินค้า", () => {
    cart.push({ name: "iPhone", price: 30000 });
    cart.push({ name: "AirPods", price: 5000 });
    expect(cart).toHaveLength(2);
  });
});
```

## 📝 ตัวอย่าง 5.2: beforeAll และ afterAll

```javascript
describe("Database Tests", () => {
  let db;

  // รันครั้งเดียวก่อน test แรก
  beforeAll(() => {
    console.log("🔌 เชื่อมต่อ Database");
    db = { connected: true };
  });

  // รันครั้งเดียวหลัง test สุดท้าย
  afterAll(() => {
    console.log("🔌 ปิดการเชื่อมต่อ Database");
    db = null;
  });

  beforeEach(() => {
    console.log("  📝 เริ่ม test ใหม่");
  });

  afterEach(() => {
    console.log("  ✅ จบ test");
  });

  test("test 1", () => {
    expect(db.connected).toBe(true);
  });

  test("test 2", () => {
    expect(db.connected).toBe(true);
  });
});
```

**ลำดับการรัน:**

```
🔌 เชื่อมต่อ Database
  📝 เริ่ม test ใหม่
  ✅ จบ test
  📝 เริ่ม test ใหม่
  ✅ จบ test
🔌 ปิดการเชื่อมต่อ Database
```

## 📝 ตัวอย่าง 5.3: Scope ของ Setup/Teardown

```javascript
describe("Outer", () => {
  beforeAll(() => console.log("1 - beforeAll Outer"));
  afterAll(() => console.log("1 - afterAll Outer"));
  beforeEach(() => console.log("1 - beforeEach Outer"));
  afterEach(() => console.log("1 - afterEach Outer"));

  test("Outer test", () => console.log("1 - test Outer"));

  describe("Inner", () => {
    beforeAll(() => console.log("2 - beforeAll Inner"));
    afterAll(() => console.log("2 - afterAll Inner"));
    beforeEach(() => console.log("2 - beforeEach Inner"));
    afterEach(() => console.log("2 - afterEach Inner"));

    test("Inner test", () => console.log("2 - test Inner"));
  });
});
```

**ลำดับการรัน:**

```
1 - beforeAll Outer
1 - beforeEach Outer
1 - test Outer
1 - afterEach Outer
2 - beforeAll Inner
1 - beforeEach Outer    ← outer beforeEach รันก่อน
2 - beforeEach Inner
2 - test Inner
2 - afterEach Inner
1 - afterEach Outer     ← outer afterEach รันทีหลัง
2 - afterAll Inner
1 - afterAll Outer
```

## 📝 ตัวอย่าง 5.4: ตัวอย่างจริง - Library System

```javascript
describe("Library System", () => {
  let library;
  let testBooks;

  beforeAll(() => {
    // โหลด test data ครั้งเดียว
    testBooks = [
      { id: 1, title: "JavaScript Basics", author: "John Doe" },
      { id: 2, title: "Python Guide", author: "Jane Smith" },
      { id: 3, title: "React Handbook", author: "Bob Wilson" },
    ];
  });

  beforeEach(() => {
    // Reset library ก่อนแต่ละ test
    library = {
      books: [...testBooks], // copy array
      borrowedBooks: [],
    };
  });

  describe("Search", () => {
    test("ค้นหาด้วยชื่อหนังสือ", () => {
      const results = library.books.filter((b) =>
        b.title.includes("JavaScript"),
      );
      expect(results).toHaveLength(1);
      expect(results[0].title).toBe("JavaScript Basics");
    });

    test("ค้นหาด้วยชื่อผู้แต่ง", () => {
      const results = library.books.filter((b) => b.author.includes("Jane"));
      expect(results).toHaveLength(1);
    });
  });

  describe("Borrow", () => {
    test("ยืมหนังสือสำเร็จ", () => {
      const book = library.books.shift(); // นำหนังสือออก
      library.borrowedBooks.push(book);

      expect(library.books).toHaveLength(2);
      expect(library.borrowedBooks).toHaveLength(1);
    });

    test("หนังสือถูก reset ก่อน test นี้", () => {
      // beforeEach ทำให้ library มี 3 เล่มอีกครั้ง
      expect(library.books).toHaveLength(3);
      expect(library.borrowedBooks).toHaveLength(0);
    });
  });
});
```

## 💡 สรุป Setup/Teardown

| Hook         | เมื่อไหร่รัน                 | ใช้ทำอะไร                        |
| ------------ | ---------------------------- | -------------------------------- |
| `beforeAll`  | ครั้งเดียว ก่อน test แรก     | เชื่อมต่อ DB, โหลด data          |
| `afterAll`   | ครั้งเดียว หลัง test สุดท้าย | ปิด connection, cleanup          |
| `beforeEach` | ก่อนทุก test                 | Reset state, สร้าง instance ใหม่ |
| `afterEach`  | หลังทุก test                 | Clear data, reset mock           |

---

# Level 6: ทดสอบ Function จริง

> ⭐⭐⭐ ความยาก: ปานกลาง-ยาก

## 🎯 เป้าหมาย

เขียน test สำหรับ function จริงๆ ที่แยกไฟล์

## 📝 ตัวอย่าง 6.1: Function พื้นฐาน

สร้างไฟล์ `src/math.js`:

```javascript
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

function multiply(a, b) {
  return a * b;
}

function divide(a, b) {
  if (b === 0) {
    throw new Error("Cannot divide by zero");
  }
  return a / b;
}

module.exports = { add, subtract, multiply, divide };
```

สร้างไฟล์ `tests/math.test.js`:

```javascript
const { add, subtract, multiply, divide } = require("../src/math");

describe("Math Functions", () => {
  describe("add()", () => {
    test("บวกเลขบวก", () => {
      expect(add(2, 3)).toBe(5);
    });

    test("บวกเลขลบ", () => {
      expect(add(-2, -3)).toBe(-5);
    });

    test("บวกเลขบวกกับลบ", () => {
      expect(add(5, -3)).toBe(2);
    });

    test("บวกกับ 0", () => {
      expect(add(5, 0)).toBe(5);
    });
  });

  describe("divide()", () => {
    test("หารเลข", () => {
      expect(divide(10, 2)).toBe(5);
    });

    test("หารด้วย 0 ต้อง throw error", () => {
      expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
    });
  });
});
```

## 📝 ตัวอย่าง 6.2: String Functions

สร้างไฟล์ `src/string-utils.js`:

```javascript
function capitalize(str) {
  if (!str) return "";
  return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}

function slugify(str) {
  return str
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, "")
    .replace(/[\s_-]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

function truncate(str, maxLength) {
  if (str.length <= maxLength) return str;
  return str.slice(0, maxLength - 3) + "...";
}

function countWords(str) {
  return str
    .trim()
    .split(/\s+/)
    .filter((word) => word.length > 0).length;
}

module.exports = { capitalize, slugify, truncate, countWords };
```

สร้างไฟล์ `tests/string-utils.test.js`:

```javascript
const {
  capitalize,
  slugify,
  truncate,
  countWords,
} = require("../src/string-utils");

describe("String Utilities", () => {
  describe("capitalize()", () => {
    test("ทำให้อักษรตัวแรกเป็นตัวใหญ่", () => {
      expect(capitalize("hello")).toBe("Hello");
    });

    test("จัดการกับ UPPERCASE", () => {
      expect(capitalize("HELLO")).toBe("Hello");
    });

    test("จัดการกับ string ว่าง", () => {
      expect(capitalize("")).toBe("");
    });

    test("จัดการกับ null/undefined", () => {
      expect(capitalize(null)).toBe("");
      expect(capitalize(undefined)).toBe("");
    });
  });

  describe("slugify()", () => {
    test("แปลง space เป็น dash", () => {
      expect(slugify("Hello World")).toBe("hello-world");
    });

    test("ลบอักขระพิเศษ", () => {
      expect(slugify("Hello, World!")).toBe("hello-world");
    });

    test("จัดการ multiple spaces", () => {
      expect(slugify("Hello    World")).toBe("hello-world");
    });
  });

  describe("truncate()", () => {
    test("ไม่ตัดถ้าสั้นกว่า maxLength", () => {
      expect(truncate("Hello", 10)).toBe("Hello");
    });

    test("ตัดและเพิ่ม ... ถ้ายาวเกิน", () => {
      expect(truncate("Hello World", 8)).toBe("Hello...");
    });
  });

  describe("countWords()", () => {
    test("นับคำ", () => {
      expect(countWords("Hello World")).toBe(2);
    });

    test("จัดการ multiple spaces", () => {
      expect(countWords("Hello    World   Test")).toBe(3);
    });

    test("string ว่าง", () => {
      expect(countWords("")).toBe(0);
    });
  });
});
```

## 📝 ตัวอย่าง 6.3: Array Functions

สร้างไฟล์ `src/array-utils.js`:

```javascript
function unique(arr) {
  return [...new Set(arr)];
}

function chunk(arr, size) {
  const result = [];
  for (let i = 0; i < arr.length; i += size) {
    result.push(arr.slice(i, i + size));
  }
  return result;
}

function flatten(arr) {
  return arr.flat(Infinity);
}

function groupBy(arr, key) {
  return arr.reduce((acc, item) => {
    const group = item[key];
    if (!acc[group]) acc[group] = [];
    acc[group].push(item);
    return acc;
  }, {});
}

module.exports = { unique, chunk, flatten, groupBy };
```

สร้างไฟล์ `tests/array-utils.test.js`:

```javascript
const { unique, chunk, flatten, groupBy } = require("../src/array-utils");

describe("Array Utilities", () => {
  describe("unique()", () => {
    test("ลบค่าซ้ำออก", () => {
      expect(unique([1, 2, 2, 3, 3, 3])).toEqual([1, 2, 3]);
    });

    test("จัดการ string", () => {
      expect(unique(["a", "b", "a"])).toEqual(["a", "b"]);
    });

    test("array ว่าง", () => {
      expect(unique([])).toEqual([]);
    });
  });

  describe("chunk()", () => {
    test("แบ่ง array เป็น chunks", () => {
      expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
    });

    test("chunk ใหญ่กว่า array", () => {
      expect(chunk([1, 2], 5)).toEqual([[1, 2]]);
    });
  });

  describe("flatten()", () => {
    test("flatten nested array", () => {
      expect(flatten([1, [2, [3, [4]]]])).toEqual([1, 2, 3, 4]);
    });
  });

  describe("groupBy()", () => {
    test("จัดกลุ่มตาม key", () => {
      const users = [
        { name: "John", role: "admin" },
        { name: "Jane", role: "user" },
        { name: "Bob", role: "admin" },
      ];

      expect(groupBy(users, "role")).toEqual({
        admin: [
          { name: "John", role: "admin" },
          { name: "Bob", role: "admin" },
        ],
        user: [{ name: "Jane", role: "user" }],
      });
    });
  });
});
```

## 📝 ตัวอย่าง 6.4: test.each - ทดสอบหลาย input

```javascript
const { add } = require("../src/math");

describe("add() with test.each", () => {
  // แบบ Array
  test.each([
    [1, 2, 3],
    [5, 5, 10],
    [-1, 1, 0],
    [0, 0, 0],
  ])("add(%i, %i) = %i", (a, b, expected) => {
    expect(add(a, b)).toBe(expected);
  });

  // แบบ Object (อ่านง่ายกว่า)
  test.each([
    { a: 1, b: 2, expected: 3 },
    { a: 5, b: 5, expected: 10 },
    { a: -1, b: 1, expected: 0 },
  ])("add($a, $b) = $expected", ({ a, b, expected }) => {
    expect(add(a, b)).toBe(expected);
  });
});
```

---

# Level 7: ทดสอบ Class และ Object

> ⭐⭐⭐ ความยาก: ปานกลาง-ยาก

## 📝 ตัวอย่าง 7.1: Class พื้นฐาน

สร้างไฟล์ `src/Book.js`:

```javascript
class Book {
  constructor(title, author, isbn) {
    if (!title || !author) {
      throw new Error("Title and author are required");
    }

    this.title = title;
    this.author = author;
    this.isbn = isbn;
    this.totalCopies = 1;
    this.borrowedCopies = 0;
  }

  get availableCopies() {
    return this.totalCopies - this.borrowedCopies;
  }

  get isAvailable() {
    return this.availableCopies > 0;
  }

  addCopies(count) {
    if (count <= 0) {
      throw new Error("Count must be positive");
    }
    this.totalCopies += count;
  }

  borrow() {
    if (!this.isAvailable) {
      throw new Error("No copies available");
    }
    this.borrowedCopies++;
    return true;
  }

  return() {
    if (this.borrowedCopies === 0) {
      throw new Error("No borrowed copies to return");
    }
    this.borrowedCopies--;
    return true;
  }
}

module.exports = Book;
```

สร้างไฟล์ `tests/Book.test.js`:

```javascript
const Book = require("../src/Book");

describe("Book", () => {
  describe("constructor", () => {
    test("สร้าง book ได้สำเร็จ", () => {
      const book = new Book("1984", "George Orwell", "978-0451524935");

      expect(book.title).toBe("1984");
      expect(book.author).toBe("George Orwell");
      expect(book.isbn).toBe("978-0451524935");
      expect(book.totalCopies).toBe(1);
      expect(book.borrowedCopies).toBe(0);
    });

    test("throw error เมื่อไม่มี title", () => {
      expect(() => new Book("", "Author")).toThrow(
        "Title and author are required",
      );
    });

    test("throw error เมื่อไม่มี author", () => {
      expect(() => new Book("Title", "")).toThrow(
        "Title and author are required",
      );
    });
  });

  describe("availableCopies", () => {
    let book;

    beforeEach(() => {
      book = new Book("Test", "Author");
      book.totalCopies = 5;
    });

    test("คำนวณจำนวนที่เหลือถูกต้อง", () => {
      book.borrowedCopies = 2;
      expect(book.availableCopies).toBe(3);
    });

    test("เหลือ 0 เมื่อยืมหมด", () => {
      book.borrowedCopies = 5;
      expect(book.availableCopies).toBe(0);
    });
  });

  describe("isAvailable", () => {
    test("true เมื่อมีหนังสือเหลือ", () => {
      const book = new Book("Test", "Author");
      expect(book.isAvailable).toBe(true);
    });

    test("false เมื่อหนังสือหมด", () => {
      const book = new Book("Test", "Author");
      book.borrowedCopies = 1;
      expect(book.isAvailable).toBe(false);
    });
  });

  describe("addCopies()", () => {
    test("เพิ่มจำนวนหนังสือได้", () => {
      const book = new Book("Test", "Author");
      book.addCopies(5);
      expect(book.totalCopies).toBe(6);
    });

    test("throw error เมื่อเพิ่มจำนวน <= 0", () => {
      const book = new Book("Test", "Author");
      expect(() => book.addCopies(0)).toThrow("Count must be positive");
      expect(() => book.addCopies(-1)).toThrow("Count must be positive");
    });
  });

  describe("borrow()", () => {
    test("ยืมหนังสือสำเร็จ", () => {
      const book = new Book("Test", "Author");
      book.totalCopies = 3;

      const result = book.borrow();

      expect(result).toBe(true);
      expect(book.borrowedCopies).toBe(1);
      expect(book.availableCopies).toBe(2);
    });

    test("throw error เมื่อหนังสือหมด", () => {
      const book = new Book("Test", "Author");
      book.borrowedCopies = 1; // ยืมไปหมดแล้ว

      expect(() => book.borrow()).toThrow("No copies available");
    });
  });

  describe("return()", () => {
    test("คืนหนังสือสำเร็จ", () => {
      const book = new Book("Test", "Author");
      book.borrowedCopies = 1;

      const result = book.return();

      expect(result).toBe(true);
      expect(book.borrowedCopies).toBe(0);
    });

    test("throw error เมื่อไม่มีหนังสือที่ยืมไป", () => {
      const book = new Book("Test", "Author");

      expect(() => book.return()).toThrow("No borrowed copies to return");
    });
  });
});
```

## 📝 ตัวอย่าง 7.2: Class ที่มี Dependency

สร้างไฟล์ `src/Library.js`:

```javascript
class Library {
  constructor(name) {
    this.name = name;
    this.books = [];
    this.members = [];
  }

  addBook(book) {
    this.books.push(book);
  }

  findBookByTitle(title) {
    return this.books.find((book) =>
      book.title.toLowerCase().includes(title.toLowerCase()),
    );
  }

  findBooksByAuthor(author) {
    return this.books.filter((book) =>
      book.author.toLowerCase().includes(author.toLowerCase()),
    );
  }

  getAvailableBooks() {
    return this.books.filter((book) => book.isAvailable);
  }

  getTotalBooks() {
    return this.books.reduce((sum, book) => sum + book.totalCopies, 0);
  }

  getStatistics() {
    const totalBooks = this.getTotalBooks();
    const availableBooks = this.getAvailableBooks().length;
    const borrowedBooks = this.books.reduce(
      (sum, book) => sum + book.borrowedCopies,
      0,
    );

    return {
      totalBooks,
      availableBooks,
      borrowedBooks,
      uniqueTitles: this.books.length,
    };
  }
}

module.exports = Library;
```

สร้างไฟล์ `tests/Library.test.js`:

```javascript
const Library = require("../src/Library");
const Book = require("../src/Book");

describe("Library", () => {
  let library;

  beforeEach(() => {
    library = new Library("Central Library");
  });

  describe("constructor", () => {
    test("สร้าง library ได้", () => {
      expect(library.name).toBe("Central Library");
      expect(library.books).toEqual([]);
      expect(library.members).toEqual([]);
    });
  });

  describe("addBook()", () => {
    test("เพิ่มหนังสือได้", () => {
      const book = new Book("1984", "George Orwell");
      library.addBook(book);

      expect(library.books).toHaveLength(1);
      expect(library.books[0].title).toBe("1984");
    });
  });

  describe("findBookByTitle()", () => {
    beforeEach(() => {
      library.addBook(new Book("JavaScript Basics", "John Doe"));
      library.addBook(new Book("Python Guide", "Jane Smith"));
      library.addBook(new Book("Advanced JavaScript", "Bob Wilson"));
    });

    test("หาหนังสือเจอ", () => {
      const book = library.findBookByTitle("JavaScript");
      expect(book.title).toBe("JavaScript Basics");
    });

    test("ไม่เจอ return undefined", () => {
      const book = library.findBookByTitle("Ruby");
      expect(book).toBeUndefined();
    });

    test("case insensitive", () => {
      const book = library.findBookByTitle("PYTHON");
      expect(book.title).toBe("Python Guide");
    });
  });

  describe("findBooksByAuthor()", () => {
    beforeEach(() => {
      library.addBook(new Book("Book 1", "John Doe"));
      library.addBook(new Book("Book 2", "Jane Smith"));
      library.addBook(new Book("Book 3", "John Doe"));
    });

    test("หาหนังสือตามผู้แต่ง", () => {
      const books = library.findBooksByAuthor("John");
      expect(books).toHaveLength(2);
    });

    test("ไม่เจอ return empty array", () => {
      const books = library.findBooksByAuthor("Unknown");
      expect(books).toEqual([]);
    });
  });

  describe("getAvailableBooks()", () => {
    test("แสดงเฉพาะหนังสือที่ว่าง", () => {
      const book1 = new Book("Book 1", "Author");
      const book2 = new Book("Book 2", "Author");

      library.addBook(book1);
      library.addBook(book2);

      book1.borrow(); // ยืมไป

      const available = library.getAvailableBooks();
      expect(available).toHaveLength(1);
      expect(available[0].title).toBe("Book 2");
    });
  });

  describe("getStatistics()", () => {
    test("แสดงสถิติถูกต้อง", () => {
      const book1 = new Book("Book 1", "Author");
      book1.totalCopies = 5;
      book1.borrowedCopies = 2;

      const book2 = new Book("Book 2", "Author");
      book2.totalCopies = 3;
      book2.borrowedCopies = 3;

      library.addBook(book1);
      library.addBook(book2);

      const stats = library.getStatistics();

      expect(stats).toEqual({
        totalBooks: 8, // 5 + 3
        availableBooks: 1, // book1 ยังว่าง, book2 หมด
        borrowedBooks: 5, // 2 + 3
        uniqueTitles: 2,
      });
    });
  });
});
```

---

# Level 8: ทดสอบ Error และ Exception

> ⭐⭐⭐ ความยาก: ปานกลาง-ยาก

## 📝 ตัวอย่าง 8.1: toThrow พื้นฐาน

```javascript
function validateAge(age) {
  if (typeof age !== "number") {
    throw new TypeError("Age must be a number");
  }
  if (age < 0) {
    throw new RangeError("Age cannot be negative");
  }
  if (age > 150) {
    throw new RangeError("Age cannot exceed 150");
  }
  return true;
}

describe("validateAge()", () => {
  test("ยอมรับ age ที่ถูกต้อง", () => {
    expect(validateAge(25)).toBe(true);
    expect(validateAge(0)).toBe(true);
    expect(validateAge(150)).toBe(true);
  });

  // ⚠️ ต้องห่อด้วย arrow function!
  test("throw TypeError เมื่อไม่ใช่ตัวเลข", () => {
    expect(() => validateAge("25")).toThrow(TypeError);
    expect(() => validateAge(null)).toThrow(TypeError);
  });

  test("throw RangeError เมื่อ age ติดลบ", () => {
    expect(() => validateAge(-1)).toThrow(RangeError);
  });

  test("throw RangeError เมื่อ age เกิน 150", () => {
    expect(() => validateAge(200)).toThrow(RangeError);
  });

  // ตรวจสอบ error message
  test("throw error พร้อม message ที่ถูกต้อง", () => {
    expect(() => validateAge(-5)).toThrow("Age cannot be negative");
  });

  // ตรวจสอบด้วย regex
  test("throw error ที่ match regex", () => {
    expect(() => validateAge("abc")).toThrow(/must be a number/);
  });
});
```

## 📝 ตัวอย่าง 8.2: Custom Error Class

```javascript
// src/errors.js
class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}

class NotFoundError extends Error {
  constructor(resource, id) {
    super(`${resource} with id ${id} not found`);
    this.name = "NotFoundError";
    this.resource = resource;
    this.id = id;
  }
}

module.exports = { ValidationError, NotFoundError };
```

```javascript
// src/userService.js
const { ValidationError, NotFoundError } = require("./errors");

const users = [
  { id: 1, name: "John", email: "john@test.com" },
  { id: 2, name: "Jane", email: "jane@test.com" },
];

function createUser(userData) {
  if (!userData.name) {
    throw new ValidationError("Name is required", "name");
  }
  if (!userData.email) {
    throw new ValidationError("Email is required", "email");
  }
  if (!/\S+@\S+\.\S+/.test(userData.email)) {
    throw new ValidationError("Invalid email format", "email");
  }

  const newUser = { id: users.length + 1, ...userData };
  users.push(newUser);
  return newUser;
}

function getUserById(id) {
  const user = users.find((u) => u.id === id);
  if (!user) {
    throw new NotFoundError("User", id);
  }
  return user;
}

module.exports = { createUser, getUserById };
```

```javascript
// tests/userService.test.js
const { createUser, getUserById } = require("../src/userService");
const { ValidationError, NotFoundError } = require("../src/errors");

describe("User Service", () => {
  describe("createUser()", () => {
    test("สร้าง user สำเร็จ", () => {
      const user = createUser({ name: "Bob", email: "bob@test.com" });
      expect(user).toHaveProperty("id");
      expect(user.name).toBe("Bob");
    });

    test("throw ValidationError เมื่อไม่มี name", () => {
      expect(() => createUser({ email: "test@test.com" })).toThrow(
        ValidationError,
      );
    });

    test("throw ValidationError เมื่อไม่มี email", () => {
      expect(() => createUser({ name: "Test" })).toThrow(ValidationError);
    });

    test("ตรวจสอบ ValidationError properties", () => {
      try {
        createUser({ name: "Test" });
        fail("Should have thrown"); // ถ้ามาถึงบรรทัดนี้ = test fail
      } catch (error) {
        expect(error).toBeInstanceOf(ValidationError);
        expect(error.field).toBe("email");
        expect(error.message).toBe("Email is required");
      }
    });

    test("throw ValidationError เมื่อ email format ไม่ถูก", () => {
      expect(() => createUser({ name: "Test", email: "invalid" })).toThrow(
        "Invalid email format",
      );
    });
  });

  describe("getUserById()", () => {
    test("หา user เจอ", () => {
      const user = getUserById(1);
      expect(user.name).toBe("John");
    });

    test("throw NotFoundError เมื่อไม่เจอ", () => {
      expect(() => getUserById(999)).toThrow(NotFoundError);
    });

    test("ตรวจสอบ NotFoundError properties", () => {
      try {
        getUserById(999);
      } catch (error) {
        expect(error).toBeInstanceOf(NotFoundError);
        expect(error.resource).toBe("User");
        expect(error.id).toBe(999);
        expect(error.message).toBe("User with id 999 not found");
      }
    });
  });
});
```

## 📝 ตัวอย่าง 8.3: ทดสอบ Error ด้วย rejects (Async)

```javascript
// ฟังก์ชัน async ที่ throw error
async function fetchUser(id) {
  if (id <= 0) {
    throw new Error("Invalid user ID");
  }
  // จำลอง API call
  return { id, name: "User " + id };
}

describe("fetchUser()", () => {
  test("throw error เมื่อ id <= 0", async () => {
    await expect(fetchUser(0)).rejects.toThrow("Invalid user ID");
    await expect(fetchUser(-1)).rejects.toThrow(Error);
  });

  test("return user เมื่อ id ถูกต้อง", async () => {
    const user = await fetchUser(1);
    expect(user).toEqual({ id: 1, name: "User 1" });
  });
});
```

---

# Level 9: Async/Await Testing

> ⭐⭐⭐⭐ ความยาก: ยาก

## 📝 ตัวอย่าง 9.1: Async Function พื้นฐาน

```javascript
// src/api.js

// จำลอง delay
function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchData(id) {
  await delay(100); // จำลอง network delay

  if (id === 999) {
    throw new Error("Not found");
  }

  return { id, data: `Data for ${id}` };
}

async function fetchMultiple(ids) {
  const results = await Promise.all(ids.map((id) => fetchData(id)));
  return results;
}

module.exports = { fetchData, fetchMultiple, delay };
```

```javascript
// tests/api.test.js
const { fetchData, fetchMultiple } = require("../src/api");

describe("API Functions", () => {
  describe("fetchData()", () => {
    // ✅ วิธีที่ 1: async/await
    test("fetch data สำเร็จ (async/await)", async () => {
      const result = await fetchData(1);

      expect(result).toEqual({ id: 1, data: "Data for 1" });
    });

    // ✅ วิธีที่ 2: return Promise
    test("fetch data สำเร็จ (return Promise)", () => {
      return fetchData(2).then((result) => {
        expect(result).toEqual({ id: 2, data: "Data for 2" });
      });
    });

    // ✅ วิธีที่ 3: resolves
    test("fetch data สำเร็จ (resolves)", async () => {
      await expect(fetchData(3)).resolves.toEqual({
        id: 3,
        data: "Data for 3",
      });
    });

    // ทดสอบ error
    test("throw error เมื่อ id = 999", async () => {
      await expect(fetchData(999)).rejects.toThrow("Not found");
    });
  });

  describe("fetchMultiple()", () => {
    test("fetch หลาย items พร้อมกัน", async () => {
      const results = await fetchMultiple([1, 2, 3]);

      expect(results).toHaveLength(3);
      expect(results[0]).toEqual({ id: 1, data: "Data for 1" });
      expect(results[2]).toEqual({ id: 3, data: "Data for 3" });
    });
  });
});
```

## 📝 ตัวอย่าง 9.2: ทดสอบ Callback (เก่า)

```javascript
// src/legacy.js
function fetchWithCallback(id, callback) {
  setTimeout(() => {
    if (id === 999) {
      callback(new Error("Not found"), null);
    } else {
      callback(null, { id, data: `Data for ${id}` });
    }
  }, 100);
}

module.exports = { fetchWithCallback };
```

```javascript
// tests/legacy.test.js
const { fetchWithCallback } = require("../src/legacy");

describe("Callback Functions", () => {
  // ใช้ done parameter
  test("fetch สำเร็จ (callback)", (done) => {
    fetchWithCallback(1, (error, result) => {
      expect(error).toBeNull();
      expect(result).toEqual({ id: 1, data: "Data for 1" });
      done(); // ⚠️ ต้องเรียก done() เพื่อบอกว่า test จบ
    });
  });

  test("fetch error (callback)", (done) => {
    fetchWithCallback(999, (error, result) => {
      expect(error).toBeInstanceOf(Error);
      expect(error.message).toBe("Not found");
      expect(result).toBeNull();
      done();
    });
  });
});
```

## 📝 ตัวอย่าง 9.3: ทดสอบ Timer

```javascript
// src/timer.js
function debounce(fn, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  };
}

function scheduleTask(task, delay) {
  return new Promise((resolve) => {
    setTimeout(() => {
      task();
      resolve("done");
    }, delay);
  });
}

module.exports = { debounce, scheduleTask };
```

```javascript
// tests/timer.test.js
const { debounce, scheduleTask } = require("../src/timer");

// ใช้ fake timers
jest.useFakeTimers();

describe("Timer Functions", () => {
  afterEach(() => {
    jest.clearAllTimers();
  });

  describe("debounce()", () => {
    test("เรียก function หลังจาก delay", () => {
      const mockFn = jest.fn();
      const debouncedFn = debounce(mockFn, 1000);

      debouncedFn();
      expect(mockFn).not.toBeCalled(); // ยังไม่ถูกเรียก

      jest.advanceTimersByTime(500);
      expect(mockFn).not.toBeCalled(); // ยังไม่ครบ 1000ms

      jest.advanceTimersByTime(500);
      expect(mockFn).toBeCalledTimes(1); // ครบ 1000ms แล้ว
    });

    test("reset timer เมื่อเรียกซ้ำ", () => {
      const mockFn = jest.fn();
      const debouncedFn = debounce(mockFn, 1000);

      debouncedFn();
      jest.advanceTimersByTime(500);

      debouncedFn(); // reset timer
      jest.advanceTimersByTime(500);
      expect(mockFn).not.toBeCalled(); // ยังไม่ครบ

      jest.advanceTimersByTime(500);
      expect(mockFn).toBeCalledTimes(1);
    });
  });

  describe("scheduleTask()", () => {
    test("รัน task หลังจาก delay", async () => {
      const mockTask = jest.fn();
      const promise = scheduleTask(mockTask, 2000);

      expect(mockTask).not.toBeCalled();

      jest.advanceTimersByTime(2000);

      await promise; // รอ promise resolve
      expect(mockTask).toBeCalledTimes(1);
    });
  });
});
```

---

# Level 10: Mocking พื้นฐาน

> ⭐⭐⭐⭐ ความยาก: ยาก

## 🎯 ทำไมต้อง Mock?

- **แยก test** ไม่ให้ขึ้นกับ dependency
- **เร็วขึ้น** ไม่ต้องรอ network/database
- **ควบคุมได้** จำลอง error, edge case
- **ไม่เปลี่ยนแปลงข้อมูลจริง**

## 📝 ตัวอย่าง 10.1: jest.fn() พื้นฐาน

```javascript
describe("jest.fn() Basics", () => {
  test("สร้าง mock function", () => {
    const mockFn = jest.fn();

    mockFn();
    mockFn("hello");
    mockFn(1, 2, 3);

    // ตรวจว่าถูกเรียกกี่ครั้ง
    expect(mockFn).toHaveBeenCalled();
    expect(mockFn).toHaveBeenCalledTimes(3);

    // ตรวจ arguments
    expect(mockFn).toHaveBeenCalledWith("hello");
    expect(mockFn).toHaveBeenCalledWith(1, 2, 3);
  });

  test("กำหนด return value", () => {
    const mockFn = jest.fn();

    // return ค่าเดียวตลอด
    mockFn.mockReturnValue(42);
    expect(mockFn()).toBe(42);
    expect(mockFn()).toBe(42);

    // return ค่าต่างกันทุกครั้ง
    mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValueOnce(3);

    expect(mockFn()).toBe(1);
    expect(mockFn()).toBe(2);
    expect(mockFn()).toBe(3);
    expect(mockFn()).toBe(42); // กลับไปใช้ค่าเดิม
  });

  test("mock implementation", () => {
    const mockFn = jest.fn((a, b) => a + b);

    expect(mockFn(2, 3)).toBe(5);
    expect(mockFn(10, 20)).toBe(30);
  });
});
```

## 📝 ตัวอย่าง 10.2: Mock Callback

```javascript
// src/processor.js
function processItems(items, callback) {
  return items.map((item) => callback(item));
}

function processAsync(items, callback) {
  return Promise.all(items.map((item) => callback(item)));
}

module.exports = { processItems, processAsync };
```

```javascript
// tests/processor.test.js
const { processItems, processAsync } = require("../src/processor");

describe("Processor", () => {
  describe("processItems()", () => {
    test("เรียก callback สำหรับทุก item", () => {
      const mockCallback = jest.fn((x) => x * 2);
      const items = [1, 2, 3];

      const result = processItems(items, mockCallback);

      expect(mockCallback).toHaveBeenCalledTimes(3);
      expect(mockCallback).toHaveBeenNthCalledWith(1, 1);
      expect(mockCallback).toHaveBeenNthCalledWith(2, 2);
      expect(mockCallback).toHaveBeenNthCalledWith(3, 3);
      expect(result).toEqual([2, 4, 6]);
    });
  });

  describe("processAsync()", () => {
    test("เรียก async callback", async () => {
      const mockCallback = jest.fn(async (x) => x * 2);
      const items = [1, 2, 3];

      const result = await processAsync(items, mockCallback);

      expect(mockCallback).toHaveBeenCalledTimes(3);
      expect(result).toEqual([2, 4, 6]);
    });
  });
});
```

## 📝 ตัวอย่าง 10.3: Mock Method ของ Object

```javascript
// src/userManager.js
class UserManager {
  constructor(database) {
    this.database = database;
  }

  async createUser(userData) {
    const user = { id: Date.now(), ...userData };
    await this.database.save("users", user);
    return user;
  }

  async getUser(id) {
    return await this.database.findById("users", id);
  }

  async deleteUser(id) {
    const user = await this.getUser(id);
    if (!user) {
      throw new Error("User not found");
    }
    await this.database.delete("users", id);
    return true;
  }
}

module.exports = UserManager;
```

```javascript
// tests/userManager.test.js
const UserManager = require("../src/userManager");

describe("UserManager", () => {
  let userManager;
  let mockDatabase;

  beforeEach(() => {
    // สร้าง mock database
    mockDatabase = {
      save: jest.fn(),
      findById: jest.fn(),
      delete: jest.fn(),
    };

    userManager = new UserManager(mockDatabase);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe("createUser()", () => {
    test("บันทึก user ลง database", async () => {
      const userData = { name: "John", email: "john@test.com" };

      const result = await userManager.createUser(userData);

      expect(mockDatabase.save).toHaveBeenCalledTimes(1);
      expect(mockDatabase.save).toHaveBeenCalledWith(
        "users",
        expect.objectContaining({
          name: "John",
          email: "john@test.com",
        }),
      );
      expect(result).toHaveProperty("id");
      expect(result.name).toBe("John");
    });
  });

  describe("getUser()", () => {
    test("ดึงข้อมูล user จาก database", async () => {
      const mockUser = { id: 1, name: "John" };
      mockDatabase.findById.mockResolvedValue(mockUser);

      const result = await userManager.getUser(1);

      expect(mockDatabase.findById).toHaveBeenCalledWith("users", 1);
      expect(result).toEqual(mockUser);
    });

    test("return null เมื่อไม่เจอ", async () => {
      mockDatabase.findById.mockResolvedValue(null);

      const result = await userManager.getUser(999);

      expect(result).toBeNull();
    });
  });

  describe("deleteUser()", () => {
    test("ลบ user สำเร็จ", async () => {
      mockDatabase.findById.mockResolvedValue({ id: 1, name: "John" });
      mockDatabase.delete.mockResolvedValue(true);

      const result = await userManager.deleteUser(1);

      expect(mockDatabase.delete).toHaveBeenCalledWith("users", 1);
      expect(result).toBe(true);
    });

    test("throw error เมื่อไม่เจอ user", async () => {
      mockDatabase.findById.mockResolvedValue(null);

      await expect(userManager.deleteUser(999)).rejects.toThrow(
        "User not found",
      );
      expect(mockDatabase.delete).not.toHaveBeenCalled();
    });
  });
});
```

## 💡 สรุป Mock Matchers

| Matcher                            | ใช้ตรวจอะไร                       |
| ---------------------------------- | --------------------------------- |
| `toHaveBeenCalled()`               | ถูกเรียกอย่างน้อย 1 ครั้ง         |
| `toHaveBeenCalledTimes(n)`         | ถูกเรียก n ครั้ง                  |
| `toHaveBeenCalledWith(args)`       | ถูกเรียกด้วย arguments นี้        |
| `toHaveBeenNthCalledWith(n, args)` | ครั้งที่ n ถูกเรียกด้วย args นี้  |
| `toHaveBeenLastCalledWith(args)`   | ครั้งสุดท้ายถูกเรียกด้วย args นี้ |
| `toHaveReturned()`                 | มีการ return ค่า                  |
| `toHaveReturnedWith(value)`        | return ค่านี้                     |

---

# Level 11: Mocking ขั้นสูง

> ⭐⭐⭐⭐⭐ ความยาก: ยากมาก

## 📝 ตัวอย่าง 11.1: jest.mock() - Mock ทั้ง Module

```javascript
// src/emailService.js
const nodemailer = require("nodemailer");

async function sendEmail(to, subject, body) {
  const transporter = nodemailer.createTransport({
    host: "smtp.gmail.com",
    port: 587,
  });

  const result = await transporter.sendMail({
    from: "noreply@library.com",
    to,
    subject,
    text: body,
  });

  return result.messageId;
}

module.exports = { sendEmail };
```

```javascript
// tests/emailService.test.js

// Mock nodemailer ก่อน import
jest.mock("nodemailer");

const nodemailer = require("nodemailer");
const { sendEmail } = require("../src/emailService");

describe("Email Service", () => {
  let mockSendMail;

  beforeEach(() => {
    mockSendMail = jest.fn().mockResolvedValue({
      messageId: "test-message-id-123",
    });

    nodemailer.createTransport.mockReturnValue({
      sendMail: mockSendMail,
    });
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  test("ส่ง email สำเร็จ", async () => {
    const result = await sendEmail(
      "user@test.com",
      "Test Subject",
      "Test Body",
    );

    expect(nodemailer.createTransport).toHaveBeenCalled();
    expect(mockSendMail).toHaveBeenCalledWith({
      from: "noreply@library.com",
      to: "user@test.com",
      subject: "Test Subject",
      text: "Test Body",
    });
    expect(result).toBe("test-message-id-123");
  });

  test("จัดการ error", async () => {
    mockSendMail.mockRejectedValue(new Error("SMTP Error"));

    await expect(sendEmail("test@test.com", "Hi", "Body")).rejects.toThrow(
      "SMTP Error",
    );
  });
});
```

## 📝 ตัวอย่าง 11.2: jest.spyOn() - สอดแนม Method

```javascript
// src/calculator.js
const calculator = {
  add(a, b) {
    return a + b;
  },
  multiply(a, b) {
    return a * b;
  },
  calculate(a, b, operation) {
    if (operation === "add") {
      return this.add(a, b);
    } else if (operation === "multiply") {
      return this.multiply(a, b);
    }
    throw new Error("Unknown operation");
  },
};

module.exports = calculator;
```

```javascript
// tests/calculator.test.js
const calculator = require("../src/calculator");

describe("Calculator with spyOn", () => {
  afterEach(() => {
    jest.restoreAllMocks(); // คืนค่า original
  });

  test("spyOn - ตรวจว่า method ถูกเรียก", () => {
    const addSpy = jest.spyOn(calculator, "add");

    const result = calculator.calculate(2, 3, "add");

    expect(addSpy).toHaveBeenCalledWith(2, 3);
    expect(result).toBe(5); // ยังทำงานจริง
  });

  test("spyOn + mockImplementation - เปลี่ยนพฤติกรรม", () => {
    const addSpy = jest
      .spyOn(calculator, "add")
      .mockImplementation((a, b) => a * 10 + b);

    const result = calculator.calculate(2, 3, "add");

    expect(result).toBe(23); // 2*10 + 3
  });

  test("spyOn + mockReturnValue", () => {
    jest.spyOn(calculator, "add").mockReturnValue(999);

    expect(calculator.add(1, 2)).toBe(999);
  });
});
```

## 📝 ตัวอย่าง 11.3: Mock File System

```javascript
// src/fileManager.js
const fs = require("fs").promises;
const path = require("path");

async function readConfig(filename) {
  const filepath = path.join(__dirname, "config", filename);
  const content = await fs.readFile(filepath, "utf-8");
  return JSON.parse(content);
}

async function writeConfig(filename, data) {
  const filepath = path.join(__dirname, "config", filename);
  await fs.writeFile(filepath, JSON.stringify(data, null, 2));
  return true;
}

async function listConfigs() {
  const configDir = path.join(__dirname, "config");
  const files = await fs.readdir(configDir);
  return files.filter((f) => f.endsWith(".json"));
}

module.exports = { readConfig, writeConfig, listConfigs };
```

```javascript
// tests/fileManager.test.js
const fs = require("fs").promises;
const { readConfig, writeConfig, listConfigs } = require("../src/fileManager");

jest.mock("fs", () => ({
  promises: {
    readFile: jest.fn(),
    writeFile: jest.fn(),
    readdir: jest.fn(),
  },
}));

describe("File Manager", () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  describe("readConfig()", () => {
    test("อ่าน config file", async () => {
      const mockConfig = { database: "localhost", port: 3000 };
      fs.readFile.mockResolvedValue(JSON.stringify(mockConfig));

      const result = await readConfig("app.json");

      expect(fs.readFile).toHaveBeenCalled();
      expect(result).toEqual(mockConfig);
    });

    test("throw error เมื่อไฟล์ไม่มี", async () => {
      fs.readFile.mockRejectedValue(new Error("ENOENT: no such file"));

      await expect(readConfig("missing.json")).rejects.toThrow("ENOENT");
    });
  });

  describe("writeConfig()", () => {
    test("เขียน config file", async () => {
      fs.writeFile.mockResolvedValue();

      const result = await writeConfig("app.json", { port: 4000 });

      expect(fs.writeFile).toHaveBeenCalled();
      expect(result).toBe(true);
    });
  });

  describe("listConfigs()", () => {
    test("แสดงรายการ config files", async () => {
      fs.readdir.mockResolvedValue([
        "app.json",
        "db.json",
        "readme.txt",
        "test.json",
      ]);

      const result = await listConfigs();

      expect(result).toEqual(["app.json", "db.json", "test.json"]);
    });
  });
});
```

## 📝 ตัวอย่าง 11.4: Mock Axios / HTTP Requests

```javascript
// src/apiClient.js
const axios = require("axios");

const API_BASE = "https://api.example.com";

async function getUsers() {
  const response = await axios.get(`${API_BASE}/users`);
  return response.data;
}

async function createUser(userData) {
  const response = await axios.post(`${API_BASE}/users`, userData);
  return response.data;
}

async function updateUser(id, userData) {
  const response = await axios.put(`${API_BASE}/users/${id}`, userData);
  return response.data;
}

module.exports = { getUsers, createUser, updateUser };
```

```javascript
// tests/apiClient.test.js
const axios = require("axios");
const { getUsers, createUser, updateUser } = require("../src/apiClient");

jest.mock("axios");

describe("API Client", () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  describe("getUsers()", () => {
    test("ดึงข้อมูล users สำเร็จ", async () => {
      const mockUsers = [
        { id: 1, name: "John" },
        { id: 2, name: "Jane" },
      ];
      axios.get.mockResolvedValue({ data: mockUsers });

      const result = await getUsers();

      expect(axios.get).toHaveBeenCalledWith("https://api.example.com/users");
      expect(result).toEqual(mockUsers);
    });

    test("จัดการ error", async () => {
      axios.get.mockRejectedValue(new Error("Network Error"));

      await expect(getUsers()).rejects.toThrow("Network Error");
    });
  });

  describe("createUser()", () => {
    test("สร้าง user สำเร็จ", async () => {
      const userData = { name: "Bob", email: "bob@test.com" };
      const createdUser = { id: 3, ...userData };
      axios.post.mockResolvedValue({ data: createdUser });

      const result = await createUser(userData);

      expect(axios.post).toHaveBeenCalledWith(
        "https://api.example.com/users",
        userData,
      );
      expect(result).toEqual(createdUser);
    });
  });

  describe("updateUser()", () => {
    test("update user สำเร็จ", async () => {
      const userData = { name: "John Updated" };
      const updatedUser = { id: 1, ...userData };
      axios.put.mockResolvedValue({ data: updatedUser });

      const result = await updateUser(1, userData);

      expect(axios.put).toHaveBeenCalledWith(
        "https://api.example.com/users/1",
        userData,
      );
      expect(result).toEqual(updatedUser);
    });
  });
});
```

---

# Level 12: Test Driven Development

> ⭐⭐⭐⭐⭐ ความยาก: ยากมาก

## 🔴🟢🔵 TDD Cycle: Red → Green → Refactor

1. **🔴 Red**: เขียน test ก่อน (จะ fail)
2. **🟢 Green**: เขียน code ให้ test ผ่าน (minimal)
3. **🔵 Refactor**: ปรับปรุง code (test ยังผ่าน)

## 📝 ตัวอย่าง: สร้าง Password Validator ด้วย TDD

### Step 1: 🔴 Red - เขียน Test แรก

```javascript
// tests/passwordValidator.test.js

const { validatePassword } = require("../src/passwordValidator");

describe("Password Validator", () => {
  test("ยอมรับ password ที่ถูกต้อง", () => {
    expect(validatePassword("Abcd1234!")).toBe(true);
  });
});
```

**รัน test → FAIL** (ยังไม่มี function)

### Step 2: 🟢 Green - เขียน Code ให้ผ่าน

```javascript
// src/passwordValidator.js

function validatePassword(password) {
  return true; // แค่ให้ผ่านก่อน
}

module.exports = { validatePassword };
```

**รัน test → PASS**

### Step 3: 🔴 Red - เพิ่ม Test: ต้องมีอย่างน้อย 8 ตัว

```javascript
test("reject password สั้นกว่า 8 ตัว", () => {
  expect(validatePassword("Ab1!")).toBe(false);
});
```

**รัน test → FAIL**

### Step 4: 🟢 Green - แก้ Code

```javascript
function validatePassword(password) {
  if (password.length < 8) return false;
  return true;
}
```

**รัน test → PASS**

### Step 5: 🔴 Red - เพิ่ม Test: ต้องมีตัวพิมพ์ใหญ่

```javascript
test("reject password ไม่มีตัวพิมพ์ใหญ่", () => {
  expect(validatePassword("abcd1234!")).toBe(false);
});
```

### Step 6: 🟢 Green - แก้ Code

```javascript
function validatePassword(password) {
  if (password.length < 8) return false;
  if (!/[A-Z]/.test(password)) return false;
  return true;
}
```

### ... ทำซ้ำ จนครบทุก requirement ...

### Final Code

```javascript
// src/passwordValidator.js

function validatePassword(password) {
  const errors = [];

  if (password.length < 8) {
    errors.push("ต้องมีอย่างน้อย 8 ตัวอักษร");
  }

  if (!/[A-Z]/.test(password)) {
    errors.push("ต้องมีตัวพิมพ์ใหญ่อย่างน้อย 1 ตัว");
  }

  if (!/[a-z]/.test(password)) {
    errors.push("ต้องมีตัวพิมพ์เล็กอย่างน้อย 1 ตัว");
  }

  if (!/[0-9]/.test(password)) {
    errors.push("ต้องมีตัวเลขอย่างน้อย 1 ตัว");
  }

  if (!/[!@#$%^&*]/.test(password)) {
    errors.push("ต้องมีอักขระพิเศษอย่างน้อย 1 ตัว");
  }

  return errors.length === 0;
}

function getPasswordErrors(password) {
  const errors = [];

  if (password.length < 8) {
    errors.push("ต้องมีอย่างน้อย 8 ตัวอักษร");
  }
  if (!/[A-Z]/.test(password)) {
    errors.push("ต้องมีตัวพิมพ์ใหญ่อย่างน้อย 1 ตัว");
  }
  if (!/[a-z]/.test(password)) {
    errors.push("ต้องมีตัวพิมพ์เล็กอย่างน้อย 1 ตัว");
  }
  if (!/[0-9]/.test(password)) {
    errors.push("ต้องมีตัวเลขอย่างน้อย 1 ตัว");
  }
  if (!/[!@#$%^&*]/.test(password)) {
    errors.push("ต้องมีอักขระพิเศษอย่างน้อย 1 ตัว");
  }

  return errors;
}

module.exports = { validatePassword, getPasswordErrors };
```

### Final Tests

```javascript
// tests/passwordValidator.test.js

const {
  validatePassword,
  getPasswordErrors,
} = require("../src/passwordValidator");

describe("Password Validator", () => {
  describe("validatePassword()", () => {
    describe("Valid passwords", () => {
      test.each(["Abcd1234!", "MyP@ssw0rd", "Str0ng!Pass", "Test123!@#"])(
        '"%s" should be valid',
        (password) => {
          expect(validatePassword(password)).toBe(true);
        },
      );
    });

    describe("Invalid passwords", () => {
      test("reject สั้นกว่า 8 ตัว", () => {
        expect(validatePassword("Ab1!")).toBe(false);
      });

      test("reject ไม่มีตัวพิมพ์ใหญ่", () => {
        expect(validatePassword("abcd1234!")).toBe(false);
      });

      test("reject ไม่มีตัวพิมพ์เล็ก", () => {
        expect(validatePassword("ABCD1234!")).toBe(false);
      });

      test("reject ไม่มีตัวเลข", () => {
        expect(validatePassword("Abcdefgh!")).toBe(false);
      });

      test("reject ไม่มีอักขระพิเศษ", () => {
        expect(validatePassword("Abcd12345")).toBe(false);
      });
    });
  });

  describe("getPasswordErrors()", () => {
    test("return empty array สำหรับ valid password", () => {
      expect(getPasswordErrors("Abcd1234!")).toEqual([]);
    });

    test("return all errors สำหรับ empty string", () => {
      const errors = getPasswordErrors("");
      expect(errors).toHaveLength(5);
    });

    test("return specific error", () => {
      const errors = getPasswordErrors("abcd1234!");
      expect(errors).toContain("ต้องมีตัวพิมพ์ใหญ่อย่างน้อย 1 ตัว");
      expect(errors).toHaveLength(1);
    });
  });
});
```

---

# Level 13: โปรเจกต์จริง: Library System

> ⭐⭐⭐⭐⭐ ความยาก: ยากมาก

## 📁 โครงสร้างโปรเจกต์

```
library-system/
├── src/
│   ├── models/
│   │   ├── Book.js
│   │   ├── Member.js
│   │   └── Loan.js
│   ├── services/
│   │   ├── BookService.js
│   │   ├── MemberService.js
│   │   └── LoanService.js
│   ├── repositories/
│   │   └── Database.js
│   └── utils/
│       └── validators.js
├── tests/
│   ├── models/
│   │   ├── Book.test.js
│   │   ├── Member.test.js
│   │   └── Loan.test.js
│   ├── services/
│   │   ├── BookService.test.js
│   │   ├── MemberService.test.js
│   │   └── LoanService.test.js
│   └── utils/
│       └── validators.test.js
├── package.json
└── jest.config.js
```

## 📝 ตัวอย่าง: Book Model + Tests

```javascript
// src/models/Book.js

class Book {
  constructor({ isbn, title, author, publishYear, copies = 1 }) {
    this.validateInput({ isbn, title, author, publishYear });

    this.isbn = isbn;
    this.title = title;
    this.author = author;
    this.publishYear = publishYear;
    this.totalCopies = copies;
    this.borrowedCopies = 0;
    this.createdAt = new Date();
  }

  validateInput({ isbn, title, author, publishYear }) {
    if (!isbn || !/^[\d-]+$/.test(isbn)) {
      throw new Error("Invalid ISBN format");
    }
    if (!title || title.trim().length === 0) {
      throw new Error("Title is required");
    }
    if (!author || author.trim().length === 0) {
      throw new Error("Author is required");
    }
    if (
      publishYear &&
      (publishYear < 1000 || publishYear > new Date().getFullYear())
    ) {
      throw new Error("Invalid publish year");
    }
  }

  get availableCopies() {
    return this.totalCopies - this.borrowedCopies;
  }

  get isAvailable() {
    return this.availableCopies > 0;
  }

  borrow() {
    if (!this.isAvailable) {
      throw new Error("No copies available for borrowing");
    }
    this.borrowedCopies++;
    return {
      isbn: this.isbn,
      borrowedAt: new Date(),
      dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days
    };
  }

  return() {
    if (this.borrowedCopies === 0) {
      throw new Error("No borrowed copies to return");
    }
    this.borrowedCopies--;
    return true;
  }

  addCopies(count) {
    if (count <= 0) {
      throw new Error("Count must be a positive number");
    }
    this.totalCopies += count;
  }

  toJSON() {
    return {
      isbn: this.isbn,
      title: this.title,
      author: this.author,
      publishYear: this.publishYear,
      totalCopies: this.totalCopies,
      availableCopies: this.availableCopies,
      isAvailable: this.isAvailable,
    };
  }
}

module.exports = Book;
```

```javascript
// tests/models/Book.test.js

const Book = require("../../src/models/Book");

describe("Book Model", () => {
  // Helper function
  const createValidBook = (overrides = {}) => {
    return new Book({
      isbn: "978-1234567890",
      title: "Test Book",
      author: "Test Author",
      publishYear: 2024,
      ...overrides,
    });
  };

  describe("constructor", () => {
    test("สร้าง book ด้วยข้อมูลที่ถูกต้อง", () => {
      const book = createValidBook();

      expect(book.isbn).toBe("978-1234567890");
      expect(book.title).toBe("Test Book");
      expect(book.author).toBe("Test Author");
      expect(book.publishYear).toBe(2024);
      expect(book.totalCopies).toBe(1);
      expect(book.borrowedCopies).toBe(0);
      expect(book.createdAt).toBeInstanceOf(Date);
    });

    test("กำหนดจำนวน copies ได้", () => {
      const book = createValidBook({ copies: 5 });
      expect(book.totalCopies).toBe(5);
    });

    describe("validation errors", () => {
      test.each([
        [{ isbn: "" }, "Invalid ISBN format"],
        [{ isbn: "abc" }, "Invalid ISBN format"],
        [{ title: "" }, "Title is required"],
        [{ title: "   " }, "Title is required"],
        [{ author: "" }, "Author is required"],
        [{ publishYear: 500 }, "Invalid publish year"],
        [{ publishYear: 3000 }, "Invalid publish year"],
      ])("throw error: %o → %s", (override, expectedError) => {
        expect(() => createValidBook(override)).toThrow(expectedError);
      });
    });
  });

  describe("availableCopies", () => {
    test("คำนวณถูกต้อง", () => {
      const book = createValidBook({ copies: 5 });
      book.borrowedCopies = 2;

      expect(book.availableCopies).toBe(3);
    });

    test("เป็น 0 เมื่อยืมหมด", () => {
      const book = createValidBook({ copies: 2 });
      book.borrowedCopies = 2;

      expect(book.availableCopies).toBe(0);
    });
  });

  describe("isAvailable", () => {
    test("true เมื่อมีหนังสือเหลือ", () => {
      const book = createValidBook();
      expect(book.isAvailable).toBe(true);
    });

    test("false เมื่อหนังสือหมด", () => {
      const book = createValidBook();
      book.borrowedCopies = 1;
      expect(book.isAvailable).toBe(false);
    });
  });

  describe("borrow()", () => {
    test("ยืมสำเร็จ - เพิ่ม borrowedCopies", () => {
      const book = createValidBook({ copies: 3 });

      const result = book.borrow();

      expect(book.borrowedCopies).toBe(1);
      expect(book.availableCopies).toBe(2);
      expect(result).toHaveProperty("isbn", "978-1234567890");
      expect(result).toHaveProperty("borrowedAt");
      expect(result).toHaveProperty("dueDate");
    });

    test("dueDate เป็น 14 วันหลังยืม", () => {
      const book = createValidBook();
      const beforeBorrow = Date.now();

      const result = book.borrow();

      const expectedDue = beforeBorrow + 14 * 24 * 60 * 60 * 1000;
      expect(result.dueDate.getTime()).toBeCloseTo(expectedDue, -3); // +-1 second
    });

    test("throw error เมื่อหนังสือหมด", () => {
      const book = createValidBook();
      book.borrow(); // ยืมไป 1 เล่ม (หมดแล้ว)

      expect(() => book.borrow()).toThrow("No copies available for borrowing");
    });

    test("ยืมได้จนหมด", () => {
      const book = createValidBook({ copies: 3 });

      book.borrow();
      book.borrow();
      book.borrow();

      expect(book.isAvailable).toBe(false);
      expect(() => book.borrow()).toThrow();
    });
  });

  describe("return()", () => {
    test("คืนสำเร็จ - ลด borrowedCopies", () => {
      const book = createValidBook({ copies: 2 });
      book.borrow();
      book.borrow();

      const result = book.return();

      expect(result).toBe(true);
      expect(book.borrowedCopies).toBe(1);
      expect(book.availableCopies).toBe(1);
    });

    test("throw error เมื่อไม่มีหนังสือที่ยืมไป", () => {
      const book = createValidBook();

      expect(() => book.return()).toThrow("No borrowed copies to return");
    });
  });

  describe("addCopies()", () => {
    test("เพิ่มจำนวนสำเร็จ", () => {
      const book = createValidBook({ copies: 2 });

      book.addCopies(3);

      expect(book.totalCopies).toBe(5);
    });

    test.each([0, -1, -10])("throw error เมื่อ count = %i", (count) => {
      const book = createValidBook();
      expect(() => book.addCopies(count)).toThrow(
        "Count must be a positive number",
      );
    });
  });

  describe("toJSON()", () => {
    test("return object ที่ถูกต้อง", () => {
      const book = createValidBook({ copies: 5 });
      book.borrowedCopies = 2;

      const json = book.toJSON();

      expect(json).toEqual({
        isbn: "978-1234567890",
        title: "Test Book",
        author: "Test Author",
        publishYear: 2024,
        totalCopies: 5,
        availableCopies: 3,
        isAvailable: true,
      });
    });

    test("ไม่รวม internal properties", () => {
      const book = createValidBook();
      const json = book.toJSON();

      expect(json).not.toHaveProperty("borrowedCopies");
      expect(json).not.toHaveProperty("createdAt");
    });
  });
});
```

## 📝 ตัวอย่าง: BookService + Mock Database

```javascript
// src/services/BookService.js

class BookService {
  constructor(database) {
    this.db = database;
  }

  async getAllBooks() {
    return await this.db.findAll("books");
  }

  async getBookByIsbn(isbn) {
    const book = await this.db.findOne("books", { isbn });
    if (!book) {
      throw new Error(`Book with ISBN ${isbn} not found`);
    }
    return book;
  }

  async searchBooks(query) {
    const allBooks = await this.db.findAll("books");
    const lowerQuery = query.toLowerCase();

    return allBooks.filter(
      (book) =>
        book.title.toLowerCase().includes(lowerQuery) ||
        book.author.toLowerCase().includes(lowerQuery),
    );
  }

  async addBook(bookData) {
    // Check duplicate
    const existing = await this.db.findOne("books", { isbn: bookData.isbn });
    if (existing) {
      throw new Error(`Book with ISBN ${bookData.isbn} already exists`);
    }

    return await this.db.insert("books", bookData);
  }

  async updateBook(isbn, updateData) {
    const book = await this.getBookByIsbn(isbn);
    const updated = { ...book, ...updateData };
    return await this.db.update("books", { isbn }, updated);
  }

  async deleteBook(isbn) {
    await this.getBookByIsbn(isbn); // Check exists
    return await this.db.delete("books", { isbn });
  }

  async getAvailableBooks() {
    const allBooks = await this.db.findAll("books");
    return allBooks.filter((book) => book.availableCopies > 0);
  }

  async borrowBook(isbn, memberId) {
    const book = await this.getBookByIsbn(isbn);

    if (book.availableCopies === 0) {
      throw new Error("No copies available");
    }

    // Update book
    const updatedBook = {
      ...book,
      borrowedCopies: book.borrowedCopies + 1,
    };
    await this.db.update("books", { isbn }, updatedBook);

    // Create loan record
    const loan = {
      isbn,
      memberId,
      borrowedAt: new Date(),
      dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
      status: "active",
    };
    await this.db.insert("loans", loan);

    return loan;
  }
}

module.exports = BookService;
```

```javascript
// tests/services/BookService.test.js

const BookService = require("../../src/services/BookService");

describe("BookService", () => {
  let bookService;
  let mockDb;

  // Test data
  const testBooks = [
    {
      isbn: "978-1234567890",
      title: "JavaScript Basics",
      author: "John Doe",
      totalCopies: 5,
      borrowedCopies: 2,
      availableCopies: 3,
    },
    {
      isbn: "978-0987654321",
      title: "Python Guide",
      author: "Jane Smith",
      totalCopies: 3,
      borrowedCopies: 3,
      availableCopies: 0,
    },
    {
      isbn: "978-1111111111",
      title: "Advanced JavaScript",
      author: "Bob Wilson",
      totalCopies: 2,
      borrowedCopies: 0,
      availableCopies: 2,
    },
  ];

  beforeEach(() => {
    // Create mock database
    mockDb = {
      findAll: jest.fn(),
      findOne: jest.fn(),
      insert: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
    };

    bookService = new BookService(mockDb);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe("getAllBooks()", () => {
    test("ดึงหนังสือทั้งหมด", async () => {
      mockDb.findAll.mockResolvedValue(testBooks);

      const result = await bookService.getAllBooks();

      expect(mockDb.findAll).toHaveBeenCalledWith("books");
      expect(result).toEqual(testBooks);
      expect(result).toHaveLength(3);
    });

    test("return empty array เมื่อไม่มีหนังสือ", async () => {
      mockDb.findAll.mockResolvedValue([]);

      const result = await bookService.getAllBooks();

      expect(result).toEqual([]);
    });
  });

  describe("getBookByIsbn()", () => {
    test("หาหนังสือเจอ", async () => {
      mockDb.findOne.mockResolvedValue(testBooks[0]);

      const result = await bookService.getBookByIsbn("978-1234567890");

      expect(mockDb.findOne).toHaveBeenCalledWith("books", {
        isbn: "978-1234567890",
      });
      expect(result.title).toBe("JavaScript Basics");
    });

    test("throw error เมื่อไม่เจอ", async () => {
      mockDb.findOne.mockResolvedValue(null);

      await expect(bookService.getBookByIsbn("999-9999999999")).rejects.toThrow(
        "Book with ISBN 999-9999999999 not found",
      );
    });
  });

  describe("searchBooks()", () => {
    beforeEach(() => {
      mockDb.findAll.mockResolvedValue(testBooks);
    });

    test("ค้นหาด้วย title", async () => {
      const result = await bookService.searchBooks("JavaScript");

      expect(result).toHaveLength(2);
      expect(result[0].title).toBe("JavaScript Basics");
      expect(result[1].title).toBe("Advanced JavaScript");
    });

    test("ค้นหาด้วย author", async () => {
      const result = await bookService.searchBooks("Jane");

      expect(result).toHaveLength(1);
      expect(result[0].author).toBe("Jane Smith");
    });

    test("case insensitive", async () => {
      const result = await bookService.searchBooks("JAVASCRIPT");

      expect(result).toHaveLength(2);
    });

    test("return empty เมื่อไม่เจอ", async () => {
      const result = await bookService.searchBooks("Ruby");

      expect(result).toEqual([]);
    });
  });

  describe("addBook()", () => {
    test("เพิ่มหนังสือสำเร็จ", async () => {
      const newBook = {
        isbn: "978-2222222222",
        title: "New Book",
        author: "New Author",
      };
      mockDb.findOne.mockResolvedValue(null); // ไม่มี duplicate
      mockDb.insert.mockResolvedValue({ id: 1, ...newBook });

      const result = await bookService.addBook(newBook);

      expect(mockDb.findOne).toHaveBeenCalledWith("books", {
        isbn: "978-2222222222",
      });
      expect(mockDb.insert).toHaveBeenCalledWith("books", newBook);
      expect(result).toHaveProperty("id", 1);
    });

    test("throw error เมื่อ ISBN ซ้ำ", async () => {
      mockDb.findOne.mockResolvedValue(testBooks[0]); // มี duplicate

      await expect(
        bookService.addBook({ isbn: "978-1234567890", title: "Test" }),
      ).rejects.toThrow("Book with ISBN 978-1234567890 already exists");

      expect(mockDb.insert).not.toHaveBeenCalled();
    });
  });

  describe("getAvailableBooks()", () => {
    test("แสดงเฉพาะหนังสือที่ว่าง", async () => {
      mockDb.findAll.mockResolvedValue(testBooks);

      const result = await bookService.getAvailableBooks();

      expect(result).toHaveLength(2);
      expect(result.every((b) => b.availableCopies > 0)).toBe(true);
    });
  });

  describe("borrowBook()", () => {
    test("ยืมหนังสือสำเร็จ", async () => {
      const book = { ...testBooks[0] }; // มี availableCopies: 3
      mockDb.findOne.mockResolvedValue(book);
      mockDb.update.mockResolvedValue(true);
      mockDb.insert.mockResolvedValue({ id: 1 });

      const result = await bookService.borrowBook("978-1234567890", "member-1");

      // ตรวจว่า update book
      expect(mockDb.update).toHaveBeenCalledWith(
        "books",
        { isbn: "978-1234567890" },
        expect.objectContaining({ borrowedCopies: 3 }), // เดิม 2, เพิ่มเป็น 3
      );

      // ตรวจว่า create loan
      expect(mockDb.insert).toHaveBeenCalledWith(
        "loans",
        expect.objectContaining({
          isbn: "978-1234567890",
          memberId: "member-1",
          status: "active",
        }),
      );

      expect(result).toHaveProperty("dueDate");
    });

    test("throw error เมื่อหนังสือหมด", async () => {
      mockDb.findOne.mockResolvedValue(testBooks[1]); // availableCopies: 0

      await expect(
        bookService.borrowBook("978-0987654321", "member-1"),
      ).rejects.toThrow("No copies available");

      expect(mockDb.update).not.toHaveBeenCalled();
      expect(mockDb.insert).not.toHaveBeenCalled();
    });
  });
});
```

---

# 🏆 สรุป: Jest Cheat Sheet

## Test Structure

```javascript
describe("Component", () => {
  beforeAll(() => {
    /* ครั้งเดียวก่อนทั้งหมด */
  });
  afterAll(() => {
    /* ครั้งเดียวหลังทั้งหมด */
  });
  beforeEach(() => {
    /* ก่อนแต่ละ test */
  });
  afterEach(() => {
    /* หลังแต่ละ test */
  });

  test("should do something", () => {
    expect(actual).toBe(expected);
  });

  test.skip("skip this test", () => {});
  test.only("run only this", () => {});
});
```

## Common Matchers

```javascript
// Equality
expect(x).toBe(y); // x === y (primitive)
expect(x).toEqual(y); // deep equality (object)
expect(x).not.toBe(y); // negate

// Truthiness
expect(x).toBeTruthy();
expect(x).toBeFalsy();
expect(x).toBeNull();
expect(x).toBeUndefined();
expect(x).toBeDefined();

// Numbers
expect(x).toBeGreaterThan(y);
expect(x).toBeLessThan(y);
expect(x).toBeCloseTo(y, decimals);

// Strings
expect(str).toMatch(/regex/);
expect(str).toContain("substring");

// Arrays
expect(arr).toContain(item);
expect(arr).toContainEqual(obj);
expect(arr).toHaveLength(n);

// Objects
expect(obj).toHaveProperty("key");
expect(obj).toMatchObject({ partial });

// Errors
expect(() => fn()).toThrow();
expect(() => fn()).toThrow("message");
expect(() => fn()).toThrow(ErrorClass);
```

## Async Testing

```javascript
// async/await
test("async", async () => {
  const result = await asyncFn();
  expect(result).toBe(x);
});

// resolves/rejects
await expect(promise).resolves.toBe(x);
await expect(promise).rejects.toThrow();
```

## Mocking

```javascript
// Mock function
const mockFn = jest.fn();
mockFn.mockReturnValue(x);
mockFn.mockResolvedValue(x);
mockFn.mockRejectedValue(error);
mockFn.mockImplementation(fn);

// Mock assertions
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(n);
expect(mockFn).toHaveBeenCalledWith(args);

// Mock module
jest.mock("./module");

// Spy
jest.spyOn(obj, "method");

// Reset
jest.clearAllMocks();
jest.resetAllMocks();
jest.restoreAllMocks();
```

## CLI Commands

```bash
npm test                    # รัน tests ทั้งหมด
npm test -- --watch         # watch mode
npm test -- --coverage      # coverage report
npm test -- --verbose       # แสดงรายละเอียด
npm test -- <pattern>       # รันเฉพาะไฟล์ที่ match
npm test -- -t "test name"  # รันเฉพาะ test ที่ชื่อ match
```
