Как я использовал тестирование мутаций, чтобы улучшить свой код
И сделай мои тесты более честными
Некоторое время назад я написал небольшую утилиту под названием 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]) }) }) })
Затем я применил те же виды декомпозиции к другим частям кода, которые раньше было очень трудно тестировать.
Заключение
Добавив тестирование мутаций, я был вынужден вернуться и сделать свой код более поддающимся тестированию, и, как следствие, я сделал его более модульным и более легким для размышлений. В результате получается, без сомнения, лучший код, хотя на самом деле он работает не лучше, чем код тестирования до мутации.
Преимущества таковы, что за последние пару выходных я просмотрел все поддерживаемые мной базы с открытым исходным кодом, добавил тестирование на мутации для всех из них и исправил все проблемы, выявленные при тестировании на мутации.
Ссылки
- ‘Делегирование работы с использованием NodeJS и AMQP’ - https://itnext.io/delegating-work-using-nodejs-and-amqp-4d3cc1f62824
amqp-delegate
- https://github.com/davesag/amqp-delegateamqp-delegate
до того, как я добавил тестирование мутаций - https://github.com/davesag/amqp-delegate/tree/1.0.3Istanbul
- https://istanbul.js.orgStryker Mutator
- https://stryker-mutator.ioRabbitMQ
- https://www.rabbitmq.comMocha
- https://mochajs.orgSinon
- https://sinonjs.orgProxyquire
- https://github.com/thlorenz/proxyquire
—
Нравится, но не подписчик? Вы можете поддержать автора, присоединившись через davesag.medium.com.