iT邦幫忙

2022 iThome 鐵人賽

DAY 27
0
Modern Web

angular專案開發指南系列 第 27

E2E 自動測試 - Cypress

  • 分享至 

  • xImage
  •  

前言

專案開發後期會進入 QA 測試階段,其中免不了各種手動點擊切換頁面或迴歸測試等,每個功能都要人工進到畫面中看一遍有沒有問題,這樣不僅工作量大重複性高,且當項目越來越大時,手動測試會變得更不可靠也很容易遺漏某個頁面沒有測到,造成產品上線後的問題。
自動化測試的場景主要是想提高測試覆蓋率,包含用戶主要使用的項目,如登錄註冊,操作表單 CRUD,修改個人資料等。E2E 端對端測試透過使用者的工作流程來檢查應用程式是否按預期的進行。

自動測試前期需要投入規劃與開發的成本,且隨著專案需求的變化,自動化測試的程式也有可能需要跟著修改,建議一開始不需要太鑽牛角尖,先從基本常規的手動操作轉自動化開始,之後再持續不斷的改善,Cypress 將會是不限於 Angular 專案使用的自動化測試的好工具。


Cypress 優點

  • GUI介面美觀友好
  • 修改測試代碼可實時重新載入
  • 可供回朔每一步的操作截圖
  • 全程自動測試過程錄影
  • 支持debug/pause隨時暫停
  • 自動等待UI更新減少異步代碼
  • 請求攔截機制 intercept

Cypress 特色

本專案整合使用 mockserver 配合假資料做自動測試

  • Cypress 可以 Mock 伺服器返回的結果,無須依賴後端伺服器,即可實現模擬網路請求
  • Spies(間諜)、Stubs(存根)、Clock(時鐘)
  • Cypress 允許你驗證並控制函式行為,Mock 伺服器的響應,更改系統時間

Cypress 安裝與啟動方式

安裝 Cypress

npm install cypress -D

啟動自動測試指令

npx cypress run

啟動自動測試 GUI 指令

npx cypress open

GUI介面

p123

E2E 自動測試 viewport GUI 模式

npx cypress open

g19

E2E 自動測試 viewport 命令列模式

npx cypress run

p124

自動測試結束後可單獨檢視每個步驟 - Timeline View

g20

生成HTML報告

命令行模式

cypress run --reporter mochawesome --reporter-options reportDir="cypress/reports",overwrite=false,html=false,json=true

配置檔模式

module.exports = defineConfig({
  reporter: 'mochawesome',
  reporterOptions: {
    reportDir: 'cypress/reports',
    overwrite: false,
    html: false,
    json: true
  }
})

執行 Cypress 時自動產生測試報告靜態檔
cypress/reports 包含測試用例個數、測試結果、測試代碼及測試結果。

p75

p76


環境配置說明

cypress.json 檔案配置說明

{
    "projectId": "w8rdav",
    "reporter": "mochawesome",
    "reporterOptions": { // 自動測試報告檔案的路徑與設定
        "reportDir": "cypress/reports",
        "overwrite": false,
        "html": true,
        "json": true
    },
    "retries": 1, // 錯誤時重試的次數
    "baseUrl": "http://localhost:3000", // network request發起的預設後臺路徑

    // 自訂環境變數
    "env": {
        "isMock": true, // 是否調用 Mockserver API
        "host": "http://localhost:4200",
    }
}

詳細配置請參考 Configuration官網配置文件


客製化指令

Cypress 帶有自己的 API,用於創建自定義命令和覆蓋現有命令 (作用範圍全域)。

使用情境:登入指令作為客製化命令供自動測試代碼調用

編輯指令 support/commands.js

support/commands

// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

// EC專案中訪問頁面加入 url的參數
Cypress.Commands.add("goVisit", (url, urlQueryParamStr = "") => {
    if (!url) return;

    const params_1 = "params_1=" + Cypress.env("params_1");
    const params_2 = "params_2=" + Cypress.env("params_2");
    const params_3 = "params_3=" + Cypress.env("params_3");
    const params_4 = "params_4=" + Cypress.env("params_4");
    const urlQueryParams = `${params_1}&${params_2}&${params_3}&${params_4}${urlQueryParamStr}`;

    cy.visit(`${Cypress.env("host")}/${url}?${urlQueryParams}`);
});

