กรกฎาคม 2020

Cypress Route คืออะไร ใช้งานอย่างไร EP6

การทดสอบ application ในบางครั้งก็จะมี dependencies อื่น ๆ ที่เกี่ยวข้องมากมาย โดยหนึ่งในนี้คือ การเรียก API data ที่ไม่สามารถทราบได้เลยว่าการที่ API ตอบกลับมานั้นจะช้าหรือเร็วแค่ใหน การเทสบางครั้งอาจราบลื่นเป็นปกติ เพราะไม่มีการถูกขัดจังหวะด้วย response time ของ API data ที่เร็ว แต่เมื่อ run test อีกครั้งกับได้ผลรับไม่เหมือนเดิม เกิด failures เพราะมี error message แจ้งกลับมาว่า timeout waiting 5000ms บ่งบอกได้ถึงการรอ network request API ที่นานจนโปรแกรมรอไม่ไหว แบบนี้จะแก้ได้อย่างไร

การ Mock API แก้ pain point นี้ได้

การ Mock API ช่วยแก้ pain point นี้ได้ โดยมีหลักการคือการ จำลอง response data ที่สร้างขึ้นมาเพื่อที่จะไม่ต้องเรียก API request จริง วิธีการนี้จะช่วย ควบคุม response time ได้ทำให้ test ของเราทำงานได้ราบลื่นไม่ติดขัด

Network Requests Route คืออะไร

Cypress ได้สร้าง API Command ที่ชื่อว่า route เพื่อใช้บริหารจัดการ XHR Object Request ของระดับ HTTP โดยทำการสร้างสภาพแวดล้อมเสมือนคอยตรวจสอบดักจับการเรียก Network URL ที่สร้างขึ้นโดยที่เราสามารถปรับปรุงเปลี่ยนแปลง Request ,Response ของ HTTP ในระดับ network layer ได้

ตัวอย่างและวิธีการเรียกใช้งาน

cy.server()

cy.route(url)
cy.route(url, response)
cy.route(method, url)
cy.route(method, url, response)
cy.route(callbackFn)
cy.route(options)

usercase ที่จะนำมายกตัวอย่างคือการ call API ของ coronavirus-19-api data ของประเทศทั้งหมดในโลกที่มีการติดเชื้อ Covid ณ ปัจจุบัน จะยกตัวอย่างการเปลี่ยนแปลงข้อมูล เมื่อเรียก api นี้ route จะทำหน้าที่ mock response ของ API เดิมที่จะ return Response Data ของทุกประเทศ แต่จะ return Response Data ของ Thailand เท่านั้น

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Window Methods</title>
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css"
    />
  </head>
  <body>
    <div class="ui container">
      <h2>Course Cypress Automated Testing</h2>
      <div class="ui top attached tabular menu">
        <a class="item" href="index.html" data-tab="zero">Index</a>
        <a class="item" href="register.html" data-tab="first">Register</a>
        <a class="item" href="register-list.html" data-tab="first-list"
          >Register List</a
        >
        <a class="item" href="window.html" data-tab="second">Window</a>
        <a class="item" href="login.html" data-tab="third">Login</a>
        <a class="item" href="change-password.html" data-tab="four"
          >Change Password</a
        >
        <a class="item active" href="table.html" data-tab="five"
          >Table Coronavirus-19</a
        >
        <a class="item" href="elements.html" data-tab="six">Chai-jQuery</a>
      </div>
      <div class="ui bottom attached tab segment active" data-tab="first">
        <table class="ui tablet stackable celled striped table" id="covidState">
          <thead>
            <tr>
              <th>No.</th>
              <th>Country</th>
              <th>Cases</th>
              <th>Today Cases</th>
              <th>Deaths</th>
              <th>Today Deaths</th>
              <th>Recovered</th>
              <th>Active</th>
              <th>Critical</th>
            </tr>
          </thead>
          <tbody></tbody>
        </table>
      </div>
    </div>
    <script>
      Number.prototype.toCurrency = function (n = 2, x = 3) {
        var re = '\\d(?=(\\d{' + (x || 3) + '})+' + (n > 0 ? '\\.' : '$') + ')';
        return this.toFixed(Math.max(0, ~~n)).replace(
          new RegExp(re, 'g'),
          '$&,'
        );
      };

      window.onload = () => {
        const data = fetch('https://coronavirus-19-api.herokuapp.com/countries')
          .then((http) => http.json())
          .then((data) => {
            //console.log('data ::==', data);
            const $covidState = document.getElementById('covidState');
            let $tbody = covidState.children[1]; // tbody
            //console.log('$tbody ::==',$tbody)
            data
              .map((item, index) => {
                return { ...item, ...{ no: index + 1 } };
              })
              .forEach((item) => {
                let $tr = document.createElement('TR');
                $tr.innerHTML = `
              <td>${item.no}</td>
              <td>${item.country}</td>
              <td>${(item.cases || 0).toCurrency(0)}</td>
              <td>${(item.todayCases || 0).toCurrency(0)}</td>
              <td>${(item.deaths || 0).toCurrency(0)}</td>
              <td>${(item.todayDeaths || 0).toCurrency(0)}</td>
              <td>${(item.recovered || 0).toCurrency(0)}</td>
              <td>${(item.active || 0).toCurrency(0)}</td>
              <td>${(item.critical || 0).toCurrency(0)}</td>
          `;
                $tbody.appendChild($tr);
              });
          });
      };
    </script>
  </body>
</html>

การทำงานของโปรแกรมมหน้านี้ เมื่อโหลดหน้าขึ้นมาจะไปเรียก API Covid นี้ทันทีด้วย window.fetch

ทดสอบสร้าง testscript ของการใช้คำสั่ง route

describe('ทดสอบการทำงานและเรียกใช้งาน Route', () => {

  let polyfill

  before(() => {
    const polyfillUrl = 'https://unpkg.com/[email protected]/dist/fetch.umd.js'
    cy.request(polyfillUrl).then(response => {
      polyfill = response.body
    })
  })

  Cypress.on('window:before:load', win => {
    delete win.fetch
    win.eval(polyfill)
  })

  it('เรียก API Covid', () => {
    cy.server()
    cy.route({
      url: '**/countries',
      method: 'GET',      // Route all GET requests
      response: [{
        "country": "Thailand",
        "cases": 3202,
        "todayCases": 5,
        "deaths": 58,
        "todayDeaths": 0,
        "recovered": 3085,
        "active": 59,
        "critical": 1,
        "casesPerOneMillion": 46,
        "deathsPerOneMillion": 0,
        "totalTests": 603657,
        "testsPerOneMillion": 8648
      }]
    }).as('routeCovid')
    cy.visit('https://cypress-testing-143fd.web.app/table.html')
      .get('#covidState').contains('Country')
      .wait('@routeCovid', { timeout: 10000 }).its('status').should('have.eq', 200)
  })
})

