Статьи

Как избежать распространенных препятствий в модульном тестировании

Почему разработчики пропускают написание тестов для некоторого кода? Возможно, потому что он опирается на файловую систему или внешний API. Возможно, потому что мы думаем, что это слишком сложно, или тестовый аккаунт для внешнего API не гарантированно будет доступен. Возможно, это связано с тем, что внешний API может быть недоступен из среды CI из-за ограничений VPN. Возможно, издевательство в вашем тестовом наборе стало настолько сложным, что поддержание насмешек не дает достаточной отдачи для затрат времени. Это может произойти, потому что макетирование слишком тесно связано с тестируемым кодом.

Иногда тестирование каждой ветви кажется непреодолимым препятствием. Знание того, как легко тестировать условия ошибок, значительно облегчает увеличение охвата ветвей.

Лучше издеваться

Модульные тесты изолируют тестируемый код от других систем. Это позволяет вам сосредоточиться на своем коде, а не на взаимодействии с другими системами. Один из способов изолировать тестируемое устройство от других систем — использовать макетирование.

Хорошо имитирует системы, а не подражает поведению тестируемого кода. Ложные серверы, к которым можно получить доступ через локальный хост, часто проще всего использовать. В этом сценарии тестируемый код может остаться неизменным, но конфигурация может быть обновлена, чтобы указать клиентам на фиктивный сервер, работающий на localhost.

Иногда макеты легко доступны в виде модулей NPM. Например, s3rverэто фиктивный сервер для сервиса Amazon S3.

const Aws = require('aws-sdk')
const S3rver = require('s3rver')
const Util = require('util')

const createMockS3Server = () => {
  return new Promise((resolve, reject) => {
    const mock = new S3rver({
      port: 4569,
      hostname: 'localhost',
      silent: true,
      directory: '/tmp/s3rver'
    }).run((error) => {
      if (error) {
        reject(error)
        return
      }
      resolve(mock)
    })
  })
}

Aws.config.s3 = {
  s3ForcePathStyle: true,
  endpoint: 'http://localhost:4569'
}

test('starts s3rver', async () => {
  const mockS3Server = await createMockS3Server()
  await s3Client.createBucket({ Bucket: namespace }).promise()
  await testCodeThatUploadsAFileToTheBucket()
  await Util.promisify(mockS3Server.close)()
})

К сожалению, во многих ситуациях тестируемая служба еще не может создать созданную версию. yakbakМодуль может быть полезным инструментом в этой ситуации. Это позволяет записывать ответы HTTP для последующего использования; без необходимости делать дополнительный сетевой запрос.

Это позволяет создавать базовые фиктивные серверы для ваших модульных тестов. Практически во всех проектах можно предположить, что HTTP-модуль Node.js и сетевой интерфейс хорошо протестированы, и дополнительное тестирование их не дает никаких дополнительных преимуществ.

Еще одним преимуществом использования yakbakявляется производительность. Тестовые наборы, которые запускаются за меньшее время, могут выполняться чаще. Они могут даже запускаться, когда нет сетевого интерфейса, что означает, что разработчики могут продолжать работать, даже если внешние API недоступны по любой причине.

const Http = require('http')
const Yakbak = require('yakbak')

const allowMockServerRecordings = !process.env.CI

const createMockNpmApi = () => {
  return Http.createServer(
    Yakbak('https://api.npmjs.org', {
      dirname: Path.join(__dirname, 'npm-api-tapes'),
      noRecord: !allowMockServerRecordings
    })).listen(7357)
}

test('Can extract module info', async () => {
  const mockNpmApi = createMockNpmApi()

  try {
    await doSomethingWithNpm('http://localhost:7357')
  }
  finally {
    await mockNpmApi.close()
  }
})

Любые запросы, которые не кэшируются, отправляются на действующий сервер, и новые записи записываются. Они должны быть проверены на достоверность и зафиксированы в хранилище, если запрос ожидается и действителен. Или вы можете заметить ленту для запроса, который вы не ожидали. Если это так, ленту можно удалить и исправить ошибку. Вы можете отслеживать, какие звонки были сделаны, потому что новая лента добавляется для каждого нового запроса.

CIПеременная среды используется для определения , является ли код , работающий в среде непрерывной интеграции (CI) , таких как CircleCI. В этой среде переменная установлена, и новые ленты не создаются. Вместо этого выдается ошибка. Это предотвращает ложные срабатывания во время тестирования CI для неожиданных запросов. Любые ленты, не ожидаемые в CI, обычно указывают на ошибку или сценарий, не рассматриваемый во время написания теста.

