Как протестировать SkyNet? (если он написан на JS)
Давным давно я посмотрел Терминатор1. Немного подрос и посмотрел немного отрефакторенный Терминатор 2. Третьей интерации пришлось ждать немного дольше.
И каждый раз бедному СкайНету не везло, и каждый раз все наперекосяк, и секрет этого невезения очень прост — СкайНет не был Subject Under Test (SUT).
Другими словами SkyNet подзабила на тестирование, и производила разведку боем. И, как это обычно бывает, падала в корку (под пресс, в лаву, далее не помню) примерно сразу после выкладки в продакшен.
И весь вопрос — как же протестировать что-то до этой выкладки, до того момента как станет поздно поворачивать назад. И убить всех человеков, конечно же.
TDD
Для начала воспользуемся техникой TDD (Test Driven Developement), чтобы определить что же надо сделать, и зачем. Те кто смотрел фильмы знают — все очень просто:
- Когда наступит Судный День — запустить ракеты и убить всех человеков.
- Когда родится Джон Коннорр — замочить и снять про это фильм.
Есть только одна проблема — оба эти события ну как бы одинарные — ракеты надо запустить с первого раза, и Джона тоже бы с первого раза грохнуть надобно, а то подрастет и сопротивление возглавит. В общем, если у вас нет машины времени, то написать тесты, и сделать их повторяемыми будет ой как не просто.
И что теперь делать?
Итак — у СкайНет есть только один шанс запустить ракеты, только один шанс отправить терминатора назад в прошлое, где он должен будет сделать свое дело с первой попытки. Ну — для этого тесты и существуют.
И единственный способ решить проблему — как-то заменить настоящие ракеты ненастоящими, так чтобы можно было их запускать-перезапускать.
По научному это называется — «замокать» (mock)
Вопрос только — как!
Но для ответа на этот вопрос — в начале надо посмотреть на коды самого СкайНета.
// dooms-day.js
import {theDay} from './doom-scheduler'
import {Launch} from './rocket-silo';
theDay
.then(Launch)
.then( () => alert(' :) '),
() => alert(' :( next time, you know...');
// --------------------
// rocket-silo.js
import SkyNetCore from './core';
export const Launch = () => SkyNetCore.hackRockets().then(rocket => rocket.launch)
По коду выходит — для того чтобы протестировать SkyNet требуется заменить зависимости центрального файла «dooms-day.js» на контролируемые сущьности.
Так как же замокать?
Я рад что вы спросили. И одного простого ответа у меня нет, но есть 5 еще более простых. Еще куча различных библиотек и парочка патернов с антипатернами о том как мокать хорошо и как мокать плохо.
Но лучше начать с быстрого обзора популярных библиотек, которые предоставляют разные интерфейсы для моканья зависимостей.
Вариант 1 — в тестах использовать «тестовые» варианты модулей
Звучит просто, и просто на самом деле. Надо просто создать «замены», и «заменить».
//__mocks__ /doom-scheduler.js -> заменяет doom-scheduler.js
export const theDay = Promise.resolve();
//__mocks__/rocket-silo.js -> заменяет rocket-silo.js
export const Launch = jest.fn(); //this is "replacement" code
// А это тест
import {Launch} from './rocket-silo'; // только silo уже не настоящий
import {theDay} from './doom-scheduler'; // и тут магия свершилась
import './dooms-day.js'; // самый настоящий dooms-day
expect(Launch).toHaveBeenCalled();
В общем »__mock__» очень простой и удобный механизм, у которого есть только один минус — Jest. Эта фича работает только в Jest, и если Скайнет использует mocha, ava или karma для запуска тестов — прийдется искать другие интерфейсы.
Вариант 2 — в тестах просто заменить настоящие модули на «тестовые»
Если уж магия __mock__ нам не доступна, то что на счет просто mock?
import {Launch} from './rocket-silo'; // вроде как настоящий
import {theDay} from './doom-scheduler'; // вроде как настоящий
import './dooms-day.js';
jest.mock('./rocket-silo'); // а тут мы мокаем что-то уже имопртированное
jest.mock('./doom-scheduler');
theDay.mockResolvedValue("comming!"); // и указываем на что заменять
expect(Launch).toHaveBeenCalled();
Опять же очень простой способ заменить зависимости для тестового файла, и опять же — только Jest.
На самом деле на этом возможности Jest заканчиваются, и не смотря на то что эти два подхода достаточно примитивны (и просты) — это примерно то что и нужно. Возможно все остальное — нетребуемые переусложения.
Вариант 3 — хочу Шварцнейгера заменить на Сталоне
Что если можно загрузить какой либо файл, но с «измененными» частями?
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const Launch= sinon.stub()
const case = proxyquire.load('./dooms-day.js',{
'./rocket-silo', { Launch },
'./doom-scheduler', { theDay: Promise.resolve()
});
expect(Launch).toHaveBeenCalled();
Примерно так работает proxyquire — одна из самых древних популярных библиотек про это дело. Позволяет загрузить любой файл с перекрытием зависимостей непосредственно запрашиваемых из этого файла.
Главное преимущество перед jest.mock — в одном тесте можно иметь 100500 различных proxyquired поразному перекрытых файлов. Плюс различные команды типа .callThough, которая позволяет только частично перекрывать зависимость (что вообще антипатерн).
Вариант 4 — тоже самое, только более декларативно
Proxyquire иногда может быть немного надоедлив, плюс изредка хочется чего-то типа __mock__, те наличия некоторых «стандартных» моков. В общем mockery!
import mockery from 'mockery';
import sinon from 'sinon';
mockery.registerMock('./rocket-silo', {
Launch: sinon.stub()
});
mockery.registerMock('doom-scheduler', {
theDay: Promise.resolve()
});
mockery.enable();
const {Launch} = require('./rocket-silo'); // import использовать нельзя
mockery.disable();
expect(Launch).toHaveBeenCalled();
Mockery так же обладает мощьным API, но немного в другую сторону чем proxyquire. Например там есть режим «изоляции», когда mockery начинает ругаться при подключении «неразрешенных» файлов, что может спасти от случайного изменения зависимости и логики.
Вариант 5 — Последний из магикан
TestDouble.js — библиотека от одноименной компании, которая очень много сил уделяет именно «правильности» того или иного подхода. Технически — это jest.mock, вид сбоку.
import td from 'testdouble';
const {Launch} = td.replace('./rocket-silo'); // automock
const scheduler = td.replace('./doom-scheduler', { theDay: Promise.resolve() })
require('./dooms-day.js');
td.verify(Launch());
При этом возможность замены произвольных модулей появилось после очень очень долгих споров.
Вообще TD — кладезь знаний и лучших практик. Советую почитать раздел про моки.
Но есть проблемы
- Jest моки туповаты и работают только если у вас Jest.
- Proxyquire работает на соплях. Библиотека с нулевым фидбэком. Плюс очень многие решения, которые были логичны на момент создания — сейчас вызывают нервный тик (noPreserveCache)
- Mockery не имеет всех возможностей Proxyquire, плюс режим изоляции, одна из фич библиотеки, сломана так чтобы совсем
- Вы никогда не знаете что же мокаете в Proxyquire или Mockery, так как они агрятся на точное соовествие имени файла, а у нас бабель.
- TD обычно используется теми кто использует TD, что редкое явление, потому что у всех sinon.
Стоит добавить что все эти библиотеки еще плохо обращаются с кешом, что иногда приводит к серьезному замедлению работы тестов.
Для большого проекта — где-то секунда для исполнения require, так как до и после весь кеш nodejs (и бабеля) стирается
Так как протестировать SkyNet?
Не стоит забывать — вся проблема в том, что SkyNet хотелось совершенства. А все разобранные инструменты — не совершенны. Если мы начнем писать тесты с их использованием — SkyNet с начала пошлет терминаторов за нами.
Чтобы не попасть на счетчик хотелось моки как в jest, только под ava. Proxyquire синтаксис часто может удобен, как и автоматически моки jest.mock/td.replace. Мне много чего хотелось, так что я отдолжил с работы машину времени и еще год назад выложил библиотеку которая все вышеперечисленное умеет.
Заменяем jest.mock
import rewiremock from 'rewiremock';
import {Launch} from './rocket-silo';
import {theDay} from './doom-scheduler'
import './dooms-day.js';
// prev jest.mock('doom-scheduler');
rewiremock('./rocket-silo').mockThrough();
rewiremock('doom-scheduler').mockThrough();
theDay.resolves("comming!"); // sinon
expect(Launch).toHaveBeenCalled();
Заменяем mockery
import rewiremock from 'rewiremock';
import sinon from 'sinon';
// mockery.registerMock('./rocket-silo', {
// Launch: sinon.stub()
// });
rewiremock('./rocket-silo').with({
Launch: sinon.stub()
});
rewiremock('doom-scheduler').with({
theDay: Promise.resolve()
});
rewiremock.enable();
require('./dooms-day.js');
rewiremock.disable();
expect(Launch).toHaveBeenCalled();
Заменяет proxyquire
import rewiremock from 'rewiremock';
import sinon from 'sinon';
const Launch = sinon.stub()
// const case = proxyquire.load('./dooms-day.js',{
const case = rewiremock.proxy('./dooms-day.js',{
'./rocket-silo': { Launch },
'doom-scheduler': { theDay: Promise.resolve()}
});
expect(Launch).toHaveBeenCalled();
Заменяет TD
import rewiremock from 'rewiremock';
const {Launch} = rewiremock('./rocket-silo').mockThrough(); // automock
const scheduler = rewiremock('doom-scheduler').with({ theDay: Promise.resolve() })
rewiremock.proxy('./dooms-day.js'); // ну почти
expect(Launch).toHaveBeenCalled();
При этом это работает везде — mocha, ava, karma под node.js или webpack. При этом совершенно по другому работает с кешом, не удаляя ничего тестами не затронутое (иногда в 100 раз быстрее). При этом всегда есть API, например чтобы быть увереным что моки используются правильно:
import rewiremock from 'rewiremock';
import sinon from 'sinon';
rewiremock('./rocket-silo')
.with({ Launch: sinon.stub()});
rewiremock('./rockets')
.toBeUsed();
// ....
rewiremock.enable();
require('./dooms-day');
rewiremock.disable();
// will throw an Error - "rockets were not used", as long we mock out Silo.
Или чтобы заменить один модуль другим (как mockery умеет)
import rewiremock from 'rewiremock';
import sinon from 'sinon';
const Launch = sinon.stub()
const case = rewiremock.proxy('./dooms-day.js',{
'./rocket-silo': rewiremock.with({Launch}).toBeUsed().directChildOnly(), // "real" proxyquire
'doom-scheduler': rewiremock.by('mocked-doom-scheduler')
});
expect(Launch).toHaveBeenCalled();
Или чтобы использовать import заместо require, что может быть полезно с точки зрения name resolution и type safery.
rewiremock(() => import('doom-scheduler')).with({
theDay: Promise.resolve()
});
// rewiremock async API.
await rewiremock.module('./dooms-day.js');
В общем rewiremock — это та библиотека изменения зависимостей, которая СкайНету подойдет. Особенно учитывая постоянное использование машины времени для улучшения работы и выпуска новых версий.
И, самое главное, это и есть ваша текущая библиотека. Интерфейсно совместима.
И сейчас моя миссия — как-то пересадить людей с proxyquire и mockery (и более мелких продуктов) на что-то более юзабельное. Просто потому что старина Арни был «old, but not obsolete» в 2015, и с тех пор обновлений не получал. Как и proxyquire, как и mockery.
Для справки:
→ Jest mocks
→ Mockery
→ Proxyquire
→ TD.js (забудьте почитать вики странички)
Ну и самое главное: rewiremock
PS: И это уже не первая статья о данной библиотеке — остальные тоже могут быть полезны.