В Angular Default тоже было не надо. Но не обманывайте ребятишек, под капотом же как-то View узнает про изменение? Видимо, декоратор и вызывает ручку для перерисовки, когда декорированная функция вызывается?)
Собственно, при написании интерфейса нужно понимать, что паттерн MVC, предполагает, что View как-то должно обновиться при изменении Model.
При стратегии OnPush в Angular View узнает об изменениях Model через паттерн Observable. Никакого волшебства в ChangeDetection. Если хочешь чтобы что-то обновилось, дерни ручку – subject.next(), markForCheck(), setState() (ой, это уже React).
Изначально Angular снимали с разработчика обязанность по логике оповещения об изменениях через zone.js, но решили больше так не делать.
Насколько я понимаю, $mol решает этот вопрос под капотом, не предоставляя разработчику ручного управления процессом ChangeDetection? Это ваше архитектурное решение)
Если вы рисуете uml для людей, а не для галочки, у вас таких примеров не будет. Ограничьте количество элементов, можно настроить длину стрелок и прочее. Диаграмма должна быть понятной - это зависит не от инструмента рисования, а от автора
Статья интересная. Сам постоянно задумываюсь над тем, что зависимость от интерфейса не даёт настоящей инверсии зависимостей.
Но примеров не хватает, как изолировать модель? Как в модели Address правильно избавиться от location API? Сразу принимать zip code в конструктор, насколько я понимаю?
А кто сказал, что там нет декомпозиции? Если интересно, то конечно, у каждого шага свой сервис, который обрабатывает логику своей части. А верхнеуровневый класс нужен, чтобы агрегировать потом совокупность этих шагов + идентификация активного шага. Я просто пытался привести пример, как из страшного большого можно выковыривать что-то поменьше и тестить.
Я не начинаю писать тесты через TDD, если не нахожу удобной абстракции, через которую тесты будут простыми и красиво выражать требования) Нужно подобрать удобный масштаб, который и реализацию не будет сковывать, и намерения поможет выразить.
У меня была задача (фронтенд) – сделать инпут БИК банка с асинхронной валидацией и автозаполнением данных названия банка/кор. счёта через ответ от сервера. Я дня 3 откладывал задачу, потому что не мог найти нужную точку, с которой было бы удобно писать тест. Изначально думал, что начать надо с валидатора – практически чистая функция) Но валидация в Angular настолько специфична, что такие тесты ничего не опишут. В итоге нашёл удобный масштаб – стал тестировать класс формы BankFormGroup целиком. Удобно описывать кейсы, которые эмулируют действия пользователя.
// test: should fill bank data
const fg = new BankFormGroup(checkBicApiMock);
fg.controls.bic.update('123456789');
expect(fg).toBeInvalid();
expect(fg).toBePending();
wait(1000) // ждём, пока проверка БИК выполнится
expect(fg).toBeValid();
expect(fg.bankName).toBe("Название банка от сервера");
Не знаю, юнит это или интеграционный тест. Но в такой форме мне удобно было описывать кейсы и писать реализацию.
Написание тестов требует того, чтобы ваши классы были маленькими и имели минимум зависимостей – это отдельная сложная тема, и удобство написания тестов в какой-то степени служит индикатором того, что ваши классы достаточно маленькие и SOLIDные.
Я понимаю, что во многих частях системы это неудобно или невозможно. В таких случаях я вытаскиваю из большого класса максимум чистых функций, маленьких классов и тестирую их отдельно. Про это и статья выше. Главное, чтобы профит был, а не ради идеи.
Пример из практики – многостраничная форма. (Да, я тупой/модный фронтендер)
Требования: пользователь по шагам заполняет данные, может возвращаться что-то редактировать, между шагами иногда должны пробрасываться данные, на последнем шаге идёт большой POST на сервер.
Понятно, что удобно для этой задачи использовать какой-то единый сервис, который сможет агрегировать итоговые данные из разных шагов, управлять активностью шагов и будет иметь 100500 зависимостей. Даже не надейтесь разработать его по TDD или замокать все зависимости. Там реально они будут сами по себе добавляться.
Поэтому сначала пишем через TDD маленький и простой класс, отвечающий за навигацию по шагам: нужно знать какой шаг активный (`+getActiveStep()`), на какой шаг можно/нельзя перейти (`+isStepAvailable(stepId)`). Используем минимальный интерфейс, который нужен только для этой функциональности – получаем игрушечный класс Stepper и крохотный интерфейс Step. Начинаем использовать нашу игрушку в большом грязном классе MainStepper. Да, большой грязный класс остаётся без тестов, но ответственность за функциональность навигации остаётся в нашем игрушечном классе и хорошо протестирована.
И такими итерациями можно прийти к тому, что наш большой грязный класс превратится в обычный фасад, использующий много маленьких простых протестированных классов/функций. Меня такой расклад в большинстве случаев устраивает.
TDD позволяет написать то, что без TDD написать практически невозможно)
Профит TDD, как я его вижу, в том, что ты описываешь очень маленькими шагами желаемое проведение, реализуешь его в коде и имеешь уверенность, что твой последний шаг не сломал предыдущие. TDD становится полезным, когда этих шагов/требований много, и вы не в состоянии удержать в голове весь набор кейсов.
В процессе написания кода в голову постоянно приходят идеи альтернативных решений. TDD позволяет пробовать их на ходу - если код стал проще и тесты остались зелёными, значит все ок. Грубо говоря, вы начинаете решать задачу через добавление 10 "ифчиков", а потом заменяете это на нормальный алгоритм и убеждаетесь, что поведение осталось равнозначным. И такие повороты можно делать на каждом шаге. И что очень важно, ОЗУ головного мозга прослужит вам дольше, чем если бы вы пытались держать все кейсы в голове разом.
На счёт 100% покрытия - это бред какой-то или фантастика.
По поводу зависимостей, которые мешают писать через TDD... Если пишите что-то через TDD (или просто надо тестировать и прокидывать моки), вытащите этот класс в укромное место, чтобы неожиданные зависимости туда не попадали, сосредоточьтесь на определенной функциональности.
Я бы не стал наследоваться, оставил в интерфейсе только необходимое. Использую подобные фишки в своих проектах.
И не обязательно использовать EventEmitter для Output событий. Это всего лишь враппер над Observable, который позволяет через флаг указать кидать события синхронно или асинхронно.
И самое страшное, что я до сих пор получаю уведомления к своим открытым ПР-ам. И прямо на глазах мой FizzBuzzEnterprise превращается в функцию из примера исходного кода в статье… Блин, чуваки шарят в садизме…
Ой, история на целую статью… Но если кратко, я устроился в довольно известную компанию, но мой стиль кунг-фу не зашёл остальным членам команды. Я люблю ООП, оборачивать примитивы классами, расписывать задачу через такое количество классов, которое требует SOLID. Команда восприняла такой подход, как не соответствующий общему стилю и переусложненный.
И тут не ясно… То ли вкусы не совпали, ты ли я реально всё переусложняю, то ли они быдлокодеры-олимпиадники)
Представьте, что каждая функция в моем примере, это отдельный файл (отдельный класс). Основная метрика не в количестве строк кода. А в количестве классов, которые придется менять. В идеальном варианте при добавлении нового измерения ничего менять не надо, нужно только добавлять новые классы-файлы.
Я бы сделал примерно так. Написал на функциях для краткости, но тестировать парсер можно и в таком виде без прокидывания зависимостей через конструктор. Убрал микрооптимизации типа генерации общего URL, query параметров. Эти вещи практически не нарушают DRY, даже в исходном варианте до рефакторинга это было преждевременно.
interface Measurement {
date: Date;
value: number;
}
interface Response {
airTemperatures: Measurement[];
waterTemperatures: Measurement[];
}
export class SimpleDate {
constructor(private date: Date) {}
/** Returns date in `DD.MM.YYYY` format */
toString(): string {
return `${this.date.getDate()}.${this.date.getMonth() + 1}.${this.date.getFullYear()}`;
}
}
async function getMeasurements(from: SimpleDate, to: SimpleDate): Promise<Response> {
return {
airTemperatures: await getAirTemperatureMeasurements(from, to),
waterTemperatures: await getWaterTemperatureMeasurements(from, to),
};
}
async function getAirTemperatureMeasurements(
from: SimpleDate,
to: SimpleDate,
): Promise<Measurement[]> {
return parseMeasurements(await getHtmlContent(`/air-temperature?from=${from}&to=${to}`));
}
// Покрываем тестами вида "html" -> Measurements[]
// Для специфичных форматов будем создавать подобные парсеры
function parseMeasurements(html: string): Measurement[] {
return []; // not implemented
}
async function getHtmlContent(url: string): Promise<string> {
return 'html...'; // not implemented
}
async function getWaterTemperatureMeasurements(
from: SimpleDate,
to: SimpleDate,
): Promise<Measurement[]> {
return parseMeasurements(await getHtmlContent(`/water-temperature?from=${from}&to=${to}`));
}
На мой взгляд, основная ошибка изначального рефакторинга — желание разбить код на слои. Слои получатся сами собой, не надо к ним стремиться. В первую очередь нужно думать про разделение кода по фичам, которые могут меняться, добавляться независимо друг от друга — SRP, OCP.
Возможно, я так же преждевременно вытащил класс SimpleDate, но не люблю подобные вещи (преобразованин даты в строку) писать в процедурном стиле.
Классная тема! Меня на днях из-за такого уволили)
Но по-моему, пример после рефакторинга не стал понятнее, или гибче. Посчитайте, сколько классов придется затронуть при добавлении нового параметра (уровня солнечной активности, например). Что будет, если часть информации придется брать с другого сайта с другим форматом?
Хороший эксперимент, душок пропал, но читаемость снизилась, расширяемость не добавилась.
Так-как я пока безработный, самому хочется эту же задачку расписать) но не обещаю)
Спасибо за статью! Вы молодцы! Я считаю, что использование в команде TDD — это уровень!
Мне тоже нравится TDD, но я скептически отношусь к тестированию UI-компонентов. На мой взгляд, это слишком сложно. А инструменты для тестирования UI настолько наворочены, что из-за этого легко потерять суть. Поэтому я упоролся и попробовал перенести код компонента из примера на обычные объекты. Типа, упрощённая аналогия, чтобы проанализировать код и подумать головой при написании тестов. Сложно сказать, что из этого получилось. Но ясно одно — через TDD я бы так не написал.
TDD — это не про то, что «пиши тесты на всё, пиши тесты впереди, будь героем!». TDD — это про то, что «пиши так, чтобы было максимально легко тестировать». В данном случае, видимо, мощность инструментов сводит TDD на нет.
Ну а теперь разбираемся… По сути ApplicationPublishModal — это враппер над переданной/захардкоженой модалкой. Он (почему-то) управляет открытием закрытием модалки, которая по идее могла бы управлять этим сама. Даже текст тестов намекает, что тут что-то не так. Ну и второе, что он делает, это определяет заголовок и текст кнопки. И тут самое оно! Если вы хотите быть уверенными, что заголовок/текст генерируется правильно в зависимости от флажка, вынесите это в отдельную чистую функция и покройте 10 тестами. Это будет супер легко, и по-TDD. Остальное — просто дублирование текста из файла с кодом, в файл с тестами. Не тратье свою жизнь на тестирование прокидывания параметров. Это ж рекурсия!)
Извините, если слишком резко. Но это мой мнение. Вот пример моих тестов по TDD.
Резюме: Вы на правильном пути. Но в системе есть места, типа функции main, которые сложно и бесполезно тестировать. Я считаю UI-компоненты такими местами. Лучший способ продолжать двигаться путем TDD — вытаскивать из компонентов всё что можно, оперировать чистыми функциями и объектами, тренироваться на них. Желаю удачи!
Я проголосовал за подход SomeOrDefault, он мне нравится, но проверки на null утомляют.
Есть ещё вариант: добавить в сервис вспомогательные методы, которые позволяют проверить потенциальные причины для выброса исключения.
interface UserRepository {
userExist(userId: string): boolean;
getUserOrThrow(userId: string): User;
}
// Пример использования:
if (userRepository.userExist(userId)) {
const user = userRepository.getUserOrThrow(userId);
// работаем с юзером, будучи уверенными, что всё ок
}
// делаем что-то другое, раз юзера не существует...
Смысл такой, мы не обрабатываем исключения через try/catch, которые бросает метод getUserOrThrow. Если исключение брошено, значит что-то пошло не по плану (500).
Но если нужно (ответить 404, например), мы проверяем причины, по которым может быть вызвано исключение, и обрабатываем их с помощью обычных if/else.
Круто, спасибо за статью! Я пережил подобный опыт. Это удивительно, но мне любую задачу становится в разы проще решать, если записывать подзадачи на листочек и вычеркивать по одной. Эффект — будто освободил 10ГБ оперативки на компе.
В Angular Default тоже было не надо. Но не обманывайте ребятишек, под капотом же как-то View узнает про изменение? Видимо, декоратор и вызывает ручку для перерисовки, когда декорированная функция вызывается?)
А можно глупый ответ? Чтобы не использовать $mol)
Собственно, при написании интерфейса нужно понимать, что паттерн MVC, предполагает, что View как-то должно обновиться при изменении Model.
При стратегии OnPush в Angular View узнает об изменениях Model через паттерн Observable. Никакого волшебства в ChangeDetection. Если хочешь чтобы что-то обновилось, дерни ручку – subject.next(), markForCheck(), setState() (ой, это уже React).
Изначально Angular снимали с разработчика обязанность по логике оповещения об изменениях через zone.js, но решили больше так не делать.
Насколько я понимаю, $mol решает этот вопрос под капотом, не предоставляя разработчику ручного управления процессом ChangeDetection? Это ваше архитектурное решение)
Если вы рисуете uml для людей, а не для галочки, у вас таких примеров не будет. Ограничьте количество элементов, можно настроить длину стрелок и прочее. Диаграмма должна быть понятной - это зависит не от инструмента рисования, а от автора
Статья интересная. Сам постоянно задумываюсь над тем, что зависимость от интерфейса не даёт настоящей инверсии зависимостей.
Но примеров не хватает, как изолировать модель? Как в модели Address правильно избавиться от location API? Сразу принимать zip code в конструктор, насколько я понимаю?
А кто сказал, что там нет декомпозиции? Если интересно, то конечно, у каждого шага свой сервис, который обрабатывает логику своей части. А верхнеуровневый класс нужен, чтобы агрегировать потом совокупность этих шагов + идентификация активного шага. Я просто пытался привести пример, как из страшного большого можно выковыривать что-то поменьше и тестить.
Ни в коем случае! TDD строго запрещает другие виды тестирования!)))
Я не начинаю писать тесты через TDD, если не нахожу удобной абстракции, через которую тесты будут простыми и красиво выражать требования) Нужно подобрать удобный масштаб, который и реализацию не будет сковывать, и намерения поможет выразить.
У меня была задача (фронтенд) – сделать инпут БИК банка с асинхронной валидацией и автозаполнением данных названия банка/кор. счёта через ответ от сервера. Я дня 3 откладывал задачу, потому что не мог найти нужную точку, с которой было бы удобно писать тест. Изначально думал, что начать надо с валидатора – практически чистая функция) Но валидация в Angular настолько специфична, что такие тесты ничего не опишут. В итоге нашёл удобный масштаб – стал тестировать класс формы BankFormGroup целиком. Удобно описывать кейсы, которые эмулируют действия пользователя.
Не знаю, юнит это или интеграционный тест. Но в такой форме мне удобно было описывать кейсы и писать реализацию.
Да, я схитрил, и одним предложением заменил целую эпопею) На меня сильно повлияла вот эта статья про отказ от зависимостей https://habr.com/ru/company/jugru/blog/545482/
Написание тестов требует того, чтобы ваши классы были маленькими и имели минимум зависимостей – это отдельная сложная тема, и удобство написания тестов в какой-то степени служит индикатором того, что ваши классы достаточно маленькие и SOLIDные.
Я понимаю, что во многих частях системы это неудобно или невозможно. В таких случаях я вытаскиваю из большого класса максимум чистых функций, маленьких классов и тестирую их отдельно. Про это и статья выше. Главное, чтобы профит был, а не ради идеи.
Пример из практики – многостраничная форма. (Да, я тупой/модный фронтендер)
Требования: пользователь по шагам заполняет данные, может возвращаться что-то редактировать, между шагами иногда должны пробрасываться данные, на последнем шаге идёт большой POST на сервер.
Понятно, что удобно для этой задачи использовать какой-то единый сервис, который сможет агрегировать итоговые данные из разных шагов, управлять активностью шагов и будет иметь 100500 зависимостей. Даже не надейтесь разработать его по TDD или замокать все зависимости. Там реально они будут сами по себе добавляться.
Поэтому сначала пишем через TDD маленький и простой класс, отвечающий за навигацию по шагам: нужно знать какой шаг активный (`+getActiveStep()`), на какой шаг можно/нельзя перейти (`+isStepAvailable(stepId)`). Используем минимальный интерфейс, который нужен только для этой функциональности – получаем игрушечный класс Stepper и крохотный интерфейс Step. Начинаем использовать нашу игрушку в большом грязном классе MainStepper. Да, большой грязный класс остаётся без тестов, но ответственность за функциональность навигации остаётся в нашем игрушечном классе и хорошо протестирована.
И такими итерациями можно прийти к тому, что наш большой грязный класс превратится в обычный фасад, использующий много маленьких простых протестированных классов/функций. Меня такой расклад в большинстве случаев устраивает.
TDD позволяет написать то, что без TDD написать практически невозможно)
Профит TDD, как я его вижу, в том, что ты описываешь очень маленькими шагами желаемое проведение, реализуешь его в коде и имеешь уверенность, что твой последний шаг не сломал предыдущие. TDD становится полезным, когда этих шагов/требований много, и вы не в состоянии удержать в голове весь набор кейсов.
В процессе написания кода в голову постоянно приходят идеи альтернативных решений. TDD позволяет пробовать их на ходу - если код стал проще и тесты остались зелёными, значит все ок. Грубо говоря, вы начинаете решать задачу через добавление 10 "ифчиков", а потом заменяете это на нормальный алгоритм и убеждаетесь, что поведение осталось равнозначным. И такие повороты можно делать на каждом шаге. И что очень важно, ОЗУ головного мозга прослужит вам дольше, чем если бы вы пытались держать все кейсы в голове разом.
На счёт 100% покрытия - это бред какой-то или фантастика.
По поводу зависимостей, которые мешают писать через TDD... Если пишите что-то через TDD (или просто надо тестировать и прокидывать моки), вытащите этот класс в укромное место, чтобы неожиданные зависимости туда не попадали, сосредоточьтесь на определенной функциональности.
OCP не только про наследование. Он про расширяемость в целом. Вот неплохой пример https://towardsdatascience.com/5-principles-to-write-solid-code-examples-in-python-9062272e6bdc
И у Мартина похожий пример есть, хотя он его больше к SRP относит. Думаю, это потому что принципы связаны.
И не обязательно использовать EventEmitter для Output событий. Это всего лишь враппер над Observable, который позволяет через флаг указать кидать события синхронно или асинхронно.
Почему никого не смущает получение злоумышленником доступа на 10-30 минут…
*Пользователь* — Меня взломали! Сделайте что-нибудь!
*Разработчики* — Да это всего на 30 минут. Не переживайте!
И тут не ясно… То ли вкусы не совпали, ты ли я реально всё переусложняю, то ли они быдлокодеры-олимпиадники)
На мой взгляд, основная ошибка изначального рефакторинга — желание разбить код на слои. Слои получатся сами собой, не надо к ним стремиться. В первую очередь нужно думать про разделение кода по фичам, которые могут меняться, добавляться независимо друг от друга — SRP, OCP.
Возможно, я так же преждевременно вытащил класс SimpleDate, но не люблю подобные вещи (преобразованин даты в строку) писать в процедурном стиле.
Но по-моему, пример после рефакторинга не стал понятнее, или гибче. Посчитайте, сколько классов придется затронуть при добавлении нового параметра (уровня солнечной активности, например). Что будет, если часть информации придется брать с другого сайта с другим форматом?
Хороший эксперимент, душок пропал, но читаемость снизилась, расширяемость не добавилась.
Так-как я пока безработный, самому хочется эту же задачку расписать) но не обещаю)
Мне тоже нравится TDD, но я скептически отношусь к тестированию UI-компонентов. На мой взгляд, это слишком сложно. А инструменты для тестирования UI настолько наворочены, что из-за этого легко потерять суть. Поэтому я упоролся и попробовал перенести код компонента из примера на обычные объекты. Типа, упрощённая аналогия, чтобы проанализировать код и подумать головой при написании тестов. Сложно сказать, что из этого получилось. Но ясно одно — через TDD я бы так не написал.
TDD — это не про то, что «пиши тесты на всё, пиши тесты впереди, будь героем!». TDD — это про то, что «пиши так, чтобы было максимально легко тестировать». В данном случае, видимо, мощность инструментов сводит TDD на нет.
Ну а теперь разбираемся… По сути ApplicationPublishModal — это враппер над переданной/захардкоженой модалкой. Он (почему-то) управляет открытием закрытием модалки, которая по идее могла бы управлять этим сама. Даже текст тестов намекает, что тут что-то не так. Ну и второе, что он делает, это определяет заголовок и текст кнопки. И тут самое оно! Если вы хотите быть уверенными, что заголовок/текст генерируется правильно в зависимости от флажка, вынесите это в отдельную чистую функция и покройте 10 тестами. Это будет супер легко, и по-TDD. Остальное — просто дублирование текста из файла с кодом, в файл с тестами. Не тратье свою жизнь на тестирование прокидывания параметров. Это ж рекурсия!)
Извините, если слишком резко. Но это мой мнение. Вот пример моих тестов по TDD.
Резюме: Вы на правильном пути. Но в системе есть места, типа функции main, которые сложно и бесполезно тестировать. Я считаю UI-компоненты такими местами. Лучший способ продолжать двигаться путем TDD — вытаскивать из компонентов всё что можно, оперировать чистыми функциями и объектами, тренироваться на них. Желаю удачи!
Есть ещё вариант: добавить в сервис вспомогательные методы, которые позволяют проверить потенциальные причины для выброса исключения.
Смысл такой, мы не обрабатываем исключения через try/catch, которые бросает метод getUserOrThrow. Если исключение брошено, значит что-то пошло не по плану (500).
Но если нужно (ответить 404, например), мы проверяем причины, по которым может быть вызвано исключение, и обрабатываем их с помощью обычных if/else.
Как вам такой подход?