Создание фиктивного сервера с помощью yakbakможет быть основой для создания более абстрактного фиктивного сервера позже. Примером этого является mock-okta. Этот инструмент состоит из нескольких yakbakлент, но с одной конечной точкой, имитирующей пользовательский код.

Mocking Modules

Другой подход, который используется, когда готовый макет для зависимости недоступен, или когда зависимость не использует HTTP в качестве средства связи, или когда зависимость, которая должна быть смоделирована, вообще не является сервером, заключается в использовании proxyquire. , Это модуль, который используется в тестах для проверки зависимостей на  require уровне. Когда тестируемый код пытается получить require внешнюю зависимость, он получает вместо этого проверенную  версию:

const Faker = require('faker')
const NoGres = require('@nearform/no-gres')
const Proxyquire = require('proxyquire').noPreserveCache().noCallThru()

test('counting users works', async () => {
  const mockPgClient = new NoGres.Client()
  mockPgClient.expect('SELECT * FROM users', [],
    [{ email: Faker.internet.exampleEmail(), name: Faker.name.firstName() }])

  const User = Proxyquire('../user', {
    pg: {
      // `Client` can't be an arrow function because it is called with `new`
      Client: function () {
        return mockPgClient
      }
    }
  })

  expect(await User.count()).to.equal(1)
  mockPgClient.done()
})

Чтобы изолировать состояние от тестовой функции, noPreserveCache()вызывается так, что каждый раз, когда proxyquireиспользуется модуль, загружается заново. Обратите внимание, что любые побочные эффекты загрузки модуля возникают в каждом тесте при использовании noPreserveCache().

В этом примере noCallThru()также используется. Это важно, когда неожиданный звонок может быть разрушительным. Например, если оператор SQL DROP TABLEбыл случайно выполнен для вашей локальной базы данных Postgres. Использование noCallThru()вызывает ошибку, если вызваны какие-либо функции, которые не были проверены.

Фейкерный модуль также используется в этом примере. Он имеет большой набор функций, которые можно использовать для создания фиктивных данных, чтобы тесты были более надежными. Например, если в вашем тесте всегда используется фальшивое электронное письмо, например alice@example.com, и ошибка возникает только в том случае, если электронное письмо содержит подчеркивание, эта проблема может быть не обнаружена до следующего выпуска. Используя фейкер, можно протестировать больше форм данных, увеличивая вероятность обнаружения ошибки до выпуска следующей версии программного обеспечения.

Файловая система

Код, который тестирует файловую систему, на первый взгляд может показаться трудным для тестирования, но есть легкодоступный модуль NPM, mock-fsкоторый облегчает его.

const Fs = require('fs')
const MockFs = require('mock-fs')

test('It loads the file', async () => {
  try {
    MockFs({
      'path/to/fake/dir/some-file.txt': 'file content here'
    });

    const content = Fs.readSync('path/to/fake/dir/some-file.txt')
    expect(content).to.equal('file content here')
  }
  finally {
    MockFs.restore();
  }
})

Даты и таймеры

Есть также модуль, который помогает тестировать код, который использует таймеры и другие функции JavaScript, связанные с датами. Это называется лолекс.

const Lolex = require('lolex');
const Sprocket = require('../sprocket')

// Sprocket.longRunningFunction may look something like:
// const longRunningFunction = async () => {
//  await new Promise((resolve) => setTimeout(resolve, 900000))
//  return true
// }

test('a long running function executes', async () => {
  const clock = Lolex.install();

  try {
    // Date.now() returns 0
    const p = Sprocket.longRunningFunction()
    clock.tick(900000)
    // Date.now() returns 900000
    const status = await p
    expect(status).to.equal(true)
  }
  finally {
    clock.uninstall()
  }
})

В этом примере код longRunningFunctionдолжен подождать 15 минут, прежде чем вернуть свое значение. Очевидно, что ожидание 15 минут каждый раз, когда longRunningFunctionвызывается из теста, далеко от идеала! Посмеиваясь над глобальной setTimeoutфункцией, мы можем вообще избежать ожидания и вместо этого продвинуть часы на 15 минут, чтобы обеспечить возвращение ожидаемого значения.

Покрытие кода

Покрытие кода не показывает, что код выполняется так, как ожидается, как модульные тесты. Покрытие кода показывает, что тестируемый код выполняется, не имеет времени ожидания, не вызывает непредвиденных ошибок или, в случае ошибки, что он перехватывается и обрабатывается, как ожидается.