使用 support/commands.js 的指令

// 訪問 somewhere url
beforeEach(() => {
    cy.goVisit("somewhere_url");
});

// 訪問 somewhere url 須另外加入參數
beforeEach(() => {
    cy.goVisit("somewhere_url", "&groupShowAll=false&userName=CypressTest");
});

詳細請參考 自定義命令


開始編寫自動測試

一個可靠的測試通常包括3個階段

  1. 設置應用程序狀態 (BeforeEach/BeforeAll)。
  2. 採取行動 (DOM Action)。
  3. 對生成的應用程序狀態進行斷言 (Assert/Expect)。

編寫步驟

  1. 訪問網頁。
  2. 查詢一個元素(畫面是否存在該元素)。
  3. 與該元素交互(操作該元素,如點擊)。
  4. 斷言頁面操作後的內容。

代碼實例

STEP 1. 進入目標頁面 (訪問網頁)

beforeEach(() => {
    cy.visit(url);
});

STEP 2. 採取行動(查詢一個元素)

it("查詢某個元素是否存在", () => {
    // 檢查是否出現標題元素
    cy.get(".title").should("have.length", 1);

    // 檢查是否出現副標題元素
    cy.get(".sub-title").should("have.length", 1);

    // 檢查是否出現目標結果畫面
    cy.get(".result").children().should("have.class", "blocker");
});

STEP 3. 對生成的應用程序狀態進行斷言(與該元素交互/斷言)

it("按下功能按鈕", () => {
    // 點擊綁定按鈕
    cy.get(":nth-child(1) > .btn").click();

    // 攔截功能 API
    cy.intercept("POST", "API_URL").as("backendAPI");

    // 斷言 API結果正確性
    cy.wait("@backendAPI").then((xhr) => {
        expect(xhr.response.statusCode).to.equal(200);
    });
});

自動測試 - 完整代碼

describe("search-agent.component.spec", () => {
    beforeEach(() => {
        cy.visit(
            `${Cypress.env("host")}/searchAgent?identifyBy=${Cypress.env("identifyBy")}&identifyValue=${Cypress.env(
                "identifyValue"
            )}&chatKeepId=${Cypress.env("chatKeepId")}&department=${Cypress.env(
                "department"
            )}&groupShowAll=false&userName=CypressTest`
        );
    });

    it("是否進入專員綁定頁面", () => {
        cy.get(".title").should("have.length", 1);
        cy.get(".sub-title").should("have.length", 1);
        cy.wait(500);
        cy.get(".result").children().should("have.class", "blocker");
    });

    it("按下綁定專員按鈕", () => {
        cy.get(":nth-child(1) > .btn").click();
        cy.intercept("POST", `${Cypress.env("backend_host")}/ecp/expressChat/contactBindAgent`).as("backendAPI");
        cy.wait("@backendAPI").then((xhr) => {
            expect(xhr.response.statusCode).to.equal(200);
        });
    });
});

STEP 4. 到 GUI 介面檢查測試是否全部通過

p125


特色指令使用範例

Spy 包裝一個方法,以記錄對該函數的調用和參數。

cypress\integration\playpround

const hello = {
    add(a, b) {
        return a + b;
    }
};

context("Spy與Stub的自動測試", () => {
		it("spy - 包裝一個方法記錄對該函數的調用和參數", () => {
        const spy = cy.spy(hello, "add");

        expect(hello.add(2, 3)).to.equal(5);

        expect(spy).to.be.calledWith(2, 3);

        expect(spy).to.be.calledWith(Cypress.sinon.match.number, Cypress.sinon.match.number);

        expect(spy).to.be.calledWith(Cypress.sinon.match(2), Cypress.sinon.match(3));

        expect(spy).to.be.calledWith(Cypress.sinon.match.any, 3);

        expect(spy).to.be.calledWith(Cypress.sinon.match.in([1, 2, 3]), 3);
    });
});

p79

Stub 替換一個函數,記錄它的使用並控制它的行為。

