Cypressで試すE2Eテスト
こんばんは。 新卒2年目の山下です。
よく「手順の共有」について考えてしまいます。
というのもエンジニアという仕事柄、知人からは聞いたことのないアプリのエラーをどうにかしてほしいと相談を受けたり、家族からはメッセージアプリLINEのアカウント引継ぎが上手くいかないからなんとかしろと言われたりで、その度にどういった手順でそうなっているのかを確認するのですが意味不明で解決のしようがありません。
仕方がないので私が手順を1つずつ確認して、「これやった? Yes」「ここ押した? No」みたいなやり取りをし、当時の手順を起こして原因を考えます。
仕事でも手順を起こすことはよくありまして、一番多いのがアプリケーションの不具合報告をするケースです。
- http://hoge.comにアクセス
- 名前に「username」を入力
- パスワードに「password」を入力
- ログインボタンを押下
こんな手順を不具合再現手順としてよく書きます。
で、大抵の場合この不具合再現手順の書き方が人によってまちまちで、多くの場合この再現手順の受け取り方は人によってまちまちです。
当然のことで、人それぞれに「あたりまえ」の基準があり、人それぞれで「言葉の意味」は異なり、例にあげた手順には人の数だけの"見えない"パターンが存在しています。
普段から私たちはこの見えないパターンを減らす努力を怠りません、ちょっとしたチャットですら相手が理解できるように無意識に言葉を選び文章をつくっていて、だからこそ見えないパターンを見つけたときにはストレスを感じたりするわけです。
何とかして見えないパターンを全て消したいところですが、人対人ではそれは不可能で、選択肢は「受け入れる」のみです。
では人の入る余地がなく「あたりまえ」の概念もないプログラムの世界はどうでしょう、手順を命令のあつまり、つまりプログラムとして作成してしまえば見えないパターンはなくなると私は信じています。
そこで今回は誰がみても手順を再現できるように、私は初見のCypressを使ったE2Eテストを試してみます。
Cypressについて
CypressはWebアプリケーションをフロントエンドからテストするためのテストツールです。
フロントエンドと聞くとSeleniumを思い浮かべますが、Seleniumよりも速く簡単にテストを行うことが出来ます。
ツール本体はJavaScriptとTypeScriptで書かれており、npmでインストールの後、macOS,Ubuntu,Windowsと幅広い環境で実行することができます。
CypressはMITライセンスのOSSです。公式リンクを載せておくので、より深く知りたい方は要参照です。
検証ホスト環境
> wsl --version
WSL バージョン: 1.0.3.0
カーネル バージョン: 5.15.79.1
Windowsバージョン: 10.0.19044.2486
> docker --version
Docker version 20.10.21, build baeda1f
テストするWebアプリを作成
まずはテストをするWebアプリを作成しますがその前にdockerで環境をつくります。
docker環境
ディレクトリ構成は以下とします。
- project_dir/
- web/ ←goで書かれたWebアプリのソースをこの中に置く
- docker-compose.yaml
docker-compose.yaml
version: '3.8'
volumes:
go_pkg:
services:
web:
image: golang:1.19-alpine
volumes:
- ./web:/web
- go_pkg:/go/pkg
working_dir: /web
ports:
- 8080:8080
tty: true
goでWebアプリを作成
webディレクトリの中にWebアプリケーションのGoソースコードを置きます。
csrf.goではcsrfもどきを、db.goではデータベースもどきを実装し、main.goから利用します。
- web/
- csrf/
- csrf_test.go
- csrf.go
- db/
- db_test.go
- db.go
- go.mod
- main.go
- csrf/
ここからソースコードを全て記載しますが、アプリケーションの要点だけつかみたい方はmain.goだけを確認してください。
csrf/csrf.go
package csrf
import (
"fmt"
"math/rand"
"net/http"
"strconv"
"time"
)
const (
csrfKey = 123456789
cookieKey = "csrf"
inputTagNameKey = "c.s.r.f"
)
// トークンが埋め込まれたinputタグ文字列を返します
func GetTokenTag(w http.ResponseWriter) string {
return fmt.Sprintf(
"<input type=\"hidden\" name=\"%s\" value=\"%s\">",
inputTagNameKey,
GetToken(w),
)
}
// トークンを返します
func GetToken(w http.ResponseWriter) string {
rand.Seed(time.Now().UnixNano())
// ランダムな数列をベーストークンとします。
baseToken := rand.Int()
// ベーストークンを暗号化したものをCookieに詰めます。
w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%d", cookieKey, crypt(baseToken)))
// ベーストークンを返します。
return strconv.Itoa(baseToken)
}
// フォームリクエストからトークンを検証して結果を返します
func IsValidForm(r *http.Request) bool {
return isValid(r, r.Form.Get(inputTagNameKey))
}
// cookieのベーストークンとトークンを比較検証して結果を返します
func isValid(r *http.Request, token string) bool {
c, err := r.Cookie(cookieKey)
if err != nil {
return false
}
baseToken, _ := strconv.Atoi(token)
cookieToken, _ := strconv.Atoi(c.Value)
// Cookieにはベーストークンを暗号化したものが保存されているので
// トークン(baseToken)を暗号化して比較をします。
return cookieToken == crypt(baseToken)
}
// inputタグのname属性の値を返します
func GetInputTagNameKey() string {
return inputTagNameKey
}
// 暗号化された文字列をかえします
func crypt(t int) int {
return t & csrfKey
}
csrf/csrf_test.go
package csrf
import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"testing"
)
var _ http.ResponseWriter = (*MockWriter)(nil)
type MockWriter struct {
httptest.ResponseRecorder
}
func (mw *MockWriter) AssertCookieCsrfToken(t *testing.T) *http.Cookie {
var cookie *http.Cookie
for _, c := range mw.Result().Cookies() {
if c.Name == cookieKey {
cookie = c
}
}
if cookie == nil {
t.Errorf("Set-Cookieヘッダが異なります。%v", mw.HeaderMap)
return nil
}
return cookie
}
func TestGetToken(t *testing.T) {
mw := MockWriter{}
GetToken(&mw)
// Set-CookieでCsrfTokenがセットされるようになっているのか確認
mw.AssertCookieCsrfToken(t)
}
func TestIsValid(t *testing.T) {
mw := MockWriter{}
baseToken := GetToken(&mw)
csrfCookie := mw.AssertCookieCsrfToken(t)
req := httptest.NewRequest("", "/", nil)
req.AddCookie(csrfCookie)
// 二つのトークンで検証が行えているのかを確認
if !isValid(req, baseToken) {
t.Errorf("トークンが一致しません。 \t%q\t%q", csrfCookie.Value, baseToken)
}
if isValid(req, baseToken+"a") {
t.Errorf("トークンが一致してします。 \t%q\t%q", csrfCookie.Value, baseToken+"a")
}
}
func TestIsValidForm(t *testing.T) {
mw := MockWriter{}
r := regexp.MustCompile("[0-9]+")
baseToken := r.FindString(GetTokenTag(&mw))
csrfCookie := mw.AssertCookieCsrfToken(t)
value := url.Values{}
value.Add(inputTagNameKey, baseToken)
req := httptest.NewRequest("", "/", nil)
req.Form = value
req.AddCookie(csrfCookie)
// 二つのトークンで検証が行えているのかを確認
if !IsValidForm(req) {
t.Errorf("トークンが一致しません。 \t%q\t%q", csrfCookie.Value, baseToken)
}
}
db/db.go
package db
import (
"errors"
"sync"
)
type table uint
type Store map[table]map[uint]any
const (
Message table = iota
)
var (
mutex = sync.Mutex{}
store = make(Store)
)
// テーブルtに値vを保存してidを返します
func Save(t table, v any) uint {
setIfNil(t)
defer mutex.Unlock()
mutex.Lock()
// idはシンプルに対象のテーブルの値の数にします。
id := uint(len(store[t]))
store[t][id] = v
return id
}
// テーブルtの中のidの値(レコード)を返します
func Get[T any](t table, id uint) (T, error) {
if v, exists := store[t][id]; exists {
if tv, ok := v.(T); ok {
return tv, nil
}
}
var v T
return v, errors.New("not found")
}
// テーブルtの全ての値(レコード)を返します
func All[T any](t table) map[uint]T {
if v, exists := store[t]; exists {
val := make(map[uint]T, len(v))
for id, content := range v {
if tContent, ok := content.(T); ok {
val[id] = tContent
} else {
break
}
}
return val
}
return map[uint]T{}
}
// テーブルtのマップが無ければ初期化してセットします
func setIfNil(t table) {
if _, ok := store[t]; !ok {
store[t] = make(map[uint]any)
}
}
// storeの中身を全削除します
func flush() {
store = make(Store)
}
db/db_test.go
package db
import (
"fmt"
"sync"
"testing"
)
func TestSave(t *testing.T) {
flush()
wg := &sync.WaitGroup{}
i := 0
for ; i < 1000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
Save(Message, "hello!! this is sample text.")
}(i)
}
wg.Wait()
id := Save(Message, ("hello!! this is sample text."))
if id != uint(i) {
t.Errorf("i:%d, id:%d", i, id)
}
}
func TestGet(t *testing.T) {
flush()
v := "hello 0123456789"
id := Save(Message, v)
if fromDB, _ := Get[string](Message, id); fromDB != v {
t.Errorf("保存した値を取得できません。expect %q but got %q", v, fromDB)
}
Save(Message, v+"1234")
if fromDB, _ := Get[string](Message, id); fromDB != v {
t.Errorf("保存した値を取得できません。expect %q but got %q", v, fromDB)
}
if _, err := Get[string](Message, id+100); err == nil {
t.Error("エラーが発生していません。")
}
}
func TestAll(t *testing.T) {
flush()
i := 0
for ; i < 1000; i++ {
Save(Message, fmt.Sprintf("hello!! this is sample text. %d", i))
}
all := All[string](Message)
if len(all) != i {
t.Errorf("expect %d, but got %d", i, len(all))
}
}
main.go
直接HTMLを返すシンプルなWebアプリケーションを作成します。
メッセージの投稿とメッセージ一覧の確認をする機能があります。
package main
import (
"fmt"
"net/http"
"strings"
"web_app/csrf"
"web_app/db"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `<html><body>
<h1>hello world</h1>
<p>today we will learn about E2E testing using the Cypress.</p>
</body></html>`,
)
})
http.HandleFunc("/post-page", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, `<html><body>
<form method="POST" action="/post">
%s
<input type="text" name="message">
<button type="submit">投稿</button>
</form>
</body></html>`,
csrf.GetTokenTag(w),
)
})
http.HandleFunc("/post", func(w http.ResponseWriter, r *http.Request) {
csrf.GetTokenTag(w)
r.ParseForm()
if !csrf.IsValidForm(r) {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, "<html><body>エラー</body></html>")
return
}
db.Save(db.Message, r.Form.Get("message"))
http.Redirect(w, r, "/list", http.StatusPermanentRedirect)
})
http.HandleFunc("/list", func(w http.ResponseWriter, _ *http.Request) {
var sb strings.Builder
for _, msg := range db.All[string](db.Message) {
sb.WriteString("<div>" + msg + "</div>")
}
fmt.Fprintf(w, `<html><body>
%v
<a href="/post-page">投稿画面へ行く</a>
</body></html>`,
sb.String(),
)
})
panic(http.ListenAndServe(":8080", nil))
}
ブラウザから確認
/(トップページ)
/post-page
inputにメッセージを入力して「投稿」を押下するとメッセージが保存され/listへ遷移します。
/list
保存された全てのメッセージを1つずつdivでラップしてhtml > bodyの中に入れます。
/post
/post-pageからメッセージを投稿した際にエラーがあれば、/postで「エラー」と表示します。
Cypress導入
テストするアプリケーションの準備が終わったので、Cypressの導入を行います。
ローカルに直でインストールすればデスクトップアプリケーション(GUI)が起動し、画面からポチポチと操作することができますが、依存ライブラリのインストールなどが手間なので今回はDockerを使用します。
docker-compose.yamlの編集
services.e2e-baseとservices.e2e-includedを追加しました。
version: '3.8'
volumes:
go_pkg:
services:
web:
image: golang:1.19-alpine
volumes:
- ./web:/web
- go_pkg:/go/pkg
working_dir: /web
ports:
- 8080:8080
tty: true
e2e-base:
image: cypress/base:16.13.0
tty: true
working_dir: /app
volumes:
- ./e2e/cypress:/app
- ./e2e/results:/results
e2e-included:
image: cypress/included:10.3.1-typescript
tty: true
working_dir: /app
volumes:
- ./e2e/cypress:/app
- ./e2e/results:/results
Cypressがメンテナンスをしているイメージは
- cypress/base
- cypress/factory
- cypress/browsers
- cypress/included
の4つがあります。
cypress/baseはcypressを実行するのに必要な依存ライブラリが準備されているイメージで、複数ブラウザの実行環境を備えたcypress/browsersのベースイメージになっており、cypress/browsersはcypress/includedのベースイメージになっています。
https://github.com/cypress-io/cypress-docker-images
cypress/includedにはcypress本体やブラウザ実行環境がすでにそろっているため、とりあえず色々できる環境をぱっと揃えるならcypress/includedがよさそうです。
初見でなにがなんなのかわからないので今回はbaseとincludedどちらも使ってみます。
とりあえず実行
docker-compose up -d
でコンテナを動かします。
各イメージがpullできたのでサイズを確認します。
> docker images | grep cypress
cypress/included 9.4.1
001a4d9270b6 12 months ago 3.01GB
cypress/base 16.13.0
4a0327b36bf7 15 months ago 1.27GB
baseの方がincludedに比べてサイズが小さいのが見て取れます。
次にコンテナの状態を確認します。
> docker-compose ps
NAME COMMAND SERVICE STATUS PORTS
techblog-cypress-e2e-base-1 "docker-entrypoint.s…" e2e-base running
techblog-cypress-e2e-included-1 "cypress run" e2e-included exited (1)
techblog-cypress-web-1 "/bin/sh" web running 0.0.0.0:8080->8080/tcp
includedにはcypressが既にはいっているので、cypress run
が実行されています。(cypress runは後ほど説明します)
includedの実行ログを確認します。
techblog-cypress-e2e-included-1 | [18:0204/155602.992337:ERROR:bus.cc(392)] Failed to connect to the bus: Failed to connect to socket /var/run/dbus/system_bus_socket: No such file or directory
techblog-cypress-e2e-included-1 | [18:0204/155602.994206:ERROR:bus.cc(392)] Failed to connect to the bus: Address does not contain a colon
techblog-cypress-e2e-included-1 | [18:0204/155602.994277:ERROR:bus.cc(392)] Failed to connect to the bus: Address does not contain a colon
techblog-cypress-e2e-included-1 | [197:0204/155603.080409:ERROR:gpu_init.cc(453)] Passthrough is not supported, GL is swiftshader, ANGLE is
techblog-cypress-e2e-included-1 | Could not find a Cypress configuration file, exiting.
techblog-cypress-e2e-included-1 |
techblog-cypress-e2e-included-1 | We looked but did not find a default config file in this folder: /
bus.ccがエラーを吐いていますがさっぱりなのでいったん無視。
最後にWe looked but did not find a default config file in this folder: / (/に設定ファイルがない)
と表示されていて、てっきり「No tests ran.」みたいな表示がされてテストが終了すると思っていたのですがどうやら設定ファイルが必要そうです。
cypress/includedでテストするためには何が必要でどう準備すれば良いのかわからないため、cypress/baseを使用して初期セットアップを行います。
Cypressのインストール
まずbaseイメージの現状を確認しますが、いまコンテナには既にCypressを動かすのに必要な依存ライブラリが存在していて、いまするべきとこはCypressのインストールなので、公式を見ながらインストールをします。(cypress/baseのdockerfile)
コンテナに入る
docker-compose exec e2e-base bash
でコンテナに入ります。
テストプロジェクトの作成をしてCypressを追加
cypressをインストールするプロジェクトを作成します。
cd /app
yarn init -y
今回はテストするアプリケーションがgoで書かれているので、npmのプロジェクトは存在しませんが、例えばreactやnext.js,expressを使用していて既にnpmのプロジェクトがある場合には、プロジェクトに含めることもできます。
次にCypressを追加します。
typescriptは個人的に使いたいので追加しておきます。
yarn add -D cypress typescript
typescriptの設定ファイルを作成します。
yarn tsc --init --types cypress --lib dom,es6
Cypressの設定ファイルを作成
cypressの設定ファイルを作成します。
touch cypress.config.ts
cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
// アクセスする際のURLのprefix
baseUrl: 'http://web:8080',
// テスト中にダウンロードしたファイルの保管場所
downloadsFolder: 'results/downloads',
// テスト中に撮影したscreenshotの保管場所
screenshotsFolder: 'results/screenshots',
// テスト実行記録動画の保管場所
videosFolder: 'results/videos',
// テスト実行を動画で記録するか否か
video: true,
// 記録した動画の圧縮率(falseで圧縮しない)
videoCompression: false,
// テストを回す前に、既にある(前回分の)downloads,screenshots,videosを削除するか否か
trashAssetsBeforeRuns: true,
// テスト対象のソースファイル(cypressではスペック(spec)ファイルと呼ばれている)
specPattern: 'test-src/**/*.cy.ts',
// specファイルが読み込まれる前に実行されるファイル、存在しないならFalseを指定
supportFile: false,
},
})
https://docs.cypress.io/guides/references/configuration
テスト実行
確認のために1度テストを実行してみます。
まずはWebアプリケーションを起動します。
docker-compose exec web go run .
Webアプリケーションを起動したらyarn cypress run
でテストを走らせます。
(e2e-baseコンテナの中で)
yarn cypress run
specファイルが見つからないと言われましたが、/app/test-src/**/*.cy.tsはcypress.config.tsで設定した内容なので、設定ファイルがきちんと反映されていることがわかりました。
Can't run because no spec files were found.
We searched for specs matching this glob pattern:
> /app/test-src/**/*.cy.ts
error Command failed with exit code 1.
お試しのテスト
準備に時間がかかりましたが、テスト本体を書いていきます。
cypress.config.tsで設定したディレクトリの配下にテストファイルを作成します。
mkdir test-src
touch test-src/first.cy.ts
first.cy.ts
describe('The Home Page', () => {
it('successfully loads', () => {
// (baseUrl)/ => http://web:8080/にアクセス
cy.visit('/')
// h1タグを取得
cy.get('h1')
// 中身がhello worldであることを確認
.should('contain.text', 'hello world')
})
})
yarn cypress run
実行結果をみると上手くテスト出来ているっぽいです。
今度は
.should('contain.text', 'hello world')
を
// hello worldの最後に!を付ける
.should('contain.text', 'hello world!')
にしてテストします。
結果を確認すると「hello world!」を期待しているけど実際は「hello world」だったよと言われているので、上手くテストできていますね。
さらに、スクリーンショットも撮影してくれているようなので見てみますが、HTMLがちゃんとレンダリングされています。
忘れていたのですがテストの実行記録を録画するようにしていたのでそれも確認します。
弊社のテックブログにはgifやmp4の再生機能がまだないのでこの感動を伝えづらいのですが、テストの様子がきちんとmp4で録画されています。
Google Chromeでテスト
実行結果の上部を確認するとBrowser: Electron 106 (headless)
の表示があるので、テストはElectronを使用して実行されているようです。
実際にアプリケーションを動かす環境に近づけるために、今現在のブラウザシェア1位のChromeを使ってテストする設定をしていこうと思うのですが、ここでcypress/includedをもう一度試してみることにします。
cypress/includedで実行
docker-compose.yaml編集し、e2e-baseをコメントアウトして代わりのe2e-includedを追加し、コンテナが起動してすぐにcypress run
が走るのを防ぐために、entrypoint: /bin/bash
を追加します。
docker-compose.yaml
version: '3.8'
volumes:
go_pkg:
services:
web:
image: golang:1.19-alpine
volumes:
- ./web:/web
- go_pkg:/go/pkg
working_dir: /web
ports:
- 8080:8080
tty: true
#e2e-base:
# image: cypress/base:16.13.0
# tty: true
# working_dir: /app
# volumes:
# - ./e2e/cypress:/app
# - ./e2e/results:/results
# - /var/run/docker.sock:/var/run/docker.sock
e2e-included:
image: cypress/included:10.3.1-typescript
entrypoint: /bin/bash
tty: true
working_dir: /app
volumes:
- ./e2e/cypress:/app
- ./e2e/results:/results
- /var/run/docker.sock:/var/run/docker.sock
コンテナ起動後、テストアプリケーションを起動してテストを実行してみます。
docker-compose up -d
docker-compose exec web go run .
docker-compose exec e2e-included cypress run
テストが実行できるまでは確認できましたが、相変わらずElectronのままなので、ブラウザの指定をする方法を調べます。
ブラウザの指定
ブラウザの指定はcypress run
にオプション--browser
を指定して実行すれば良いようです。
指定できるブラウザはcypress info
で確認をします。(下部に<name>
:<channel>
で指定しろと記載がありますが、<channel>
だけで動作します。複数channelがあれば必須なのかもしれません)
ブラウザを指定して実行してみます。
docker-compose exec e2e-included bash -c "cypress run --browser chrome
問題なくChromeで実行されます。
cypress/factoryイメージ
cypress/includedはcypress/browsersにcypress本体をインストールしたイメージで、cypress/browsersのDockerfileをみると各ブラウザをインストールするRUNコマンドの記載があります。
事情があり特定のバージョンのChromeを使いたいときはどうするか、cypress/browsersから特定のバージョンのtagがついたイメージを探すか、自力でブラウザをインストールするかしても良いですが、そんな時はcypress/factoryを使うのが良いです。
imageをビルドする際のarg
にバージョンを指定することで、特定のバージョンがインストールされたイメージを作成することができ、バージョン指定できる項目は以下の通りです。
- NODE_VERSION
- YARN_VERSION
- CYPRESS_VERSION
- CHROME_VERSION
- FIREFOX_VERSION
- EDGE_VERSION
docker-composeで使用する例を挙げます。
Dockerfile
FROM cypress/factory #<-この時点でcypress/browsers相当
RUN yan add -D cypress # ←これでcypress/included相当
docker-compose.yaml
version: '3.8'
services:
my-cypress:
build:
context: .
dockerfile: ./Dockerfile
args:
CHROME_VERSION: '107.0.5304.121-1'
EDGE_VERSION: '100.0.1185.29-1'
FIREFOX_VERSION: '107.0'
このdocker-compose.yamlをビルドすると、Dockerfile(services.my-cypress.build.dockerfileで指定のもの)がビルドされ、FROMに指定したcypress/factoryのDockerfileのONBUILDが走る仕組みです。
https://github.com/cypress-io/cypress-docker-images/tree/master/factory
テストを書く
メッセージを投稿して表示を確認するテスト
時間をとってCypressのドキュメントをなめてきました。
思った以上に高機能で、ドキュメントのボリュームがその機能の量を物語っています。
いくつか基本的な機能を試しつつテストを作成してみます。
beforeEachでテストのセットアップをする
CypressではbeforeEachを使うことで、他のテストフレームワークと同じようにテストのセットアップを行うことが可能で、通常はデータベースのシーディングやログインの認証情報を持たせたユーザオブジェクトの作成をします。
今回のアプリケーションでは投稿されたメッセージをオンメモリで保持しているので、beforeEachでは
- webコンテナのstop
- アプリケーションの起動
を行いデータのリセットをします。
まずservices.e2e-base.volumesに- /var/run/docker.sock:/var/run/docker.sock
を追加してコンテナの中からdocker engine apiを叩けるようにします。
次にdocker engine apiを簡単に叩くためのdockerodeをyarn addします。
yarn add -D dockerode @types/dockerode
cypress.config.tsにwebコンテナを操作する処理を追加します。
e2e.setupNodeEventsにon('taskの名前', ハンドラ)の形式で記述すると各テストからはcy.task('taskの名前')
で呼び出し可能で、ハンドラはnode.js上で実行されるため/var/run.docker.sockにリクエストを送信(正確には書き込み)をすることができます。
cypress.config.ts
setupNodeEvents(on, config) {
on('task',{
async runServer() {
const docker = new Docker({socketPath: '/var/run/docker.sock'})
const web = await (await docker.listContainers()).find((v,i,info) => {
return v.Labels['com.docker.compose.service'] === 'web'
})
if (web === undefined) {
throw new Error('web service is down.')
}
const webContainer = docker.getContainer(web?.Id)
await webContainer.stop()
await webContainer.start()
const exec = await webContainer.exec({
Cmd: ['go', 'run', '.'],
AttachStdin: false,
AttachStdout: false,
AttachStderr: false,
Tty: false,
})
await exec.start({Detach: true})
return null
}
})
},
メッセージ0件を確認
it()がコールされる度にbeforeEachが走るので、/listにはメッセージが1つも表示されません。
test-src/post-message.cy.ts
describe('Post_Meesage', () => {
beforeEach(() => {
cy.task('runServer')
})
it('メッセージが0件の表示を確認', () => {
cy.visit('/list')
cy.get('body > div').should($div => {
// body > divが0個なのを確認
expect($div).to.have.length(0)
})
})
}
メッセージを投稿
type(文字列, オプション)はinputに文字を入力するapiで、オプションのdelayでは1キーストロークの後の待機時間を1000msに指定しているので、18秒かけてサブミットを行います。
{leftArrow}ではカーソルを1つ左に、{backspace}ではカーソルの左の文字を削除、{enter}では今form内のinputにフォーカスがあるため、サブミットの挙動になります。(今での多くのブラウザがエンターでサブミットをする仕様をもっています)
test-src/post-message.cy.ts
it('メッセージを投稿', () => {
const message = '👻こんにちは!!!祝初投稿'
cy.visit('/post-page')
cy.get('input[name=message]')
// 👻こんにちは!!!祝初投稿 を入力
// 👻こんにちは!!!祝|初投稿 3回左矢印を押して「祝」の右にカーソルをおく
// 👻こんにちは!!!|初投稿 バックスペースで祝を削除
// エンターでサブミット
.type(
`${message}{leftArrow}{leftArrow}{leftArrow}{backspace}{enter}`,
// 文字の入力の速さ(ms)
{delay: 1000},
)
cy.get('body > div').should($div => {
// body > divが1個なのを確認
expect($div).to.have.length(1)
expect($div.first()).to.contain('👻こんにちは!!!初投稿')
})
})
ちょっと分かりづらいですが動画でも文字が打たれたり、カーソルが移動している様子を確認できます。
メッセージを2回投稿して表示を確認するテストも追加します。
test-src/post-message.cy.ts
it('メッセージを2回投稿', () => {
const messages = ['1回目の投稿', '2回目の投稿']
cy.visit('/post-page')
cy.get('input[name=message]')
.type(`${messages[0]}{enter}`)
cy.get('body > div').should($div => {
// body > divが1個なのを確認
expect($div).to.have.length(1)
expect($div.first()).to.contain(messages[0])
})
// post-pageに遷移
cy.get('a').click()
cy.get('input[name=message]')
.type(`${messages[1]}`)
// submit
cy.get('button').click()
cy.get('body > div').should($div => {
expect($div).to.have.length(2)
// 投稿したメッセージが全て表示されている
messages.reverse().forEach((message, i) => {
expect($div[i]).to.have.text(message)
});
})
})
cookieの操作
cy.clearCookies()でcookieを削除後にサブミットをして、「エラー」の表示があることを確認しています。
test-src/post-message.cy.ts
it('cookieを削除して投稿', () => {
const messages = 'message'
cy.visit('/post-page')
// cookieを削除
cy.clearCookies()
cy.get('input[name=message]')
.type(`${messages[0]}{enter}`)
cy.get('body').should('have.text', 'エラー')
// screenshot作成
cy.screenshot({clip:{x:0,y:0,width:100,height:100}})
})
またcy.screenshot()ではエラー画面のスクリーンショットをclipにした内容でクリップして保存をしているので、「results/screenshots/post-message.cy.ts/Post_Meesage -- cookieを削除して投稿.png」を確認すると、画面左上(x:0,y:0)から右100下に100pxの範囲が画像として保存されいるのがわかります。
非同期を待つ
ブラウザでfetch APIを使用する場合のテストを確認します。
テストアプリケーションの修正
main.go
http.HandleFunc("/fetch-page", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `<html><body>
<button id="button" type="button">fetch</button>
<div id="edit">hello</div>
<script>
document.getElementById('button').addEventListener('click', async e => {
e.preventDefault();
// responseをdiv#editに表示する
document.getElementById('edit').textContent = await (await fetch('/fetch')).text();
});
</script>
</body></html>`)
})
http.HandleFunc("/fetch", func(w http.ResponseWriter, _ *http.Request) {
// 5秒待って返信
time.Sleep(5 * time.Second)
fmt.Fprint(w, "ok")
})
テスト作成
cy.intercept()とcy.wait()を使って、非同期通信を待つことができます。
cy.intercept()の第三引数に値を渡すと、そのリクエスト(ルーティング)はスタブになり、サーバにリクエストは送信されません。
fetch.cy.ts
describe('Fetch_Test', () => {
it('非同期通信を確認', () => {
// waitするリクエストを「fetch」という名前で定義
cy.intercept('GET', '/fetch').as('fetch')
cy.visit('/fetch-page')
// buttonを押下してfetchする
cy.get('#button').click()
// 定義したfetchをまつ
cy.wait('@fetch')
cy.get('#edit').contains('ok')
cy.screenshot()
})
it('スタブの確認', () => {
// waitするリクエストを「fetch」という名前で定義
const message = 'スタブ!!'
// /fetchはmessageを返すというスタブ
cy.intercept('GET', '/fetch', message).as('fetch')
cy.visit('/fetch-page')
// buttonを押下してfetchする
cy.get('#button').click()
// 定義したfetchをまつ
cy.wait('@fetch')
cy.get('#edit').contains(message)
cy.screenshot()
})
})
ソースコードって非破壊的
さてここまででCypressを使ったE2Eテストを試してみましたがどんな印象でしょうか。
サンプルのWebアプリケーションが現実離れしてシンプルなことは承知の上で「何をしているのかが読みやすい」と感じます。
また、テストの手順がそのままソースコードになっているので誰がみても同じ手順で同じ結果ができ、冒頭でお話しした「見えないパターン」はありません。(当然冪等なテストをつくる努力は必要ですが、手順自体は1パターンに収まっています)
ソースコード(またはプログラム)は「何かをする手順」という視点からみたときに非破壊的なので、テストの手順を書くにはうってつけだということがよくわかりました。
一般的にテストの数はアプリケーションの規模に比例するので、修正の度に人がテストすることも勿論大切ですが、例えば「初期表示まではCypressで担保する」みたいな使い方をして時間を節約してみるのも良いと思います。
cypress-realworld-appとCypress Cloudについて
最後にcypress-realworld-appとCypress Cloudを紹介して終わります。
cypress-realworld-app
cypress-realworld-appはCypressが提供している、Cypressを試すためのデモアプリケーションです。
デモアプリケーションはreactを使用したSPA(おそらく)で、cypress-realworld-appという名前でGithubにリポジトリがあります。
- https://github.com/cypress-io/cypress-realworld-app
- https://www.cypress.io/blog/2020/06/11/introducing-the-cypress-real-world-app/
どんなフォルダ構成でどんな機能をつかってテストすればよいのか、CIへの組み込みはどうするのかなど、参考になると思います。
Cypress Cloud
CypressはCypress Cloudというサービスを提供しており、アカウントを作成後クラウド上(Webインターフェース)でテストの実行結果の確認や分析を行うことができます。
先に紹介したcypress-realworld-appをCypress Cloud上で確認することができます。
Webサイト・システムの
お悩みがある方は
お気軽にご相談ください
出張またはWeb会議にて、貴社Webサイトの改善すべき点や
ご相談事項に無料で回答いたします。