FullStackOpen Part5-d End to end testing メモ

Cypress

cypressは近年使われだしたEnd to end(E2E)ライブラリ
今回はフロントエンド側にnpm install --save-devする

npm install --save-dev cypress

フロントエンド側のnpm scriptに追加

//frontend package.json
{
  // ...
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "cypress:open": "cypress open"  },
  // ...
}

バックエンド側には以下を追加

    "start:test": "cross-env NODE_ENV=test node index.js"

npm run cypress:openで開始

Chromeだと何故か接続できなかったが、Edgeだとできた。詳細不明

cypressのテストファイルは./cypress/e2e/note_app.cy.jsのように格納されている

describe('Note app', () => {
  it('front page can be opened', () => {
    cy.visit('http://localhost:3000')
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2023')
  })
})

テストはCypress画面からクリックして実行

ESLintがエラーを吐くのでeslint-plugin-cypressをインストール

npm install eslint-plugin-cypress --save-dev

.eslintrc.jsを以下のように変更

module.exports = {
    "env": {
        "browser": true,
        "es6": true,
        "jest/globals": true,

        "cypress/globals": true
    },
    "extends": [ 
      // ...
    ],
    "parserOptions": {
      // ...
    },
    "plugins": [

        "react", "jest", "cypress"
    ],
    "rules": {
      // ...
    }
}

.eslintignoreにcypress.config.jsを追加しておくとよい

Writing to a form

フォームのテストはこんな感じ
共通部分のcy.visit()はbeforeEachに入れる

describe('Note ', () => {

  beforeEach(()=> {
    cy.visit('http://localhost:3000')
  })

  it('front page can be opened', () => {
    cy.contains('Notes')
  })

  it('login form can be opened', () => {
    cy.get('#username').type('yourusername')
    cy.get('#password').type('yourpassword')
    cy.get('#login-button').click()

    cy.contains('bruther logged in')
  })

})

Testing new note form

新規ノートを作る場合、ログインが必要なのでbeforeEachブロックでログインを記述した後、itで新規ノートを作成するテストを書く

  describe('when logged in', () => {
    beforeEach(() => {
      cy.get('#username').type('bruh')
      cy.get('#password').type('bruther')
      cy.get('#login-button').click()

    })

    it('a new note can be created', () => {
      cy.contains('New note').click()
      cy.get('#note-input').type('a note created by cypress')
      cy.contains('Save').click()
      cy.contains('a note created by cypress')
    })
  })

Controlling the state of the database

テストにDBへの書き込みが絡むと複雑になりがち
テストの一貫性を持たせるため、DBのデータを初期化して始めるのが一般的
そのためE2Eテスト用のAPIエンドポイントを作成し、テストモードの時はそこにアクセスし、DBのデータを削除できるようにしておく

バックエンド側 ./controllers/testing.js

const testingRouter = require('express').Router()
const Note = require('../models/note')
const User = require('../models/user')

testingRouter.post('/reset', async (request, response) => {
  await Note.deleteMany({})
  await User.deleteMany({})

  response.status(204).end()
})

module.exports = testingRouter

バックエンド側 app.jsにもエンドポイントを追加
ただしprocess.env.NODE_ENVがtestの時のみ有効化するように設定

// ...

app.use('/api/login', loginRouter)
app.use('/api/users', usersRouter)
app.use('/api/notes', notesRouter)


if (process.env.NODE_ENV === 'test') {
  const testingRouter = require('./controllers/testing')
  app.use('/api/testing', testingRouter)
}

app.use(middleware.unknownEndpoint)
app.use(middleware.errorHandler)

module.exports = app

テストする際には開始時のコマンドがnpm run start:testとする

テスト側でも反映

describe('Note app', function() {
   beforeEach(function() {

    cy.request('POST', 'http://localhost:3001/api/testing/reset')
    const user = {
      name: 'Matti Luukkainen',
      username: 'mluukkai',
      password: 'salainen'
    }
    cy.request('POST', 'http://localhost:3001/api/users/', user) 
    cy.visit('http://localhost:3000')
  })
  
  it('front page can be opened', function() {
    // ...
  })

  it('user can login', function() {
    // ...
  })

  describe('when logged in', function() {
    // ...
  })
})

