Pular para o conteúdo

d

Testes end-to-end

Até aqui, nós testamos o backend como um todo ao nível de API, usando testes de integração e testamos alguns componentes do frontend usando testes unitários.

Agora, vamos ver uma forma de testar o sistema como um todo usando testes End to End (E2E).

Podemos realizar testes E2E de aplicações web utilizando um navegador e uma biblioteca de test. Existem muitas bibliotecas disponíveis. Um exemplo é Selenium, que pode ser usado com quase todos os navegadores. Outra opção são os chamados headless browsers, que são navegadores sem nenhuma interface grafica. O Chrome, por exemplo, pode ser utilizado no modo headless.

Testes E2E são provavelmente a categoria mais útil de testes, pois permitem testar o sistema pela mesma interface de um usuário real.

Eles também têm algumas desvantagens. Configurar testes E2E é mais desafiador do que testes unitários ou de integração. Além disso, eles tendem a ser bastante lentos e, em um sistema grande, o tempo de execução pode ser de minutos ou até mesmo horas. Isso é ruim para o desenvolvimento, porque durante a codificação é benéfico poder executar os testes o máximo possível, caso ocorram regressões.

Os testes E2E também podem ser instáveis. Alguns testes podem passar em uma ocasião e falhar em outra, mesmo que o código não seja alterado.

Cypress

A biblioteca E2E Cypress se tornou popular no último ano. O Cypress é extremamente fácil de usar e, quando comparado ao Selenium, por exemplo, dá muito menos problemas e dores de cabeça. Seu princípio de funcionamento é radicalmente diferente da maioria das bibliotecas de teste E2E, pois os testes do Cypress são executados completamente dentro do navegador. Outras bibliotecas executam os testes em um processo Node, que está conectado ao navegador por meio de uma API.

Vamos fazer alguns testes de end-to-end para o nosso aplicativo de notas.

Começamos instalando o Cypress no frontend como uma dependência de desenvolvimento

npm install --save-dev cypress

e adicionando um npm-script para executá-lo:

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

Ao contrário dos testes unitários do frontend, os testes do Cypress podem estar no repositório do frontend ou do backend, ou até mesmo em seus próprios repositórios separados.

Os testes exigem que o sistema testado esteja em execução. Ao contrário de nossos testes de integração do backend, os testes do Cypress não iniciam o sistema quando são executados.

Vamos adicionar um script npm para o backend que o inicia no modo de teste, ou seja, para que NODE_ENV seja test.

{
  // ...
  "scripts": {
    "start": "NODE_ENV=production node index.js",
    "dev": "NODE_ENV=development nodemon index.js",
    "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend",
    "deploy": "fly deploy",
    "deploy:full": "npm run build:ui && npm run deploy",
    "logs:prod": "fly logs",
    "lint": "eslint .",
    "test": "jest --verbose --runInBand",
    "start:test": "NODE_ENV=test node index.js"  },
  // ...
}

Obs.: Para fazer o Cypress funcionar com o WSL2, talvez seja necessário fazer algumas configurações adicionais. Esses dois links são ótimos lugares para começar.

Quando tanto o backend quanto o frontend estiverem em execução, podemos iniciar o Cypress com o comando

npm run cypress:open

O Cypress pergunta que tipo de testes estamos fazendo. Vamos responder "E2E Testing":

tela do cypress com seta apontando para a opção de testes e2e

Em seguida, um navegador é selecionado (por exemplo, Chrome) e então clicamos em "Create new spec":

opção criar novo spec com uma seta indicativa

Vamos criar o arquivo de teste cypress/e2e/note_app.cy.js:

cypress com o caminho cypress/e2e/note_app.cy.js

Poderíamos editar os testes no Cypress, mas vamos usar o VS Code:

vscode mostrando edições do teste e cypress mostrando o spec adicionado

Agora podemos fechar a visualização de edição do Cypress.

Vamos alterar o conteúdo do teste da seguinte maneira:

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

O teste é executado clicando no teste no Cypress:

Executando o teste, podemos ver como a aplicação se comporta durante a execução do teste:

cypress mostrando a automação do teste de notas

A estrutura do teste deve parecer familiar. Eles usam blocos describe para agrupar diferentes casos de teste, assim como o Jest. Os casos de teste foram definidos com o método it. O Cypress pegou essas partes da biblioteca de testes Mocha, que ele usa internamente.

cy.visit e cy.contains são comandos do Cypress, e sua finalidade é bastante óbvia. cy.visit abre o endereço web fornecido a ele como parâmetro no navegador usado no teste. cy.contains procura pela string que recebeu como parâmetro na página.

Poderíamos ter declarado o teste usando uma arrow function

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

No entanto, o Mocha recomenda que as arrow functions não sejam usadas, pois podem causar alguns problemas em determinadas situações.

Se cy.contains não encontrar o texto que está procurando, o teste não passa. Portanto, se expandirmos nosso teste da seguinte maneira

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

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

o teste falha

cypress mostrando falha esperando encontrar wtf

Vamos remover o código com falha do teste.

A variável cy que nossos testes usam nos dá um erro desagradável do Eslint

captura de tela do vscode mostrando que cy não está definido

Podemos nos livrar disso instalando o eslint-plugin-cypress como uma dependência de desenvolvimento

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

e alterando a configuração em .eslintrc.js da seguinte forma:

module.exports = {
    "env": {
        "browser": true,
        "es6": true,
        "jest/globals": true,
        "cypress/globals": true    },
    "extends": [ 
      // ...
    ],
    "parserOptions": {
      // ...
    },
    "plugins": [
        "react", "jest", "cypress"    ],
    "rules": {
      // ...
    }
}

Preenchendo um formulário

Vamos expandir nosso código para que o teste tente fazer login em nossa aplicação. Vamos supor que nosso backend contenha um usuário com o nome de usuário mluukkai e senha salainen.

O teste começa abrindo o formulário de login.

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

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

O teste procura primeiro o botão de login pelo seu texto e clica no botão com o comando cy.click.

Ambos os nossos testes começam da mesma maneira, abrindo a página http://localhost:3000, então devemos separar a parte compartilhada em um bloco beforeEach que é executado antes de cada teste:

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

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

O campo de login contém dois campos input, nos quais o teste deve escrever.

O comando cy.get permite buscar elementos por seletores CSS.

Podemos acessar o primeiro e o último campo de entrada na página e escrever neles com o comando cy.type assim:

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

O teste funciona. O problema é que se adicionarmos mais campos de entrada posteriormente, o teste quebrará porque espera que os campos necessários sejam os primeiros e os últimos na página.

Seria melhor atribuir identificadores únicos aos nossos inputs e usá-los para encontrá-los. Alteramos nosso formulário de login da seguinte forma:

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

Também adicionamos um id ao nosso botão de envio para que possamos acessá-lo em nossos testes.

O teste fica assim:

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

A última linha garante que o login foi bem-sucedido.

Observe que o seletor CSS para o id-selector é #, então, se quisermos buscar um elemento com o id username, o seletor CSS é #username.

Note que passar no teste nesta etapa requer a existência de um usuário no banco de dados de teste do ambiente de backend, cujo nome de usuário é mluukkai e a senha é salainen. Crie um usuário, se necessário!

Testando o formulário de nova nota

Agora vamos adicionar métodos de teste para testar a funcionalidade de "nova nota":

describe('Note app', function() {
  // ..
  describe('when logged in', function() {    beforeEach(function() {      cy.contains('log in').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')    })  })})

O teste foi definido em seu próprio bloco describe. Apenas usuários logados podem criar novas notas, então adicionamos o login à aplicação em um bloco beforeEach.

O teste pressupõe que, ao criar uma nova nota, a página contém apenas um campo de entrada, então ele o busca da seguinte maneira:

cy.get('input')

Se a página contiver mais campos de entrada, o teste falhará.

erro do cypress - cy.type só pode ser chamado em um único elemento

Devido a esse problema, seria melhor atribuir um id à entrada e pesquisar o elemento pelo seu id.

A estrutura dos testes é a seguinte:

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

  it('user can log in', function() {
    cy.contains('log in').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('log in').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() {
      // ...
    })
  })
})

O Cypress executa os testes na ordem em que estão no código. Portanto, primeiro ele executa user can log in (usuário pode fazer login), onde o usuário faz login. Em seguida, o Cypress executará a new note can be created (uma nova nota pode ser criada), para o qual um bloco beforeEach também faz login. Por que fazer isso? O usuário não está logado após o primeiro teste? Não, porque cada teste começa do zero do ponto de vista do navegador. Todas as alterações no estado do navegador são revertidas após cada teste.

Controlando o estado do banco de dados

Se os testes precisarem ser capazes de modificar o banco de dados do servidor, a situação imediatamente se torna mais complicada. Idealmente, o banco de dados do servidor deve ser o mesmo sempre que executamos os testes, para que os testes possam ser reproduzíveis com confiabilidade e facilidade.

Assim como nos testes de unidade e integração, nos testes E2E é melhor esvaziar o banco de dados e possivelmente formatá-lo antes de executar os testes. O desafio nos testes E2E é que eles não têm acesso ao banco de dados.

A solução é criar endpoints de API para os testes do backend. Podemos esvaziar o banco de dados usando esses endpoints. Vamos criar um novo router para os testes:

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

e adicioná-lo ao backend somente se a aplicação estiver em modo de teste:

// ...

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

Após as alterações, uma solicitação HTTP POST para o endpoint /api/testing/reset esvazia o banco de dados. Verifique se o backend está sendo executado no modo de teste iniciando-o com o seguinte comando (previamente configurado no arquivo package.json):

  npm run start:test

O código backend modificado pode ser encontrado no GitHub, na branch part5-1.

Em seguida, vamos alterar o bloco beforeEach para que ele esvazie o banco de dados do servidor antes da execução dos testes.

Atualmente, não é possível adicionar novos usuários por meio da interface do usuário do frontend, então adicionamos um novo usuário ao backend a partir do bloco 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() {
    // ...
  })
})

Durante a formatação, o teste faz requisições HTTP para o backend com cy.request.

Ao contrário do passado, agora os testes começam com o backend no mesmo estado todas as vezes. O backend conterá um usuário e nenhuma nota.

Vamos adicionar mais um teste para verificar se podemos alterar a importância das notas.

Algum tempo atrás nós alteramos o frontend de forma que uma nova nota é adicionada como importante por padrão, o campo important é true:

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

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

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

Há várias maneiras de testar isso. No exemplo a seguir, primeiro procuramos por uma nota e clicamos no botão tornar não importante. Em seguida, verificamos se a nota agora contém um botão tornar importante.

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

O primeiro comando procura por um componente que contém o texto outra nota cypress, e depois por um botão tornar não importante dentro dele. Em seguida, ele clica no botão.

O segundo comando verifica se o texto do botão foi alterado para tornar importante.

Os testes e o código frontend atual podem ser encontrados no GitHub, na branch part5-9.

Teste de falha de login

Vamos fazer um teste para garantir que uma tentativa de login falhe se a senha estiver incorreta.

O Cypress executará todos os testes a cada vez por padrão, e à medida que o número de testes aumenta, isso começa a consumir bastante tempo. Ao desenvolver um novo teste ou depurar um teste quebrado, podemos definir o teste com it.only em vez de it, para que o Cypress execute apenas o teste necessário. Quando o teste estiver funcionando, podemos remover o .only.

A primeira versão dos nossos testes é a seguinte:

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

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

    cy.contains('wrong credentials')
  })

  // ...
)}

O teste usa cy.contains para garantir que o aplicativo exiba uma mensagem de erro.

O aplicativo renderiza a mensagem de erro em um componente com a classe CSS error:

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

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

Podemos fazer o teste garantir que a mensagem de erro seja renderizada no componente correto, ou seja, o componente com a classe CSS error:

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

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

Primeiro, usamos cy.get para procurar um componente com a classe CSS error. Em seguida, verificamos se a mensagem de erro pode ser encontrada a partir desse componente. Observe que o seletor de classe CSS começa com um ponto, então o seletor para a classe error é .error.

Podemos fazer o mesmo usando a sintaxe do should:

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

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

Usar o should é um pouco mais complicado do que usar contains, mas permite testes mais diversos do que contains, que funciona apenas com base no conteúdo de texto.

Uma lista das asserções mais comuns que podem ser usadas com o should pode ser encontrada aqui.

Por exemplo, podemos garantir que a mensagem de erro seja vermelha e tenha uma borda:

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

O Cypress requer que as cores sejam fornecidas em formato rgb.

