Web全栈开发学习笔记—Part5 测试 React 应用—d.端到端测试

目录

Cypress

Writing to a form

Some things to note

Testing new note form

Controlling the state of the database

Failed login test

Bypassing the UI

Changing the importance of a note


接下来研究一种使用端到端End to End (E2E)测试,将系统作为一个整体的测试方法。

可以使用浏览器和测试库对 web 应用进行 E2E 测试。 例如Selenium ,几乎可以用于任何浏览器。

另一个浏览器选项是所谓的headless browsers ,这是一种没有用户界面的浏览器。

Chrome 可以在 headless 模式下使用。

E2E 测试可能是最有用的一类测试,因为测试系统的界面与真实用户使用的界面相同。

它们也有一些缺点。 配置 E2E 测试比单元测试或集成测试更麻烦,也往往非常慢,对于一个大型系统,执行时间可能是几分钟,甚至几小时。 这对开发是不利的,因为在编码期间,如果遇到代码回归测试 ,尽可能多地运行测试。

E2E 测试也可能是片状的。有些测试可能一次通过,另一次失败,即使代码根本没有改变。

Cypress

E2E库Cypress非常容易使用,与Selenium相比需要少得多麻烦问题。

它的操作原理与大多数 E2E 测试库完全不同,因为 Cypress 测试完全在浏览器中运行。

其他库在一个 node 进程中运行测试,进程通过一个 API 连接到浏览器。

为便笺应用做一些端到端的测试。

首先将 Cypress 安装到前端 ,作为开发依赖项

npm install --save-dev cypress

添加一个 npm-script 来运行它:

{
  // ...
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "server": "json-server -p3001 db.json",
    "cypress:open": "cypress open"  },
  // ...
}

与前端的单元测试不同,Cypress 测试可以位于前端或后端仓库中,甚至可以位于它们自己的单独仓库中。

这些测试要求测试系统正常运行。 与我们的后端集成测试不同,Cypress 测试在系统运行时不启动

在后端中添加一个 npm-script,在测试模式下启动它,或者使NODE_ENV设置 为test

{
  // ...
  "scripts": {
    "start": "cross-env NODE_ENV=production node index.js",
    "dev": "cross-env NODE_ENV=development nodemon index.js",
    "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend",
    "deploy": "git push heroku master",
    "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy",
    "logs:prod": "heroku logs --tail",
    "lint": "eslint .",
    "test": "cross-env NODE_ENV=test jest --verbose --runInBand",
    "start:test": "cross-env NODE_ENV=test node index.js"  },
  // ...
}

当后端和前端都在运行时,可以使用如下命令启动 Cypress

npm run cypress:open

当我们第一次运行 Cypress 时,会创建一个Cypress 目录。 它包含一个集成 子目录,我们将在其中放置测试。 Cypress 在 integration/examples 目录中创建了一系列测试示例,但是我们可以将examples 目录删除,在文件note_app.spec.js 中创建我们自己的测试:

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

从打开的窗口开始测试:

fullstack content

运行测试会打开浏览器,并显示应用在运行测试时的行为:

fullstack content

测试的结构使用describe 块对不同的测试用例进行分组,像 Jest 那样。 测试用例已经用it 方法定义了。

Cypress从Mocha测试库中借用这些部件,并在底层使用。

cy.visit将浏览器中打开的网址作为参数进行测试。 cy.contains将搜索的字符串作为参数。

我们可以使用箭头函数声明测试

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 2020')
  })
})

Mocha 建议不要使用箭头函数。

如果cy.contains 没有找到正在搜索的文本,则测试不会通过。

如果像这样扩展测试

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

  it('front page contains random text', function() {    cy.visit('http://localhost:3000')    cy.contains('wtf is this app?')  })})

将会失败

fullstack content

从测试中删除失败的代码。

Writing to a form

【写入表单】

扩展测试,测试登录功能,登录到我们的应用。

假设后端包含一个用户名为mluukkai 和密码salainen 的用户。

测试从打开登录表单开始。

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

  it('login form can be opened', function() {
    cy.visit('http://localhost:3000')
    cy.contains('login').click()
  })
})

测试首先通过文本搜索登录按钮,然后用命令cy.click单击该按钮。

两个测试都是以同样的方式开始的,都是通过打开http://localhost:3000 页面,所以将共享部分分隔为beforeEach 块运行:

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

  it('login form can be opened', function() {
    cy.contains('login').click()
  })
})

