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

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

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

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

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



    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: И это уже не первая статья о данной библиотеке — остальные тоже могут быть полезны.
    • +20
    • 5,9k
    • 1
    Поделиться публикацией
    Похожие публикации
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 1
    • +1
      Мил человек, в Ваш СкайНет грохнется ещё до первого теста, если в слове «сущность» вы срочно не удалите мягкий знак между Ща и эН…

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое