Pular para o conteúdo
b Testando o back-endc Administração de usuáriosd Autenticação por token

a

Estrutura de uma aplicação back-end, introdução a testes

Vamos continuar nosso trabalho no backend da aplicação de notas que iniciamos na parte 3

Estrutura do projeto

Antes de adentrarmos no tópico de testes, nós iremos modificar a estrutura do projeto para aderir às melhores práticas do Node.js.

Após fazer as mudanças na estrutura do diretório de nosso projeto, terminaremos com a seguinte estrutura:

├── index.js
├── app.js
├── build
│   └── ...
├── controllers
│   └── notes.js
├── models
│   └── note.js
├── package-lock.json
├── package.json
├── utils
│   ├── config.js
│   ├── logger.js
│   └── middleware.js  

Até o momento estamos utilizando o console.log e console.error para imprimir diferentes informações do código. No entanto, esse não é o melhor jeito de fazer as coisas. Vamos separar todas as impressões no console em seu próprio módulo utils/logger.js:

const info = (...params) => {
  console.log(...params)
}

const error = (...params) => {
  console.error(...params)
}

module.exports = {
  info, error
}

O logger que criamos tem duas funções: info para imprimir mensagens normais no console; e error para as mensagens de erro.

Extrair o código de log para seu próprio módulo é uma boa ideia, por diversos motivos. Se decidirmos escrever logs em um arquivo ou enviá-los para um serviço externo de logging, como graylog ou papertrail só precisaremos realizar mudanças em um determinado lugar.

O conteúdo de index.js, arquivo usado para iniciar a aplicação, fica simplificado da seguinte forma:

const app = require('./app') // atual aplicação Express
const config = require('./utils/config')
const logger = require('./utils/logger')

app.listen(config.PORT, () => {
  logger.info(`Server running on port ${config.PORT}`)
})

O arquivo index.js somente importa a aplicação do arquivo app.js e depois inicia a aplicação. A função info do módulo logger é usada para imprimir no console, informando que a aplicação está sendo executada.

Agora, o app Express e o código encarregado de cuidar do servidor web estão separados, seguindo assim as melhores práticas. Uma das vantagens desse método é que a aplicação poderá agora ser testada a nível de chamadas de API HTTP, sem realizar chamadas via HTTP sobre a rede, o que resultará em execuções de testes mais rápidas.

O gerenciamento de variáveis de ambiente é extraído em um arquivo separado utils/config.js:

require('dotenv').config()

const PORT = process.env.PORT
const MONGODB_URI = process.env.MONGODB_URI

module.exports = {
  MONGODB_URI,
  PORT
}

As outras partes da aplicação podem acessar as variáveis de ambiente importando o módulo de configuração (config.js)

const config = require('./utils/config')

logger.info(`Server running on port ${config.PORT}`)

Os gerenciadores de rota também foram movidos para um módulo dedicado. Os gerenciadores de evento das rotas são normalmente chamados de controllers (controladores), por esta razão nós criamos um novo diretório chamado controllers. Todas as rotas relacionadas às notas estão agora em notes.js, que é um módulo dentro do diretório controllers.

O conteúdo do módulo notes.js é o seguinte:

const notesRouter = require('express').Router()
const Note = require('../models/note')

notesRouter.get('/', (request, response) => {
  Note.find({}).then(notes => {
    response.json(notes)
  })
})

notesRouter.get('/:id', (request, response, next) => {
  Note.findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(note)
      } else {
        response.status(404).end()
      }
    })
    .catch(error => next(error))
})

notesRouter.post('/', (request, response, next) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })

  note.save()
    .then(savedNote => {
      response.json(savedNote)
    })
    .catch(error => next(error))
})

notesRouter.delete('/:id', (request, response, next) => {
  Note.findByIdAndDelete(request.params.id)
    .then(() => {
      response.status(204).end()
    })
    .catch(error => next(error))
})

notesRouter.put('/:id', (request, response, next) => {
  const body = request.body

  const note = {
    content: body.content,
    important: body.important,
  }

  Note.findByIdAndUpdate(request.params.id, note, { new: true })
    .then(updatedNote => {
      response.json(updatedNote)
    })
    .catch(error => next(error))
})

module.exports = notesRouter

Essa é quase uma cópia exata do conteúdo que anteriormente estava no arquivo index.js

Mas há algumas mudanças significativas. No início do arquivo nós criamos um novo objeto router (roteador):

const notesRouter = require('express').Router()

//...

module.exports = notesRouter

O módulo exporta o roteador para estar disponível para todos os consumidores.

Todas as rotas estão agora definidas no objeto router, parecido com o que fizemos antes com o objeto que representava a aplicação inteira.

Vale ressaltar que os caminhos nos manipuladores de rotas foram encurtados. Na versão anterior, tínhamos:

app.delete('/api/notes/:id', (request, response) => {

E agora temos:

notesRouter.delete('/:id', (request, response) => {

O que exatamente são esses objetos router? O manual do Express explica o seguinte:

Um objeto router é uma instância isolada de middleware e rotas. Você pode pensar nele como uma "mini-aplicação", capaz de somente executar funções de middleware e de roteamento. Toda aplicação Express possui um app router integrado.

De fato, o router é um middleware, que pode ser utilizado para definir "rotas relacionadas" a um determinado lugar, e que tipicamente é colocado em seu próprio módulo.

O arquivo app.js que cria a aplicação recebe o router e o utiliza da seguinte forma:

const notesRouter = require('./controllers/notes')
app.use('/api/notes', notesRouter)

Esse router que definimos mais cedo é usado se a URL da requisição começar com /api/notes. Por este motivo, o objeto notesRouter somente deve definir rotas com caminhos relativos, por exemplo o caminho vazio / ou apenas o parâmetro /:id.

Após estas mudanças, nosso arquivo app.js ficará desta forma:

const config = require('./utils/config')
const express = require('express')
const app = express()
const cors = require('cors')
const notesRouter = require('./controllers/notes')
const middleware = require('./utils/middleware')
const logger = require('./utils/logger')
const mongoose = require('mongoose')

mongoose.set('strictQuery', false)

logger.info('connecting to', config.MONGODB_URI)

mongoose.connect(config.MONGODB_URI)
  .then(() => {
    logger.info('connected to MongoDB')
  })
  .catch((error) => {
    logger.error('error connecting to MongoDB:', error.message)
  })

app.use(cors())
app.use(express.static('build'))
app.use(express.json())
app.use(middleware.requestLogger)

app.use('/api/notes', notesRouter)

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

module.exports = app

O código coloca diferentes middleware em uso, um deles é o notesRouter que está acoplado à rota /api/notes.

Nosso middleware personalizado foi movido para o novo módulo utils/middleware.js:

const logger = require('./logger')

const requestLogger = (request, response, next) => {
  logger.info('Method:', request.method)
  logger.info('Path:  ', request.path)
  logger.info('Body:  ', request.body)
  logger.info('---')
  next()
}

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

const errorHandler = (error, request, response, next) => {
  logger.error(error.message)

  if (error.name === 'CastError') {
    return response.status(400).send({ error: 'malformatted id' })
  } else if (error.name === 'ValidationError') {
    return response.status(400).json({ error: error.message })
  }

  next(error)
}

module.exports = {
  requestLogger,
  unknownEndpoint,
  errorHandler
}

A responsabilidade de estabelecer a conexão com o banco de dados foi dada ao módulo app.js. O arquivo note.js que está no diretório models somente define o schema do Mongoose para as notas:

const mongoose = require('mongoose')

const noteSchema = new mongoose.Schema({
  content: {
    type: String,
    required: true,
    minlength: 5
  },
  important: Boolean,
})

noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

module.exports = mongoose.model('Note', noteSchema)

Para recapitular, após estas mudanças a estrutura de diretórios estará desta forma:

├── index.js
├── app.js
├── build
│   └── ...
├── controllers
│   └── notes.js
├── models
│   └── note.js
├── package-lock.json
├── package.json
├── utils
│   ├── config.js
│   ├── logger.js
│   └── middleware.js  

Para aplicações pequenas, a estrutura de diretórios não é muito relevante. Mas uma vez que a aplicação começa a crescer, você precisará estabelecer algum tipo de estrutura e separar diferentes responsabilidades da aplicação em módulos distintos. Isso facilitará muito o desenvolvimento da aplicação.

As aplicações Express não requerem uma estrutura de diretórios pré-determinada ou convenção de nomes para arquivos. Em contrapartida, Ruby on Rails de fato requer uma estrutura específica. Nossa estrutura atual simplesmente segue algumas das melhores práticas que você poderá encontrar na internet.

Você pode encontrar o código atual da nossa aplicação na branch part4-1 neste repositório GitHub.

Se você fizer o clone do projeto, execute o comando npm install antes de iniciar a aplicação com o comando npm run dev.

Observação sobre os exports

Nós fizemos dois diferentes tipos de exportações, isto é, exports, nesta parte. Em primeiro lugar, por exemplo, o arquivo utils/logger.js faz export da seguinte forma:

const info = (...params) => {
  console.log(...params)
}

const error = (...params) => {
  console.error(...params)
}

module.exports = {  info, error}

O código exporta um objeto que possui dois campos, ambos funções. As funções podem ser utilizadas de duas maneiras diferentes: a primeira maneira é requerer o objeto inteiro e referenciar cada função por meio do objeto, usando a notação de ponto:

const logger = require('./utils/logger')

logger.info('message')

logger.error('error message')

A segunda maneira é desestruturar as funções para variáveis no momento do require:

const { info, error } = require('./utils/logger')

info('message')
error('error message')

A segunda maneira pode ser mais indicada caso somente uma pequena parte das funções exportadas forem utilizadas no código.

No arquivo controller/notes.js a exportação funciona assim:

const notesRouter = require('express').Router()
const Note = require('../models/note')

// ...

module.exports = notesRouter

Neste caso, há somente uma "coisa" sendo exportada, logo a única maneira de usá-la é assim:

const notesRouter = require('./controllers/notes')

// ...

app.use('/api/notes', notesRouter)

Agora o "objeto" exportado (neste caso um objeto router) é atribuído a uma variável e usado como tal.

Testando aplicações Node

Nós negligenciamos completamente uma área essencial no desenvolvimento de software chamada de testes.

Vamos iniciar nossa jornada nos testes dando uma olhada nos testes unitários (unit tests). A lógica de nossa aplicação é tão simples, que não faz muito sentido os testes unitários. Vamos criar um novo arquivo utils/for_testing.js e escrever algumas funções simples para praticarmos a escrita de testes:

const reverse = (string) => {
  return string
    .split('')
    .reverse()
    .join('')
}

const average = (array) => {
  const reducer = (sum, item) => {
    return sum + item
  }

  return array.reduce(reducer, 0) / array.length
}

module.exports = {
  reverse,
  average,
}

A função average utiliza o método de array reduce. Se você ainda não está familiarizado com este método, agora é uma boa oportunidade para assistir os três primeiros vídeos da série Functional Javascript no YouTube. (Nota dos tradutores: o vídeo está em inglês, mas você pode ativar as legendas ocultas e escolher o idioma português).

Existem muitas bibliotecas para testes ou test runners disponíveis para JavaScript. Neste curso, nós vamos utilizar uma biblioteca de testes desenvolvida e usada internamente pelo Facebook, chamada jest, que se assemelha à antiga principal biblioteca de testes JavaScript Mocha.

Jest é uma escolha natural para este curso, pois trabalha bem com tests de backend, e brilha quando chega o momento de testar aplicações React.

Usuários de Windows: Jest pode não funcionar se o diretório do projeto estiver em um caminho com espaço no nome.

Já que os testes somente são executados durante a etapa de desenvolvimento da aplicação, iremos instalar o jest como uma dependência de desenvolvimento:

npm install --save-dev jest

Vamos definir um script para os testes com o comando npm script test que servirá para executar os testes com o Jest e relatar a execução em um estilo verboso:

{
  //...
  "scripts": {
    "start": "node index.js",
    "dev": "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"  },
  //...
}

O Jest requer a especificação de que o ambiente de execução é o Node. Isso pode ser feito adicionando as seguintes linhas ao final do arquivo package.json:

{
 //...
 "jest": {
   "testEnvironment": "node"
 }
}

Vamos criar um diretório separado, chamado de tests, e nele vamos criar um novo arquivo chamado reverse.test.js contendo o seguinte:

const reverse = require('../utils/for_testing').reverse

test('reverse of a', () => {
  const result = reverse('a')

  expect(result).toBe('a')
})

test('reverse of react', () => {
  const result = reverse('react')

  expect(result).toBe('tcaer')
})

test('reverse of releveler', () => {
  const result = reverse('releveler')

  expect(result).toBe('releveler')
})

A configuração do ESLint que adicionamos ao projeto na parte anterior está reclamando sobre os comandos test e expect em nosso arquivo de teste, já que a configuração não permite globals. Vamos nos livrar disso adicionando "jest": true na propriedade env do arquivo .eslintrc.js.

module.exports = {
  'env': {
    'commonjs': true,
    'es2021': true,
    'node': true,
    'jest': true,  },
  // ...
}

Na primeira linha, o código de teste importa a função que será testada e a atribui a uma variável chamada reverse:

const reverse = require('../utils/for_testing').reverse

Testes individuais são definidos com a função test. O primeiro parâmetro desta função é a descrição do teste como uma string. O segundo parâmetro, é a função que define a funcionalidade para o caso de teste. A funcionalidade para o segundo caso de teste é a seguinte:

() => {
  const result = reverse('react')

  expect(result).toBe('tcaer')
}

Primeiro, nós executamos o código que será testado, o que significa que geraremos a string reversa de react. Após, verificamos o resultado com a função expect. Expect envolve o resultado em um objeto que suporta uma coleção de funções de comparadores (matcher), que podem ser usadas durante a checagem de resultados. Já que neste caso de teste nós estamos comparando duas strings, podemos usar o matcher toBe. (Nota dos tradutores: No contexto de testes em programação, um "matcher" seria um método que realiza uma comparação entre valores esperados e valores obtidos durante a execução dos testes, com o objetivo de verificar se o comportamento do código está correto).

Como esperado, todos os testes passaram:

terminal output from npm test

Por padrão, o Jest espera que os nomes dos arquivos de teste contenham .test. Neste curso, seguiremos a convenção de nomes em nossos arquivos de teste com a extensão .test.js.

Jest possui excelentes mensagens de erro. Vamos quebrar o teste para demonstrar isso:

test('palindrome of react', () => {
  const result = reverse('react')

  expect(result).toBe('tkaer')
})

Executando o teste acima, resultará na seguinte mensagem de erro:

terminal output shows failure from npm test

Vamos adicionar alguns poucos testes para a função average, em um novo arquivo tests/average.test.js.

const average = require('../utils/for_testing').average

describe('average', () => {
  test('of one value is the value itself', () => {
    expect(average([1])).toBe(1)
  })

  test('of many is calculated right', () => {
    expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5)
  })

  test('of empty array is zero', () => {
    expect(average([])).toBe(0)
  })
})

O teste revela que a função não funciona corretamente com um array vazio (isso se deve ao fato da divisão por zero no JavaScript resultar em NaN):

terminal output showing empty array fails with jest

Ajustar a função é fácil:

const average = array => {
  const reducer = (sum, item) => {
    return sum + item
  }

  return array.length === 0
    ? 0
    : array.reduce(reducer, 0) / array.length
}

Se o length (largura) do array é 0, então retorna 0, em todos os outros casos, usamos o método reduce para calcular a média.

Existem algumas pequenas coisas para observar sobre os testes que escrevemos. Definimos um bloco describe em volta dos testes que recebe o nome average:

describe('average', () => {
  // tests
})

Os blocos de descrição (describe blocks) são utilizados para agrupar testes em uma coleção lógica. A saída do teste no Jest também usa o nome do bloco de descrição:

screenshot of npm test showing describe blocks

Como veremos mais tarde, os blocos describe são necessários quando queremos executar alguma configuração compartilhada ou operações de encerramento (teardown) para um grupo de testes.

Outra coisa a se observar é que escrevemos testes de maneira compacta, sem atribuir a saída da função testada a uma variável:

test('of empty array is zero', () => {
  expect(average([])).toBe(0)
})