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

И сделай мои тесты более честными

Некоторое время назад я написал небольшую утилиту под названием amqp-delegate, которая использует стандартную amqplib библиотеку для упрощения создания и вызова удаленных рабочих через aqmp шину сообщений, такую ​​как Rabbit MQ.

Я написал об этом статью под названием Делегирование работы с использованием NodeJS и AMQP.

Я был на пляже, когда писал это, и мне было очень лениво. Чтобы получить красивый зеленый значок 100% покрытия на моем репо, я обманул и использовал /* istanbul ignore next */, чтобы полностью игнорировать invoke функцию моего рабочего делегата.

В своих тестах я добавил небольшое TODO примечание:

// TODO: work out how to test the channel.consume callback

а затем Я skipped тест.

Я решил, что тестировал этот код с помощью своих интеграционных тестов, поэтому одержимость защитой кода unit была пустой тратой времени. Мой код работал, следовательно, это был хороший код.

Затем я прочитал о библиотеке тестирования мутаций под названием Stryker Mutator и решил, что добавлю ее в свою библиотеку, просто чтобы посмотреть, что она может для меня сделать.

Что такое мутационное тестирование.

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

Мутационное тестирование хитроумно ломает ваш код, изменяя false на true, изменяя значения strings и numbers, меняя plus на minus и тому подобное, а затем снова и снова запуская ваши тесты для каждой мутации вашего кода. Если тесты по-прежнему проходят, несмотря на изменения, внесенные в ваш код, ваши тесты считаются неработающими.

В идеальном мире ни один из ваших тестов не выдержит мутации вашего кода.

Пробовать это

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

Чтобы дать более подробный пример, вот код из функции makeDelegator, о которой я упоминал выше.

Вот как выглядела функция invoke. Вы можете понять, почему это сложно провести модульное тестирование.

const invoke = async (name, ...params) => {
  if (!channel) throw new Error(QUEUE_NOT_STARTED)
  const queue = await channel.assertQueue('', { exclusive: true })
  const buffer = Buffer.from(JSON.stringify(params))
  const correlationId = v4()
  const replyTo = queue.queue
  return new Promise((resolve, reject) => {
    channel.consume(
      replyTo,
      message => {
        if (message.properties.correlationId === correlationId) {
          try {
            const result = JSON.parse(message.content.toString())
            return resolve(result)
          } catch (err) {
            return reject(err)
          }
        } else return reject(WRONG_CORRELATION_ID)
      },
      { noAck: true }
    )
    channel.sendToQueue(name, buffer, { correlationId, replyTo })
  })
}

Функция работает, регистрируя получателя сообщений, а затем отправляя данные в очередь сообщений. Потребитель сообщения ждет, пока он не получит ответ с правильным correlationId, и только после этого отправляет resolve или reject promise.

Вызов resolve или reject зарыт глубоко в обработчике ответного сообщения, что очень затрудняет модульное тестирование. Однако, будучи в некотором роде комплитистом, я просто должен был попробовать.

Рефакторинг

Первым шагом было извлечение обработчика ответного сообщения и его изолированное тестирование.

Функция-обработчик нуждается в доступе к функциям resolve и reject всеобъемлющего обещания, а также к correlationId для сравнения с собственными correlationId message. Я создал следующую служебную функцию curried:

src/utils/messageCorrelator.js

const { WRONG_CORRELATION_ID } = require('../errors')
const messageCorrelator = (correlationId, resolve, reject) =>
  message => {
    if (message.properties.correlationId === correlationId) {
      try {
        const result = JSON.parse(message.content.toString())
        return resolve(result)
      } catch (err) {
        return reject(err)
      }
    }
    return reject(WRONG_CORRELATION_ID)
  }
module.exports = messageCorrelator

Это легко проверить. Просто передайте заглушки для функций resolve и reject и настройте сценарии, когда correlationIds совпадают или не совпадают. Кроме того, для полноты, добавьте тест, когда ответное сообщение content не может быть проанализировано как JSON.

const { expect } = require('chai')
const { stub, resetHistory } = require('sinon')
const messageCorrelator =
  require('../../../src/utils/messageCorrelator')
const { WRONG_CORRELATION_ID } = require('../../../src/errors')
describe('utils/messageCorrelator', () => {
  const RESOLVED = 'resolved'
  const REJECTED = 'rejected'
  const resolve = stub().returns(RESOLVED)
  const reject = stub().returns(REJECTED)
  const correlationId = '123456'
  const content = { test: 'data', is: 'good data' }
  const correlate =
    messageCorrelator(correlationId, resolve, reject)
  let result
  context('given matching correlationId', () => {
    context('given unparsable message content', () => {
      const message = {
        properties: {
          correlationId
        },
        content: 'junk'
      }
      before(() => {
        result = correlate(message)
      })
      after(resetHistory)
      it('invoked reject with a SyntaxError', () => {
        expect(reject).to.have.been.called
        const err = reject.firstCall.args[0]
        expect(err).to.be.instanceof(SyntaxError)
      })
      it('returned the evaluated rejection', () => {
        expect(result).to.equal(REJECTED)
      })
    })
    context('given parsable message content', () => {
      const message = {
        properties: {
          correlationId
        },
        content: JSON.stringify(content)
      }
      before(() => {
        result = correlate(message)
      })
      after(resetHistory)
      it('invoked resolve with parsed content', () => {
        expect(resolve).to.have.been.calledWith(content)
      })
      it('returned the evaluated rejection', () => {
        expect(result).to.equal(RESOLVED)
      })
    })
  })
  context('given non-matching correlationId', () => {
    const message = {
      properties: {
        correlationId: 'some-other-id'
      },
      content: JSON.stringify(content)
    }
    before(() => {
      result = correlate(message)
    })
    after(resetHistory)
    it('invoked reject with WRONG_CORRELATION_ID', () => {
      expect(reject).to.have.been.calledWith(WRONG_CORRELATION_ID)
    })
    it('returned the evaluated rejection', () => {
      expect(result).to.equal(REJECTED)
    })
  })
})

Теперь у меня была эта утилита, я мог вытащить логику вызова из функции invoke, создав простую каррированную invoker функцию следующим образом:

const messageCorrelator = require('./messageCorrelator')
const invoker = (correlationId, channel, replyTo) =>
  async (name, params) =>
    new Promise((resolve, reject) => {
      channel.consume(
        replyTo,
        messageCorrelator(correlationId, resolve, reject),
        { noAck: true }
      )
      channel.sendToQueue(
        name,
        Buffer.from(JSON.stringify(params)),
        {
          correlationId,
          replyTo
        }
      )
    })
module.exports = invoker

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

В ряде тестов используйте тестовую утилиту для создания подделки channel, которую я могу передать вместо настоящего amqp channel.

const fakeChannel = () => ({
  assertExchange: stub(),
  publish: stub(),
  close: stub(),
  assertQueue: stub(),
  purgeQueue: stub(),
  bindQueue: stub(),
  prefetch: stub(),
  consume: stub(),
  ack: stub(),
  nack: stub(),
  sendToQueue: stub()
})

Я использую proxyquire, чтобы заглушить messageCorrelator, поскольку я уже тестировал это отдельно, поэтому тест выглядит так:

const { expect } = require('chai')
const { stub, match } = require('sinon')
const proxyquire = require('proxyquire')
const { fakeChannel } = require('../fakes')
describe('utils/invoker', () => {
  const channel = fakeChannel()
  const messageCorrelator = stub()
  const correlationId = '12345'
  const name = 'some name'
  const param = 'some param'
  const replyTo = 'some replyTo address'
  const invoker = proxyquire('../../../src/utils/invoker', {
    './messageCorrelator': messageCorrelator
  })
  const invocation = invoker(correlationId, channel, replyTo)
  const message = 'a message'
  before(() => {
    messageCorrelator.returns(message)
    invocation(name, [param])
  })
  it('called the messageCorrelator with the right values', () => {
    expect(messageCorrelator).to.have.been.calledWith(
      correlationId,
      match.func,
      match.func
    )
  })
  it('called channel.consume with the right values', () => {
    expect(channel.consume).to.have.been.calledWith(
      replyTo,
      message,
      { noAck: true }
    )
  })
  it('called channel.sendToQueue with the right values', () => {
    expect(channel.sendToQueue).to.have.been.calledWith(
      name,
      Buffer.from(JSON.stringify([param])),
      { correlationId, replyTo }
    )
  })
})

Затем я мог бы реорганизовать исходную функцию invoke, чтобы она была намного проще:

const invoke = async (name, ...params) => {
  if (!channel) throw new Error(QUEUE_NOT_STARTED)
  const queue = await channel.assertQueue('', { exclusive: true })
  const correlationId = v4()
  const replyTo = queue.queue
  const invocation = invoker(correlationId, channel, replyTo)
  return invocation(name, params)
}

Это намного проще проверить.

describe('invoke', () => {
  const invocation = stub()
  context('before the delegator was started', () => {
    before(() => {
      delegator = makeDelegator({ exchange })
    })
    after(resetHistory)
    it('throws QUEUE_NOT_STARTED', () =>
     expect(delegator.invoke())
       .to.be.rejectedWith(QUEUE_NOT_STARTED))
  })
  context('after the delegator was started', () => {
    const name = 'some name'
    const param = 'some param'
    before(async () => {
      queue = fakeQueue()
      delegator = makeDelegator({ exchange })
      channel = fakeChannel()
      connection = fakeConnection()
      channel.assertQueue.resolves(queue)
      connection.createChannel.resolves(channel)
      amqplib.connect.resolves(connection)
      await delegator.start()
      invocation.resolves()
      invoker.returns(invocation)
      await delegator.invoke(name, param)
    })
    after(resetHistory)
    it('called channel.assertQueue with the right params', () => {
      expect(channel.assertQueue).to.have.been.calledWith('', {
        exclusive: true
      })
    })
    it('called the invoker with the right params', () => {
      expect(invoker).to.have.been.calledWith(
        correlationId,
        channel,
        queue.queue
      )
    })
    it('called invocation with the right params', () => {
      expect(invocation).to.have.been.calledWith(name, [param])
    })
  })
})

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

Заключение

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

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

Ссылки

Нравится, но не подписчик? Вы можете поддержать автора, присоединившись через davesag.medium.com.