cypress\integration\playpround

const hello = {
    global: () => {
        return "Mall";
    },
    world: () => {
        return "Hello";
    },
    testReturn: () => {
        return "testReturn";
    },
    greet(name) {
        return `Hello, ${name}!`;
    },
};

context("Spy與Stub的自動測試", () => {
    it("最普通的執行方式", () => {
        expect(hello.world()).to.equal("Hello");
    });

    it("stub - 創建一個存根並手動替換一個函數", () => {
        hello.global = cy.stub();
        hello.global();
        expect(hello.global).to.be.calledOnce;
    });

    it("stub - 用存根代替方法", () => {
        cy.stub(hello, "world");
        hello.world();
        expect(hello.world).to.be.calledOnce;
    });

    it("stub - 指定一個存根方法的返回值", () => {
        cy.stub(hello, "testReturn").returns("this is a return");
        const resp = hello.testReturn();
        console.log(resp);

        expect(hello.testReturn).to.be.calledOnce;
        expect(hello.testReturn()).to.equal("this is a return");
    });

    it("stub - 用存根帶入參數", () => {
        cy.stub(hello, "greet")
            .callThrough()
            .withArgs(Cypress.sinon.match.string)
            .returns("Hi")
            .withArgs(Cypress.sinon.match.number)
            .throws(new Error("Invalid name"));

        // input string
        expect(hello.greet("World")).to.equal("Hi");

        // input number
        expect(() => hello.greet(42)).to.throw("Invalid name");

        expect(hello.greet).to.have.been.calledTwice;

        // when no input equal (Hello, undefined!)
        expect(hello.greet()).to.equal("Hello, undefined!");
    });
});

p80

Intercept 間諜和存根網絡請求和響應。

攔截後臺 API 攔截請求並覆寫 Response,不調用後臺 API,正確的用法如下,

cy.intercept('/users/**')
cy.intercept('GET', '/users*')
cy.intercept({
  method: 'GET',
  url: '/users*',
  hostname: 'localhost',
})

cy.intercept('POST', '/users*', {
  statusCode: 201,
  body: {
    name: 'Peter Pan',
  },
})

cy.intercept('/users*', { hostname: 'localhost' }, (req) => {
  /* do something with request and/or response */
})

實際範例攔截後臺 API 如下,

it("Intercept 使用範例", () => {
    // req.reply 提供假的回應如果不使用就會打真實後臺
    cy.intercept("POST", `${Cypress.env("host")}/somewhere_url`, {
        statusCode: 200,
        body: {
            name: "Peter Pan",
        },
    }).as("backendAPI");

    // cy.wait 等待 backendAPI 完成後執行
    cy.wait("@backendAPI").then((xhr) => {
        // statusCode 等於 200 即代表後端 API 回應完成
        expect(xhr.response.statusCode).to.equal(200);
    });
});

實際範例不攔截後臺 API 如下,

it("按下綁定專員按鈕", () => {
    // req.reply提供假的回應如果不使用就會打真實後臺
    cy.intercept("POST", `${Cypress.env("hostECP")}/somewhere_url`).as("backendAPI");

    // Response 沒有攔截會調用真實 API

    // cy.wait 等待 backendAPI 完成後執行
    cy.wait("@backendAPI").then((xhr) => {
        // statusCode 等於 200 即代表後端 API 回應完成
        expect(xhr.response.statusCode).to.equal(200);
    });
});

測試代碼偵錯方式

Debug 可以寫 cy.debug() 或直接 F12打斷點 (Chrome引擎下)

cy.debug()
cy.debug(options)

// 正確用法
cy.debug().getCookie('app') // Pause to debug at beginning of commands
cy.get('nav').debug() // Debug the `get` command's yield

p126

Pause 停止 cy 運行命令並允許與被測應用程序交互。

cy.pause()
cy.pause(options)

// 正確用法
cy.pause().getCookie('app') // Pause at the beginning of commands
cy.get('nav').pause() // Pause after the 'get' commands yield

p127


檔案結構與語言類別介紹

