はじめに
こんにちは、 SmartHR Advent Calendar 15日目の @aomoriringo です。
SmartHRのQAグループでSET(Software Engineer in Test)として働いています。
SETとしては1人目として入社したため、その時点ではE2Eテスト自体が存在していませんでした。
現在はCypressでのE2Eテストに取り組んでおり、これまでに溜まった知見をご紹介したいと思います。
E2Eテストの位置付け
SmartHR Advent Calendar 2日目で、 wata727さんが Capybaraによるテストの話を書いてくれています。
Capybaraでブラウザを介したテストコードを書いているのに、それとは別になぜCypressによるテストを書く必要があるのでしょうか?
ここで、「テストから見えてくるグーグルのソフトウェア開発」に出てくる Sテスト, Mテスト, Lテスト という表現を紹介しましょう。
Sテストは、ほとんどの場合(かならずではないが)自動化され、1個の関数またはモジュールに含まれるコードを対象とする。
チェック対象として重点が置かれるのは、データ破壊、エラー条件、off-by-oneエラーなどの関数でよく見られる誤りだ。Sテストは、数秒かそれ以下という短時間で終わる。(中略)
Sテストが答えるべき問いは、**「このコードは、するはずのことをしているか」**だ。Mテストは、通常自動化されており、複数の相互作用する機能が含まれていることが多い。
重点が置かれるのは、互いに呼び出しあったり、直接やり取りしたりする関数(これらは最近傍関数と呼ばれる)の間の相互作用だ。(中略)
Mテストが答えるべき問いは、**「これらの最近傍関数は設計通りに相互作用しているか」**だ。Lテストは、3つ以上(通常はもっと多く)の機能から構成され、実際のユーザーが使うシナリオを表現し、実際のユーザーデータソースを使い、実行にも1時間、あるいはそれ以上の時間がかかる。
機能全体の統合もチェックするが、Lテストの主眼は、ソフトウェアがユーザーのニーズを満足させるかどうかをチェックするということで、ほかのテストよりも結果が重視されることが多い。(中略)
Lテストが答えるべき問いは、**「製品はユーザーが予期している通りに動作し、適切な結果を生成するか」**だ。==== テストから見えてくるグーグルのソフトウェア開発 pp.41-42
CapybaraもCypressも両方ブラウザ操作を自動化するツールなのですが、上述の引用にあてはめて、CapybaraではMテスト、CypressではLテストを書くという棲み分けをしています。
Capybaraによるテストコードはプロダクトのコードを書くエンジニア自身によって書かれており、CIにも含まれています。
一方、CypressによるテストコードはQAグループに所属しているエンジニア(つまり私)によって書かれており、こちらはCIには含めていません。
CypressによるテストをCIに含めていない理由はいくつかありますが、一番大きかったのは ステージング環境、つまりインフラ構成が本番環境と限りなく近い環境でE2Eテストを動作させたい という要件を満たしたかったからです。
CIの中で仮想的にたてられる環境ではなく、ステージング環境で毎日動作させることにより、アプリケーションだけでなくインフラの部分まで含んだ問題を検知できるようにしています。
Cypressの気に入っているところ
(Seleniumと比べて)動作が速い
これはCypressの利点として様々な場所で言われていることですが、Seleniumに比べると相当動作が速いです。きちんと測ったことはないですが、体感的には倍以上速いのではないかと思います。
どうしても実行時間が長くなるE2Eテストですから、実行が速いことは大事です。
Cypress Dashboard
Cypress自体はOSSなのですが、Cypressにより実行されたテスト結果をホスティングしてくれるCypress DashboardというSaaSがあります。
特に、テストを実行したときのブラウザ上の様子を全て動画で保存してくれるのが強力です。E2Eテストツールでエラー時にスクリーンショットを撮る機能は昔からよくありますが、スクリーンショットだけだと何が起こっているかわからないことも多いです。その点、動画があると問題特定までの時間がかなり違って助かります。
現在は毎日ステージングに対してテストを実行し、終わったらslack上に結果が流れるようにしています。
非同期通信がテストできる
Seleniumの場合、DOM上のelementを見ることで状態を確認することになるのですが、これは非同期通信が走るような画面をテストする上で困難がつきまといます。
例えば非同期通信でPOSTが飛ぶが、完了しても画面上には何の変化も及ばさない、というような機能があった場合、seleniumでは「このぐらいのwaitがあればrequestは完了しているだろう」という決め打ちでwait秒数を決めるか、あるいはPOSTが完了していることを確認できるAPIをポーリングする処理を書くとかする必要があります。
一方、Cypressの場合は以下に示すように、globパターンにマッチするリクエストが完了するまで待つ、といった処理を書くことが出来ます。
cy.server();
cy.route("POST", "**/users").as("postUser");
cy.visit("/users");
cy.get("#first-name").type("Julius{enter}");
cy.wait("@postUser");
また、waitにthenをつなげることで、受け取ったHTTP responseに対するassertを書くこともできます。
cy.wait("@getAccount").then((xhr) => {
expect(xhr.status).to.eq(200);
});
特にSPAな作りのアプリケーションをテストする場合にありがたいですね。
Cypressでハマったところと回避策
「あるページがXXXという状態になるまでリロードし続ける」という処理
サーバ側で何らかの処理が行われていて、ある時からページに反映される、という状況を考えてみましょう。
表示しているページが動的に更新される場合は、cypressでただ単に cy.contains("処理完了")
のように書くと、「処理完了」という文字列が現れるまで自動的に待ってくれるのですが、ページ自体は静的でreloadしなければいけない、という場合は少し工夫が必要になります。
cy.get()
や cy.contains()
が失敗した場合がテスト自体が失敗となり、かつこれをcatch節などで処理することもできません。
そこで、一旦ページの状態に関わらず存在する要素を cy.get()
などで取得します。これをthen節に受け渡すとjQueryオブジェクトが取得できるため、jQueryのメソッドを使って要素の存在判定を行うことができます。
以下は「処理完了」という文字列がページに現れるまでリロードと文字列の存在判定を行うコードの例です。
it("waitUntil example", () => {
cy.visit("/static/page");
cy.waitUntil(() => {
cy.reload();
return cy.get("body").then($val => {
const succeed_text = $val.find(":contains('処理完了')");
return succeed_text.length > 0;
});
}, {timeout: 60000, interval: 5000});
// something assertion
});
cy.waitUntil()
は cypress-wait-until というプラグインを入れることで使えるようになるコマンドで、trueもしくはtrueを返すPromiseオブジェクトが渡されるまで処理を繰り返してくれます。
また、 timeout
と interval
で試行時間のタイミングを調整することができます。上記の例だと5秒ごとにリロードと判定を繰り返し、全体で60秒経過したらFailとするという意味になります。
1テストで複数のドメインにアクセスすることができない
現在CypressでE2Eテストをやる上で最大の障害はこの問題かと思います。
Cypressでは、1テストの中で複数のドメインにアクセスするようなテストを書くことが出来ません。
つまり、以下のようなコードが実行できません。
describe("multi domain", () => {
it("test", () => {
cy.visit("https://cypress.io");
cy.visit("https://github.com/cypress-io/cypress");
});
});
実行すると以下のようなエラーとなります。
CypressError: cy.visit() failed because you are attempting to visit a second unique domain.
You may only visit a single unique domain per test.
Different subdomains are okay, but unique domains are not.
The previous domain you visited was: 'https://www.cypress.io'
You're attempting to visit this new domain: 'https://github.com'
You may need to restructure some of your code to prevent this from happening.
https://on.cypress.io/cannot-visit-second-unique-domain
上記の例ではitで2つのドメインURLにアクセスしていますが、beforeやbeforeEachに書いても同様にエラーとなってしまいます。
describe("multi domain", () => {
before(() => {
cy.visit("https://cypress.io");
});
it("test", () => {
cy.visit("https://github.com/cypress-io/cypress");
});
});
では、itを分けて書いてみてはどうでしょうか。
let somevalue = null;
describe("multi domain", () => {
it("domain1", () => {
cy.visit("https://cypress.io");
somevalue = "domain1";
})
it("domain2", () => {
console.log(somevalue);
cy.visit("https://github.com/cypress-io/cypress");
});
});
この場合、クラッシュせずに実行することはできます。
が、domain2のテストに差し掛かって、既にspec中でアクセスしたものと異なるドメインを検知した時点で、cypressはブラウザ自体を再起動してからdomain2のテストを走らせる、という動作をします。
結果、 console.log(somevalue);
の出力結果は null
となってしまいます。
つまり、テストをまたいで状態を受け継ぐことができません。
この挙動を理解した上でテストを書いていくことは容易ではないため、cypressでは実質的に 1specファイルでは複数のドメインにアクセスすることができない という制限があるのです。( hoge.domain.com
と fuga.domain.com
のように、サブドメインはOK)
この問題はGitHub issueで既に2年以上議論されているのですが、未だ解決には至っていません。
E2Eテストで複数のドメインにアクセスする必要はどのようなものがあるでしょうか。
私が直面したのはサービスへのログインの問題で、認証サービスとアプリケーションのサービスが異なるドメインを持っているため、複数ドメインにアクセスできないとアプリケーションにそもそもたどり着けないというものでした。
以下ではログインの問題に絞り、解決策をいくつか書いてみます。
解決策1: cy.request() を使う
公式が提示している方法です。
「複数のドメインにアクセスすることができない」というのはあくまで cy.visit()
に関する制限で、HTTPメソッドを送信できる cy.request()
にはこの制限は適用されません。
よって、公式は「ログインに必要なCookie情報は cy.request()
でとればいいじゃない」と言っているわけです。
// cypress/support/commands.js
Cypress.Commands.add("requestLogin", () => {
cy.request({
url: "/login",
method: "POST",
form: true,
body: {
user: {
username: "your_username",
password: "your_password"
}
}
});
});
例えばこのようにコマンドを登録しておくことで、before
ステップで cy.requestLogin()
でこの処理を呼び出すことができます。
HTTP POSTのみでログイン処理が完了する場合はこれで用が足りるのですが、私がテストしたい対象はSAML認証が必要だったため、HTTPリクエストだけでは不十分でした。
解決策2: Cookie情報をファイル経由で受け渡す
「1spec内では複数のドメインにはvisitできない」「ログインにはCookie情報が必要」の両方を満たそうと考えた時、「じゃあCookie情報を受け取るだけのspecを作って、それが最初に実行されるようにすればいいのでは?」という考えが浮かびました。
// cypress/integration/service1/__getcookie_spec.js
describe("get session", () => {
it("save cookie", () => {
cy.login();
cy.visit("https://domain1.com/login");
cy.get("#user_username").type("your_username");
cy.get("#user_password").type("your_password{enter}");
cy.getCookies().then(cs => {
cy.writeFile("cache/cookie/service1.json", {
session_id: cs.session_id,
session_key: cs.session_key
});
});
});
});
// cypress/integration/service1/test1_spec.js
describe("login test", () => {
before(() => {
cy.readFile("cache/cookie/service1.json").then(cs => {
cy.setCookie("session_id", cs.session_id);
cy.setCookie("session_key", cs.session_key);
});
});
it("show need to login page", () => {
cy.visit("https://domain2.com/");
// something assertion
});
});
この状態で、実行時は以下のようにspecディレクトリを指定します。
cookie情報を取得するspecファイルの名前を __getcookie_spec.js
にしているのは、alphabeticalで最初に実行されるようにするためです。
$ npx cypress run --spec "cypress/integration/service1/*"
半ばヤケクソで実装した苦肉の策でしたが、今のところこれでうまく動いています。自分でもdirtyだな、とは思いますが・・・
解決策3: cy.task() でCookie情報を取得する
解決策2の案は、あるspecの動作が別のspecに依存している、というのがよくない点ですね。
これを1spec内に閉じるにはどうすればいいのでしょうか?
私自身また実装はできていないのでコードはここでご紹介できないのですが、 cy.task()
を使うという案があります。
Cypressで書いたテストコードは基本的にブラウザ上で走ることになるのですが、 cy.task()
を使うと Node.js で動作するJavaScriptコードを実行し、結果を受け取ることができます。
Cookie情報を取得する処理をタスクとして実装し、それをbeforeステップから呼び出すことでCookieを取得できるのでは? と目論んでいます。
応用編: CypressでPDFのテキストをassertする
SmartHRは人事労務を対象としているサービスで、様々な書類をPDF形式で出力することができます。
この時、生成したPDFに、意図したテキストが正しく含まれているか(あるいは含まれていないか)、ということはサービスの性質上大変重要です。
これをE2Eテストで確認できるようにしてみましょう。
方針としては、PDFからテキストを抽出するnpmライブラリの pdf-parse と、先ほども紹介した cy.task()
を利用します。
cy.task()
で呼び出されたコードはNode.js上で動作するので、ここではnpmモジュールをrequireして使うことができるのです。
Node.js上で実行したいコードは、 cypress/plugins/index.js
に登録することで、 cy.task()
で呼び出すことができるようになります。
以下のコードを呼び出す場合、例えば cy.task("getPdfTextFromFile", "/my/path/sample.pdf")
と書けばよいです。
// cypress/plugins/index.js
const fs = require('fs');
const pdf = require('pdf-parse');
const request = require('request');
module.exports = (on, config) => {
on('task', {
// ファイルシステム上のPDFファイルから抽出した文字列を返す
getPdfTextFromFile(path) {
let readBuffer = fs.readFileSync(path);
return new Promise((resolve) => {
pdf(readBuffer).then((data) => {
resolve(data);
});
});
},
// 指定されたURLからPDFファイルをダウンロードし、ファイルシステム上に保存する
downloadPdf(args) {
const writeFilePath = args.path;
const cookieHeader = args.cookies.map(e => e.name + "=" + e.value).join(";");
return new Promise((resolve, reject) => {
request({url: args.url, encoding: null, headers: {Cookie: cookieHeader}}, (err, res, body) => {
if (!res) {
return reject(new Error("No response"));
}
if (res.statusCode !== 200) {
return reject(new Error("Bad status code: " + res.statusCode));
}
const contentDisposition = res.headers["content-disposition"];
if (!contentDisposition || contentDisposition.indexOf("inline") === -1) {
return reject(
new Error("Broken response: does not contain content-disposition of inline file type")
);
}
fs.writeFileSync(writeFilePath, body);
resolve(body);
})
});
}
})
};
downloadPdf
で行っている処理は、実はNode.jsでわざわざやらなくても、specファイルの中で記述することができます。
ファイルの取得は cy.request()
で、ファイルシステムへの保存は cy.writeFile()
で可能です。
しかし、現在それらのコマンドではxlsxやpdfファイルを正しく保存できないというissue が報告されており、これらを使うとデータの欠落したPDFファイルが保存されてしまいます。
上記のコードは、このIssueで roma-glushko 氏によって回避策として提示されているものをほぼそのまま使わせてもらっています。
それでは次に、これらのコードを組み合わせて、テストコードから使いやすいようにカスタムコマンドを作成しましょう。
カスタムコマンドは cypress/support/commands.js
に定義します。
// 指定したURLをPDFファイルとして解釈し、ファイル内のテキスト情報を返す
Cypress.Commands.add("getPdfText", (url) => {
const tmpFilePath = "cache/tmp/getPdfText.pdf";
return cy.getCookies().then(cookies => {
cy.task("downloadPdf", {url: url, path: tmpFilePath, cookies: cookies});
return cy.task("getPdfTextFromFile", tmpFilePath);
});
});
これでテストから簡単にPDFの中のテキストを取得できるようになりました!
テストから呼び出す例を以下に示します。
cy.get("#csv-file-link").then($anchor => {
const pdfUrl = $anchor.attr("href");
cy.getPdfText(pdfUrl).then((data) => {
expect(data.text).include("山田");
expect(data.text).include("一郎");
expect(data.text).include("ヤマダ");
expect(data.text).include("イチロウ");
});
});
これで、PDFファイルの中に姓名が含まれているかをassertできましたね。
「PDFの中のテキストを読む」というと仰々しくて大変そうな感じがしますが、かなり少ないコードで実現できました。pdf-parse便利!
あとはPDFの中にどのようにテキストが埋め込まれているか、というファイルの元々の性質に依存します。
また、pdf-parseが返してくれるのはあくまでテキスト情報なので、テキストがPDF上のどの位置にあるか、ということはわかりません。
位置情報のassertにもゆくゆくはチャレンジしたいと考えています。
まとめ
Cypressの知見をいろいろごった煮にしてご紹介してきました。
現在は約400件、全て実行すると1時間ほどかかるテストをCypressで管理していて、とても安定して動作させられています。皆さんもぜひCypressで素敵なE2Eライフを送りましょう。
ところで、冒頭で「約3ヶ月ほどE2Eテストに取り組んできた」と書きましたが、SETの仕事はE2Eテストだけにとどまりません。
社内で手動でのテストをより便利にする支援ツールを作ったり、プロダクトの仕様レビューに参加して仕様の段階で品質の観点からみた問題点がないかをチェックする、など様々あります。
これらの解説はまた別の機会に・・・