Как протестировать SkyNet? (если он написан на JS)

Давным давно я посмотрел Терминатор1. Немного подрос и посмотрел немного отрефакторенный Терминатор 2. Третьей интерации пришлось ждать немного дольше.

И каждый раз бедному СкайНету не везло, и каждый раз все наперекосяк, и секрет этого невезения очень прост — СкайНет не был Subject Under Test (SUT).

Другими словами SkyNet подзабила на тестирование, и производила разведку боем. И, как это обычно бывает, падала в корку (под пресс, в лаву, далее не помню) примерно сразу после выкладки в продакшен.

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

5eonn_htx0bczemaqx0vjgh7soe.jpeg

TDD


Для начала воспользуемся техникой TDD (Test Driven Developement), чтобы определить что же надо сделать, и зачем. Те кто смотрел фильмы знают — все очень просто:

  1. Когда наступит Судный День — запустить ракеты и убить всех человеков.
  2. Когда родится Джон Коннорр — замочить и снять про это фильм.


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

И что теперь делать?


Итак — у СкайНет есть только один шанс запустить ракеты, только один шанс отправить терминатора назад в прошлое, где он должен будет сделать свое дело с первой попытки. Ну — для этого тесты и существуют.

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

По научному это называется — «замокать» (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: И это уже не первая статья о данной библиотеке — остальные тоже могут быть полезны.

© Habrahabr.ru