檔案結構

  • /cypress
    • /fixtures
      • example.json
    • /integration (測試代碼)
      • /examples
        • example.spec.js (格式為 .spec.js
    • /plugins
      • index.js
    • /support (自定義命令)
      • commands.js
      • index.js
    • /screenshots (截圖文件)

測試語言類別整理

全局變量

beforeAll(fn)

在此文件中的任何測試運行之前運行一個函數。

afterAll(fn)

在此文件中的所有測試完成後運行一個函數。

beforeEach(fn) 

在此文件中的每個測試運行之前運行一個函數。

afterEach(fn)

在此文件中的每個測試完成後運行一個函數。

describe/context(name, fn)

describe(name, fn)創建一個塊,將多個相關測試組合在一個“測試套件”中。

test/it(name, fn)

測試文件中需要的只是test運行測試的方法。

測試語言類別

  • 測試斷言
    • should
    • then
    • each:遍歷執行(對於數組)
    • spread:then 的 each 版
  • 查詢
    • containsget
      • childrenclosestfind
      • eqfilternot
      • firstlast
      • nextnextAllnextUntil
      • parentparentsparentsUntil
      • prevprevAllprevUntil
      • siblings
    • windowdocumenttitle
    • its:取得對象中的字段,如 cy.get('ul li').its('length')
    • root:當前上下文的根元素節點
    • within:設定上下文元素
  • 操作
    • 用戶操作
      • clickdblclickrightclick
      • blurfocusfocused
      • trigger
    • 表單/輸入框
      • checkuncheckselect
      • clear
      • type
      • submit
    • scrollIntoViewscrollTo
    • invoke:調用對象中的函數,如 cy.get('div').invoke('show')
  • 瀏覽器
    • viewport
    • clearCookieclearCookiesgetCookiegetCookiessetCookie
    • clearLocalStorage
  • 網絡請求
    • visitreload
    • hashlocationurl
    • go:相當於 window.history.go
    • request:HTTP 請求
  • 功能性
    • 語法糖
      • as:設置為別名
      • and:進行多個測試
      • end:截斷當前測試(後續鏈式調用將重新計算)
      • wrap:包裝一個對象(以便支持 cy 命令)
    • 調用監聽
      • spy:監聽對象中的函數
      • stub:替換對象中的函數
    • Timer
      • clock:覆寫原生時鐘(將會影響 setTimeout 等原生函數)
      • tick:跳過時間,加快測試速度(需要先 cy.clock()
      • wait:顯式等待
  • 內置工具
  • [**Cypress._](https://docs.cypress.io/api/utilities/_.html#SyntaxLodash**
  • [**Cypress.$](https://docs.cypress.io/api/utilities/$.htmljQuery**

Cypress Dashboard操作畫面

進入Dashboard

p85

測試案例執行結果

p86

測試案例分析圖表

p87


結論

早期 Angular 專案建立時會連帶地將 Protractor 也配置妥當,讓使用者方便進行 E2E 的自動測試,隨著時間的推移 Angular v12 版本推出時宣佈在 Angular v12 後,Protractor 將不再內建於新專案中,預計會在 Angular v15 時,Angular 會正式終結 Protractor。
使用 Cypress 能幫助 Angular 專案非常出色地進行 E2E 測試且適用於任何前端框架或是網站,執行起來也比其他框架要快的多,相信未來 Cypress 會有更多關於 Angular 使用 Cypress 框架進行 E2E 測試的案例。

自動測試不需要一蹴而就,尤其當你面對的是那些原本都是人工測試的遺留專案時,透過慢慢將過去的測試案例轉化為自動測試代碼的同時,慢慢累積自動化能量,讓 QA 人員逐漸把過去手工的部份轉為自動,幾輪之後當提高自動測試的覆蓋率時,自然能享受到測試自動化所帶來的紅利。

單元測試與 E2E 測試都介紹完了,搭配自動工作流程,才能更好地發揮這些工具的價值,下一篇帶大家看一下 Gitlab CI/CD。


參考

Cypress 官方網站

Cypress 學習指南

Testing Angular

Cypress Intro


上一篇
Angular 單元測試 - Karma
下一篇
Gitlab工作流程介紹
系列文
angular專案開發指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言