อธิบายการทำงานในส่วนโค๊ดนี้

บรรทัดที่ 18 เริ่มสร้าง network server (cy.server())
บรรทัดที่ 19 สร้าง route เพื่อ mock api ที่ติดต่อภายนอกโดยกำหนด response คือ array object ของ ประเทศไทย เท่านั้น
บรรทัดที่ 37 visit เข้าไปที่หน้าเว็บตัวอย่าง
บรรทัดที่ 39 ตรวจสอบ status code equal 200 หรือไม่

เมื่อลองทดสอบ run test การทำงานของโปรแกรมจะเปลี่ยนไป ระบบจะแสดงแค่ข้อมูลของประเทศไทย โดยที่ไม่ได้มีการแก้ไขโปรแกรมที่ทดสอบแต่อย่างไร เพียงแต่ทำการ mockup API ที่แสดงข้อมูลด้วย Cypress Route เท่านั้น

เพียงเท่านี้เราก็สามารถควบคุม data ที่มาจากการ call API จากที่ต่าง ๆ ได้ แล้วโดยไม่ต้องรอการ response กลับจาก API นั้น ๆ

หากเพื่อนคนใดอยากที่จะทำความรู้จัก Route Command นี้เพิ่มเติมให้เข้าไปที่ลิ้งนี้ได้เลย Cypress Route

Cypress ทำความรู้จัก และตัวอย่างการใช้งาน Spy,Stub EP5

Spy & Stub เป็น API Command ที่มีความพิเศษกว่า API Command อื่น ๆ เราจะไม่ค่อยได้ใช้งาน command นี้มากนัก แต่ถ้าถึงสถานะการนั้นแล้ว spy ,stub นี้แหละที่จะมาเป็นพระเอกช่วยเราได้ในแต่ละสถานะการนั้น ๆ เลย

Spy ,Stub คืออะไร

ก่อนที่จะไปรู้ถึงว่าจะนำ Spy ,Stub ไปใช้กับลักษณะงานแบบไหน ควรต้องเข้าใจก่อนว่า 2 commands นี้ คืออะไร มีความสามารถอย่างไรบ้าง

Stub ทำหน้าที่จำลอง Behavior ของโปรแกรมที่เรากำลังจะทดสอบ โดยหลักการทำงานของ Stub ก็ดัก function ของ Object ของโปรแกรมในที่นี้คือ Object ที่กำลังสนใจมีส่วนการทำงานเกี่ยวข้องกับระบบ หรือ feature ที่กำลังทำงานในขณะนั้น โดยจะคอยดักจับ และยังสามารถเปลี่ยนแปลงการทำงานของ Behavior function นั้น ไปให้เป็นไปตามผลลัพธ์ที่เราต้องการจะให้เป็น โดยข้อดีของการใช้ Stub เพื่อแก้ไข function (Behavior) นี้เพื่อลด dependencies ต่าง ๆ ของโปรแกรมทำไม่ให้เกิด Flaky Test เช่น window.confirm ของ window object ที่ต้องการ action ของปุ่ม Ok (ตกลง) หรือ Cancel (ยกเลิก) เพื่อทำให้โปรแกรมทำงานใน Step ถัด ๆ ไป โดยปกติ API Command ที่มีให้นั้นไม่สามารถจะแก้ไขหรือเลือก choice นี้ได้ จำเป็นต้องใช้ Stub มาเปลี่ยนแปลง Behavior นี้

Spy เกือบที่จะเหมือนกับ Stub ถ้าในทางการเรียกใช้งานแทบจะแยกไม่ออกเลยว่าแตกต่างกันอย่างไร แต่มีสิ่งที่ Spy เองไม่สามารถทำได้เหมือน Stub คือการเปลี่ยนแปลง Behavior ของการทำงานได้เหมือน Stub แต่ทำหน้าที่ได้เพียงแฝงตัว สอดแนม อาจจะเพิ่มการ capture ,record หรือ track log ของการทำงานได้เท่านั้น

Spy /Stub การทำงานเหล่านี้ได้นำเข้ามาจาก Sinon.js จึงสามารถเปิด document ได้เลย สามารถอ่านเนื้อหาการใช้งานได้จากที่นี่
Stub https://sinonjs.org/releases/latest/stubs/
Spy https://sinonjs.org/releases/latest/spies/

เมื่อไหร่ และสถานการณ์แบบไหนถึงจะนำมาใช้ละ

มอง scenarios case จะเกี่ยวกับ behavior ของ javascript function ต่าง ๆ ที่ทีในโปรแกรมที่กำลังทดสอบ เช่น window.alert ,window.confirm ,window.promt หรือแม้แต่ function ต่าง ๆ ที่ custom define ที่ window object ก็สามารถดัก track ติดตามได้หมด

ตัวอย่างโปรแกรม

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button type="button" onclick="clickPromt()">Click Promt</button>
    <script>
      function clickPromt(){
        const data = window.prompt('enter your name.')
        if(data){
          window.alert(data)
        }
      }
    </script>
  </body>
</html>
describe('ทดสอบการทำงานของ Stub', () => {
  it('ทดสอบดักจับ behavior ของ window.promt', () => {
    cy.visit('http://127.0.0.1:5500/cypress/integration/spy-stub/index.html')
    .window().then(win =>{
      cy.stub(win,'prompt',()=>{
        return 'Hardcode "Hello World"'
      }).as('stubPromt')
    })
    .get('button').click()
    .get('@stubPromt').should('be.calledOnce')
  });
});

ตัวอย่างการทำงานของ Stub

