Cypress.io 登入的測試案例


這一篇專門講一下如何在cypress.io的測試程式裡面,撰寫登入系統的部分,之後如果有機會會慢慢更新

Logging in

這一段的目的是將測試行為中常常重複的登入動作,希望用一行指令就可以解決掉登入這件事情,很多功能都需要識別使用者身份,底下的程式是直接打後端 API 去做登入,略過了模擬頁面輸入帳密的行為,利用cy.request()來向後端實際發出請求登入

describe("前台", function () {
  it("進入member頁面", function () {
    cy.request("POST", "Account/SignIn", {
      loginId: "real@user.com",
      password: "realPassword",
    });
    cy.visit("/member");
    cy.url().should("contain", "/member");
    cy.getCookie(".AspNetCore.Cookies").should("exist");
  });
});

上面的測試中,我們採用了cy.request()實際向後端登入送出帳密,之後則是瀏覽頁面,在官網的範例中,利用set up的方式去建立假資料 這樣做的好處是,確保測試程式沒有相依資料庫,因為他的準備工作都在執行測試之前用set up做掉了

但是因為每個人的情境不一樣,工作、開發流程、團隊文化、甚至是機器配置都不一樣,實際上你也可以選擇在開發環境的資料庫中使用已存在的帳號進行測試登入行為,只要你的團隊有共識即可

Login with CSRFTOken

真實世界裡面,網站為了避免資安攻擊,大部分都會加上一些防護措施,尤其是登入頁面,開發 ASP.NET MVC網站,也通常會利用在頁面埋上一個 token 的方式來判斷這個請求是否是從正確頁面過來的,通常後端會做這樣的事情

// AccountController.cs
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Login(LoginForm loginForm)
{
	//...some code
	return this.View(loginForm);
}

還有這樣的事情

// login.cshtml
@using (Html.BeginForm("Login", "Account", FormMethod.Post))
{
	@Html.AntiForgeryToken()
	@* 帳號 *@
	<div class="form-group">
		@Html.LabelFor(model => model.Account)
		@Html.EditorFor(model => model.Account, new { htmlAttributes = new { @class = "form-control", placeholder = "請輸入帳號" } })
		@Html.ValidationMessageFor(model => model.Account, "", new { @class = "text-danger" })
	</div>
	@*.... ....*@
}

如果是這樣子的登入頁面,就沒辦法用先前的方式背景登入,所以我們要透過一些小技巧來做

增加一個 cypress 的指令,叫做loginByCSRF,這個指令接收 token 參數後,背景發出一個請求給後端登入頁面,傳送帳號、密碼及 token

let account = "fakeUser";
let password = "fakePassword";

Cypress.Commands.add("loginByCSRF", (csrfToken) => {
  cy.request({
    method: "POST",
    url: "/Account/Login",
    failOnStatusCode: false,
    form: true,
    body: {
      account,
      password,
      __RequestVerificationToken: csrfToken,
    },
  });
});

在測試程式中,需要做登入的地方

  1. 利用request取得登入頁面 Html,從中取得 token
  2. 再次發出request,這一次將帳密跟 token 一起送給後端登入判斷
let account = "fakeUser";
let password = "fakePassword";

cy.request("/Account/Login")
  .its("body")
  .then((body) => {
    const $html = Cypress.$(body);
    const csrf = $html.find("input[name=__RequestVerificationToken]").val();

    cy.loginByCSRF(csrf).then((resp) => {
      // assert 登入是否成功
      expect(resp.status).to.eq(200);
    });
  });

這個範例是從官方文件:Recipes看到的,還有很多範例可以參考

完整的範例如下