Хотя существуют разные мнения относительно того, какой уровень покрытия кода желателен и есть ли убывающая отдача при переходе, скажем, с 90% на 100% покрытия кода, многие разработчики согласны с тем, что тесты покрытия кода полезны.

Просто загрузка вашего кода дает вам 60-70% покрытия кода, поэтому мы на самом деле говорим только о покрытии за последние 30-40%. Использование интеллектуального фаззинга может повысить эту начальную точку чуть больше до 100%, при этом с вашей стороны не потребуются дополнительные усилия.

Smart Fuzzing

Согласно MongoDB, они обнаруживают гораздо больше ошибок, прежде чем достигают производства с помощью интеллектуального фаззинга, чем они делают с модульными тестами (см. Https://engineering.mongodb.com/post/mongodbs-javascript-fuzzer-creating-chaos). В то время как MongoDB использует продвинутую технику интеллектуального фаззинга, которая изменяет код модульного теста, нам не нужно заходить так далеко, чтобы извлечь выгоду из интеллектуального фаззинга. Используя встроенную функцию проверки Hapi для определения того, как выглядят запросы и / или ответы, мы не только получаем выгоду от проверки, но и можем использовать эту информацию для запуска интеллектуальных фаззин-тестов.

const createServer = async () => {
  const server = Hapi.server({ port: 7357 })

  server.route({
    method: 'GET',
    path: '/{name}/{size}',
    handler: (request) => request.params,
    options: {
      response: {
        sample: 10,
        schema: Joi.object().keys({
          name: Joi.string().min(1).max(10),
          size: Joi.number()
        })
      },
      validate: {
        params: {
          name: Joi.string().min(1).max(10),
          size: Joi.number()
        }
      }
    }
  })

  await server.start()
  console.log('Server running at: ${server.info.uri}')
  return server
}

test('endpoints do not time out and any errors are caught', async () => {
    const testServer = await createServer()
    try {
      await SmartFuzz(testServer)
    }
    finally {
      await testServer.stop()
    }
  })
})

В то же количество времени я потратил , пытаясь настроить  swagger-test или  got-swag для использования в файлах «тест .js» с использованием запросов с проверкой подлинности, я уже написал первую итерацию SmartFuzz. Важно иметь инструменты, когда это возможно, которые можно использовать из тестового кода Node.js, поскольку это значительно упрощает создание тестовой среды. Хотя они, вероятно, являются приличными инструментами командной строки, менее удобно, что их нелегко перенести в файлы «test .js».

Тестирование обработки ошибок

Также связанные с покрытием кода, тестирование веток часто является «последней милей» тестирования покрытия. Часто эти ветки являются случаями обработки ошибок, что увеличивает сложность. Тестирование кода в условиях ошибки приводит к тестированию большего количества веток.

Прежде чем мы проверим условия ошибки, короткое примечание. Иногда люди путаются, если во время тестирования выводятся сообщения об ошибках. Тем не менее, важно проверить условия ошибок, даже если они вызывают вывод. Одно из решений состоит в том, чтобы заглушить  console.log и  console.error скрыть сообщения, но я предпочитаю видеть вывод, чтобы помочь отладке любых найденных ошибок. Итак, давайте добавим полезное сообщение для людей, которые могут быть встревожены:

console.warn('⚠️ Note: error message output is expected as part of normal test operation.')

Большинство библиотек утверждений содержат код для проверки ожидаемых отклонений обещаний. По какой-то причине мне не удалось сделать эти работы. На практике я использовал эту функцию:

const expectRejection = async (p, message) => {
  try {
    await p
  } catch (error) {
    if (error.message === message) return
    throw new Error('Expected error "${message}" but found "${error.message}"')
  }

  throw new Error('Expected error "${message}" but no error occurred.')
}

Использование этой функции облегчает тестирование ошибок. Когда это легко, нет оправдания тому, чтобы не тестировать еще много ветвей в коде, значительно увеличить охват кода и одновременно тестировать код в то время, когда он наиболее уязвим, когда произошла ошибка.

test('invalid input causes an error', async () => {
  await expectRejection(doSomeTest({ input: 'invalid' }), 'Validation failed.')
})

Заключение

Готовы увеличить окупаемость теста? Применяйте эти советы на практике сегодня. Не позволяйте внешним системам, сложным проверкам, тестированию веток или низкому охвату кода мешать!