เมื่อมีการเขียนคำสั่ง stub เพื่อเริ่มใช้งาน cypress จะสร้างแถบ SPIES/STUBS มาพร้อมแสดงชื่อการทำงานของ behavior นั้น เมื่อโปรแกรมมีการเรียกใช้งาน behavior (function) stub จะแสดงเลข 1 หมายถึงการถูกเรียกทำงาน 1 ครั้ง และโปรแกรมจะ alert ค่าที่รับจาก promt ว่า “Hardcode “Hello World”” ที่ถูก command stub return ค่ามาให้ จึงทำให้การ call function ภายในโปรแกรมทุกครั้งจะได้ promt value ว่า “Hardcode Hello World” เสมอ เพราะถูก stub ดักเปลี่ยนค่าในระหว่างการทำงาน
เพิ่มการ assert เพื่อตรวจสอบการ call function นั้นด้วย “be.calledOnce” อีกครั้งเพื่อให้แน่ใจว่าโปรแกรมทำงาน function อย่างน้อย 1 ครั้ง

นี่คือตัวอย่างการใช้งาน stub กับ function promt ที่ทาง window object ของ browser มีให้ใช้งานเป็นปกติอยู่แล้ว เพื่อน ๆ ที่ติดตามบทความนี้มีปัญหา หรืออยากปรึกษา ขอความช่วยเหลือ สามารถฝากคำถามไว้ใต้โพสนี้ได้เลย

Cypress Assertions ต่าง ๆ ที่ควรต้องรู้ EP4

บทความก่อนหน้านี้ ทำให้เราได้พอที่จะเข้าใจ concept ของการใช้วาน Cypress กันมาบ้างแล้ว บทความนี้จะขอกล่าวถึงเรื่องการ Assertion Expect Case ต่าง ๆ ว่าเขียนอย่างไร และสามารถทำอยากที่เราต้องการได้หรือไม่

อธิบายการ Assertions ของ Automated Testing

assertion (การยืนยัน) ในทางงาน test คือการยืนยันการตรวจสอบของสิ่งที่กำลังสนใจ กับผลลัพธ์ที่คาดหวัง

describe('My First Test', () => {
  it('Does not do much!', () => {
    expect(true).to.equal(true)
  })
})

สร้างไฟล์ my-first-test.spec.js เขียนโค๊ดการเทสง่าย ๆ จากนั้นใส่ assertion expect(true).to.equal(true) โดย true แรกคือการที่เราจะแทนค่าที่เรากำลังสนใจที่จะตรวจสอบ true ค่าที่ 2 คือผลลัพธ์ที่ต้องการให้เป็นผลลัพธ์สำหรับการเทสในเงื่อนไขนี้

BDD Assertions

การทดสอบ case scenario ก่อนการเริ่มเขียนโค๊ดจริง ที่เพิ่มการคาดหวังทดสอบ ในเรื่องพฤษติกรรมของทำงานของ website cypress ใช้ library ของ Chaijs Assertion Library (Expect/Should) ที่มีพร้อมครบในเรื่องการทำ assertion case ต่าง ๆ

const person = {
  name: 'Poolsawat',
  friends: [
    { name: 'Jane' },
    { name: 'Poom', age: 30 },
  ],
  cars: [
    'Nissan',
    'Toyota'
  ],
  getName: () => {
    return "Poolsawat"
  },
  "aa bb" : "Test"
}

// https://github.com/chaijs/chai
describe('BDD Assertions Chaijs', () => {

  it('use expect expected json data', () => {
    expect(person.name).to.not.equal('Jane')
    expect(person.friends[0]).to.deep.equal({ name: 'Jane' })
    expect({ a: { b: ['x', 'y'] } }).to.have.nested.property('a.b[1]')
    expect({ a: { b: ['x', 'y'] } }).to.nested.include({ 'a.b[1]': 'y' })
    expect([1, 2]).to.have.ordered.members([1, 2]).but.not.have.ordered.members([2, 1])
    expect(person.friends[1]).to.have.any.keys('age')
    expect(person.friends[1]).to.have.all.keys('name', 'age')
    expect(person.name).to.be.a('string')

    expect([1, 2, 3]).to.include(2)

    expect(undefined).to.not.be.ok
    expect(true).to.be.true
    expect(false).to.be.false
    expect(null).to.be.null
    expect(undefined).to.be.undefined
    expect(person['aa bb']).to.exist
    expect([]).to.be.empty
    expect(arguments).to.be.arguments

    expect(42).to.equal(42)

    expect({ name: 'Jane' }).to.deep.equal({ name: 'Jane' })
    expect({ name: 'Jane' }).to.eql({ name: 'Jane' })

    expect(10).to.be.greaterThan(5)

    expect(10).to.be.at.least(10)

    expect(5).to.be.lessThan(10)

    expect('test').to.have.length.of.at.most(4)

    expect(7).to.be.within(5, 10)
    expect([1, 2, 3]).to.be.instanceOf(Array)

    expect(person).to.have.property('name')
    expect(person).to.have.deep.property('friends')
    expect('test').to.have.ownProperty('length')

    expect({ a: 1 }).to.have.ownPropertyDescriptor('a')

    expect('test').to.have.lengthOf(4)
    expect('testing').to.match(/^test/)

    expect('testing').to.have.string('test')
    expect({ pass: 1, fail: 2 }).to.have.keys('pass', 'fail')
    const fn = () => {
      throw Error
    }
    //expect(fn).to.throw(Error)

    expect(person).to.respondTo('getName')

    //expect(person).itself.to.respondTo('name')
    expect(1).to.satisfy((num) => { return num > 0 })

    expect(1.5).to.be.closeTo(1, 0.5)

    expect([1, 2, 3]).to.include.members([3, 2])
    expect(2).to.be.oneOf([1, 2, 3])
    //expect(fn).to.change(obj, 'val')
    //expect(fn).to.increase(obj, 'val')
    //expect(fn).to.decrease(obj, 'val')

  })
})
Chainer Example
not expect(name).to.not.equal('Jane')
deep expect(obj).to.deep.equal({ name: 'Jane' })
nested expect({a: {b: ['x', 'y']}}).to.have.nested.property('a.b[1]')
expect({a: {b: ['x', 'y']}}).to.nested.include({'a.b[1]': 'y'})
ordered expect([1, 2]).to.have.ordered.members([1, 2]).but.not.have.ordered.members([2, 1])
any expect(arr).to.have.any.keys('age')
all expect(arr).to.have.all.keys('name', 'age')
a(type)
Aliases: an
expect('test').to.be.a('string')
include(value)
Aliases: contain, includes, contains
expect([1,2,3]).to.include(2)
ok expect(undefined).to.not.be.ok
true expect(true).to.be.true
false expect(false).to.be.false
null expect(null).to.be.null
undefined expect(undefined).to.be.undefined
exist expect(myVar).to.exist
empty expect([]).to.be.empty
arguments
Aliases: Arguments
expect(arguments).to.be.arguments
equal(value)
Aliases: equals, eq
expect(42).to.equal(42)
deep.equal(value) expect({ name: 'Jane' }).to.deep.equal({ name: 'Jane' })
eql(value)
Aliases: eqls
expect({ name: 'Jane' }).to.eql({ name: 'Jane' })
greaterThan(value)
Aliases: gt, above
expect(10).to.be.greaterThan(5)
least(value)
Aliases: gte
expect(10).to.be.at.least(10)
lessThan(value)
Aliases: lt, below
expect(5).to.be.lessThan(10)
most(value)
Aliases: lte
expect('test').to.have.length.of.at.most(4)
within(start, finish) expect(7).to.be.within(5,10)
instanceOf(constructor)
Aliases: instanceof
expect([1, 2, 3]).to.be.instanceOf(Array)
property(name, [value]) expect(obj).to.have.property('name')
deep.property(name, [value]) expect(deepObj).to.have.deep.property('tests[1]', 'e2e')
ownProperty(name)
Aliases: haveOwnProperty, own.property
expect('test').to.have.ownProperty('length')
ownPropertyDescriptor(name)
Aliases: haveOwnPropertyDescriptor
expect({a: 1}).to.have.ownPropertyDescriptor('a')
lengthOf(value) expect('test').to.have.lengthOf(3)
match(RegExp)
Aliases: matches
expect('testing').to.match(/^test/)
string(string) expect('testing').to.have.string('test')
keys(key1, [key2], […])
Aliases: key
expect({ pass: 1, fail: 2 }).to.have.keys('pass', 'fail')
throw(constructor)
Aliases: throws, Throw
expect(fn).to.throw(Error)
respondTo(method)
Aliases: respondsTo
expect(obj).to.respondTo('getName')
itself expect(Foo).itself.to.respondTo('bar')
satisfy(method)
Aliases: satisfies
expect(1).to.satisfy((num) => { return num > 0 })
closeTo(expected, delta)
Aliases: approximately
expect(1.5).to.be.closeTo(1, 0.5)
members(set) expect([1, 2, 3]).to.include.members([3, 2])
oneOf(values) expect(2).to.be.oneOf([1,2,3])
change(function)
Aliases: changes
expect(fn).to.change(obj, 'val')
increase(function)
Aliases: increases
expect(fn).to.increase(obj, 'val')
decrease(function)
Aliases: decreases
expect(fn).to.decrease(obj, 'val')