make importantトグルをチェックするには以下のコード

describe('Note app', function() {
  // ...

  describe('when logged in', function() {
    // ...

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.contains('new note').click()
        cy.get('input').type('another note cypress')
        cy.contains('save').click()
      })

      it('it can be made not important', function () {
        cy.contains('another note cypress')
          .contains('make not important')
          .click()

        cy.contains('another note cypress')
          .contains('make important')
      })
    })
  })
})

Failed login test

ログイン失敗時の挙動テストは以下のように書く

  it.only('login fails with wrong password', () => {
    cy.get('#username').type('bruh')
    cy.get('#password').type('wrongpassword')
    cy.get('#login-button').click()

  cy.get('.error')
    .should('contain', 'wrong credentials')
    .and('have.css', 'color', 'rgb(255, 0, 0)')
    .and('have.css', 'border-style', 'solid')

  cy.get('html').should('not.contain', 'Matti Luukkainen logged in')

shouldを使うと幅広い記述ができ、andで繋げられる。
shouldの使用法はこちら

Bypassing the UI

Cypressでテスト記述する際にUIを使ってログインしているが、時間がかかるため、HTTPリクエストでログインをすることを推奨している

HTTPリクエストでログインしてトークンを取得するために、以下のコードを記述
./cypress/support/commands.jsに追加
Cypress.Commands.add('login', 関数)みたいな感じ

Cypress.Commands.add('login', ({ username, password }) => {
  cy.request('POST', 'http://localhost:3001/api/login', {
    username, password
  }).then(({ body }) => {
    localStorage.setItem('loggedNoteappUser', JSON.stringify(body))
    cy.visit('http://localhost:3000')
  })
})

Cypress.Commands.add('createNote', ({ content, important }) => {
  cy.request({
    url: 'http://localhost:3001/api/notes',
    method: 'POST',
    body: { content, important },
    headers: {
      'Authorization': `Bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}`
    }
  })

  cy.visit('http://localhost:3000')
})

定義したコマンドを使うには以下のようにする

describe('when logged in', function() {
  beforeEach(function() {
    cy.login({ username: 'mluukkai', password: 'salainen' })
  })

  it('a new note can be created', function() {
    // ...
  })

  // ...
})

Cypress中で使う環境変数は./cypress.config.jsで定義

const { defineConfig } = require("cypress")

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
    },
    baseUrl: 'http://localhost:3000',
  },
  env: {

    BACKEND: 'http://localhost:3001/api'
  }
})

以下のような感じで環境変数を使用する

describe('Note ', function() {
  beforeEach(function() {


    cy.request('POST', `${Cypress.env('BACKEND')}/testing/reset`)
    const user = {
      name: 'Matti Luukkainen',
      username: 'mluukkai',
      password: 'secret'
    }

    cy.request('POST', `${Cypress.env('BACKEND')}/users`, user)
    cy.visit('')
  })
  // ...
})

baseUrlはcy.visit('')のようにすればよい

Changing the importance of a note

ボタンが複数あるときには継続してそのボタンにアクセスしたい状況もある
その場合、asを使って名前を付けておくことでアクセスしやすくなる

it('one of those can be made important', function () {
  cy.contains('second note').parent().find('button').as('theButton')
  cy.get('@theButton').click()
  cy.get('@theButton').should('contain', 'make not important')
})

Running and debugging the tests

cypressのテストはコマンドをキューのような形で実行する
そのためPromiseのようにthenを使ってアクセスしてデバッグする必要がある

it('then example', function() {
  cy.get('button').then( buttons => {
    console.log('number of buttons', buttons.length)
    cy.wrap(buttons[0]).click()
  })
})


この記事が気に入ったらサポートをしてみませんか?