Como todos os testes são para o mesmo componente que acessamos usando cy.get, podemos encadeá-los usando o 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')
})

Vamos concluir o teste para também verificar se o aplicativo não renderiza a mensagem de sucesso 'Matti Luukkainen logged in':

it('login fails with wrong password', function() {
  cy.contains('log in').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')})

O comando should é mais frequentemente usado encadeando-o após o comando get (ou outro comando similar que pode ser encadeado). O cy.get('html') usado no teste significa praticamente o conteúdo visível de todo o aplicativo.

Também podemos verificar o mesmo encadeando o comando contains com o comando should com um parâmetro ligeiramente diferente:

cy.contains('Matti Luukkainen logged in').should('not.exist')

NOTA: Algumas propriedades CSS comportam-se de forma diferente no Firefox. Se você executar os testes com o Firefox:

running

então os testes que envolvem, por exemplo, border-style, border-radius e padding, serão aprovados no Chrome ou Electron, mas falharão no Firefox:

borderstyle

Bypassando a interface de usuário

Atualmente, temos os seguintes testes:

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

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

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

  describe('when logged in', function() {
    beforeEach(function() {
      cy.contains('log in').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() {
      // ... 
    })
   
  })
})

Primeiro, testamos o login. Em seguida, em seu próprio bloco "describe", temos um monte de testes que esperam que o usuário esteja logado. O usuário é logado no bloco beforeEach.

Como mencionado acima, cada teste parte do zero! Os testes não iniciam a partir do estado em que os testes anteriores terminaram.

A documentação do Cypress nos dá o seguinte conselho: Teste completamente o fluxo de login - mas apenas uma vez!. Portanto, em vez de fazer login de um usuário usando o formulário no bloco beforeEach, o Cypress recomenda que nós façamos o bypass da interface de usuário e uma requisição HTTP ao backend para fazer o login. A razão para isso é que fazer login com uma requisição HTTP é muito mais rápido do que preencher um formulário.

Nossa situação é um pouco mais complicada do que no exemplo da documentação do Cypress porque, quando um usuário faz login, nossa aplicação salva seus detalhes no localStorage. No entanto, o Cypress também pode lidar com isso. O código é o seguinte:

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

  // ...
})

Podemos acessar a resposta de uma cy.request com o método then. Por baixo dos panos, cy.request, assim como todos os comandos do Cypress, são promessas. A função de retorno de chamada salva os detalhes de um usuário logado no localStorage e recarrega a página. Agora não há diferença para um usuário fazer login usando o formulário de login.

Se e quando escrevermos novos testes para nossa aplicação, teremos que usar o código de login em vários lugares. Devemos transformá-lo em um comando personalizado.

Comandos personalizados são declarados em cypress/support/commands.js. O código para fazer login é o seguinte:

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

Usar nosso comando personalizado é fácil e nosso teste fica mais limpo:

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

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

  // ...
})

O mesmo se aplica à criação de uma nova anotação, agora que pensamos sobre isso. Temos um teste que cria uma nova anotação usando o formulário. Também criamos uma nova anotação no bloco beforeEach do teste que testa a alteração da importância de uma anotação."

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

Vamos criar um novo comando personalizado para criar uma nova nota. O comando irá criar uma nova nota com uma requisição 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')
})

O comando espera que o usuário esteja logado e que os detalhes do usuário estejam salvos no localStorage.

Agora o bloco de formatação fica assim:

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: true        })      })

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

Existe mais uma característica irritante em nossos testes. O endereço da aplicação http:localhost:3000 está codificado em vários lugares.

Vamos definir o baseUrl para a aplicação no arquivo de configuração cypress.config.js, que é pré-gerado do Cypress:

const { defineConfig } = require("cypress")

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

Todos os comandos nos testes utilizam o endereço da aplicação

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

podem ser transformados em

cy.visit('')

O endereço codificado da backend http://localhost:3001 ainda está nos testes. A documentation do Cypress recomenda definir outros endereços utilizados pelos testes como variáveis de ambiente.

Vamos expandir o arquivo de configuração cypress.config.js da seguinte maneira:

const { defineConfig } = require("cypress")

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

Vamos substituir todos os endereços do backend nos testes da seguinte maneira

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