TDD Assertions

คล้ายกับ BDD คือเป็นการเขียน test ต่าง ก่อนการเริ่มเขียน code แต่จะไม่ละเอียดเท่า BDD เพราะไม่รู้ Behavior(พฤติกรรม) ของผู้ใช้งานสามารถใช้ ChaiJs Assert Feature

describe('TDD Assertions', () => {
  it('use assert.* ', () => {
    assert.isOk('everything', 'everything is ok')
    assert.isNotOk(false, 'this will pass')
    assert.equal(3, 3, 'vals equal')
    assert.notEqual(3, 4, 'vals not equal')
    assert.strictEqual(true, true, 'bools strict eq')
    assert.notStrictEqual(5, '5', 'not strict eq')
    assert.deepEqual({ id: '1' }, { id: '1' })
    assert.notDeepEqual({ id: '1' }, { id: '2' })
    assert.isAbove(6, 1, '6 greater than 1')
    assert.isAtLeast(5, 2, '5 gt or eq to 2')
    assert.isBelow(3, 6, '3 strict lt 6')
    assert.isAtMost(4, 4, '4 lt or eq to 4')
    assert.isTrue(true, 'this val is true')
    assert.isNotTrue('tests are no fun', 'val not true')
    assert.isFalse(false, 'val is false')
    assert.isNotFalse('tests are fun', 'val not false')
    //assert.isNull(err, 'there was no error')
    assert.isNotNull('hello', 'is not null')
    assert.isNaN(NaN, 'NaN is NaN')
    assert.isNotNaN(5, '5 is not NaN')
    assert.exists(5, '5 is not null or undefined')
    assert.notExists(null, 'val is null or undefined')
    assert.isUndefined(undefined, 'val is undefined')
    assert.isDefined('hello', 'val has been defined')
    assert.isFunction(x => x * x, 'val is func')
    assert.isNotFunction(5, 'val not funct')
    assert.isObject({ num: 5 }, 'val is object')
    assert.isNotObject(3, 'val not object')
    assert.isArray(['unit', 'e2e'], 'val is array')
    assert.isNotArray('e2e', 'val not array')
    assert.isString('e2e', 'val is string')
    assert.isNotString(2, 'val not string')
    assert.isNumber(2, 'val is number')
    assert.isNotNumber('e2e', 'val not number')
    //assert.isFinite('e2e', 'val is finite')
    assert.isBoolean(true, 'val is bool')
    assert.isNotBoolean('true', 'val not bool')
    assert.typeOf('e2e', 'string', 'val is string')
    assert.notTypeOf('e2e', 'number', 'val not number')

  })
})
Assertion Example
.isOk(object, [message]) assert.isOk('everything', 'everything is ok')
.isNotOk(object, [message]) assert.isNotOk(false, 'this will pass')
.equal(actual, expected, [message]) assert.equal(3, 3, 'vals equal')
.notEqual(actual, expected, [message]) assert.notEqual(3, 4, 'vals not equal')
.strictEqual(actual, expected, [message]) assert.strictEqual(true, true, 'bools strict eq')
.notStrictEqual(actual, expected, [message]) assert.notStrictEqual(5, '5', 'not strict eq')
.deepEqual(actual, expected, [message]) assert.deepEqual({ id: '1' }, { id: '1' })
.notDeepEqual(actual, expected, [message]) assert.notDeepEqual({ id: '1' }, { id: '2' })
.isAbove(valueToCheck, valueToBeAbove, [message]) assert.isAbove(6, 1, '6 greater than 1')
.isAtLeast(valueToCheck, valueToBeAtLeast, [message]) assert.isAtLeast(5, 2, '5 gt or eq to 2')
.isBelow(valueToCheck, valueToBeBelow, [message]) assert.isBelow(3, 6, '3 strict lt 6')
.isAtMost(valueToCheck, valueToBeAtMost, [message]) assert.isAtMost(4, 4, '4 lt or eq to 4')
.isTrue(value, [message]) assert.isTrue(true, 'this val is true')
.isNotTrue(value, [message]) assert.isNotTrue('tests are no fun', 'val not true')
.isFalse(value, [message]) assert.isFalse(false, 'val is false')
.isNotFalse(value, [message]) assert.isNotFalse('tests are fun', 'val not false')
.isNull(value, [message]) assert.isNull(err, 'there was no error')
.isNotNull(value, [message]) assert.isNotNull('hello', 'is not null')
.isNaN(value, [message]) assert.isNaN(NaN, 'NaN is NaN')
.isNotNaN(value, [message]) assert.isNotNaN(5, '5 is not NaN')
.exists(value, [message]) assert.exists(5, '5 is not null or undefined')
.notExists(value, [message]) assert.notExists(null, 'val is null or undefined')
.isUndefined(value, [message]) assert.isUndefined(undefined, 'val is undefined')
.isDefined(value, [message]) assert.isDefined('hello', 'val has been defined')
.isFunction(value, [message]) assert.isFunction(x => x * x, 'val is func')
.isNotFunction(value, [message]) assert.isNotFunction(5, 'val not funct')
.isObject(value, [message]) assert.isObject({num: 5}, 'val is object')
.isNotObject(value, [message]) assert.isNotObject(3, 'val not object')
.isArray(value, [message]) assert.isArray(['unit', 'e2e'], 'val is array')
.isNotArray(value, [message]) assert.isNotArray('e2e', 'val not array')
.isString(value, [message]) assert.isString('e2e', 'val is string')
.isNotString(value, [message]) assert.isNotString(2, 'val not string')
.isNumber(value, [message]) assert.isNumber(2, 'val is number')
.isNotNumber(value, [message]) assert.isNotNumber('e2e', 'val not number')
.isFinite(value, [message]) assert.isFinite('e2e', 'val is finite')
.isBoolean(value, [message]) assert.isBoolean(true, 'val is bool')
.isNotBoolean(value, [message]) assert.isNotBoolean('true', 'val not bool')
.typeOf(value, name, [message]) assert.typeOf('e2e', 'string', 'val is string')
.notTypeOf(value, name, [message]) assert.notTypeOf('e2e', 'number', 'val not number')

