Cypressで試すE2Eテスト

こんばんは。 新卒2年目の山下です。

よく「手順の共有」について考えてしまいます。

というのもエンジニアという仕事柄、知人からは聞いたことのないアプリのエラーをどうにかしてほしいと相談を受けたり、家族からはメッセージアプリLINEのアカウント引継ぎが上手くいかないからなんとかしろと言われたりで、その度にどういった手順でそうなっているのかを確認するのですが意味不明で解決のしようがありません。

仕方がないので私が手順を1つずつ確認して、「これやった? Yes」「ここ押した? No」みたいなやり取りをし、当時の手順を起こして原因を考えます。

仕事でも手順を起こすことはよくありまして、一番多いのがアプリケーションの不具合報告をするケースです。

  1. http://hoge.comにアクセス
  2. 名前に「username」を入力
  3. パスワードに「password」を入力
  4. ログインボタンを押下

こんな手順を不具合再現手順としてよく書きます。

で、大抵の場合この不具合再現手順の書き方が人によってまちまちで、多くの場合この再現手順の受け取り方は人によってまちまちです。

当然のことで、人それぞれに「あたりまえ」の基準があり、人それぞれで「言葉の意味」は異なり、例にあげた手順には人の数だけの"見えない"パターンが存在しています。

普段から私たちはこの見えないパターンを減らす努力を怠りません、ちょっとしたチャットですら相手が理解できるように無意識に言葉を選び文章をつくっていて、だからこそ見えないパターンを見つけたときにはストレスを感じたりするわけです。

何とかして見えないパターンを全て消したいところですが、人対人ではそれは不可能で、選択肢は「受け入れる」のみです。

では人の入る余地がなく「あたりまえ」の概念もないプログラムの世界はどうでしょう、手順を命令のあつまり、つまりプログラムとして作成してしまえば見えないパターンはなくなると私は信じています。

そこで今回は誰がみても手順を再現できるように、私は初見の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

ここからソースコードを全て記載しますが、アプリケーションの要点だけつかみたい方は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では

  1. webコンテナのstop
  2. アプリケーションの起動

を行いデータのリセットをします。

まず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の範囲が画像として保存されいるのがわかります。

error画面のクリップ画像

非同期を待つ

ブラウザで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にリポジトリがあります。

どんなフォルダ構成でどんな機能をつかってテストすればよいのか、CIへの組み込みはどうするのかなど、参考になると思います。

Cypress Cloud

CypressはCypress Cloudというサービスを提供しており、アカウントを作成後クラウド上(Webインターフェース)でテストの実行結果の確認や分析を行うことができます。

先に紹介したcypress-realworld-appをCypress Cloud上で確認することができます。

Webサイト・システムの
お悩みがある方は
お気軽にご相談ください

お問い合わせ 03-6380-6022(平日09:30~18:30)

出張またはWeb会議にて、貴社Webサイトの改善すべき点や
ご相談事項に無料で回答いたします。

無料相談・サイト診断 を詳しく見る

多くのお客様が気になる情報をまとめました、
こちらもご覧ください。