登录字段包含两个input 字段,将这两个字段写入其中。

cy.get命令允许通过 CSS 选择器搜索元素。

可以访问页面上的第一个和最后一个input字段,并使用命令cy.type向它们写入内容,如下所示:

it('user can login', function () {
  cy.contains('login').click()
  cy.get('input:first').type('mluukkai')
  cy.get('input:last').type('salainen')
})  

这个测试是有效的。 问题是,如果稍后添加更多的input字段,测试将中断,因为它期望需要的字段是页面上的第一个和最后一个。

最好是给input提供唯一的 id 并通过id找到它们。

更改登录表单,如下所示

const LoginForm = ({ ... }) => {
  return (
    <div>
      <h2>Login</h2>
      <form onSubmit={handleSubmit}>
        <div>
          username
          <input
            id='username'
            value={username}
            onChange={handleUsernameChange}
          />
        </div>
        <div>
          password
          <input
            id='password'
            type="password"
            value={password}
            onChange={handlePasswordChange}
          />
        </div>
        <button id="login-button" type="submit">
          login
        </button>
      </form>
    </div>
  )
}

我们还为提交按钮添加了一个 id,这样就可以在测试中访问它。

测试变成了

describe('Note app',  function() {
  // ..
  it('user can log in', function() {
    cy.contains('login').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('salainen')
    cy.get('#login-button').click()
    cy.contains('Matti Luukkainen logged in')  })
})

最后一行确保登录成功。

注意 CSS 的 id-选择器是 # ,所以如果我们想搜索 id 是 username 的元素,CSS 选择器是# username

Some things to note

【需要注意的事情】

测试首先单击打开登录表单的按钮:

cy.contains('login').click()

填写完表格后,单击提交按钮即可提交表格

cy.get('#login-button').click()

两个按钮都有文本login,但它们是两个单独的按钮。

实际上,这两个按钮一直都在应用的 DOM 中,但是由于其中一个是 display:none 每次只有一个按钮可见。

如果我们通过文本搜索按钮,cy.contains将返回第一个按钮,或者打开登录表单的按钮。

即使按钮不可见,也会发生这种情况。

为了防止名称冲突,给出了提交按钮 id login-button,我们可以用它来访问它。

注意到,我们的测试使用的变量 cy 给了我们一个 Eslint 错误

fullstack content

可以通过安装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": {
      // ...
    }
}

Testing new note form

【测试新建便笺的表单】

下面添加测试来测试新建便笺的功能:

describe('Note app', function() {
  // ..
  describe('when logged in', function() {
    beforeEach(function() {
      cy.contains('login').click()
      cy.get('input:first').type('mluukkai')
      cy.get('input:last').type('salainen')
      cy.get('#login-button').click()
    })

    it('a new note can be created', function() {
      cy.contains('new note').click()
      cy.get('input').type('a note created by cypress')
      cy.contains('save').click()
      cy.contains('a note created by cypress')
    })
  })
})

测试已经在它自己的describe 块中定义了。

只有登录的用户才能创建新的便笺,因此将登录添加到应用的beforeEach 块中。

在创建新便笺时,页面只包含一个input,因此像这样搜索该便笺

cy.get('input')

如果页面包含更多的input,测试就会中断

fullstack content

由于这一点,最好再给input一个id,并通过id来搜索它。

测试的结构如下:

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

  it('user can log in', function() {
    cy.contains('login').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('salainen')
    cy.get('#login-button').click()

    cy.contains('Matti Luukkainen logged in')
  })

  describe('when logged in', function() {
    beforeEach(function() {
      cy.contains('login').click()
      cy.get('input:first').type('mluukkai')
      cy.get('input:last').type('salainen')
      cy.get('#login-button').click()
    })

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

Cypress 按照测试在代码中的顺序运行测试。 所以它首先运行user can log in,用户在这里登录。 然后 cypress 将运行 a new note can be created ,beforeEach 也会执行一遍登录。

因为就浏览器而言,每个测试都是从零开始的。

在每次测试后,对浏览器状态的所有更改都会被重置。

Controlling the state of the database

【控制数据库状态】

如果测试需要能够修改服务器的数据库,那么情况会立即变得更加复杂。 理想情况下,每次运行测试时,服务器的数据库应该是相同的,这样我们的测试就可以可靠且容易地重复。

与单元测试和集成测试一样,E2E 测试最好是在测试运行之前清空数据库并尽可能格式化数据库。 E2E 测试的挑战在于,他们无法访问数据库。

解决方案是为测试创建后端的 API 接口。

我们可以使用这些接口清空数据库。

为测试创建一个新的路由

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

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

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

module.exports = router

如果应用在 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

更改之后,对/api/testing/reset 接口的 HTTP POST 请求将清空数据库。

接下来,更改beforeEach 块,以便在运行测试之前清空服务器的数据库。

目前不能通过前端的 UI 添加新用户,因此从 beforeEach 块向后端添加一个新用户。

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() {
    // ...
  })
})