Chai-jQuery

การ assert ส่วนหน้า UI (DOM object) โดยจะใช้คำสั่งการ assert เหล่านี้ หลังจากเรียก command cy.get() หรือ cy.contains()

Cypress.config('baseUrl','https://cypress-testing-143fd.web.app/')

describe('Chai-jQuery', () => {
  it('expect DOM Element ', () => {
    cy.visit('/elements.html')
      .get('form.ui.form').should($el => {
        expect($el).to.have.attr('class', 'ui form')

        const $notDisabled = $el.find('#text')
        expect($notDisabled).to.have.prop('disabled', false)

        expect($el).to.have.css('background-color', 'rgb(255, 255, 200)')
        expect($el).to.have.data('random', '77547562649602259494')
        expect($el).to.have.class('ui')

        const $email = $el.find('[name="email"]')
        expect($email).to.have.id('email')

        const $html = $el.find('#i-love-test-html')
        expect($html).to.have.html('\n              <h1>I love testing</h1>\n            ')

        const $text = $el.find('#i-love-test-text')
        expect($text).to.have.text('\n              I love testing\n            ')
        expect($text).to.contain('I love testing')

        expect($email).to.have.value('[email protected]')

        expect($email).to.be.visible

        const $hidden = $el.find('[name="hidden"]')
        expect($hidden).to.be.hidden

        const $option0 = $el.find('#select option:eq(1)')
        expect($option0).not.to.be.selected

        const $options = $el.find('#select option')
        expect($options).to.have.lengthOf(5)
        
        cy.log('lengthOf------------')
        const $mazda = $el.find('[name="mazda-checked"]')
        expect($mazda).not.to.be.checked

        const $focus = $el.find('#text')
        //expect($focus).not.to.be.focused
        //expect($focus).to.have.focus

        expect($focus).to.be.enabled

        const $disabled = $el.find('[name="text-disabled"]')
        expect($disabled).to.be.disabled

        //expect($focus).not.to.be.empty
        //expect($nonexistent).not.to.exist
        //expect($emptyEl).to.match(':empty')
        //expect($el).to.contain('text')
        //expect($el).to.have.descendants('div')
      })


  })
})
https://cypress-testing-143fd.web.app/elements.html
Chainers Assertion
attr(name, [value]) expect($el).to.have.attr('foo', 'bar')
prop(name, [value]) expect($el).to.have.prop('disabled', false)
css(name, [value]) expect($el).to.have.css('background-color', 'rgb(0, 0, 0)')
data(name, [value]) expect($el).to.have.data('foo', 'bar')
class(className) expect($el).to.have.class('foo')
id(id) expect($el).to.have.id('foo')
html(html) expect($el).to.have.html('I love testing')
text(text) expect($el).to.have.text('I love testing')
value(value) expect($el).to.have.value('[email protected]')
visible expect($el).to.be.visible
hidden expect($el).to.be.hidden
selected expect($option).not.to.be.selected
checked expect($input).not.to.be.checked
focus[ed] expect($input).not.to.be.focused
expect($input).to.have.focus
enabled expect($input).to.be.enabled
disabled expect($input).to.be.disabled
empty expect($el).not.to.be.empty
exist expect($nonexistent).not.to.exist
match(selector) expect($emptyEl).to.match(':empty')
contain(text) expect($el).to.contain('text')
descendants(selector) expect($el).to.have.descendants('div')

Sinon-Chai

การ assert behavior ของ website event ภายใน ต่าง ๆ ทั้ง window object function (alert ,confirm ,prompt) หรือ custom js function ต่าง ๆ จะใช้ assert spy , stub ตัวอย่างที่สามารถ assert ได้ เช่น มีการ call spy/stub หรือไม่ call กี่ครั้ง จะสามารถเช็คได้ด้วย assert ชุดนี้

