實際上撰寫 e2e 測試的時候,我們常常會需要做一些預設的測試資料在資料庫內,假設我今天想要測試會員在網站上購物的流程,那麼網站一定會需要有商品、會員、訂單等等資料結構。 在這樣的情況下,為了確保測試的可重複性,通常會在測試開始之前做測試資料的初始化;測試完畢之後做測試資料的清除。
Hook
如果有寫過前端測試,像是jest
、mocha
,那這部分應該很輕鬆就能理解,官網上也有說明,其實也沒有很困難,這些東西就只是代表,跑測試的時候,什麼時機點會觸發這些對應的事件,我們可以透過這些 Hook 來將我們要處理的事情,插入在這些時間點,通常在每個測試開始之前,我們會插入該測試所需要初始化的事件
官方的例子就已經蠻清楚的,如果還是有問題,其實就直接跑看看,觀察一下 Log 就行了;在下面的例子裡面,注意到hook
可以放在最外層,也可以放在describe
區段之內,兩者的意義是不同的
beforeEach(() => {
// root-level hook
// runs before every test
});
describe("Hooks", () => {
before(() => {
// runs once before all tests in the block
});
beforeEach(() => {
// runs before each test in the block
});
afterEach(() => {
// runs after each test in the block
});
after(() => {
// runs once after all tests in the block
});
});
使用 node.js 執行初始化
文件有寫到使用的情境;我們目前希望在測試程式裡面要去影響到資料庫的內容,所以透過cy.exec()
這個指令去執行node.js
的指令
假設我的資料庫用的是mariaDB
,因此先安裝好套件npm install mariaDB
,再依照官方文件說明進行修改,就可以操作資料庫了
// appConfig.js
exports.orderId = 1234567890;
// dbConfig.js
exports.dbConfig = {
host: "127.0.0.1",
port: 4006,
user: "admin",
password: "admin",
database: "mydb",
connectionLimit: 5,
};
// mariaDBHelper.js
const mariaDb = require("mariadb");
const config = require("../_config/dbConfig");
const pool = mariaDb.createPool(config.dbConfig);
async function clearAsync(orderId) {
let conn = await pool.getConnection();
let response = await conn.query("select id from order where orderId = ?", [
orderId,
]);
if (response[0] !== undefined) {
const qaId = response[0].id;
response = await conn.query("delete from order where Id= ?", [qaId]);
response = await conn.query("delete from order_comment where qa_id=?", [
qaId,
]);
console.log("cleanup qaId:" + qaId);
}
if (conn) {
conn.end();
}
}
exports.clearDB = async function (appConfig) {
await clearAsync(appConfig.orderId);
};
// db_clear.js
const appConfig = require("./_config/appConfig.js");
const mariaDBHelper = require("./helper/mariaDBHelper.js");
mariaDBHelper.clearDB(appConfig).then(() => {
process.exit(0);
});
// package.json
{
//... 略
"scripts": {
"db:init": "node ./db/init.js"
}
}
實際在測試程式就利用beforeEach
的hook
來執行初始化的動作
describe('質檢單', () => {
beforeEach(() => {
cy.exec('npm run db:init');
});
it('myTest', () => {
//...some test code
});
}
執行測試後,會發現左側有Hook
的名稱
我不想串真實資料怎麼辦
那就用假資料來做測試吧,可能有一些情境是你不想再跑測試的時候,讓他去吃到 API 過來的資料,而是想要模擬一個固定的回傳結果來測試;這樣的做法就是讓測試與外部相依隔離開來,所以會這樣做的情況,一般來說就已經不會再是整合測試的範疇,而是逐漸往單元測試靠攏了,當然具體如何還是要看實際的程式碼與應用情境
cy.route(url);
cy.route(url, response);
cy.route(method, url);
cy.route(method, url, response);
cy.route(callbackFn);
cy.route(options);
模擬一個假資料回應
cypress.io
提供route語法,因此可以將指定的url
,替換為預先指定好的回應結果
例如下列的指令,將會監聽符合條件的網址請求,並回應一個 name 為 Phoebe 的使用者資料
cy.route(/users\/\d+/, { id: 1, name: "Phoebe" });
當請求網址符合剛才的正則表達式,實際取得的回應結果就會是剛才的假資料
$.get("https://localhost:7777/users/1337", (data) => {
console.log(data); // => {id: 1, name: "Phoebe"}
});
模擬多個假資料回應
在下面這個範例透過as()
、cy.wait()
的方式,先幫route
設定一個別名,然後透過wait
去等候這個指令的執行結果,然後我們可以再次透過route
去重複指定相同 url 的回應結果;透過相同的別名就可以取得新的回應結果了
cy.server();
cy.route("/beetles", []).as("getBeetles");
cy.get("#search").type("Weevil");
// wait for the first response to finish
cy.wait("@getBeetles");
// the results should be empty because we
// responded with an empty array first
cy.get("#beetle-results").should("be.empty");
// now re-define the /beetles response
cy.route("/beetles", [{ name: "Geotrupidae" }]);
cy.get("#search").type("Geotrupidae");
// now when we wait for 'getBeetles' again, Cypress will
// automatically know to wait for the 2nd response
cy.wait("@getBeetles");
// we responded with 1 beetle item so now we should
// have one result
cy.get("#beetle-results").should("have.length", 1);
靜態測試資料 (fixture)
上面的做法都是將假資料寫在程式內,但為了方便管理,透過指定將靜態資料讀取進來,在將它設定為回應結果,應該是比較實務的做法,我們可以透過cy.fixture()
做到這件事情,語法的細節可以參考官網文件
cy.fixture(filePath);
cy.fixture(filePath, encoding);
cy.fixture(filePath, options);
cy.fixture(filePath, encoding, options);
一種方式是先用fixture
接著再route
cy.fixture("user").then((user) => {
user.firstName = "Jane";
// work with the users array here
cy.route("GET", "**/user/123", user);
});
另外一種方式是直接一行解決掉,使用route
的時候跟他說資料來自fixture
cy.server();
cy.route("**/posts/*", "fixture:logo.png").as("getLogo");
cy.route("**/users", "fixture:users/all.json").as("getUsers");
cy.route("**/admin", "fx:users/admin.json").as("getAdmin");
當然也可以透過別名來串聯這兩個指令,具體還是看自己喜歡哪種方式
cy.fixture("user").as("fxUser");
cy.route("POST", "**/users", "@fxUser");
上述的所有範例都取自官網
cy.route()
可以拿來做假資料,也可以直接發出請求cy.request()
會真的跟指定end-point
發出請求,cy.route()
則不一定
此外,需要特別補充的是,在/fixtures/
底下的 json 檔案,如果發生了無法解析JSON
的錯誤,可以檢查一下是否檔案的編碼格式有沒有包含BOM
,能夠正常運作的是不包含BOM
的,所以記得要將BOM
移除掉
如果使用VSCode
做編輯器,可以在下方資訊點選後選擇Save with Encoding
,並選擇UTF-8
的格式;如果使用Rider
的話,也可以點選Remove BOM
結論
測試資料的初始化、清除。要做到怎樣的程度,應該還是要看環境決定,如果只是在工程師自己開發環境在練習可能還無所謂,能跑就好;但是,如果不是在開發環境內,可能就要考慮一下,如果資料庫有髒資料的話會不會有甚麼影響,最好能夠避免這些副作用,cypress
能夠做到整合測試資料的初始化與清除,但它也能夠用stub
的方式模擬回應結果來隔絕外部相依,這些應該是看情境搭配,相輔相成的
如果我想要完全模擬使用者的操作行為,也做好了測試資料的初始化與清除作業,但是偏偏流程當中有一個環節是跟其他公司的服務串接的,例如串接外部金流,但是對方卻沒有提供測試信用卡給你刷,可是你卻又要測試刷卡購物流程,難道你會每次測試都拿自己的卡出來真的刷嗎?肯定不會嘛,所以勢必要針對這個服務做隔離,當然目的還是再整合測試,但是卻隔絕了外部環境的相依,畢竟我們要測試的是刷卡購物的流程,而不是這張卡到底能不能刷過;如果我們要測試刷卡不過的購物流程,那就再寫一個模擬刷卡失敗的情境就好了
對於cypress
的使用我還在摸著石頭過河,文章若有錯誤的地方,請不吝指正,謝謝