Простой JavaScript: разбираемся с mocking, stubbing и интерфейсами

11 октября 2019

Простой JavaScript позволяет писать понятный и гибкий код. Рассмотрим основные концепции программирования и способы их применения в JavaScript.

Mocking, stubbing и mutating

Для тестирования кода можно использовать самописные библиотеки: они медленные и с их помощью сложно смоделировать реально работающую ФС. А можно воспользоваться библиотеками Proxyquire или Sinon. Proxyquire позволяет переопределить импорт файла, а Sinon может мутировать методы. Для упрощения тестирования удобно использовать оба инструмента, но между ними есть различие.

Предположим, что у вас есть модуль «a», импортирующий модуль «b». Proxyquire импортирует модуль «а» и переопределит модуль «b». Это не повлияет на импорт модуля «b» в других местах программы. Sinon повлияет на экспорт модуля «b» по всему коду – вы должны это помнить.

import fs from 'fs'
import { promisify } from 'util'const readFileAsync = promisify(fs.readFile)export function readJsonFile (filePath) {
return readFileAsync(filePath).then(JSON.parse)
}import fs from 'fs'
import test from 'ava';
import { stub } from 'sinon'
import proxyquire from 'proxyquire'test('readJsonFile with proxyquire', async function (t) {
t.plan(2)
const { readJsonFile } = proxyquire('./foo.js', {
fs: {
readFile(filePath, callback) {
t.is(filePath, 'myTestFile')
return callback(null, '{ success: true }')
}
}
})
const results = await readJsonFile('myTestFile')
t.deepEqual(results, { success: true })
})test('readJsonFile with sinon', async function (t) {
t.plan(1)
const fsStub = stub(fs, 'readFile')
.withArgs('myTestFile')
.callsArg(2, null, '{ success: true }')
const results = await readJsonFile('myTestFile')
t.deepEqual(results, { success: true })
fsStub.restore()
})
import fs from 'fs'
import { promisify } from 'util'
const readFileAsync = promisify(fs.readFile)
export function readJsonFile (filePath) {
return readFileAsync(filePath).then(JSON.parse)
}
import fs from 'fs'
import test from 'ava';
import { stub } from 'sinon'
import proxyquire from 'proxyquire'
test('readJsonFile with proxyquire', async function (t) {
t.plan(2)
const { readJsonFile } = proxyquire('./foo.js', {
fs: {
readFile(filePath, callback) {
t.is(filePath, 'myTestFile')
return callback(null, '{ success: true }')
}
}
})
const results = await readJsonFile('myTestFile')
t.deepEqual(results, { success: true })
})
test('readJsonFile with sinon', async function (t) {
t.plan(1)
const fsStub = stub(fs, 'readFile')
.withArgs('myTestFile')
.callsArg(2, null, '{ success: true }')
const results = await readJsonFile('myTestFile')
t.deepEqual(results, { success: true })
fsStub.restore()
})

Почему stubs – это плохо?

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

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

Есть неприятный момент – блокировка. Как Proxyquire, так и Sinon попросят вас обновить свои тесты, если вы измените библиотеку fs на fs-extra-promise. Вы по-прежнему будете использовать функцию readFileAsync, а Sinon и Proxyquire будут пытаться переопределить fs.readFile.

Простой JavaScript: альтернативы?

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

export default function ({ readFileAsync }) {
return {
readJsonFile (filePath) {
return readFileAsync(filePath).then(JSON.parse)
}
}
}
import test from 'ava'
import foo from './foo'
test('foo with dependency inversion', function (t) {
t.plan(2)

const dependencies = {
readFileAsync(filePath) {
t.is(filePath, 'bar')

return Promise.resolve('{ success: true '})
}
}

const result = await foo(dependencies).readJsonFile('bar')
t.deepEqual(result, { success: true })
})

export default function ({ readFileAsync }) {
return {
readJsonFile (filePath) {
return readFileAsync(filePath).then(JSON.parse)
}
}
}

import test from 'ava'

import foo from './foo'

test('foo with dependency inversion', function (t) {
t.plan(2)

const dependencies = {
readFileAsync(filePath) {
t.is(filePath, 'bar')

return Promise.resolve('{ success: true '})
}
}

const result = await foo(dependencies).readJsonFile('bar')
t.deepEqual(result, { success: true })
})
Код получился компактным и без применения мутаций. Теперь модуль принимает readFileAsync, а не создает эту функцию самостоятельно. Такой подход лучше тем, что он не перегружен лишним функционалом.