Cypress.config('baseUrl','https://cypress-testing-143fd.web.app/')
describe('Sinon-Chai', () => {
  it('use sinon expect spy&tub', () => {
    let stubFirst ,stubSecound ,stubThird,stubFour,stubFive;
    let countStubFiveClick = 0;
    cy.visit('/window.html',{
      onLoad(win){
        stubFirst = cy.stub(win,'mySpyCalledFirst',()=>{})
        stubSecound = cy.stub(win,'mySpyCalledSecound',()=>{})
        stubThird = cy.stub(win,'mySpyCalledThird',()=>{})
        stubFour = cy.stub(win,'mySpyCalledFour',()=>{
          return 'mySpyCalledFour click 999'
        })
        stubFive = cy.stub(win,'mySpyCalledFive',()=> countStubFiveClick+= 2)
      }
    })
    .get('#btnFirst').click()
    .get('#btnSecound').click()
    .get('#btnSecound').click()
    .get('#btnThird').click()
    .get('#btnThird').click()
    .get('#btnThird').click()
    .get('#btnFour').click()
    .get('#btnFive').click().get('#btnFive').click()
    //.wait(2000)
    .then(()=>{
      expect(stubFirst).to.be.called
      expect(stubFirst).to.have.callCount(1)
      expect(stubFirst).to.be.calledOnce
      expect(stubSecound).to.be.calledTwice
      expect(stubThird).to.be.calledThrice
      expect(stubFirst).to.be.calledBefore(stubSecound)
      expect(stubThird).to.be.calledAfter(stubSecound)
      expect(stubFour).to.have.returned('mySpyCalledFour click 999')
      expect(stubFive).to.have.returned(countStubFiveClick)
      expect(stubFirst).to.have.always.returned(undefined)
      /*expect(stubFirst).to.be.calledWithNew
      expect(stubFirst).to.always.be.calledWithNew
      expect(spy).to.be.calledOn(context)
      expect(spy).to.always.be.calledOn(context)
      expect(spy).to.be.calledWith(...args)
      expect(spy).to.always.be.calledWith(...args)
      expect(spy).to.be.calledWithExactly(...args)
      expect(spy).to.always.be.calledWithExactly(...args)
      expect(spy).to.be.calledWithMatch(...args)
      expect(spy).to.always.be.calledWithMatch(...args)
      
      expect(spy).to.have.thrown(errorObjOrErrorTypeStringOrNothing)
      expect(spy).to.have.always.thrown(errorObjOrErrorTypeStringOrNothing)*/
    })
    

  })
})
Sinon.JS property/method Assertion
called expect(spy).to.be.called
callCount expect(spy).to.have.callCount(n)
calledOnce expect(spy).to.be.calledOnce
calledTwice expect(spy).to.be.calledTwice
calledThrice expect(spy).to.be.calledThrice
calledBefore expect(spy1).to.be.calledBefore(spy2)
calledAfter expect(spy1).to.be.calledAfter(spy2)
calledWithNew expect(spy).to.be.calledWithNew
alwaysCalledWithNew expect(spy).to.always.be.calledWithNew
calledOn expect(spy).to.be.calledOn(context)
alwaysCalledOn expect(spy).to.always.be.calledOn(context)
calledWith expect(spy).to.be.calledWith(...args)
alwaysCalledWith expect(spy).to.always.be.calledWith(...args)
calledWithExactly expect(spy).to.be.calledWithExactly(...args)
alwaysCalledWithExactly expect(spy).to.always.be.calledWithExactly(...args)
calledWithMatch expect(spy).to.be.calledWithMatch(...args)
alwaysCalledWithMatch expect(spy).to.always.be.calledWithMatch(...args)
returned expect(spy).to.have.returned(returnVal)
alwaysReturned expect(spy).to.have.always.returned(returnVal)
threw expect(spy).to.have.thrown(errorObjOrErrorTypeStringOrNothing)
alwaysThrew expect(spy).to.have.always.thrown(errorObjOrErrorTypeStringOrNothing)

คำสั่ง asserts เหล่านี้ต้องใช้การใช้งาน บ่อย ๆ ถึงจะจำการเรียกใช้งานได้ บทความ ถัดไปจะมาเล่าถึงการใช้งาน spy / stub คอยติดตามด้วยนะครับ

Cypress แนะนำโครงสร้างของไฟล์ testscript (*.spec.js) EP3