Os testes e o código frontend podem ser encontrados no GitHub na branch part5-10.

Alterando a importância de uma nota

Por último, vamos dar uma olhada no teste que fizemos para alterar a importância de uma nota. Primeiro, vamos alterar o bloco de formatação para criar três notas em vez de uma:

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

Como o comando cy.contains realmente funciona?

Quando clicamos no comando cy.contains('segunda nota') no Cypress Test Runner, vemos que o comando busca pelo elemento que contém o texto segunda nota:

cypress test clicando no corpo de teste e na segunda nota

Ao clicar na linha seguinte .contains('make important'), vemos que o teste usa o botão 'make important' correspondente à segunda nota:

cypress test clicando em  'make important'

Quando encadeado, o segundo comando contains continua a busca a partir do componente encontrado pelo primeiro comando.

Se não tivéssemos encadeado os comandos e, em vez disso, escrevêssemos:

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

o resultado teria sido completamente diferente. A segunda linha do teste clicaria no botão de uma nota errada:

cypress mostrando erro e incorretamente tentando clicar no primeiro botão

Ao codificar testes, você deve verificar no test runner se os testes usam os componentes corretos!

Vamos alterar o componente Note para que o texto da nota seja renderizado em um elemento 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>
  )
}

Nossos testes quebram! Como o test runner revela, cy.contains('second note') agora retorna o componente que contém o texto, e o botão não está nele.

cypress  monstrando que o teste quebrou tentando clicar em 'make important'

Uma maneira de corrigir isso é a seguinte:

it('one 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')
})

Na primeira linha, usamos o comando parent para acessar o elemento pai do elemento que contém a segunda nota e encontrar o botão dentro dele. Em seguida, clicamos no botão e verificamos se o texto nele muda.

Observe que usamos o comando find para pesquisar o botão. Não podemos usar cy.get aqui, porque ele sempre procura em toda a página e retornaria todos os 5 botões na página.

Infelizmente, agora temos algum código duplicado nos testes, porque o código para procurar o botão correto é sempre o mesmo.

Nesses tipos de situações, é possível usar o comando 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')
})

Agora, a primeira linha encontra o botão correto e o salva como theButton usando o as. As linhas seguintes podem usar o elemento nomeado com cy.get('@theButton').

Executando e depurando os testes

Por fim, algumas observações sobre como o Cypress funciona e como depurar seus testes.

A forma dos testes do Cypress dá a impressão de que os testes são código JavaScript normal, e poderíamos, por exemplo, tentar o seguinte:

const button = cy.contains('log in')
button.click()
debugger
cy.contains('logout').click()

No entanto, isso não funcionará. Quando o Cypress executa um teste, ele adiciona cada comando cy a uma fila de execução. Quando o código do método de teste é executado, o Cypress executará cada comando na fila, um por um.

Os comandos do Cypress sempre retornam undefined, portanto, button.click() no código acima causaria um erro. Uma tentativa de iniciar o depurador não interromperia o código entre a execução dos comandos, mas sim antes que qualquer comando tenha sido executado.

Os comandos do Cypress são como promessas, então, se quisermos acessar seus valores de retorno, precisamos fazer isso usando o comando then. Por exemplo, o teste a seguir imprimiria o número de botões na aplicação e clicaria no primeiro botão:

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

Interromper a execução do teste com o depurador é possível. O depurador só é iniciado se o console do Cypress test runner estiver aberto.

O console do desenvolvedor é extremamente útil ao depurar seus testes. Você pode ver as solicitações HTTP feitas pelos testes na guia "Network" e a guia "Console" mostrará informações sobre seus testes:

console do desenvolvedor enquanto o cypress está sendo executado

Até agora, executamos nossos testes Cypress usando o test runner gráfico. Também é possível executá-los a partir da linha de comando. Só precisamos adicionar um script npm para isso:

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

Agora podemos executar nossos testes a partir da linha de comando com o comando npm run test:e2e

saída do terminal ao executar os testes e2e do npm mostrando a aprovação

Observe que os vídeos da execução do teste serão salvos em cypress/videos/, portanto, você provavelmente deve ignorar esse diretório no git. Também é possível desativar a criação de vídeos.

O código frontend e de teste pode ser encontrado no GitHub na branch part5-11.