Присмехът е кодов мирис

Димни арт кубчета за пушене - MattysFlicks - (CC BY 2.0)
Забележка: Това е част от поредицата „Compose Software“ (сега книга!) За изучаване на функционални програми и техники за композиционен софтуер в JavaScript ES6 + от самото начало. Поддържайте настройките. Има много повече от това, което предстои!
<Предишна | << Започнете отначало

Едно от най-големите оплаквания, които чувам за тестовете на TDD и единици, е, че хората се борят с всички подигравки, необходими за изолиране на единици. Някои хора се борят да разберат как тестовете на техните единици са дори смислени. Всъщност видях, че разработчиците се губят толкова много в макети, фалшификати и мъничета, че са написали цели файлове на тестовете на единици, при които изобщо не е бил използван действителен код за изпълнение. Ами сега.

От другата страна на спектъра е обичайно да се види, че разработчиците се всмукват толкова много в догмата на TDD, че смятат, че абсолютно трябва да постигнат 100% покритие на кода по всякакъв начин, дори ако това означава, че трябва да направят кодовата си база по-сложна за да го издърпам.

Често казвам на хората, че подигравките са миризма на код, но повечето разработчици преминават през етапа в своите TDD умения, където искат да постигнат 100% тестово покритие и не могат да си представят свят, в който не използват макети широко. За да притиснат макети в приложението си, те са склонни да обвиват функциите за инжектиране на зависимости около своите единици или (по-лошото), пакетират услуги в контейнери за инжектиране на зависимост.

Angular извежда това до крайност чрез печене на инжектиране на зависимостта точно във всички класове на ъгловите компоненти, като изкушава потребителите да разглеждат инжектирането на зависимост като основно средство за разделяне. Но инжектирането на зависимост не е най-добрият начин за осъществяване на отделяне.

TDD трябва да доведе до по-добър дизайн

Процесът на усвояване на ефективен TDD е процес на научаване как да се изграждат по-модулни приложения.

TDD има опростяващ ефект върху кода, а не усложняващ ефект. Ако установите, че кодът ви става по-труден за четене или поддържане, когато го направите по-тестваем или трябва да раздуете кода си с котел за инжектиране на зависимост, правите TDD грешно.

Не си губете времето за вклиняване на зависимостта в приложението си, за да можете да се подигравате с целия свят. Много е вероятно, че те боли повече, отколкото помага. Писането на по-тестов код трябва да опрости кода. Тя трябва да изисква по-малко редове от код и по-четими, гъвкави, поддържани конструкции. Инжектирането в зависимост има обратен ефект.

Този текст съществува, за да ви научи на две неща:

  1. Можете да напишете отделен код без инжектиране на зависимост и
  2. Увеличаването на покритието на кода носи намаляваща възвръщаемост - колкото повече се доближавате до 100% покритие, толкова повече трябва да усложните кода на приложението си, за да се сближите още повече, което може да подкопае важната цел за намаляване на грешките в приложението ви.

По-сложният код често е придружен от по-затрупан код. Искате да създадете непрекъснат код по същите причини, по които искате да поддържате къщата си подредена:

  • По-голямото струпване води до по-удобни места за скриване на бъгове, което води до повече бъгове и
  • По-лесно е да намерите това, което търсите, когато има по-малко затруднения да се загубите.

Какво е миризма на код?

"Кодовата миризма е повърхностна индикация, която обикновено съответства на по-задълбочен проблем в системата." ~ Мартин Фаулър

Кодовата миризма не означава, че нещо определено не е наред или че нещо трябва да се поправи веднага. Това е правило, което трябва да ви предупреди за евентуална възможност да подобрите нещо.

Този текст и неговото заглавие по никакъв начин не предполагат, че всички подигравки са лоши или че никога не трябва да се подигравате с нищо.

Освен това различните видове код се нуждаят от различни нива на макети (и различни видове). Някои кодове съществуват главно за улесняване на I / O, като в този случай не може да се направи нищо друго освен тестови входове / изходи, а намаляването на макети може да означава, че покритието на теста на устройството ви ще бъде близо до 0.

Ако в кода ви няма логика (само тръби и чисти състави), може да е приемливо 0% покритие за тест на единица, ако се приеме, че интеграцията или функционалното ви покритие са близо 100%. Ако обаче има логика (условни изрази, присвояване на променливи, изрични извиквания на функции към единици и т.н. ...), вероятно се нуждаете от покритие на тестовете на единици и може да има възможности за опростяване на вашия код и намаляване на подигравателните изисквания.

Какво е макет?

Макетът е тестов двойник, който стои за истински код за изпълнение по време на процеса на тестване на единица. Макетът е способен да генерира твърдения за това как е бил манипулиран от тествания субект по време на тестовия цикъл. Ако вашият тестов двойник създава твърдения, това е макет в специфичния смисъл на думата.