ก่อนที่จะเริ่มเขียน testscript (ของเรียกไฟล์โค๊ดของการ test ว่า “testscript” ) โดยที่ชื่อไฟล์โดยปกติจะสร้างกำหนดเป็นชื่อ {name}.spec.js ที่จะถูกเก็บไว้ที่ folder cypress/integration/* โดยเมื่อกำหนดชื่อ ตาม pattern นี้ cypress จะรู้จัก testscript นี้โดยอัตโนมัติ

satang pro referral

โครงสร้างไฟล์ testscript ใช้ Mocha.js library มาช่วยจัดการ

describe ,it ,before ,after ,beforeEach ,afterEach ,context ต่าง ๆ เหล่านี้เป็นแท็กคำสั่ง ของ MochaJS Testing Library ที่ได้รับความนิยมเป็นอย่างมากกับ NodeJs Project เพราะด้วยถูกพัฒนาด้วย Javascript จึงเขียน test ได้ทั้ง frontend (cypress framework) หรือแม้แต่ backend (nodejs framework project ต่างๆ) ขออธิบายหน้าที่การทำงานของแต่ละแท็กสคริปคำสั่งแต่ละ คำสั่งทำงานอย่างไรบ้าง

before (ก่อนหน้า) เป็น lifecycle ของ testscript จะทำเมื่่อเริ่มรัน describe เพียงครั้งแรกเท่านั้น
beforeEach (ก่อนหน้าแต่ละ) คล้ายกับ before แต่จะถูกทำทุกการรันแท็ก it จะทำเมื่อเริ่มแรก ของ it
describe (อธิบาย) เมื่อเริ่มเขียน testscript ทำหน้าที่อธิบายการทำงานของกลุ่ม testcase นี้ มักจะใช้งานร่วมกับแท็ก it ซึ่งจะเป็นซับ testcase ของกลุ่ม test นี้
context (บริบท) หน้าที่คล้ายกับ describe เลือกใช้งานตามความถนัด
it (มัน ,เคส) แท็กเขียนโค๊ด test ที่นี่จะเริ่มเขียน API commands ต่าง ๆ ที่นี่
afterEach (หลังจากนั้นแต่ละ) ทำหลังจากเรียก it ทุกครั้ง จะทำ test บางอย่างหลังจาก it (เคส) ทำงานครบแล้ว
after (หลังจาก) ทำท้ายสุดหลังจาก test มาถึงท้าย กลุ่มเทส (describe)

describe('กลุ่มเทสเคส', () => {
  before(() => {
    cy.log('mocha [before]')
  });
  beforeEach(() => {
    cy.log('mocha [beforeEach]')
  });
  context('หมวดย่อยเทสเคส', () => {
    it('เทสย่อย', () => {
      cy.log('mocha [context:it]')
    });
  });
  it('เทสเคส', () => {
    cy.log('mocha [it]')
  });
  afterEach(() => {
    cy.log('mocha [afterEach]')
  });
  after(() => {
    cy.log('mocha [after]')
  });

});

*.skip (* context ,describe ,it)
การสั่งให้ cypress ข้ามการทำงาน block testcase นี้ไปไม่ต้องรัน ตัวอย่างเช่น

describe('กลุ่มเทสเคส', () => {
  before(() => {
    cy.log('mocha [before]')
  });
  beforeEach(() => {
    cy.log('mocha [beforeEach]')
  });
  context.skip('หมวดย่อยเทสเคส 1', () => {
    it('เทสย่อย', () => {
      cy.log('mocha [context:it]')
    });
  });
  context('หมวดย่อยเทสเคส 2', () => {
    it('เทสย่อย', () => {
      cy.log('mocha [context:it]')
    });
  });
  it('เทสเคส', () => {
    cy.log('mocha [it]')
  });
  afterEach(() => {
    cy.log('mocha [afterEach]')
  });
  after(() => {
    cy.log('mocha [after]')
  });

});

*.only (* context ,describe ,it)
การที่กำหนดให้ cypress สนใจเพียง testscript ที่กำลังทำงานอยู่เพียงเท่านั้น ถึงจะมีโค๊ด testscript อื่นอยู่ในไฟล์ก็จะไม่แสดงในหน้าการแสดงผล (cypress ui)

describe('กลุ่มเทสเคส', () => {
  before(() => {
    cy.log('mocha [before]')
  });
  beforeEach(() => {
    cy.log('mocha [beforeEach]')
  });
  context.skip('หมวดย่อยเทสเคส 1', () => {
    it('เทสย่อย', () => {
      cy.log('mocha [context:it]')
    });
  });
  context.only('หมวดย่อยเทสเคส 2', () => {
    it('เทสย่อย', () => {
      cy.log('mocha [context:it]')
    });
  });
  it('เทสเคส', () => {
    cy.log('mocha [it]')
  });
  afterEach(() => {
    cy.log('mocha [afterEach]')
  });
  after(() => {
    cy.log('mocha [after]')
  });

});

การทำงานแบบวนลูป

การใส่ loop เพื่อรันค่าจากตัวแปรค่าข้อมูลของเราทำได้ตามปริมาณข้อมูล input ถือว่าเป็นส่วนช่วยให้ reuse การใช้งานโค๊ดได้มีประสิทธิภาพยิ่งขึ้น เหมือนการเขียนโปรแกรมทั่วไปโดยใส่ loop ครอบ describe ,context ,it นั้น ๆ ตามตัวอย่างนี้

const mock = ['A', 'B', 'C']
describe('กลุ่มเทสเคส', () => {
  before(() => {
    cy.log('mocha [before]')
  })
  beforeEach(() => {
    cy.log('mocha [beforeEach]')
  })
  mock.forEach(levelFirst => {
    context.skip('หมวดย่อยเทสเคส mock level:: ' + levelFirst, () => {
      mock.forEach(levelSecond => {
        it('เทสย่อย level:: '+levelSecond, () => {
          cy.log('mocha [context:it]')
        })
      })
    })
  })
  context('หมวดย่อยเทสเคส 2', () => {
    it('เทสย่อย', () => {
      cy.log('mocha [context:it]')
    })
  })
  it('เทสเคส', () => {
    cy.log('mocha [it]')
  })
  afterEach(() => {
    cy.log('mocha [afterEach]')
  })
  after(() => {
    cy.log('mocha [after]')
  })

})

จบแล้วสำหรับเนื้อหาบทความนี้ บทความหน้าจะมาต่อกันกับเรื่องการ should/expect case ต่าง ๆ จะสามารถทำได้อย่างไรบ้าง และตัวอย่างโค๊ดจะเขียนอย่างไร คอยติดตาม เป็นกำลังใจด้วยนะครับ

Cypress แนะนำอธิบาย 20 API Commands ที่ใช้งานบ่อย EP2

visit
ใช้สำหรับเข้า website test ถือว่าเป็น command ที่ต้องรู้ลำดับแรก ๆ ตัวอย่างการใช้งาน cy.visit(‘https://google.com‘) มี function callback ที่น่าสนใจ คือ onBeforeLoad , onLoad สามารถใส่ behavior function การทำงานในส่วนนี้ได้

get
ระบุ selector element ของหน้าจอ เพื่อจะเป็น target ในการ action command ถัดไปกรณี ต้องการให้ระบุ element เพื่อทำงานอะไรบ้างอย่าง เช่น set value ในช่อง text ,set text บน label เป็นต้น ตัวอย่างการเรียกใช้งาน cy.get(‘{selector}’) โดย selector คือ input ,.class ,#id ,[name=””] เป็นต้น

type
ใช้สำหรับ set value ให้กับ input tag ต่าง ๆ เช่น input [text ,date ,phone ,password ,number ,…] รวมถึง textarea ได้อีกด้วย ตัวอย่างการใช้งาน cy.get(‘{selector}’).type(‘commade type’)

satang pro referral

clear
ใช้ clear value ในช่อง input ตัวอย่างการใช้งาน cy.clear()

as
ทำหน้าที่เป็น command กำหนด alias เป็นการกำหนด reference ให้กับ command ที่กำลังสนใจ หรือ ใช้งานซ้ำ ๆ สามารถ reuse เรียกใช้งานซ้ำได้ ตัวอย่างการใช้งาน cy.(‘{selector}’).as(‘mySelector’) อยากต้องการระบุอ้างอิงถึงก็เพียงแค่ cy.get(‘@mySelector’) ถึงชื่อ alias ก็สามารถใช้ทำงานได้เหมือนการเรียก cy.get(‘{selector}’) นั้นอีกครั้ง

check
ใช้กับ input [radio ,checkbox] กำหนด state ให้กับ element เหล่านี้เป็นการ checked ตัวอย่างการใช้งาน cy.get(‘{selector}’).check() โดยที่ {selector} นั้นต้องเป็น radio ,checkbox command ถึงจะทำงานได้ถูกต้อง

click
ชื่อก็บอกอยู่แล้ว กำหนด event click ให้กับ element ที่กำลังสนใจ เกือบแทบทุก element สามารถเรียก command นี้ได้เกือบหมด (ถ้าไม่ถูก Disabled) ตัวอย่างการใช้งานจะใช้งานร่วมกับ command get คือ cy.get(‘{selector}’).click()

contains
สำหรับเทียบค่าข้อความที่คาดหวังว่าจะมีใน element ที่เรากำลังสนใจ (คล้ายกับการ LIKE ‘%%’) ตัวอย่างการใช้งาน cy.get(‘{selector}’).contains(‘ข้อความ’) ถ้าหา element ที่มีข้อความไม่พบจะตก fail case เลย

debug
กำหนดการ pause ให้การรัน testcase หยุดช่ำขณะเพื่อดูการทำงานของโปรแกรม สามารถกด continues ต่อไปได้ เมื่อต้องการให้โปรแกรมทำงานต่อไป

each
การ LOOP elements ที่ได้จากการ get(‘{selector}’) แล้วต้องการที่จะทำงานอะไรบ้างอย่างกับ element ที่ selector ได้ ก็จะเรียก command each นี้ โดย command จะมี callback function ให้เขียนคำสั่งอื่นได้ ๆ ตัวอย่างการใช้งาน cy.get(‘{selector}’).each(()=> {/* todo something */ })