describe("頁面測試", () => {
  const account = "testuser";
  const password = "testpass";

  Cypress.Commands.add("loginByCSRF", (csrfToken) => {
    cy.request({
      method: "POST",
      url: "/Account/Login",
      failOnStatusCode: false,
      form: true,
      body: {
        account,
        password,
        __RequestVerificationToken: csrfToken,
      },
    });
  });

  beforeEach(function () {
    cy.request("/Account/Login")
      .its("body")
      .then((body) => {
        const $html = Cypress.$(body);
        const csrf = $html.find("input[name=__RequestVerificationToken]").val();

        cy.loginByCSRF(csrf).then((resp) => {
          expect(resp.status).to.eq(200);
        });
      });
  });

  it("會員專區", () => {
    cy.visit("/Member");
    cy.url().should("contain", "/Member");
  });
});

重構登入

我們將原本的程式碼進行重構,將一些可能重複利用的程式碼抽離出來放到其他地方去,盡量讓測試程式乾淨一點

將會員登入的部分放到會員的helper裡面,之後需要登入就直接呼叫此方法

// helper/memberHelper.js
export default {
  signIn: (user) => {
    cy.request("/Account/Login")
      .its("body")
      .then((body) => {
        const $html = Cypress.$(body);
        const csrf = $html.find("input[name=__RequestVerificationToken]").val();

        cy.loginByCSRF(user, csrf).then((resp) => {
          expect(resp.status).to.eq(200);
        });
      });
  },
};

一些測試的會員登入帳號就放在這邊

// helper/user.js
export default {
  art: { account: "art", password: "111" },
  bob: { account: "bob", password: "222" },
  cat: { account: "cat", password: "333" },
};

將新增的指令放在這個檔案內,就可以於 cypress 內重複使用

// cypress/support/commands.js
Cypress.Commands.add("loginByCSRF", (user, token) => {
  cy.request({
    method: "POST",
    url: "/Account/Login",
    failOnStatusCode: false,
    form: true,
    body: {
      account: user.account,
      password: user.password,
      __RequestVerificationToken: token,
    },
  });
});

測試程式中引用檔案後,直接改寫如下,相較於先前版本,更容易理解,不過如果想要省略掉 user.js,其實也可以讓自訂指令從接收user物件,改為直接接收帳號密碼參數,這個就看個人選擇了

// /cypress/integration/MyTest/my.spec.js
import member from "../../../helper/memberHelper";
import users from "../../../helper/user";

describe("頁面測試", () => {
  beforeEach(function () {
    member.signIn(users.art);
  });

  it("會員專區", () => {
    cy.visit("/Member");
    cy.url().should("contain", "/Member");
  });
});

補充 - 20220714

因為先前的文章作法現在看來有很多改進空間,不調整原先的文章,直接補充在這邊

去除多餘的結構

原先的登入方式將登入行為與取得 token 的部分拆成兩個部分,應該可以放在一起,讓邏輯維持在同一個地方,而不是分散兩地,現在我會把它寫成下面這樣

// cypress/support/commands.js
Cypress.Commands.add("login", ({ account, password }) => {
  cy.request("/Account/Login")
    .its("body")
    .then((body) => {
      const $html = Cypress.$(body);
      const csrf = $html.find("input[name=__RequestVerificationToken]").val();

      cy.request({
        method: "POST",
        url: "/Account/Login",
        failOnStatusCode: false,
        form: true,
        body: {
          account,
          password,
          __RequestVerificationToken: csrf,
        },
      });
    });
});
//sample.spec.js
describe("頁面測試", () => {
  beforeEach(function () {
    cy.login({ account: "guest", password: "guest" });
  });

  it("檢視會員專區頁面", () => {
    cy.visit("/Member");
    cy.url().should("contain", "/Member");
  });
});

因為自訂指令希望是通用,所以將它與原先版本的 user, memberHelper 解耦合,上面的做法是用request去做,我想應該也可以做成直接透過cy.visit(),然後用cy.get()然後再 type()去實際操作登入,坦白說我對於這個有點困惑,但至少兩種方法我都能夠做的出來。等我要用的時候也有辦法做到;但目前的想法是,希望測試案例聚焦在我想要測試的東西上面,如果是想要驗證登入後的行為,我會選擇讓登入的動作在背景就做掉,而不是完全百分百的模擬登入操作的這件事情