Терминът „макет“ също се използва по-общо за обозначаване на използването на всякакъв вид тест двойник. За целите на този текст ще използваме взаимозаменяемо думите „макет“ и „тествам двойно“, за да съответстваме на популярната употреба. Всички тестови двойки (манекени, шпиони, фалшификати и т.н.) са в сила за истински код, към който тестваният обект е плътно свързан, следователно всички тестови двойки са индикация за свързване и може да има възможност за опростяване на прилагането и подобряване качеството на тествания код. В същото време елиминирането на необходимостта от подигравки може радикално да опрости самите тестове, защото няма да се налага да конструирате макети.

Какво е единичен тест?

Единичните тестове тестват отделни единици (модули, функции, класове) в изолация от останалата част от програмата.

Тестове за контрастни единици с тестове за интеграция, които тестват интеграции между две или повече единици, и функционални тестове, които тестват приложението от гледна точка на потребителя, включително пълни работни процеси за взаимодействие с потребители от симулирана манипулация на потребителския интерфейс, до актуализации на слоя данни и обратно към изхода на потребителя (например екранното представяне на приложението). Функционалните тестове са подмножество на тестовете за интеграция, защото тестват всички единици на приложение, интегрирани в контекста на работещото приложение.

Като цяло, единиците се тестват, като се използва само публичният интерфейс на устройството (известен още като "обществен API" или "повърхност"). Това се нарича тестване на черни кутии. Тестването с черни кутии води до по-малко крехки тестове, тъй като детайлите за внедряване на даден модул са склонни да се променят с течение на времето, отколкото публичния API на устройството. Ако използвате тестване в бяла кутия, където тестовете са запознати с подробности за внедряването, всяка промяна в детайлите за внедряване може да прекъсне теста, дори ако публичният API продължава да функционира според очакванията. С други думи, тестването с бели кутии води до пропиляна преработка.

Какво е тестовото покритие?

Покритието на кода се отнася до количеството код, обхванато от тестовите случаи. Отчетите за покритието могат да бъдат създадени чрез инструментиране на кода и записване кои линии са били упражнявани по време на тест. По принцип се опитваме да създадем високо ниво на покритие, но кодовото покритие започва да дава намаляваща възвръщаемост, тъй като се приближава до 100%.

Според моя опит увеличаването на покритието над ~ 90% изглежда има малка продължителна корелация с по-ниска плътност на бъгове.

Защо това е така? Не означава ли 100% тестван код, че знаем със 100% сигурност, че кодът прави това, за което е създаден?

Оказва се, че не е толкова просто.

Това, което повечето хора не осъзнават, е, че има два вида покритие:

  1. Покритие на кода: каква част от кода се упражнява и
  2. Покритие на случая: колко от случаите на употреба са обхванати от тестовите пакети

Покритието на случаите се отнася до сценарии за използване на случаи: Как кодът ще се държи в контекста на реалната среда, с реални потребители, реални мрежи и дори хакери, които умишлено се опитват да подкопаят дизайна на софтуера за нечестиви цели.

Отчетите за покритие идентифицират слабости в обхвата на кода, а не слабости в обхвата на регистрите. Един и същ код може да се прилага за повече от един случай на използване, а един случай на използване може да зависи от код извън теста за изпитване или дори в отделно приложение или API на трети страни.

Тъй като случаите на използване могат да включват среда, множество устройства, потребители и мрежови условия, е невъзможно да се покрият всички необходими случаи на използване с тестов пакет, който съдържа само тестове на единица. Тестовете на единици по дефиниционни тестови единици изолирано, а не в интеграция, което означава, че тестовият набор, съдържащ само тестови единици, винаги ще има близо 0% покритие на случаите за сценарии за интеграция и функционален случай.

100% покритие на кода не гарантира 100% покритие на регистъра.

Разработчиците, насочени към 100% покритие на код, преследват грешен показател.

Какво е плътно свързване?

Необходимостта от подигравки, за да се постигне изолация на единица за целите на изпитванията на единици, се причинява от свързване между единици. Стегнатото свързване прави кода по-твърд и чуплив: по-вероятно е да се счупи, когато са необходими промени. Като цяло, по-малко свързване е желателно от само себе си, защото прави кода по-лесен за разширяване и поддържане. Фактът, че също така прави тестването по-лесно, като елиминира нуждата от подигравки, е просто черешката на тортата.

От това можем да заключим, че ако се подиграваме с нещо, може да има възможност да направим кода си по-гъвкав, като намалим свързването между единици. След като направите това, вече няма да се нуждаете от подигравките.

Свързването е степента, до която единица код (модул, функция, клас и т.н. ...) зависи от други единици код. Стегнатото свързване или висока степен на свързване се отнася до вероятността дадена единица да се счупи, когато се направят промени в нейните зависимости. С други думи, колкото по-стегнат е съединителят, толкова по-трудно е да се поддържа или разширява приложението. Разхлабеното свързване намалява сложността на поправяне на бъгове и адаптиране на приложението към нови случаи на използване.

Свързването е под различни форми:

  • Свързване на подклас: Подкласовете зависят от прилагането и цялата йерархия на родителския клас: най-строгата форма на свързване, налична в OO дизайн.
  • Контролни зависимости: Код, който контролира неговите зависимости, като им казва какво да правят, например, предаване на имена на методи и т.н. ... Ако API за контрол на зависимостта се промени, зависимият код ще се счупи.
  • Зависимости на изменяемо състояние: Код, който споделя изменяемо състояние с друг код, например, може да промени свойствата на споделен обект. Ако относителният момент на мутациите се промени, той може да прекъсне зависимия код. Ако времето е недетерминирано, може да бъде невъзможно да се постигне коректност на програмата без пълен преглед на всички зависими единици: например, може да има непоправим заплитане на условията на състезанието. Фиксирането на една грешка може да доведе до появата на други в други зависими единици.
  • Зависимости на формата на държавата: Код, който споделя структури от данни с друг код и използва само подмножество на структурата. Ако формата на споделената структура се промени, тя може да наруши зависимия код.
  • Съчетаване на събитие / съобщение: Код, който комуникира с други единици чрез предаване на съобщения, събития и т.н. ...

Какво причинява плътно свързване?

Стегнатото свързване има много причини:

  • Мутация срещу неизменност
  • Странични ефекти срещу чистота / изолирани странични ефекти
  • Претоварване на отговорността срещу „Направи едно нещо“ (DOT)
  • Процедурни инструкции срещу описание на структурата
  • Класово наследяване срещу състав

Императивният и обектно-ориентиран код е по-податлив на плътно свързване, отколкото функционален код. Това не означава, че програмирането във функционален стил прави кода ви имунизиран срещу стегнатото свързване, но функционалният код използва чисти функции като елементарна единица на състава, а чистите функции са по-малко уязвими от стегнатото свързване по природа.

Чисти функции:

  • Имайки един и същ вход, винаги връщайте един и същ изход и
  • Не предизвиква странични ефекти

Как чистите функции намаляват свързването?

  • Неизменяемост: Чистите функции не мутират съществуващите стойности. Вместо това те връщат нови.
  • Без странични ефекти: Единственият наблюдаван ефект на чистата функция е нейната възвръщаема стойност, така че няма шанс тя да пречи на работата на други функции, които може да наблюдават външно състояние, като екран, DOM, конзолата, стандартно , мрежата или диска.
  • Направете едно нещо: Чистите функции правят едно нещо: картографирайте някакъв вход към някакъв подходящ изход, като избягвате претоварването на отговорността, което има тенденция към язва на обект и клас, базиран на код.
  • Структура, а не инструкции: Чистите функции могат да бъдат запомнени безопасно, което означава, че ако системата има безкрайна памет, всяка чиста функция може да бъде заменена с таблица за търсене, която използва входа на функцията като индекс, за да извлече съответната стойност от таблицата. С други думи, чистите функции описват структурните връзки между данните, а не инструкциите, които компютърът трябва да следва, така че два различни набора от конфликтни инструкции, изпълнявани едновременно, не могат да стъпват на пръстите на един друг и да създават проблеми.

Какво общо има композицията с подигравките?

Всичко. Същността на цялата разработка на софтуер е процесът на разбиване на голям проблем на по-малки, независими парчета (разлагане) и съставяне на решенията заедно, за да се образува приложение, което решава големия проблем (състав).

Присмехът е необходим, когато стратегията ни за разлагане се е провалила.

Присмехът е необходим, когато единиците, използвани за разбиване на големия проблем на по-малки части, зависят един от друг. Казано по друг начин, присмехът е необходим, когато предполагаемите ни атомни единици всъщност не са атомни и стратегията ни за разлагане не успя да разложи по-големия проблем на по-малки, независими проблеми.

Когато разлагането успее, е възможно да се използва общ инструмент за композиция, за да композирате парчетата отново заедно. Примери:

  • Функционален състав, например, lodash / fp / compose
  • Компонентен състав, например съставяне на компоненти от по-висок ред с функционален състав
  • Състав на държавния магазин / модел, например, комбинирани редуктори Redux
  • Обект или фабричен състав, например, миксини или функционални смеси
  • Процесен състав, например, преобразуватели
  • Обещаващ или монадичен състав, например, asyncPipe (), състав на Kleisli с composeM (), composeK () и т.н. ...
  • и др ...

Когато използвате помощни програми за общ състав, всеки елемент от състава може да бъде тестван отделно, без да се подиграва на останалите.

Самите композиции ще бъдат декларативни, така че ще съдържат нулева логика за проверка на единица (вероятно, помощната програма за композиция е библиотека на трети страни със собствени тестови единици).

При тези обстоятелства няма нищо смислено за единичен тест. Вместо това ви трябват интеграционни тестове.

Нека контрастираме императивен и декларативен състав, като използваме познат пример:

// Функционален състав ИЛИ
// импортиране на тръба от 'lodash / fp / flow';
const тръба = (... fns) => x => fns.reduce ((y, f) => f (y), x);
// Функции за съставяне
const g = n => n + 1;
const f = n => n * 2;
// императивен състав
const doStuffBadly = x => {
  const afterG = g (x);
  const afterF = f (afterG);
  връщане следF;
};
// Декларативен състав
const doStuffBetter = тръба (g, f);
console.log (
  doStuffBadly (20), // 42
  doStuffBetter (20) // 42
);

Функционалният състав е процесът на прилагане на функция към връщащата се стойност на друга функция. С други думи, вие създавате тръбопровод от функции, след което предавате стойност на тръбопровода и стойността ще премине през всяка функция като етап в сборна линия, трансформирайки по някакъв начин стойността, преди да бъде прехвърлена към следващата функция в тръбопровод. В крайна сметка последната функция в тръбопровода връща крайната стойност.

начална оценка -> [g] -> [f] -> резултат

Това е основното средство за организиране на кода на приложението на всеки основен език, независимо от парадигмата. Дори Java използва функции (методи) като основен механизъм за предаване на съобщения между различните екземпляри от класа.

Можете да съставяте функции ръчно (императивно) или автоматично (декларативно). На езици без първокласни функции нямате голям избор. Вие сте останали с императив. В JavaScript (и почти всички други основни популярни езици) можете да го направите по-добре с декларативен състав.

Императивният стил означава, че командваме компютъра да направи нещо стъпка по стъпка. Това е ръководство за начина на употреба. В горния пример императивният стил гласи:

  1. Вземете аргумент и го задайте на x
  2. Създайте обвързване, наречено afterG и му присвойте резултата от g (x)
  3. Създайте обвързване, наречено afterF и му присвойте резултата от f (afterG)
  4. Върнете стойността на afterF.

Версията на императивния стил изисква логика, която трябва да бъде тествана. Знам, че това са просто прости задачи, но често виждам (и пиша) грешки при преминаване или връщане на грешна променлива.

Декларативният стил означава, че казваме на компютъра връзките между нещата. Това е описание на структурата, използвайки изравнителни разсъждения. Декларативният пример казва:

  • doStuffBetter е тръбната композиция на g и f.

Това е.

Ако приемем, че f и g имат свои собствени тестове на единица, а pipe () има свои собствени тестове на единица (използвайте потока () от Lodash или pipe () от Ramda, и това ще стане), няма нова логика тук за единица тест.

За да работи правилно този стил, единиците, които съставяме, трябва да бъдат отделени.

Как да премахнем съединителя?

За да премахнем свързването, първо се нуждаем от по-добро разбиране откъде идват зависимостите на свързването. Ето основните източници, приблизително в зависимост от това колко плътно е съединението:

Плътно свързване:

  • Наследяване на класа (свързването се умножава по всеки слой наследяване и всеки клас на потомък)
  • Глобални променливи
  • Друго изменящо се глобално състояние (DOM на браузъра, споделено хранилище, мрежа и т.н. ...)
  • Внос на модул със странични ефекти
  • Неявни зависимости от състави, например, const poboljšaни WidgetFactory = композиране (eventEmitter, widgetFactory, подобрения); където widgetFactory зависи от eventEmitter
  • Съдове за инжектиране на зависимостта
  • Параметри на инжектиране на зависимостта
  • Контролни параметри (външно устройство контролира обекта, като му казва какво да прави)
  • Променливи параметри

Рехаво съединение:

  • Внос на модул без странични ефекти (при тестване на черна кутия, не целият внос се нуждае от изолиране)
  • Изпращане на съобщение / pubsub
  • Неизменни параметри (все още могат да причинят споделени зависимости от формата на състоянието)

По ирония на съдбата, повечето източници на свързване са механизми, първоначално проектирани да намалят свързването. Това има смисъл, защото за да се прекомпозират нашите по-малки решения на проблеми в цялостно приложение, те трябва да се интегрират и да комуникират по някакъв начин. Има добри начини и лоши. Източниците, които причиняват тясно свързване, трябва да се избягват винаги, когато е практично. Опциите за разхлабено свързване обикновено са желателни при здравословно приложение.

Може да се объркате, че класифицирах контейнери за инжектиране на зависимост и параметри за инжектиране на зависимост в групата „плътно съединяване“, когато толкова много книги и публикации в блога ги категоризират като „хлабави съединения“. Свързването не е двоично. Това е градиентна скала. Това означава, че всяко групиране ще бъде някак субективно и произволно.

Начертавам линията с прост, обективен лакмусов тест:

Може ли устройството да се тества без да се подиграва на зависимостите? Ако не може, тя е плътно свързана с подигравателните зависимости.

Колкото повече зависимости има вашата единица, толкова по-голяма е вероятността да има проблемни връзки. Сега, когато разбираме как се случва свързването, какво можем да направим по въпроса?

  1. Използвайте чисти функции като атомна единица на състава, за разлика от класове, императивни процедури или мутиращи функции.
  2. Изолирайте страничните ефекти от останалата част от вашата логика на програмата. Това означава, че не смесвайте логиката с I / O (включително мрежов I / O, рендеринг потребителски интерфейс, регистрация и т.н. ...).
  3. Премахнете зависимата логика от императивните състави, така че да могат да станат декларативни състави, за които не се нуждаят от собствени тестове на единица. Ако няма логика, няма нищо смислено за единичен тест.

Това означава, че кодът, който използвате за настройка на мрежови заявки и обработващи заявки, няма да се нуждае от единични тестове. Вместо това използвайте тестове за интеграция.

Това повтаря:

Не проверявайте I / O модула.
I / O е за интеграции. Вместо това използвайте интеграционни тестове.

Напълно добре е да се подигравате и фалшифицирате за тестове за интеграция.

Използвайте чисти функции

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

Ако сте предали масив или обект и искате да върнете променена версия на този обект, не можете просто да направите промените в обекта и да го върнете. Трябва да създадете ново копие на обекта с необходимите промени. Можете да направите това с методите на аксесоара на масив (не с методите на мутатора), Object.assign (), като използвате нов празен обект като цел или синтаксиса за разпространение на масива или обекта. Например:

// Не е чисто
const signInUser = user => user.isSignedIn = true;
const foo = {
  име: „Foo“,
  isSignedIn: false
};
// Foo беше мутиран
console.log (
  signInUser (foo), // вярно
  foo // {name: "Foo", isSignedIn: true}
);

срещу…

// Чист
const signInUser = user => ({... потребител, isSignedIn: true});
const foo = {
  име: „Foo“,
  isSignedIn: false
};
// Foo не е мутиран
console.log (
  signInUser (foo), // {name: "Foo", isSignedIn: true}
  foo // {name: "Foo", isSignedIn: false}
);

Освен това можете да опитате библиотека за неизменни типове данни, като Mori или Immutable.js. Надявам се, че някой ден ще получим хубав набор от неизменни типове данни, подобни на Clojure в JavaScript, но не затаих дъх.

Може да мислите, че връщането на нови обекти може да доведе до хит на производителността, защото създаваме нов обект, вместо да използваме повторно съществуващите, но щастлив страничен ефект от това е, че можем да открием промените в обектите, използвайки сравнение на идентичност (= == проверка), така че не е нужно да обикаляме целия обект, за да открием дали нещо се е променило.

Можете да използвате този трик, за да накарате React компонентите да се изобразяват по-бързо, ако имате сложно дърво на състоянието, за което може да не е необходимо да преминавате в дълбочина с всеки пропуск на рендеринга. Наследи от PureComponent и той изпълнява би трябвалоComponentUpdate () с плитка опора и сравнение на състоянието. Когато открие равенство на идентичността, той знае, че нищо не се е променило в тази част на дървото на държавата и може да продължи без дълбоко държавно преминаване.

Чистите функции също могат да бъдат запомнени, което означава, че не е нужно да изграждате целия обект отново, ако сте виждали същите входове преди. Можете да търгувате с изчислителната сложност за паметта и да съхранявате предварително изчислени стойности в таблицата за търсене. За изчислително скъпите процеси, които не изискват неограничена памет, това може да е чудесна стратегия за оптимизация.

Друго свойство на чистите функции е, че тъй като те нямат странични ефекти, е безопасно да се разпределят сложни изчисления в големи групи от процесори, като се използва стратегия за разделяне и завладяване. Тази тактика често се използва за обработка на изображения, видео или аудио кадри, използвайки масово успоредни графични процесори, първоначално предназначени за графика, но сега често използвани за много други цели, като научни изчисления.

С други думи, мутацията не винаги е по-бърза и често е с порядък по-бавна, защото изисква микрооптимизация за сметка на макрооптимизациите.

Изолирайте страничните ефекти от останалата част от вашата логика на програмата

Има няколко стратегии, които могат да ви помогнат да изолирате страничните ефекти от останалата част от вашата логика на програмата. Ето някои от тях:

  1. Използвайте pub / sub, за да отделите I / O от изгледи и програмна логика. Вместо да директно да задейства странични ефекти в изгледи на потребителски интерфейс или програмна логика, излъчете обект на събитие или действие, описващо събитие или намерение.
  2. Изолирайте логиката от I / O, например, съставете функции, които връщат обещания, използвайки asyncPipe ().
  3. Използвайте обекти, които представляват бъдещи изчисления, а не директно задействане на изчисления с I / O, например call () от redux-saga всъщност не извиква функция. Вместо това той връща обект с препратка към функция и нейните аргументи, а средният софтуер на сагата го нарича за вас. Това прави call () и всички функции, които го използват, чисти функции, които са лесни за тестване на единица, без да са необходими подигравки.

Използвайте pub / sub

Pub / sub е кратко за модела за публикуване / абониране. В модела за публикуване / абонамент единиците не се обаждат директно помежду си. Вместо това те публикуват съобщения, които други звена (абонати) могат да слушат. Издателите не знаят какви (ако има) единици ще се абонират, а абонатите не знаят какво (ако има) издателите ще публикуват.

Pub / sub се записва в Document Object Model (DOM). Всеки компонент в приложението ви може да слуша събития, изпратени от DOM елементи, като например движение на мишката, кликвания, събития за превъртане, натискане на клавиши и т.н. Назад, когато всички създадоха уеб приложения с jQuery, беше обичайно за jQuery персонализирани събития да превърнат DOM в шина на pub / sub събитие, за да отделят изглед от гледна точка на рендерите от логиката на състоянието.

Pub / sub също се пече в Redux. В Redux създавате глобален модел за състояние на приложение (наричан магазин). Вместо директно да се манипулират модели, изгледи и входно-изходни обработчици изпращат обекти за действие в магазина. Обектът за действие има специален ключ, наречен тип, който различни редуктори могат да слушат и да реагират. Освен това Redux поддържа междинен софтуер, който също може да слуша и отговаря на конкретни типове действия. По този начин вашите изгледи не трябва да знаят нищо за това как се обработва състоянието на приложението ви, а логиката на състоянието не трябва да знае нищо за изгледите.

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

Изолирайте логиката от I / O

Понякога можете да използвате монадни композиции (като обещания), за да премахнете зависимата логика от вашите композиции. Например следната функция съдържа логика, която не можете да тествате, без да се подигравате на всички функции на асинхронизация:

async функция uploadFiles ({потребител, папка, файлове}) {
  const dbUser = изчакайте readUser (потребител);
  const folderInfo = изчакайте getFolderInfo (папка);
  ако (изчакайте haveWriteAccess ({dbUser, folderInfo})) {
    върнете uploadToFolder ({dbUser, folderInfo, файлове});
  } else {
    хвърли нова грешка ("Няма достъп за писане до тази папка");
  }
}

Нека въведем някакъв помощен псевдо-код, за да го изпълним:

const log = (... args) => console.log (... args);
// Игнорирайте тези. В реалния си код бихте импортирали
// истинските неща.
const readUser = () => Promise.resolve (true);
const getFolderInfo = () => Promise.resolve (true);
const haveWriteAccess = () => Promise.resolve (true);
const uploadToFolder = () => Promise.resolve ('Успех!');
// дръзки начални променливи
const user = '123';
const папка = '456';
const files = ['a', 'b', 'c'];
async функция uploadFiles ({потребител, папка, файлове}) {
  const dbUser = изчакайте readUser ({потребител});
  const folderInfo = изчакайте getFolderInfo ({папка});
  ако (изчакайте haveWriteAccess ({dbUser, folderInfo})) {
    върнете uploadToFolder ({dbUser, folderInfo, файлове});
  } else {
    хвърли нова грешка ("Няма достъп за писане до тази папка");
  }
}
uploadFiles ({потребител, папка, файлове})
  .След (Дневник)
;

А сега го рефакторирайте, за да използва обещаваща композиция чрез asyncPipe ():

const asyncPipe = (... fns) => x => (
  fns.reduce (async (y, f) => f (изчакайте y), x)
);
const uploadFiles = asyncPipe (
  readUser,
  getFolderInfo,
  haveWriteAccess,
  uploadToFolder
);
uploadFiles ({потребител, папка, файлове})
  .След (Дневник)
;

Условната логика лесно се отстранява, защото обещанията имат вградено условно разклонение. Идеята е, че логиката и I / O не се смесват добре, така че искаме да премахнем логиката от кода, зависим от I / O.

За да направим този вид композиция да работи, трябва да осигурим 2 неща:

  1. haveWriteAccess () ще отхвърли, ако потребителят няма достъп за запис. Това премества условната логика в контекста на обещанието, така че изобщо не е необходимо да я тестваме или да се притесняваме за нея (обещанията имат свои собствени тестове, включени в кода на JS двигателя).
  2. Всяка от тези функции приема и разрешава с един и същ тип данни. Бихме могли да създадем тип pipelineData за тази композиция, която е само обект, съдържащ следните ключове: {потребител, папка, файлове, dbUser ?, folderInfo? }. Това създава зависимост за споделяне на структура между компонентите, но можете да използвате по-общи версии на тези функции на други места и да ги специализирате за този тръбопровод с тънки опаковъчни функции.

При изпълнение на тези условия е тривиално да тествате всяка от тези функции в изолация една от друга, без да се подигравате с другите функции. Тъй като извадихме цялата логика извън тръбопровода, в този файл не остава нищо смислено за тест на единица. Всичко, което остава да тестваме, са интеграциите.

Запомнете: Логиката и I / O са отделни проблеми.
Логиката е мислене. Ефектите са действия. Помислете, преди да действате!

Използвайте обекти, които представляват бъдещи изчисления

Стратегията, използвана от redux-saga, е да използва обекти, които представляват бъдещи изчисления. Идеята е подобна на връщането на монада, само че не винаги трябва да бъде монада, която се връща. Монадите са способни да съставят функции с верижната операция, но можете ръчно да веригирате функциите, използвайки вместо това императивен стил. Ето груба скица за това как прави редукс-сага:

// захар за console.log, който ще използваме по-късно
const log = msg => console.log (msg);
const call = (fn, ... args) => ({fn, args});
const put = (msg) => ({msg});
// импортиран от I / O API
const sendMessage = msg => Promise.resolve ('някакъв отговор');
// внесена от държателя за обработка / редуктор
const handleResponse = response => ({
  тип: 'RECEIVED_RESPONSE',
  полезен товар: отговор
});
const handleError = err => ({
  тип: 'IO_ERROR',
  полезен товар: грешка
});
функция * sendMessageSaga (msg) {
  опитвам {
    const response = добив на повикване (sendMessage, msg);
    добив на добив (handleResponse (отговор));
  } улов (грешка) {
    добив пускане (handleError (грешка));
  }
}

Можете да видите всички обаждания, извършвани в тестовете на вашето устройство, без да се подигравате на мрежовия API или да извиквате странични ефекти. Бонус: Това прави приложението ви изключително лесно за отстраняване на грешки, без да се притеснявате за недетерминирано състояние на мрежата и т.н. ...

Искате да симулирате какво се случва в приложението ви, когато се появи мрежова грешка? Просто се обадете на iter.throw (NetworkError)

От друга страна, някои междинен софтуер на библиотеката задвижва функцията и всъщност задейства страничните ефекти в производственото приложение:

const iter = sendMessageSaga ('Здравей, свят!');
// Връща обект, представящ състоянието и стойността:
const step1 = iter.next ();
влизане (етап 1);
/ * =>
{
  направено: невярно,
  стойност: {
    fn: sendMessage
    args: ["Здравей, свят!"]
  }
}
* /

Унищожете обекта call () от получената стойност, за да инспектирате или извикате бъдещото изчисление:

const {value: {fn, args}} = step1;

Ефектите стартират в истинския междинен софтуер. Можете да пропуснете тази част, когато тествате и отстранявате грешки.

const step2 = fn (args);
step2.then (влизане); // "някакъв отговор"

Ако искате да симулирате мрежов отговор, без да се подигравате с API или http повикванията, можете да предадете симулиран отговор в .next ():

iter.next (simulatedNetworkResponse);

Оттам можете да продължите да се обаждате .next (), докато свърши е истина и функцията ви приключи.

Използвайки генератори и представяне на изчисления в тестовете на вашата единица, можете да симулирате всичко до, но да изключите извикването на реалните странични ефекти. Можете да предавате стойности в .next () обаждания за фалшиви отговори или да хвърляте грешки в итератора, за да фалшифицирате грешки и да обещаете отхвърляне.

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

„Кодовите миризми“ са предупредителни знаци, а не закони. Смеховете не са зли.

Всичко това за използването на по-добра архитектура е страхотно, но в реалния свят трябва да използваме API на други хора и да се интегрираме със стария код, а има много API, които не са чисти. Изолираните тестови двойници могат да бъдат полезни в тези случаи. Например експресните пропуски споделено състояние за изменяне и моделиране на странични ефекти чрез продължаване на преминаването.

Нека да разгледаме общ пример. Хората се опитват да ми кажат, че файлът за дефиниция на експресния сървър се нуждае от инжектиране на зависимост, защото как иначе ще тествате всички неща, които влизат в експресното приложение? Напр .:

const express = изисквам ('express');
const app = express ();
app.get ('/', функция (req, res) {
  res.send („Здравей, свят!“)
});
app.listen (3000, функция () {
  console.log („Примерно слушане на приложение на порт 3000!“)
});

За да "тестваме единица" на този файл, трябва да разработим решение за инжектиране на зависимост и след това да предаваме макети за всичко в него (вероятно включително и самия express ()). Ако това беше много сложен файл, в който различните обработващи заявки използваха различни функции на експрес и разчитайки, че тази логика ще бъде там, вероятно ще трябва да измислите доста сложен фалшив, за да направите това да работи. Виждал съм разработчици да създават сложни фалшификати и подигравки с неща като express, междинния софтуер на сесията, манипулатори на журнали, мрежови протоколи в реално време. Сам съм се сблъсквал с твърди подигравателни въпроси, но верният отговор е прост.

Този файл не се нуждае от единични тестове.

Файлът за дефиниране на сървъра за експресно приложение е по дефиниция основната точка за интегриране на приложението. Тестването на експресен файл на приложение е чрез дефиниция тестване на интеграция между вашата програмна логика, експрес и всички манипулатори за това експресно приложение. Абсолютно не трябва да пропускате тестовете за интеграция, дори ако можете да постигнете 100% покритие на единица тест.

Вместо да се опитвате да тествате този файл, изолирайте програмната си логика в отделни единици и тествайте тези файлове. Напишете реални тестове за интеграция за сървърния файл, което означава, че действително ще попаднете в мрежата или поне ще създадете действителните http съобщения, допълнени със заглавки, като използвате инструмент като супертест.

Нека рефакторираме експресния пример Hello World, за да го направим по-тест:

Издърпайте манипулатора на здравей в собствения си файл и напишете тестове на единици за него. Няма нужда да се подигравате с останалите компоненти на приложението. Това очевидно не е чиста функция, така че ще трябва да шпионираме или подиграваме обекта на отговор, за да сме сигурни, че се обаждаме .send ().

const hello = (req, res) => res.send ('Здравей, свят!');

Бихте могли да го тествате нещо подобно. Разменете изявлението if за любимото си очакване за тестова рамка:

{
  const очаквано = 'Hello World!';
  const msg = `трябва да се обади .send () с $ {очаква}};
  const res = {
    изпрати: (действително) => {
      ако (действително! == очаквано) {
        хвърли нова грешка (`NOT OK $ {msg}`);
      }
      console.log (`OK: $ {msg}`);
    }
  }
  здравей ({}, res);
}

Издърпайте инструмента за слушане в собствен файл и напишете тестове на единици за него също. Тук имаме същия проблем. Експресните манипулатори не са чисти, затова трябва да шпионираме дърводобивника, за да сме сигурни, че ще бъде извикан. Тестването е подобно на предишния пример:

const handleListen = (лог, порт) => () => лог (`Примерно приложение за слушане на порт $ {port}!`);

Всичко, което остава в сървърния файл сега, е логиката на интегриране:

const express = изисквам ('express');
const hello = изисквам ('./ hello.js');
const handleListen = изисквам ('./ handleListen');
const log = изисквам ('./ log');
const порт = 3000;
const app = express ();
app.get ('/', здравей);
app.listen (порт, handleListen (порт, лог));

Все още се нуждаете от тестове за интеграция за този файл, но по-нататъшните тестове на единица няма да подобрят значимо покритието на вашия случай. Използваме някои много минимални инжекционни зависимости, за да предадем логър в handleListen (), но със сигурност няма нужда от рамка за инжектиране на зависимост за експресни приложения.

Присмехът е чудесен за тестове за интеграция

Тъй като тестовете за интеграция тестват съвместни интеграции между единици, е напълно добре да се фалшифицират сървъри, мрежови протоколи, мрежови съобщения и т.н., за да се възпроизведат всички различни условия, които ще срещнете по време на комуникация с други звена, потенциално разпределени в клъстери от процесори или отделни машини в мрежа.

Понякога ще искате да тествате как устройството ви ще комуникира с API на трети страни, а понякога тези API са твърде скъпи за тестване за реални. Можете да записвате реални транзакции на работния процес срещу реалните услуги и да ги преигравате от фалшив сървър, за да проверите доколко вашето устройство се интегрира с услуга на трета страна, която действително работи в отделен мрежов процес. Често това е най-добрият начин за тестване на неща като „видяхме ли правилните заглавки на съобщенията?“

Има много полезни инструменти за тестване на интеграцията, които дроселират честотната лента на мрежата, въвеждат изоставане в мрежата, произвеждат мрежови грешки и в противен случай тестват много други условия, които са невъзможни за тестване с помощта на единични тестове, които осмиват комуникационния слой.

Невъзможно е да се постигне 100% покритие на случаите без тестове за интеграция. Не ги пропускайте, дори ако успеете да постигнете 100% тестово покритие. Понякога 100% не е 100%.

Следващи стъпки

  • Научете защо смятам, че всеки екип за разработка трябва да използва TDD в подкаста за кръстосване.
  • JS Cheerleader документира нашите приключения в Instagram.

Научете повече на EricElliottJS.com

Видео уроци за тестване на единици са на разположение за членове на EricElliottJS.com. Ако не сте член, регистрирайте се днес.

Ерик Елиът е автор на „Програмиране на JavaScript приложения“ (O’Reilly) и „Научете JavaScript с Ерик Елиът“. Той е допринесъл за софтуерни преживявания за Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC и най-добрите звукозаписни артисти, включително Usher, Frank Ocean, Metallica и много други.

Той работи отдалечено отвсякъде с най-красивата жена в света.