eq
ตัวย่อของ equal ที่แปลว่า เท่ากัน case ที่ใช้งานบ่อย คือการเทียบหาลำดับของ elements ที่พบได้มากกว่า 1 element ตัวอย่างการใช้งาน cy.get(‘{selector}’).eq(0) โดย 0 คือ index ของ elements ที่เจอ

find
ต้องการค้นหา element เป้าหมายเพียง element เดียว หลังจากที่ selector ได้ elements มากกว่า 1 หรือจะระบุ selector ตั้งแต่แรกเริ่ม get เลยก็ได้ ตัวอย่างการใช้งาน cy.get(‘{selector}’).find(‘.class-unique’) command จะ return element มาเพียง 1 element เท่านั้น

fixture
command เรียก static resource date ต่าง ๆ ที่ถูกจัดเก็บไว้ที่ path cypress/fixture/** โดยมีประเภทไฟล์ที่สามารถเรียกไฟล์ได้หลายนามสกุลด้วยกัน เช่น .json ,.csv ,.txt ,.png ,.pjg เป็นต้น ตัวอย่างการเรียกใช้งาน cy.fixture(‘{file-name}’).then(response => {/* doto something */ })

log
แสดง variable หรือ text ออกทางหน้าจอ run ui คล้ายกับ console.log(”) ของ javascript เพื่อแต่พื้นที่การแสดงอยู่คนละที่กัน

next
ถัดไป จะ selector element ตำแหน่งถัดไปของ element ก่อนเรียก command นี้ โดยสังเกตุจะเป็น element level เดียวกับ element ที่เรียก command นี้ ตัวอย่างการใช้งาน cy.get(‘{selector}’).next() จะได้ element ถัดไปทันที

prev
ก่อนหน้า เมื่อมี element ถัดไป (next command) ก็ต้องมีการหา element ก่อนหน้า ดังนั้น command นี้จะทำหน้าที่หา element ก่อนหน้าที่จะเรียก command นี้

not
เป็นนิเสธน์ ใช้กรอง element ไม่สนใจ elements ที่ถูกเรียกภายใน command นี้ ตัวอย่างการเรียกใช้งาน cy.get(‘{selectors}’).not(‘{.not-use-element}’)

request
ใช้เรียก api หรือ url ที่ต้องการจะเข้าถึงแบบ background run (คล้าย ๆ การเรียก AJAX ของ jQuery เหมือนกัน ตัวอย่างการเรียกใช้งาน cy.request(‘{url-api}’) หรือจะเพิ่มการเรียก method อื่น ๆ ด้วย cy.request({method : ‘POST’ , url : ‘{url-api}’})

should
ถือว่าเป็นพระเอกของเรื่องการทำ automated testing เลยก็ว่าได้เพราะถ้าการเขียน testscript ไม่มีการเขียนการคาดหวังผลลัพธ์ เพื่อที่จะใช้ตรวจสอบข้อมูลต่าง ๆ ละก็ไม่รู้ว่าการเขียน automated test จะมีประโยชน์อะไรเลย ตัวอย่างการใช้งานก็เรียกว่าทำได้หลากหลายมาก เช่น cy.get(‘{selector}’).should(‘have.text’,'{ข้อความ}’) หรือแม้แต่จะเรียกแบบ callback function ก็ทำได้โดย cy.get(‘{selector}’).should($selector => { /* todo something */ }) โดยการตรวจสอบในรุปแบบอื่น ๆ สามารถเข้าไปดูได้จากลิ้งนี้ command should

submit
ใส่ action Submit ให้กับ element FORM ตัวอย่างการเรียกใช้งาน cy.get(‘selector*’).submit() *คือ selector ของ FORM

wait
จะเรียกว่าเป็น command ที่เป็นตัวช่วยชีวิตกรณีการไม่แน่ใจว่าการทำงานจะเสร็จเมื่อไหร่ มักจะใส่ wait นี้เพื่อรอการทำงานก่อนหน้านี้ให้แน่ใจว่าทำงานเสร็จเรียบร้อย ตัวอย่างการใช้งาน cy.wait(4000) คือ ให้รอ 4 วินาที และจึงทำงานต่อไปได้

และนี้คือ API commands ของ Cypress ที่น่าจะเป็น commands ที่เรียกใช้งานได้บ่อย ที่สุด บทความต่อไปจะไปล้วงลึกการทำงานของ Spy/Stub ที่เป็นเรื่องที่ไม่รู้ไม่ได้ เลยจริง ๆ รอติดตามนะครับ