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()
})
})