Куда ведет зависимость?

Импортируемые зависимости следует размещать в коде как можно ниже. Желательно импортировать их однократно в точке входа.

export default function ({ readFileAsync, writeFileAsync }) {
return {
readJsonFile(fileName) {
return readFileAsync(`${fileName}.json`).then(JSON.parse)
},
writeJsonFile(filePath, fileContent) {
return writeFileAsync(filePath, JSON.stringify(fileContent))
}
}
}
export default function ({ readJsonFile, writeJsonFile }) {
return {
getContent(contentName) {
// business logic goes here.
return readJsonFile(contentName)
},
writeContent(contentName, contentText) {
// business logic goes here
return writeJsonFile(contentName, contentText)
}
}
}
import fs from 'fs-extra-promise'
import jsonInterface from './json'
import contentInterface from './content'
const json = jsonInterface(fs)
const content = contentInterface(json)
export default content

export default function ({ readFileAsync, writeFileAsync }) {
return {
readJsonFile(fileName) {
return readFileAsync(`${fileName}.json`).then(JSON.parse)
},
writeJsonFile(filePath, fileContent) {
return writeFileAsync(filePath, JSON.stringify(fileContent))
}
}
}

export default function ({ readJsonFile, writeJsonFile }) {
return {
getContent(contentName) {
// business logic goes here.
return readJsonFile(contentName)
},
writeContent(contentName, contentText) {
// business logic goes here
return writeJsonFile(contentName, contentText)
}
}
}

import fs from 'fs-extra-promise'
import jsonInterface from './json'
import contentInterface from './content'

const json = jsonInterface(fs)
const content = contentInterface(json)

export default content
В примере показаны зависимости, перемещенные в точку входа приложения. Все, кроме index.js “осело” в интерфейсе. Это позволяет приложению быть гибким, легко изменяемым и тестируемым.

На что еще способна инверсия зависимостей?

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

Примером общего интерфейса является компонент React. В TypeScript он может выглядеть так:

interface ComponentLifecycle {
constructor(props: Object);
componentDidMount?(): void;
shouldComponentUpdate?(nextProps: Object, nextState: Object, nextContext: any): boolean;
componentWillUnmount?(): void;
componentDidCatch?(error: Error, errorInfo: ErrorInfo): void;
setState(
state: ((prevState: Object, props: Object) => Object,
callback?: () => void
): void;
render(): Object | null;
state: Object;
}



interface ComponentLifecycle {
constructor(props: Object);
componentDidMount?(): void;
shouldComponentUpdate?(nextProps: Object, nextState: Object, nextContext: any): boolean;
componentWillUnmount?(): void;
componentDidCatch?(error: Error, errorInfo: ErrorInfo): void;
setState(
state: ((prevState: Object, props: Object) => Object,
callback?: () => void
): void;
render(): Object | null;
state: Object;
}

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

Переходим к принципу «открытости / закрытости». В нем говорится, что наше программное обеспечение должно быть открыто для расширения, но закрыто для модификации. Этот процесс, возможно, вам знаком, если вы создавали программное обеспечение на Angular или React. Они обеспечивают общий интерфейс, который можно расширить для создания программного обеспечения.

Вместо того, чтобы использовать сторонние интерфейсы, вы можете начать писать свои для создания собственного ПО.

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

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

Инверсия зависимостей и принцип «открытости/закрытости» позволяют писать повторно используемый, хорошо тестируемый и «предсказуемый» софт. Простой JavaScript не будет включать беспорядочный код, модули станут функционировать единой группой, разработанной и функционирующей по одному шаблону.

Множественная реализация

Еще одно преимущество в применении интерфейса – реализация различными способами.

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

Похожим образом работает React и React-Native. Оба фреймворка используют один и тот же компонент и интерфейсы, но реализуют их по-разному. Внутри React Native есть функционал для работы как с IOS, так и с Android. Несколько реализаций позволяют писать логику один раз и выполнять ее различными способами.

Вместо точки

Теперь, когда вы узнали, как простой JavaScript позволяет работать с инверсией зависимостей и принципом «открытости / закрытости», можно применить его в коде:

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