在对测试进行格式化时,使用cy.request对后端进行 HTTP 请求。

与以前不同的是,现在每次测试都以相同的状态从后端开始。 后端将包含一个用户,没有便笺。

再添加一个测试,我们可以改变便笺的重要性。

改变前端,新便笺默认是不重要的,important 字段是false:

const NoteForm = ({ createNote }) => {
  // ...

  const addNote = (event) => {
    event.preventDefault()
    createNote({
      content: newNote,
      important: false    })

    setNewNote('')
  }
  // ...
} 

有多种方法可以测试。 在下面的示例中,首先搜索一个便笺,单击它的make important 按钮。 然后我们检查便笺现在是否包含一个make not 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 important', function () {
        cy.contains('another note cypress')
          .contains('make important')
          .click()

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

第一个命令搜索包含文本another note cypress 的组件,然后搜索其中的make important 按钮。 然后点击按钮。

第二个命令检查按钮上的文本是否更改为make not important

Failed login test

【测试登录失败】

做一个测试,如果密码是错误的,确保登录失败。

Cypress 默认情况下每次都会运行所有测试,随着测试数量的增加,它会变得相当耗时。

当开发一个新的测试或者调试一个失败的测试时,可以用it.only 而不是it 来定义测试,这样 Cypress 就只运行所需的测试。

当测试所有工作时,可以删除 .only

测试的第一个版本如下:

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

  it.only('login fails with wrong password', function() {
    cy.contains('login').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('wrong')
    cy.get('#login-button').click()

    cy.contains('wrong credentials')
  })

  // ...
)}

该测试使用cy.contains来确保应用输出错误消息。

应用将错误消息渲染给一个带有 CSS 类为error 的组件:

const Notification = ({ message }) => {
  if (message === null) {
    return null
  }

  return (
    <div className="error">      {message}
    </div>
  )
}

可以让测试确保错误消息被渲染给了正确的组件,或者说带有 CSS 类为error 的组件:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').contains('wrong credentials')})

首先使用cy.get来搜索带有 CSS 类为error 的组件。 然后我们检查是否可以从这个组件中找到错误消息。

CSS 类选择器以句号开始,所以类为error 的选择器是 .error

也可以使用should语法:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').should('contain', 'wrong credentials')})

使用 should 允许比contains 更多样化的测试,contains 是仅基于文本内容的。

常用的断言列表可以在这里

例如,我们可以确保错误消息是红色的,并且有一个边框:

it('login fails with wrong password', function() {
  // ...

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

Cypress 需要将颜色设置为rgb

因为所有测试都是针对我们使用cy.get访问到的同一个组件,所以可以使用and链接它们。

it('login fails with wrong password', function() {
  // ...

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

完成测试,这样它还可以检查应用没把渲染成功消息'Matti Luukkainen logged in'展示出来:

it.only('login fails with wrong password', function() {
  cy.contains('login').click()
  cy.get('#username').type('mluukkai')
  cy.get('#password').type('wrong')
  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 应当总是与get 链接(或其他某个可链接命令)

使用cy.get('html') 可以访问应用的所有可见内容。

Bypassing the UI

【绕过用户界面】

目前有如下测试:

describe('Note app', function() {
  it('user can login', function() {
    cy.contains('login').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('salainen')
    cy.get('#login-button').click()

    cy.contains('Matti Luukkainen logged in')
  })

  it.only('login fails with wrong password', function() {
    // ...
  })

  describe('when logged in', function() {
    beforeEach(function() {
      cy.contains('login').click()
      cy.get('input:first').type('mluukkai')
      cy.get('input:last').type('salainen')
      cy.get('#login-button').click()
    })

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

首先测试登录。 然后在 describe 块中有一系列测试,期望用户登录。 用户会在beforeEach 块中登录。

每个测试都是从零开始的! 不是从以前测试的结束状态开始的。

Cypress 文档建议: 完全测试登录流程——但只有一次!

不要使用beforeEach 块中的表单登录用户,而是绕过 UI ,对后端执行 HTTP 请求以登录。因为使用 HTTP 请求登录要比填写表单快得多。

我们的情况比 Cypress 文档中的示例要复杂一些,当用户登录时,应用将其详细信息保存到了 localStorage 中。

Cypress 也可以处理这个问题。代码如下

describe('when logged in', function() {
  beforeEach(function() {
    cy.request('POST', 'http://localhost:3001/api/login', {
      username: 'mluukkai', password: 'salainen'
    }).then(response => {
      localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body))
      cy.visit('http://localhost:3000')
    })
  })

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

  // ...
})

可以使用 then 方法访问对cy.request的响应。 在底层,cy.request和所有 Cypress 命令一样,都是promises

回调函数将登录用户的详细信息保存到 localStorage,然后重新加载页面。

现在和用户使用登录表单登录没有区别。

如果在应用中编写新的测试,必须在多个地方使用登录代码。

我们使它成为一个自定义命令

自定义命令在cypress/support/commands.js. 中声明。

登录的代码如下:

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

测试变得更简洁:

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

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

  // ...
})

这同样适用于创建一个新的便笺。 在测试的beforeEach 块中新建了一个便笺,改变便笺的重要性:

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

  describe('when logged in', function() {
    it('a new note can be created', function() {
      cy.contains('new note').click()
      cy.get('input').type('a note created by cypress')
      cy.contains('save').click()

      cy.contains('a note created by cypress')
    })

    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 important', function () {
        // ...
      })
    })
  })
})

为制作新便笺创建一个新的自定义命令。 该命令将使用 HTTP POST 请求生成一个新的记录:

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

该命令期望用户登录,并将用户的详细信息保存到 localStorage。

现在格式块变成:

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

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

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.createNote({
          content: 'another note cypress',
          important: false
        })
      })

      it('it can be made important', function () {
        // ...
      })
    })
  })
})

Changing the importance of a note

【改变便笺的重要性】

最后看一下为改变便笺的重要性所做的测试。

首先我们要改变块,创建三个便笺:

describe('when logged in', function() {
  describe('and several notes exist', function () {
    beforeEach(function () {
      cy.createNote({ content: 'first note', important: false })
      cy.createNote({ content: 'second note', important: false })
      cy.createNote({ content: 'third note', important: false })
    })

    it('one of those can be made important', function () {
      cy.contains('second note')
        .contains('make important')
        .click()

      cy.contains('second note')
        .contains('make not important')
    })
  })
})

当我们在 Cypress Test Runner中单击 cy.contains('second note') 命令时,我们会看到该命令搜索包含文本second note 的元素:

fullstack content

通过单击下一行 .contains('make important') ,可以看到测试使用

对应于second note的'make important'按钮:

fullstack content

链接时,第二个contains 命令会继续从第一个命令找到的组件中搜索。

如果这么写:

cy.contains('second note')
cy.contains('make important').click()

结果会完全不同。 测试的第二行会点击一个错误便笺的按钮:

fullstack content

在编写测试代码时,应该检查测试运行程序是否使用了正确的组件!

更改 Note 组件,以便将 Note 的文本渲染为span

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important' : 'make important'

  return (
    <li className='note'>
      <span>{note.content}</span>
      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

测试运行程序提示, cy.contains('second note')现在返回包含文本的组件,而按钮不在其中。

fullstack content

解决这个问题的方法如下:

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

第一行使用parent命令来访问包含second note 的元素的父元素,并在其中找到按钮。

然后我们点击按钮,检查上面的文本是否改变。

注意,使用命令find来搜索按钮。 不能在这里使用cy.get ,因为它总是从 整个页面进行搜索,并返回页面上的所有5个按钮。

现在测试中有一些复制/粘贴,因为搜索正确按钮的代码总是相同的。

可以使用as命令:

it.only('other 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')
})

现在第一行找到正确的按钮,并使用as 保存为theButton。 下面的代码行可以使用命名元素 cy.get('@theButton')来获取。

猜你喜欢

转载自blog.csdn.net/qq_39389123/article/details/112416470