Юнит-тесты переоценены

Original author: Alexey Golub
  • Translation
Предлагаем вам перевод поста «Unit Testing is Overrated» от Alex Golub, чтобы подискутировать на тему юнит-тестов. Действительно ли они переоценены, как считает автор, или же являются отличным подспорьем в работе? Опрос — в конце поста


Результаты использования юнит-тестов: отчаяние, мучения, гнев

Важность тестирования в современной разработке ПО сложно переоценить. Для создания успешного продукта недостаточно выпустить его и сразу забыть, это долгий итеративный процесс. После изменения каждой строки кода программа должна сохранять свою функциональность, что подразумевает необходимость тщательного тестирования.

В процессе развития отрасли разработки ПО совершенствовались и методики тестирования. Они постепенно сдвигались в сторону автоматизации и повлияли на саму структуру ПО, порождая такие «мантры», как «разработка через тестирование» (test-driven development), делая упор на такие паттерны, как инверсия зависимостей (dependency inversion), и популяризируя построенные на их основе высокоуровневые архитектуры.

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

Однако, несмотря на существование различных подходов, современные «best practices» в основном подталкивают разработчиков к использованию конкретно юнит-тестирования. Тесты, область контроля которых находится в пирамиде Майка Кона выше, или пишутся как часть более масштабного проекта (часто совершенно другими людьми), или полностью игнорируются.

Преимущество такого подхода часть поддерживается следующим аргументом: юнит-тесты обеспечивают в процессе разработки наибольшую полезность, потому что способны быстро отслеживать ошибки и помогают применять упрощающие модульность паттерны разработки. Эта мысль стала настолько общепринятой, что сегодня термин «юнит-тестирование» в какой-то мере сливается с автоматизированным тестированием в целом, из-за оно чего теряет часть своего значения и вводит в замешательство.

Когда я был менее опытным разработчиком, я неукоснительно следовал этим «best practices», полагая, что они могут сделать мой код лучше. Мне не особо нравилось писать юнит-тесты из-за всех связанных с этим церемоний с абстракциями и созданием заглушек, но таким был рекомендованным подход, а кто я такой, чтобы с ним спорить?

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

Агрессивно продвигаемые «best practices» часто имеют тенденцию к созданию вокруг себя карго-культов, соблазняющих разработчиков применять паттерны разработки или использовать определённые подходы, не позволяя им задуматься. В контексте автоматизированного тестирования такая ситуация возникла с нездоровой одержимостью отрасли юнит-тестированием.

В этой статье я поделюсь своими наблюдениями о данном способе тестирования и расскажу о том, почему считаю его неэффективным. Также я скажу о том, какие подходы использую для тестирования своего кода, как в open-source-проектах, так и в повседневной работе.

Примечание: код примеров этой статьи написан на C#, но при объяснении моей позиции сам язык не (особо) важен.

Примечание 2: я пришёл к выводу, что терминология программирования совершенно не передаёт свой смысл, потому что каждый, похоже, понимает её по-своему. В этой статье я буду использовать «стандартные» определения: юнит-тестирование направлено на проверку наименьших отдельных частей кода, сквозное тестирование (end-to-end testing) проверяет самые отдалённые друг от друга входные точки ПО, а интеграционное тестирование (integration testing) используется для всего промежуточного между ними.

Примечание 3: если вам не хочется читать статью целиком, то можете сразу перейти к выводам в конце.

Заблуждения о юнит-тестировании


Юнит-тесты, как понятно из их названия, связаны с понятием «юнита», обозначающим очень маленькую изолированную часть системы. Не существует формального определения того, что такое юнит, и насколько он должен быть мал, но чаще всего принимается, что он соответствует отдельной функции модуля (или методу объекта).

Обычно, если код пишется без учёта юнит-тестирования, тестирование некоторых функций в полной изоляции может оказаться невозможным, потому что они могут иметь внешние зависимости. Чтобы обойти эту проблему, мы можем применить принцип инверсии зависимостей и заменить конкретные зависимости абстракциями. Затем эти абстракции можно заменить на реальные или фальшивые реализации; это зависит от того, выполняется ли код обычным образом, или как часть теста.

Кроме того, ожидается, что юнит-тесты должны быть чистыми. Например, если функция содержит код, записывающий данные в файловую систему, то эту часть тоже нужно абстрагировать — в противном случае, тест, проверяющий такое поведение, будет считаться интеграционным тестом, потому что он покрывает ещё и интеграцию юнита с файловой системой.

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

Чтобы продемонстрировать, как эти нюансы влияют на проектирование, давайте возьмём пример простой системы, которую мы хотим протестировать. Представьте, что мы работаем над приложением, вычисляющим время восхода и заката; свою задачу оно выполняет при помощи следующих двух классов:

public class LocationProvider : IDisposable
{
    private readonly HttpClient _httpClient = new HttpClient();

    // Gets location by query
    public async Task<Location> GetLocationAsync(string locationQuery) { /* ... */ }

    // Gets current location by IP
    public async Task<Location> GetLocationAsync() { /* ... */ }

    public void Dispose() => _httpClient.Dispose();
}

public class SolarCalculator : IDiposable
{
    private readonly LocationProvider _locationProvider = new LocationProvider();

    // Gets solar times for current location and specified date
    public async Task<SolarTimes> GetSolarTimesAsync(DateTimeOffset date) { /* ... */ }

    public void Dispose() => _locationProvider.Dispose();
}

Хотя представленная выше структура совершенно верна с точки зрения ООП, ни для одного из этих классов невозможно провести юнит-тестирование. Поскольку LocationProvider зависит от своего собственного экземпляра HttpClient, а SolarCalculator, в свою очередь, зависит от LocationProvider, невозможно изолировать бизнес-логику, которая может содержаться внутри методов этих классов.

Давайте выполним итерацию кода и заменим конкретные реализации абстракциями:

public interface ILocationProvider
{
    Task<Location> GetLocationAsync(string locationQuery);

    Task<Location> GetLocationAsync();
}

public class LocationProvider : ILocationProvider
{
    private readonly HttpClient _httpClient;

    public LocationProvider(HttpClient httpClient) =>
        _httpClient = httpClient;

    public async Task<Location> GetLocationAsync(string locationQuery) { /* ... */ }

    public async Task<Location> GetLocationAsync() { /* ... */ }
}

public interface ISolarCalculator
{
    Task<SolarTimes> GetSolarTimesAsync(DateTimeOffset date);
}

public class SolarCalculator : ISolarCalculator
{
    private readonly ILocationProvider _locationProvider;

    public SolarCalculator(ILocationProvider locationProvider) =>
        _locationProvider = locationProvider;

    public async Task<SolarTimes> GetSolarTimesAsync(DateTimeOffset date) { /* ... */ }
}

Благодаря этому мы сможем отделить LocationProvider от SolarCalculator, но взамен размер кода увеличился почти в два раза. Обратите также внимание на то, что нам пришлось исключить из обоих классов IDisposable, потому что они больше не владеют своими зависимостями, а следовательно, не отвечают за их жизненный цикл.

Хотя некоторым подобные изменения могут показаться усовершенствованиями, важно указать на то, что определённые нами интерфейсы не имеют практической пользы, за исключением возможности проведения юнит-тестирования. В нашей структуре нет необходимости в полиморфизме, то есть в нашем конкретном случае такие абстракции являются самоцельными (т.е. абстракциями ради абстракций).

Давайте попробуем воспользоваться преимуществами проделанной работы и написать юнит-тест для SolarCalculator.GetSolarTimesAsync:

public class SolarCalculatorTests
{
    [Fact]
    public async Task GetSolarTimesAsync_ForKyiv_ReturnsCorrectSolarTimes()
    {
        // Arrange
        var location = new Location(50.45, 30.52);
        var date = new DateTimeOffset(2019, 11, 04, 00, 00, 00, TimeSpan.FromHours(+2));

        var expectedSolarTimes = new SolarTimes(
            new TimeSpan(06, 55, 00),
            new TimeSpan(16, 29, 00)
        );

        var locationProvider = Mock.Of<ILocationProvider>(lp =>
            lp.GetLocationAsync() == Task.FromResult(location)
        );

        var solarCalculator = new SolarCalculator(locationProvider);

        // Act
        var solarTimes = await solarCalculator.GetSolarTimesAsync(date);

        // Assert
        solarTimes.Should().BeEquivalentTo(expectedSolarTimes);
    }
}

Мы получили простой тест, проверяющий, что SolarCalculator правильно работает для известного нам местоположения. Так как юнит-тесты и их юниты тесно связаны, мы используем рекомендуемую систему наименований, а название метода теста соответствует паттерну Method_Precondition_Result («Метод_Предусловие_Результат»).

Чтобы симулировать нужное предусловие на этапе Arrange, нам нужно внедрить в зависимость юнита ILocationProvider соответствующее поведение. В данном случае мы реализуем это заменой возвращаемого значения GetLocationAsync() на местоположение, для которого заранее известно правильное время восхода и заката.

Обратите внимание, что хотя ILocationProvider раскрывает два разных метода, с точки зрения контракта мы не имеем возможности узнать, какой из них вызывается. Это означает, что при выборе имитации одного из этих методов мы делаем предположение о внутренней реализации тестируемого метода (которую в предыдущих фрагментах кода мы намеренно скрыли).

В конечном итоге, тест правильно проверяет, что бизнес-логика внутри GetSolarTimesAsync работает ожидаемым образом. Однако давайте перечислим наблюдения, сделанные нами в процессе работы.

1. Юнит-тесты имеют ограниченную применимость

Важно понимать, что задача любого юнит-теста очень проста: проверять бизнес-логику в изолированной области действия. Применимость юнит-тестирования зависит от взаимодействий, которые нам нужно протестировать.

Например, логично ли подвергать юнит-тесту метод, вычисляющий время восхода и заката при помощи долгого и сложного математического алгоритма? Скорее всего да.

Имеет ли смысл выполнять юнит-тест метода, отправляющего запрос к REST API для получения географических координат? Скорее всего нет.

Если рассматривать юнит-тестирование как самоцель, то вы вскоре обнаружите, что несмотря на множество усилий, большинство тестов неспособно обеспечить нужный вам уровень уверенности просто потому, что они тестируют не то, что необходимо. В большинстве случаев гораздо выгоднее тестировать более обширные взаимодействия при помощи интегрального тестирования, чем фокусироваться конкретно на юнит-тестах.

Любопытно, что некоторые разработчики в подобных ситуациях в конечном итоге всё-таки пишут интегральные тесты, но по-прежнему называют их юнит-тестами. В основном это вызвано путаницей, которая окружает это понятие. Конечно, можно заявить, что размер юнита можно выбирать произвольно и что он может охватывать несколько компонентов, но из-за этого определение становится очень нечётким, а потому использование термина оказывается совершенно бесполезным.

2. Юнит-тесты приводят к усложнению структуры

Один из наиболее популярных аргументов в пользу юнит-тестирования заключается в том, что оно стимулирует вас проектировать ПО очень модульным образом. Аргумент основан на предположении, что проще воспринимать код, когда он разбит на множество мелких компонентов, а не на малое количество крупных.

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

Кроме того, избыточное использование абстракций, необходимое для обеспечения изоляции компонентов, создаёт множество необязательных косвенных взаимодействий. Хоть сама по себе эта техника является невероятно мощной и полезной, абстракции неизбежно повышают когнитивную сложность, ещё сильнее затрудняя восприятие кода.

Из-за таких косвенных взаимодействий мы в конечном итоге теряем определённую степень инкапсуляции, которую могли бы сохранить. Например, ответственность за управление сроком жизни отдельных зависимостей переходит от содержащих их компонентов к какому-то другому не связанному с ними сервису (обычно к контейнеру зависимостей).

Часть инфраструктурной сложности тоже можно делегировать фреймворку внедрения зависимостей, что упрощает конфигурирование зависимостей, управление ими и активацию. Однако это снижает компактность, что в некоторых случаях, например, при написании библиотеки, нежелательно.

В конечном итоге, хоть и очевидно, что юнит-тестирование влияет на проектирование ПО, его полезность весьма спорна.

3. Юнит-тесты затратны

Можно логично предположить, что из-за своего малого размера и изолированности юнит-тесты очень легко и быстро писать. К сожалению, это ещё одно заблуждение; похоже, оно довольно популярно, особенно среди руководства.

Хоть упомянутая выше модульная архитектура заставляет нас думать, что индивидуальные компоненты можно рассматривать отдельно друг от друга, на самом деле юнит-тесты от этого не выигрывают. В действительности, сложность юнит-теста только растёт пропорционально количеству его внешних взаимодействий; это вызвано всей той работой, которую необходимо проделать для достижения изолированности при сохранении требуемого поведения.

Показанный выше пример довольно прост, однако в реальном проекте этап Arrange довольно часто может растягиваться на множество длинных строк, в которых просто задаются предусловия одного теста. В некоторых случаях имитируемое поведение может быть настолько сложным, что почти невозможно распутать его, чтобы разобраться, что оно должно было делать.

Кроме того, юнит-тесты по самой своей природе очень тесно связаны с тестируемым кодом, то есть все трудозатраты для внесения изменений по сути удваиваются, чтобы тест соответствовал обновлённому коду. Ухудшает ситуацию и то, что очень немногим разработчикам эта задача кажется увлекательной, поэтому они просто сбрасывают её на менее опытных членов команды.

4. Юнит-тесты зависят от подробностей реализации

Печальным следствием юнит-тестирования на основе заглушек (mocks) заключается в том, что любой тест, написанный по этой технике, обязательно учитывает реализацию. Имитируя конкретную зависимость, тест начинает полагаться на то, как тестируемый код потребляет эту зависимость, что не регулируется публичным интерфейсом.

Эта дополнительная связь часто приводит к неожиданным проблемам, при которых изменения, которые, казалось бы, ничего не могут сломать, начинают давать сбой при устаревании заглушек. Это может очень напрягать и в конечном итоге отталкивает разработчиков от рефакторинга кода, потому что никогда непонятно, возникла ли ошибка в тесте из-за действительной регрессии или из-за того, что он зависит от подробностей реализации.

Ещё более сложным может быть юнит-тестирование кода с хранением состояния, потому что наблюдение за мутациями через публичный интерфейс может оказаться невозможным. Чтобы обойти эту проблему, обычно можно внедрять шпионов, то есть своего рода имитируемое поведение, регистрирующее вызов функций и помогающее убедиться, что юнит использует свои зависимости правильно.

Разумеется, когда мы зависим не только от вызова конкретной функции, но и от количества вызовов и переданных аргументов, то тест становится ещё более тесно связанным с реализацией. Написанные таким образом тесты полезны только для внутренней специфики и обычно даже ожидается, что они не будут изменяться (крайне неразумное ожидание).

Слишком сильная зависимость от подробностей реализации также очень усложняет сами тесты, учитывая объём подготовки, необходимый для имитации определённого поведения; особенно справедливо это, когда взаимодействия нетривиальны или присутствует множество зависимостей. Когда тесты становятся настолько сложными, что трудно понимать само их поведение, то кто будет писать тесты для тестирования тестов?

5. Юнит-тесты не используют действия пользователей

Какое бы ПО вы не разрабатывали, его задача — обеспечение ценности для конечного пользователя. На самом деле, основная причина написания автоматизированных тестов — обеспечение гарантии отсутствия непреднамеренных дефектов, способных снизить эту ценность.

В большинстве случаев пользователь работает с ПО через какой-нибудь высокоуровневый интерфейс типа UI, CLI или API. Хотя в самом коде могут применяться множественные слои абстракции, для пользователя важен только тот уровень, который он видит и с которым взаимодействует.

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

Разумеется, если вы хотите гарантировать правильность работы какого-то элемента, то нужно проверить именно его и посмотреть, действительно ли он работает правильно. В нашем случае, лучшим способом обеспечения уверенности в системе является симуляция взаимодействия реального пользователя с высокоуровневым интерфейсом и проверка того, что он работает в соответствии с ожиданиями.

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

Тестирование на основе заглушек ставит ценность таких тестов под ещё большее сомнение, потому что части системы, которые бы использовались, заменяются на имитации, ещё сильнее отдаляя симулируемое окружение от реальности. Невозможно обеспечить уверенность удобства работы пользователя, тестируя нечто непохожее на эту работу.


Юнит-тестирование — отличный способ проверки работы заглушек

Тестирование на основе пирамиды


Так почему же мы как отрасль решили, что юнит-тестирование должно быть основным способом тестирования ПО, несмотря на все его изъяны? В основном это вызвано тем, что тестирование на высоких уровнях всегда считалось слишком трудным, медленным и ненадёжным.

Если обратиться к традиционной пирамиде тестирования, то она предполагает, что наиболее значимая часть тестирования должна выполняться на уровне юнитов. Смысл в том, что поскольку крупные тесты считаются более медленными и сложными, для получения эффективного и поддерживаемого набора тестов нужно сосредоточить усилия на нижней части спектра интеграции:


Сверху — сквозное тестирование, в центре — интегральное тестирование, внизу — юнит-тестирование

Метафорическая модель, предлагаемая пирамидой, должна передать нам мысль о том, что для качественного тестирования должны использоваться множество различных слоёв, ведь если сконцентрироваться на крайностях, то это может привести к проблемам: тесты будут или слишком медленными и неповоротливыми, или бесполезными и не обеспечивающими никакой уверенности. Тем не менее, упор делается на нижние уровни, потому что считается, что там возврат инвестиций в разработку тестов наиболее высок.

Высокоуровневые тесты, хоть и обеспечивают наибольшую уверенность, часто оказываются медленными, сложными в поддержке или слишком широкими для включения в обычно быстрый процесс разработки. Именно поэтому в большинстве случаев такие тесты поддерживаются специалистами QA, ведь обычно считается, что писать их должны не разработчики.

Интегральное тестирование, которое на абстрактной части спектра лежит где-то между юнит-тестированием и полным сквозным тестированием, часто совершенно игнорируется. Непонятно, какой конкретно уровень интеграции предпочтим, как структурировать и организовывать такие тесты. Кроме того, существуют опасения, что они выйдут из под контроля. Поэтому многие разработчики отказываются от них в пользу более чётко очерченной крайности, которой и является юнит-тестирование.

Из-за этих причин всё тестирование в процессе разработки обычно остаётся на самом дне пирамиды. На самом деле, это стало настолько стандартным, что тестирование разработки и юнит-тестирование сегодня стали практически синонимами, что приводит к путанице, усиливаемой докладами на конференциях, постами в блогах, книгами и даже некоторыми IDE (по мнению JetBrains Rider, все тесты являются юнит-тестами).

По мнению большинства разработчиков, пирамида тестирования выглядит примерно так:


Сверху — не моя проблема, внизу — юнит-тестирование

Хотя эта пирамида стала достойной уважения попыткой превратить тестирование ПО в решённую задачу, в этой модели очевидно есть множество проблем. В частности, используемые в ней допущения справедливы не во всех контекстах, особенно допущение о том, что высокоинтегральные наборы тестов являются медленными или трудными.

Мы как люди естественно склонны полагаться на информацию, переданную нам более опытными людьми, благодаря чему можем использовать знания предыдущих поколений и применять вторую систему мышления к чему-то более полезному. Это важная эволюционная черта, чрезвычайно повысившая нашу выживаемость как вида.

Однако когда мы экстраполируем свой опыт в инструкции, то обычно воспринимаем их как хорошие сами по себе, забывая об условиях, неотъемлемо связанных с их актуальностью. На самом деле эти условия меняются, и когда-то совершенно логичные выводы (или best practices) могут оказаться не столь хорошо применимыми.

Если взглянуть на прошлое, то очевидно, что в 2000-х высокоуровневое тестирование было сложным, вероятно, оно оставалось таким даже в 2009 году, но на дворе 2020 год и мы уже живём в будущем. Благодаря прогрессу технологий и проектирования ПО эти проблемы стали гораздо менее важными, чем ранее.

Сегодня большинство современных фреймворков предоставляет какой-нибудь отдельный слой API, используемый для тестирования: в нём можно запускать приложение в симулируемой среде внутри памяти, которое очень близко к реальной. Такие инструменты виртуализации, как Docker, также позволили нам проводить тесты, полагающиеся на действительные инфраструктурные зависимости, сохраняя при этом свою детерминированность и скорость.

У нас есть такие решения, как Mountebank, WireMock, GreenMail, Appium, Selenium, Cypress и бесконечное множество других, они упрощают различные аспекты высокоуровневого тестирования, которые когда-то считались недостижимыми. Если вы не разрабатываете десктопные приложения для Windows и не вынуждены использовать фреймворк UIAutomation, то у вас, скорее всего, есть множество возможных вариантов выбора.

В одном из моих предыдущих проектов у нас был веб-сервис, тестировавшийся на границе системы при помощи почти сотни поведенческих тестов, на параллельное выполнение которых тратилось менее 10 секунд. Разумеется, при использовании юнит-тестов можно добиться гораздо более быстрого выполнения, но учитывая обеспечиваемую уверенность, мы даже не рассматривали такую возможность.

Однако заблуждение о медленности тестов — не единственное ошибочное допущение, на котором основана пирамида. Принцип применения большинства тестов на уровне юнитов работает только тогда, когда эти тесты действительно обеспечивают ценность, что, разумеется, зависит от того, какой объём бизнес-логики находится в тестируемом коде.

В некоторых приложениях бизнес-логики может быть много (например, в системах подсчёта зарплаты), в некоторых она почти отсутствует (например, в CRUD-приложениях), а большинство ПО находится где-то посередине. Большинство проектов, над которыми работал лично я, не содержали такого объёма, чтобы была необходимость в обширном покрытии юнит-тестами; с другой стороны, в них было много инфраструктурной сложности, для которой было бы полезно интегральное тестирование.

Разумеется, в идеальном мире разработчик смог бы оценить контекст проекта и создать способ тестирования, наиболее подходящий для решения насущных задач. Однако в реальности большинство разработчиков даже не задумывается об этом, слепо наворачивая горы юнит-тестов в соответствии с рекомендациями best practices.

Наконец, по-моему, было бы справедливо сказать, что создаваемая пирамидой тестирования модель слишком проста в целом. Вертикальная ось представляет спектр тестирования как линейную шкалу, при которой любое повышение уверенности компенсируется эквивалентной величиной потери поддерживаемости и скорости. Это может быть истинным, если вы сравниваете крайние случаи, но не всегда правда для точек в промежутке между ними.

Пирамида также не учитывает того, что изоляция сама по себе имеет цену, она не возникает бесплатно просто благодаря «избеганию» внешних взаимодействий. Учитывая то, сколько труда требуется для написания и поддержки заглушек, вполне возможно, что менее изолированный тест может быть дешевле и в конечном итоге обеспечит бОльшую уверенность, хоть и при чуть более низкой скорости выполнения.

Если принять во внимание эти аспекты, то кажется вероятным, что шкала окажется нелинейной, и что точка максимального возврата инвестиций находится где-то ближе к середине, а не к уровню юнитов:


В конечном итоге, если вы пытаетесь определить эффективный набор тестов для своего проекта, пирамида тестирования — не лучший образец, которому можно следовать. Гораздо логичнее сосредоточиться на том, что относится конкретно к вашему контексту, а не полагаться на «best practices».

Тестирование на основе реальности


На самом базовом уровне тест обеспечивает ценность, если он гарантирует нам, что ПО работает правильно. Чем больше мы уверены, тем меньше нам нужно полагаться на себя в поиске потенциальных багов и регрессий при внесении изменений в код, потому что мы поручаем заниматься этим тестам.

Это доверие, в свою очередь, зависит от точности воспроизведения тестом настоящего поведения пользователя. Тестовый сценарий, работающий на границе системы без знаний её внутренней специфики должен обеспечивать нам бОльшую уверенность (а значит, и ценность), чем тест, работающий на нижнем уровне.

По сути, степень получаемой от тестов уверенности — это основная метрика, которой должна измеряться их ценность. А основная цель — это её максимальное увеличение.

Разумеется, как мы знаем, в деле задействованы и другие факторы: цена, скорость, возможность параллелизации и прочие, и все они тоже важны. Пирамида тестирования создаёт сильные допущения о том, как взаимосвязаны масштабирование этих элементов, но эти допущения не универсальны.

Более того, эти факторы являются вторичными относительно основной цели — достижения уверенности. Дорогой и долго выполняемый тест, обеспечивающий большую уверенность, бесконечно более полезен, чем чрезвычайно быстрый и простой тест, который не делает ничего.

Поэтому я считаю, что лучше писать тесты с максимально возможной степенью интеграции, поддерживая при этом их разумную скорость и сложность.

Значит ли это, что каждый создаваемый нами тест должен быть сквозным? Нет, но мы должны стремиться как можно дальше продвинуться в этом направлении, обеспечивая при этом допустимый уровень недостатков.

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

В таком случае у нас скорее всего получатся тесты, разбросанные по нескольким уровням шкалы интеграции при кажущемся отсутствии ощущения структуры. Такой проблемы не возникает при юнит-тестировании, потому что в нём каждый тест связан с конкретным методом или функцией, поэтому структура обычно отзеркаливает сам код.

К счастью, это не важно, потому что упорядочивание тестов по отдельным классам или модулям не имеет значения само по себе; скорее, это побочный эффект юнит-тестирования. Вместо этого тесты должны разделятся по истинной пользовательской функциональности, которую они проверяют.

Такие тесты часто называют функциональными (functional), потому что они основаны на требованиях к функциональности ПО, описывающих его возможности и способ их работы. Функциональное тестирование — это не ещё один слой пирамиды, а совершенно перпендикулярная ей концепция.

Вопреки распространённому мнению, для написания функциональных тестов не требуется использовать Gherkin или фреймворк BDD, их можно реализовать при помощи тех же инструментов, которые применяются для юнит-тестирования. Например, давайте подумаем, как мы можем переписать пример из начала статьи так, чтобы тесты были структурированы на основе поддерживаемого поведения пользователей, а не юнитов кода:

public class SolarTimesSpecs
{
    [Fact]
    public async Task User_can_get_solar_times_automatically_for_their_location() { /* ... */ }

    [Fact]
    public async Task User_can_get_solar_times_during_periods_of_midnight_sun() { /* ... */ }

    [Fact]
    public async Task User_can_get_solar_times_if_their_location_cannot_be_resolved() { /* ... */ }
}

Обратите внимание, что сама реализация тестов скрыта, потому что она не связана с тем, что они функциональны. Важно здесь то, что тесты и их структура задаются требованиями к ПО, а их масштаб теоретически может изменяться от сквозного тестирования и даже до уровня юнитов.

Называя тесты в соответствии со спецификациями, а не с классами, мы получаем дополнительное преимущество — устраняем эту необязательную связь. Теперь, если мы решим переименовать SolarCalculator во что-то ещё или переместим его в другой каталог, то названия тестов не нужно будет изменять.

Если придерживаться такой структуры, то наш набор тестов по сути принимает вид живой документации. Вот, например, как организован набор тестов в CliWrap (xUnit заменил нижние подчёркивания на пробелы):


Пока элемент ПО выполняет нечто хотя бы отдалённо полезное, то он всегда имеет функциональные требования. Они могут быть или формальными (документы спецификации, пользовательские истории, и т.д.) или неформальными (в устной форме, допускаемые, тикеты JIRA, записанные на туалетной бумаге, и т.д.)

Преобразование неформальных спецификаций в функциональные тесты часто может быть сложным процессом, потому что для этого требуется отступить от кода и заставить себя взглянуть на ПО с точки зрения пользователя. В моих open-source-проектах мне помогает составление файла readme, в котором я перечисляю список примеров использования, а затем кодирую их в тесты.

Подведём итог: можно заключить, что лучше разделять тесты по цепочкам поведений, а не по внутренней структуре кода.

Если объединить оба вышеупомянутых подхода, то образуется структура мышления, дающая нам чёткую цель написания тестов, а также понимание организации; при этом нам не нужно полагаться ни на какие допущения. Мы можем использовать эту структуру для создания набора тестов для проекта, сосредоточенный на ценности, а затем масштабировать его в соответствии с важными в текущем контексте приоритетами и ограничениями.

Принцип заключается в том, чтобы вместо упора на определённую область или набор областей, мы создаём набор тестов на основании пользовательской функциональности, стремясь как можно точнее покрыть эту функциональность.

Функциональное тестирование для веб-сервисов (с помощью ASP.NET Core)


Вероятно, вы не понимаете, из чего же состоит функциональное тестирование и как конкретно оно должно выглядеть, особенно если не занимались им раньше. Поэтому разумно будет привести простой, но законченный пример. Для этого мы превратим наш калькулятор восходов и закатов в веб-сервис и покроем его тестам в соответствии с правилами, изложенными в предыдущей части статьи. Это приложение будет основано на ASP.NET Core — веб-фреймворке, с которым я знаком больше всего, но такой же принцип должен быть применим к любой другой платформе.

Наш веб-сервис раскрывает свои конечные точки для вычисления времени восхода и заката на основании IP пользователя или указанного местоположения. Чтобы всё было чуть интереснее, для ускорения ответов мы добавим слой кэширования Redis, хранящий предыдущие вычисления.

Тесты будут выполняться запуском приложения в симулируемой среде, в которой оно может получать HTTP-запросы, обрабатывать маршрутизацию, выполнять валидацию и демонстрировать поведение. практически идентичное приложению, запущенному в продакшене. Также мы используем Docker, чтобы наши тесты использовали те же инфраструктурные зависимости, что и реальное приложение.

Чтобы разобраться, с чем мы имеем дело, давайте сначала рассмотрим реализацию веб-приложения. Обратите внимание, что некоторые части фрагментов кода ради краткости пропущены, а полный проект можно посмотреть на GitHub.

Для начала нам нужно найти способ определения местоположения пользователя по IP, выполняемое при помощи класса LocationProvider, который мы видели в предыдущих примерах. Он является простой обёрткой вокруг внешнего сервиса GeoIP-поиска под названием IP-API:

public class LocationProvider
{
    private readonly HttpClient _httpClient;

    public LocationProvider(HttpClient httpClient) =>
        _httpClient = httpClient;

    public async Task<Location> GetLocationAsync(IPAddress ip)
    {
        // If IP is local, just don't pass anything (useful when running on localhost)
        var ipFormatted = !ip.IsLocal() ? ip.MapToIPv4().ToString() : "";

        var json = await _httpClient.GetJsonAsync($"http://ip-api.com/json/{ipFormatted}");

        var latitude = json.GetProperty("lat").GetDouble();
        var longitude = json.GetProperty("lon").GetDouble();

        return new Location
        {
            Latitude = latitude,
            Longitude = longitude
        };
    }
}

Для преобразования местоположения во время восхода и заката мы воспользуемся алгоритмом вычисления восхода и заката, опубликованным Военно-морской обсерваторией США. Сам алгоритм слишком длинный, поэтому мы не будем его приводить здесь, а остальная часть реализации SolarCalculator имеет следующий вид:

public class SolarCalculator
{
    private readonly LocationProvider _locationProvider;

    public SolarCalculator(LocationProvider locationProvider) =>
        _locationProvider = locationProvider;

    private static TimeSpan CalculateSolarTimeOffset(Location location, DateTimeOffset instant,
        double zenith, bool isSunrise)
    {
        /* ... */

        // Algorithm omitted for brevity

        /* ... */
    }

    public async Task<SolarTimes> GetSolarTimesAsync(Location location, DateTimeOffset date)
    {
        /* ... */
    }

    public async Task<SolarTimes> GetSolarTimesAsync(IPAddress ip, DateTimeOffset date)
    {
        var location = await _locationProvider.GetLocationAsync(ip);

        var sunriseOffset = CalculateSolarTimeOffset(location, date, 90.83, true);
        var sunsetOffset = CalculateSolarTimeOffset(location, date, 90.83, false);

        var sunrise = date.ResetTimeOfDay().Add(sunriseOffset);
        var sunset = date.ResetTimeOfDay().Add(sunsetOffset);

        return new SolarTimes
        {
            Sunrise = sunrise,
            Sunset = sunset
        };
    }
}

Так как это веб-приложение MVC, нам также потребуется контроллер, предоставляющий конечные точки для раскрытия функциональности приложения:

[ApiController]
[Route("solartimes")]
public class SolarTimeController : ControllerBase
{
    private readonly SolarCalculator _solarCalculator;
    private readonly CachingLayer _cachingLayer;

    public SolarTimeController(SolarCalculator solarCalculator, CachingLayer cachingLayer)
    {
        _solarCalculator = solarCalculator;
        _cachingLayer = cachingLayer;
    }

    [HttpGet("by_ip")]
    public async Task<IActionResult> GetByIp(DateTimeOffset? date)
    {
        var ip = HttpContext.Connection.RemoteIpAddress;
        var cacheKey = $"{ip},{date}";

        var cachedSolarTimes = await _cachingLayer.TryGetAsync<SolarTimes>(cacheKey);
        if (cachedSolarTimes != null)
            return Ok(cachedSolarTimes);

        var solarTimes = await _solarCalculator.GetSolarTimesAsync(ip, date ?? DateTimeOffset.Now);
        await _cachingLayer.SetAsync(cacheKey, solarTimes);

        return Ok(solarTimes);
    }

    [HttpGet("by_location")]
    public async Task<IActionResult> GetByLocation(double lat, double lon, DateTimeOffset? date)
    {
        /* ... */
    }
}

Как показано выше, конечная точка /solartimes/by_ip в основном просто делегирует исполнение SolarCalculator, а кроме того, имеет очень простую логику кэширования для избавления от избыточных запросов к сторонним сервисам. Кэширование выполняется классом CachingLayer, инкапсулирующим клиент Redis, используемый для хранения и получения JSON-контента:

public class CachingLayer
{
    private readonly IConnectionMultiplexer _redis;

    public CachingLayer(IConnectionMultiplexer connectionMultiplexer) =>
        _redis = connectionMultiplexer;

    public async Task<T> TryGetAsync<T>(string key) where T : class
    {
        var result = await _redis.GetDatabase().StringGetAsync(key);

        if (result.HasValue)
            return JsonSerializer.Deserialize<T>(result.ToString());

        return null;
    }

    public async Task SetAsync<T>(string key, T obj) where T : class =>
        await _redis.GetDatabase().StringSetAsync(key, JsonSerializer.Serialize(obj));
}

Все описанные выше части соединяются вместе в классе Startup, конфигурирующем конвейер запросов и регистрирующем требуемые сервисы:

public class Startup
{
    private readonly IConfiguration _configuration;

    public Startup(IConfiguration configuration) =>
        _configuration = configuration;

    private string GetRedisConnectionString() =>
        _configuration.GetConnectionString("Redis");

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc(o => o.EnableEndpointRouting = false);

        services.AddSingleton<IConnectionMultiplexer>(
            ConnectionMultiplexer.Connect(GetRedisConnectionString()));

        services.AddSingleton<CachingLayer>();

        services.AddHttpClient<LocationProvider>();
        services.AddTransient<SolarCalculator>();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
            app.UseDeveloperExceptionPage();

        app.UseMvcWithDefaultRoute();
    }
}

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

Хоть проект и довольно прост, это приложение уже содержит в себе достаточное количество инфраструктурной сложности: оно полагается на сторонний веб-сервис (провайдера GeoIP), а также на слой хранения данных (Redis). Это вполне стандартная схема, используемая во многих реальных проектах.

В классическом подходе, сосредоточенном на юнит-тестировании, мы бы нацелились на слой сервисов и, возможно, на слой контроллера приложения, и писали бы изолированные тесты, гарантирующие правильное выполнение каждой ветви кода. Такой подход будет в какой-то мере полезен, но он никогда не обеспечит нам уверенности в том, что истинные конечные точки со всеми middleware и периферийными компонентами работают как положено.

Так что вместо этого мы напишем тесты, нацеленные непосредственно на конечные точки. Для этого нам понадобится создать отдельный тестовый проект и добавить несколько инфраструктурных компонентов, поддерживающих наши тесты. Один из них — это FakeApp, который будет использоваться для инкапсуляции виртуального экземпляра приложения:

public class FakeApp : IDisposable
{
    private readonly WebApplicationFactory<Startup> _appFactory;

    public HttpClient Client { get; }

    public FakeApp()
    {
        _appFactory = new WebApplicationFactory<Startup>();
        Client = _appFactory.CreateClient();
    }

    public void Dispose()
    {
        Client.Dispose();
        _appFactory.Dispose();
    }
}

Основная часть работы здесь уже выполнена WebApplicationFactory — предоставляемой фреймворком утилитой, позволяющей нам загрузить программу в память в целях тестирования. Также она предоставляет нам API для переопределения конфигурации, регистрации сервисов и конвейера обработки запросов.

Мы можем использовать экземпляр этого объекта в тестах для запуска приложения и отправки запросов с предоставленным HttpClient, а затем проверять соответствует ли ответ нашим ожиданиям. Этот экземпляр может быть или общим для нескольких тестов, или создаваться отдельно для каждого теста.

Поскольку мы также используем Redis, нам нужен способ запуска нового сервера, который будет использоваться приложением. Существует множество способов реализации этого, но для простого примера я решил использовать в этих целях API оборудования (fixture) фреймворка xUnit:

public class RedisFixture : IAsyncLifetime
{
    private string _containerId;

    public async Task InitializeAsync()
    {
        // Simplified, but ideally should bind to a random port
        var result = await Cli.Wrap("docker")
            .WithArguments("run -d -p 6379:6379 redis")
            .ExecuteBufferedAsync();

        _containerId = result.StandardOutput.Trim();
    }

    public async Task ResetAsync() =>
        await Cli.Wrap("docker")
            .WithArguments($"exec {_containerId} redis-cli FLUSHALL")
            .ExecuteAsync();

    public async Task DisposeAsync() =>
        await Cli.Wrap("docker")
            .WithArguments($"container kill {_containerId}")
            .ExecuteAsync();
}

Показанный выше код реализует интерфейс IAsyncLifetime, позволяющий нам определять методы, которые будут выполняться до и после запусков тестов. Мы используем эти методы для запуска контейнера Redis в Docker с последующим его уничтожением после завершением тестирования.

Кроме того, класс RedisFixture также раскрывает метод ResetAsync, который можно использовать для выполнения команды FLUSHALL, удаляющей все ключи из базы данных. Мы будем вызывать этот метод перед каждым тестом для сброса Redis к чистому состоянию. В качестве альтернативы мы могли бы просто перезапускать контейнер, что занимает больше времени, но более надёжно.

Настроив инфраструктуру, можно переходить к написанию первого теста:

public class SolarTimeSpecs : IClassFixture<RedisFixture>, IAsyncLifetime
{
    private readonly RedisFixture _redisFixture;

    public SolarTimeSpecs(RedisFixture redisFixture)
    {
        _redisFixture = redisFixture;
    }

    // Reset Redis before each test
    public async Task InitializeAsync() => await _redisFixture.ResetAsync();

    [Fact]
    public async Task User_can_get_solar_times_for_their_location_by_ip()
    {
        // Arrange
        using var app = new FakeApp();

        // Act
        var response = await app.Client.GetStringAsync("/solartimes/by_ip");
        var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

        // Assert
        solarTimes.Sunset.Should().BeWithin(TimeSpan.FromDays(1)).After(solarTimes.Sunrise);
        solarTimes.Sunrise.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromDays(1));
        solarTimes.Sunset.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromDays(1));
    }
}

Как видите, схема очень проста. Нам достаточно лишь создать экземпляр FakeApp и использовать предоставленный HttpClient для отправки запросов к одной из конечных точек, как бы это происходило в реальном веб-приложении.

Конкретно этот тест запрашивает маршрут /solartimes/by_ip, определяющий время восхода и заката для текущей даты на основании IP пользователя. Так как мы полагаемся на настоящего провайдера GeoIP и не знаем, каким будет результат, то выполняем утверждения на основе свойств, чтобы гарантировать валидность времени восхода и заката.

Хоть эти утверждения и способны отследить множество потенциальных багов, они не дают нам полной уверенности в совершенной правильности результата. Однако существует пара способов, которыми мы можем улучшить ситуацию.

Очевидный способ — можно заменить настоящего провайдера GeoIP фальшивым экземпляром, всегда возвращающим одинаковое местоположение, что позволит нам жёстко прописать в коде ожидаемое время восхода и заката. Недостаток такого подхода в том, что мы по сути уменьшаем масштаб интеграции, то есть мы не сможем убедиться, что приложение правильно общается со сторонним сервисом.

В качестве альтернативного подхода мы можем заменить IP-адрес, который тестовый сервер получает от клиента. Благодаря этому мы сделаем тест более строгим, сохраняя при этом тот же масштаб интеграции.

Для этого нам понадобится создать фильтр запуска, позволяющий нам при помощи middleware инъектировать выбранный IP-адрес в контекст запроса:

public class FakeIpStartupFilter : IStartupFilter
{
    public IPAddress Ip { get; set; } = IPAddress.Parse("::1");

    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> nextFilter)
    {
        return app =>
        {
            app.Use(async (ctx, next) =>
            {
                ctx.Connection.RemoteIpAddress = Ip;
                await next();
            });

            nextFilter(app);
        };
    }
}

Затем мы можем соединить его с FakeApp, зарегистрировав его в качестве сервиса:

public class FakeApp : IDisposable
{
    private readonly WebApplicationFactory<Startup> _appFactory;
    private readonly FakeIpStartupFilter _fakeIpStartupFilter = new FakeIpStartupFilter();

    public HttpClient Client { get; }

    public IPAddress ClientIp
    {
        get => _fakeIpStartupFilter.Ip;
        set => _fakeIpStartupFilter.Ip = value;
    }

    public FakeApp()
    {
        _appFactory = new WebApplicationFactory<Startup>().WithWebHostBuilder(o =>
        {
            o.ConfigureServices(s =>
            {
                s.AddSingleton<IStartupFilter>(_fakeIpStartupFilter);
            });
        });

        Client = _appFactory.CreateClient();
    }

    /* ... */
}

Теперь мы можем дополнить тест, чтобы он использовал конкретные данные:

[Fact]
public async Task User_can_get_solar_times_for_their_location_by_ip()
{
    // Arrange
    using var app = new FakeApp
    {
        ClientIp = IPAddress.Parse("20.112.101.1")
    };

    var date = new DateTimeOffset(2020, 07, 03, 0, 0, 0, TimeSpan.FromHours(-5));
    var expectedSunrise = new DateTimeOffset(2020, 07, 03, 05, 20, 37, TimeSpan.FromHours(-5));
    var expectedSunset = new DateTimeOffset(2020, 07, 03, 20, 28, 54, TimeSpan.FromHours(-5));

    // Act
    var query = new QueryBuilder
    {
        {"date", date.ToString("O", CultureInfo.InvariantCulture)}
    };

    var response = await app.Client.GetStringAsync($"/solartimes/by_ip{query}");
    var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}

Некоторых разработчиков по-прежнему может тревожить использование в тестах стороннего веб-сервиса, потому что это может привести к недетерминированным результатам. В то же время, можно возразить, что нам действительно нужно встроить в наши тесты эту зависимость, потому что мы хотим знать, будет ли он ломаться или изменяться неожиданным образом, потому что это способно приводить к багам в нашем собственном ПО.

Разумеется, не всегда мы можем использовать реальные зависимости, например, если у сервиса есть ограничения на использование, он стоит денег, или просто медленный или ненадёжный. В таких случаях нам придётся заменить его на фальшивую (предпочтительно не на заглушку) реализацию для использования в тестах. Однако в нашем случае всё не так.

Аналогично тому, как мы сделали с первым тестом, можно написать тест, покрывающий вторую конечную точку. Этот тест проще, потому что все входящие параметры передаются напрямую как часть запроса URL:

[Fact]
public async Task User_can_get_solar_times_for_a_specific_location_and_date()
{
    // Arrange
    using var app = new FakeApp();

    var date = new DateTimeOffset(2020, 07, 03, 0, 0, 0, TimeSpan.FromHours(+3));
    var expectedSunrise = new DateTimeOffset(2020, 07, 03, 04, 52, 23, TimeSpan.FromHours(+3));
    var expectedSunset = new DateTimeOffset(2020, 07, 03, 21, 11, 45, TimeSpan.FromHours(+3));

    // Act
    var query = new QueryBuilder
    {
        {"lat", "50.45"},
        {"lon", "30.52"},
        {"date", date.ToString("O", CultureInfo.InvariantCulture)}
    };

    var response = await app.Client.GetStringAsync($"/solartimes/by_location{query}");
    var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}

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

Важно также заметить, что хоть мы и пытались этого по возможности избегать, всё равно можно снизить масштаб интеграции, если на то будут реальные причины. В данном случае можно попробовать покрыть дополнительные случаи юнит-тестами.

Обычно это означало бы, что нам каким-то образом нужно изолировать SolarCalculator от LocationProvider, что, в свою очередь, подразумевает заглушки. К счастью, есть хитрый способ этого избежать.

Мы можем изменить реализацию SolarCalculator разделив чистые и загрязнённые части кода:

public class SolarCalculator
{
    private static TimeSpan CalculateSolarTimeOffset(Location location, DateTimeOffset instant,
        double zenith, bool isSunrise)
    {
        /* ... */
    }

    public SolarTimes GetSolarTimes(Location location, DateTimeOffset date)
    {
        var sunriseOffset = CalculateSolarTimeOffset(location, date, 90.83, true);
        var sunsetOffset = CalculateSolarTimeOffset(location, date, 90.83, false);

        var sunrise = date.ResetTimeOfDay().Add(sunriseOffset);
        var sunset = date.ResetTimeOfDay().Add(sunsetOffset);

        return new SolarTimes
        {
            Sunrise = sunrise,
            Sunset = sunset
        };
    }
}

Мы изменили код так, что вместо использования LocationProvider для получения местоположения, метод GetSolarTimes получает его как явный параметр. Благодаря этому нам также больше не понадобится инверсия зависимостей, потому что зависимости для инвертирования отсутствуют.

Чтобы снова соединить всё вместе, нам достаточно изменить контроллер:

[ApiController]
[Route("solartimes")]
public class SolarTimeController : ControllerBase
{
    private readonly SolarCalculator _solarCalculator;
    private readonly LocationProvider _locationProvider;
    private readonly CachingLayer _cachingLayer;

    public SolarTimeController(
        SolarCalculator solarCalculator,
        LocationProvider locationProvider,
        CachingLayer cachingLayer)
    {
        _solarCalculator = solarCalculator;
        _locationProvider = locationProvider;
        _cachingLayer = cachingLayer;
    }

    [HttpGet("by_ip")]
    public async Task<IActionResult> GetByIp(DateTimeOffset? date)
    {
        var ip = HttpContext.Connection.RemoteIpAddress;
        var cacheKey = ip.ToString();

        var cachedSolarTimes = await _cachingLayer.TryGetAsync<SolarTimes>(cacheKey);
        if (cachedSolarTimes != null)
            return Ok(cachedSolarTimes);

        // Composition instead of dependency injection
        var location = await _locationProvider.GetLocationAsync(ip);
        var solarTimes = _solarCalculator.GetSolarTimes(location, date ?? DateTimeOffset.Now);

        await _cachingLayer.SetAsync(cacheKey, solarTimes);

        return Ok(solarTimes);
    }

    /* ... */
}

Так как имеющиеся тесты не знают о подробностях реализации, этот простой рефакторинг их не сломает. Сделав это, мы можем написать дополнительные короткие тесты для более подробного покрытия бизнес-логики без необходимости заглушек:

[Fact]
public void User_can_get_solar_times_for_New_York_in_November()
{
    // Arrange
    var location = new Location
    {
        Latitude = 40.71,
        Longitude = -74.00
    };

    var date = new DateTimeOffset(2019, 11, 04, 00, 00, 00, TimeSpan.FromHours(-5));
    var expectedSunrise = new DateTimeOffset(2019, 11, 04, 06, 29, 34, TimeSpan.FromHours(-5));
    var expectedSunset = new DateTimeOffset(2019, 11, 04, 16, 49, 04, TimeSpan.FromHours(-5));

    // Act
    var solarTimes = new SolarCalculator().GetSolarTimes(location, date);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}

[Fact]
public void User_can_get_solar_times_for_Tromso_in_January()
{
    // Arrange
    var location = new Location
    {
        Latitude = 69.65,
        Longitude = 18.96
    };

    var date = new DateTimeOffset(2020, 01, 03, 00, 00, 00, TimeSpan.FromHours(+1));
    var expectedSunrise = new DateTimeOffset(2020, 01, 03, 11, 48, 31, TimeSpan.FromHours(+1));
    var expectedSunset = new DateTimeOffset(2020, 01, 03, 11, 48, 45, TimeSpan.FromHours(+1));

    // Act
    var solarTimes = new SolarCalculator().GetSolarTimes(location, date);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}

Хоть эти тесты больше и не используют полную область интеграции, они всё равно исходят из функциональных требований к приложению. Так как у нас уже есть другой высокоуровневый тест, покрывающий конечную точку целиком, мы можем сделать эти тесты более узкими, не жертвуя при этом общим уровнем уверенности.

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

Наконец, нам стоит сделать что-нибудь, чтобы гарантировать правильную работу слоя кэширования Redis. Даже несмотря на то, что мы используем его в своих тестах, на деле он никогда не возвращает кэшированный ответ, потому что между тестами база данных сбрасывается к исходному состоянию.

Проблема при тестировании таких аспектов, как кэширование, заключается в том, что их невозможно задать функциональными требованиями. Пользователь, не знающий о внутренней работе приложения, не может узнать, возвращён ли ответ из кэша.

Однако если наша задача заключается только в тестировании интеграции между приложением и Redis, нам не нужно писать тесты, знающие об особенностях реализации, и вместо этого можно сделать нечто подобное:

[Fact]
public async Task User_can_get_solar_times_for_their_location_by_ip_multiple_times()
{
    // Arrange
    using var app = new FakeApp();

    // Act
    var collectedSolarTimes = new List<SolarTimes>();

    for (var i = 0; i < 3; i++)
    {
        var response = await app.Client.GetStringAsync("/solartimes/by_ip");
        var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

        collectedSolarTimes.Add(solarTimes);
    }

    // Assert
    collectedSolarTimes.Select(t => t.Sunrise).Distinct().Should().ContainSingle();
    collectedSolarTimes.Select(t => t.Sunset).Distinct().Should().ContainSingle();
}

Тест несколько раз выполняет запрос к одной конечной точке и утверждает, что результат всегда остаётся одинаковым. Этого достаточно для проверки правильности кэширования ответов и возврата их таким же образом, как и обычных ответов.

В конечном итоге мы получили простой набор тестов, который выглядит вот так:


Обратите внимание, что скорость выполнения тестов довольно хороша — самый быстрый интегральный тест завершается за 55 мс, а самый медленный — меньше чем за секунду (из-за холодного запуска). Учитывая то, что эти тесты задействуют весь рабочий цикл, в том числе все зависимости и инфраструктуру, при этом не используя никаких заглушек, я могу сказать, что это более чем приемлемо.

Если вы хотите самостоятельно поэкспериментировать с проектом, то его можно найти на GitHub.

Недостатки и ограничения


К сожалению, серебряной пули нет, и описанные в этой статье решения тоже страдают от потенциальных недостатков. В интересах справедливости будет разумно их упомянуть.

Одна из самых серьёзных проблем, с которыми я столкнулся при реализации высокоуровневого функционального тестирования — поиск удовлетворительного баланса между полезностью и применимостью. По сравнению с подходами, целиком сосредоточенными на юнит-тестировании, здесь приходится прикладывать больше усилий для проверки достаточной детерминированности таких тестов, их скорости выполнения, возможности выполнения независимо друг от друга и в целом их применимости в процессе разработки.

Широкомасштабность тестов также подразумевает необходимость более глубокого понимания зависимостей проекта и технологий, которые в нём применяются. Важно знать, как они используются, легко ли их контейнировать, наличие возможных вариантов и необходимые компромиссы.

В контексте интегрального тестирования аспект «тестируемости» определяется не тем, насколько хорошо можно изолировать код, а тем, насколько реальная инфраструктура приспособлена к тестированию и облегчает его. Это накладывает определённые требования с точки зрения технической компетенции и на ответственное лицо, и на команду в целом.

Кроме того, на подготовку и конфигурирование тестовой среды может потребоваться некоторое время, потому что для него требуется создание оборудования (fixtures), подключение фальшивых реализаций, добавление специализированного поведения инициализации и очистки, и так далее. Все эти аспекты придётся поддерживать в процессе увеличения масштабов и сложности проекта.

Само по себе написание функциональных тестов требует также чуть больше планирования, потому что теперь речь идёт не о покрытии каждого метода каждого класса, а об определении требований к ПО и превращению их в код. Иногда бывает сложно понять эти требования и то, какие из них являются функциональными, потому что для этого необходимо думать как пользователь.

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

Хотя всегда существуют способы частичного устранения этой проблемы, это всегда становится компромиссом: изолированные тесты лучше указывают на причину ошибки, а интегральные тесты лучше показывают её воздействие. Оба вида равно полезны, поэтому всё сводится к тому, что вы посчитаете более важным.

В конечном итоге, я всё равно считаю, что функциональное тестирование стоит того, несмотря на все его недостатки, потому что, по моему мнению, оно приводит к повышению удобства и качества разработки. Я уже давно не занимался классическим юнит-тестированием и не собираюсь к нему возвращаться.

Выводы


Юнит-тестирование — популярный подход к тестированию ПО, но в основном по ошибочным причинам. Часто его навязывают как эффективный способ для тестирования разработчиками своего кода, стимулирующий к использованию best practices проектирования, однако многие считают его затруднительным и поверхностным.

Важно понимать, что тестирование в процессе разработки не равно юнит-тестированию. Основная цель — не написание максимально изолированных тестов, а достижение уверенности в том, что код работает в соответствии с функциональными требованиями. И для этого есть способы получше.

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

Вот основные уроки:

  1. Рассуждайте критически и подвергайте сомнению best practices
  2. Не полагайтесь на пирамиду тестирования
  3. Разделяйте тесты по функциональности, а не по классам, модулям или области действия
  4. Стремитесь к максимально высокому уровню интеграции, сохраняя при этом разумные скорость и затраты
  5. Избегайте жертвования структурой ПО в пользу тестируемости
  6. Используйте заглушки только в крайних случаях

Существуют и другие замечательные статьи об альтернативных подходах к тестированию в современной разработке ПО. Вот те из них, которые показались интересными лично мне:

Only registered users can participate in poll. Log in, please.

Так переоценены или нет?

  • 33.0%Однозначно — да78
  • 17.8%Однозначно — нет42
  • 49.2%Сильно зависит от ситуации116
QIWI
Ведущий платёжный сервис нового поколения в России

Comments 302

    +20
    Не читал (многабукаф, только пролистал) но осуждаю :) Мне лично юнит-тесты очень помогают не поломать что-нибудь, особенно когда общее представление о проекте еще не оформилось и приходится часто вносить правки. Я уверен (вернее даже знаю), что они сэкономили мне кучу времени. Другое дело, что не надо это возводить в абсолют, конечно, и стремиться к 100% покрытию или менять архитектуру в угоду тестируемости, которая может зависеть от используемого фреймворка для тестов.
      +6
      Что в ваших юнит-тестах есть юнит? Ведь на ранних этапах проекта постоянно меняются внутренние интерфейсы, значит меняются сигнатуры методов, значит ломаются тесты и их постоянно нужно поддерживать. Где в таком случае помощь от юнит-тестов?
        +8
        Я отдельные модули по одному пишу. И в процессе покрываю тестами (это конечно не best practices, когда сначала пишут тесты, а потом код). Да, часть времени уходит на переписывание и тестов тоже, но дальше, когда модуль уже более-менее готов и уже может работать с другими (которые я начинаю писать, когда предыдущий почти оформился). И вот здесь тесты начинают очень помогать, так как теперь уже приходится вносить небольшие изменения в уже почти готовый код.
        P.S. Это я рассказываю, как я над своим собственным проектом работаю. А заказчики как правило на тестах экономят. Но попадаются и такие, для которых тесты чуть ли не важнее кода.
          +2
          (это конечно не best practices, когда сначала пишут тесты, а потом код)

          Почему же? Есть целая методология под это — Test Driven Development.
          Я отдельные модули по одному пишу

          Приведите, пожалуйста, парочку примеров.
          Вот, скажем, стандартный пример из моей практики: приходит ХТТП реквест на регистрацию, надо сделать следующие действия (happy path):
          1. Сходить в базу проверить, нет ли пользователя.
          2. Создать пользователя в базе
          3. Закинуть в message bus сообщение, что новый пользователь был создан
          4. Ответить 200 клиенту

          Предположим, мы вынесли эту логику на некий бизнес-слой, т.е. у нас есть функция SignUpUser, которая внутри вот это всё делает. Что и как мы будем тестить?
            +2
            1. Сформировать HTTP-запрос
            2. Отправить в SignUpUser
            3. Проверить что у нас в базе появилась запись (для тестов можно и тестовую базу иметь (имхо лучше так, я лично для своего проекта использую sqlite), или замокать класс для доступа к БД)
            4. Проверить что там в message bus (надеюсь Вы интерфейсы используете, или просто хардкодите какую-то реализацию?)
            5. Проверить ответ
            P.S. А дальше извините, у меня работа а я и так уже три часа тут торчу.
              0
              P.S. А дальше извините, у меня работа а я и так уже три часа тут торчу.

              Разумеется. Если будет время и желание, я бы с удовольствием обсудил это подробнее, т.к. тема лёгких и полезных юнит тестов очень интересна, особенно в сравнении чистых функций и алгоритмов против типичного энтерпрайза.
                +1
                То, что вы описали — это не юнит-тест. О чем в статье и говорится:
                Любопытно, что некоторые разработчики в подобных ситуациях в конечном итоге всё-таки пишут интегральные тесты, но по-прежнему называют их юнит-тестами.
                0

                Какой-то UserService с методом SignUpUser(UserRegistrationData userData) получает в зависимости IUserRepository и IMessageBus, делает простые вещи типа:


                if (this.repo.findByEmail(userData.email) != null) {
                  throw new NonUnqiueUserEmail(userData.email);
                }
                User user = User.fromRegistrationData(userData); // вариант new User(userData)
                this.repo.add(user);
                this.bus.publish(new UserRegistered(user));

                Два мока: MemoryUserRepo и MemoryMessageBus


                Два основных теста:


                • пустой репо и шина пробрасываются в конструктор сервиса, тестовые данные передаются в метод, после его выполнения проверяется, что юзер в репозитории появился (вариант — вызван метод add c нужным параметром) и сообщение опубликовано (вариант — вызван метод publish с нужным параметром).
                • то же, но перед вызовом метода добавляем юзера с тем же email и проверяем, что выбросилось исключение, а репозиторий и шина пустые (вариант — не вызывались)

                Это покрывает именно бизнес-логику.

                  +2
                  Спасибо. Именно такой вариант я наблюдал и практиковал у себя.
                  Что заметил из интересного:
                  1. Приходится заглядывать в реализацию, дабы знать, какой именно метод IUserRepository вызывается и какой надо мокать. Когда классы разрастаются, это начинает напрягать и концептуально я не сторонник тестировать, зная реализацию (classical TDD мне по душе больше, чем mockist). В принципе, решается дроблением Repository на более атомарные операции.
                  2. Если вы используете строгие моки, то вы также убеждаетесь, что ничего кроме этих зависимостей не вызвали. Звучит неплохо, но на практике у нас вылилось в невероятно хрупкие тесты. Без строгих моков тоже не идеально, но я лично готов это стерпеть.
                  3. При увеличении количества вызываемых методов и классов, сетап моков становится сложнее и сложнее. Частично решается так же, как в пункте 1, но не до конца.
                  4. Признаться, не уверен, стоит ли действительно тестировать такую логику. Я не припомню ни одного случая, когда у нас вызовы функций исчезали или дублировались, при этом написание таких тестов в более сложной системе это не пара минут.

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

                  Что меня очень интересует, это то, можно ли с минимальными усилиями описать спецификацию «мы делаем Б и В (publish, add) только если условие А истинно, в противном случае возвращаем ошибку». То есть, вместо того, чтобы императивно проверять, что и когда вызывается, выразить правила декларативно с гарантией того, что они не нарушатся разработчиком случайно.

                  Из того, что встречал — вместо непосредственно выполнения этих действий вернуть структуру, описывающую, что и как делать, наподобие AST:
                  blog.ploeh.dk/2017/07/31/combining-free-monads-in-f
                  Правда, и ASТ также придётся валидировать на корректность тестами.
                  Вопрос того, насколько с этим удобно работать конкретно в .NET на C# для меня пока открыт.
                    0
                    Приходится заглядывать в реализацию, дабы знать, какой именно метод IUserRepository вызывается и какой надо мокать.

                    Решается созданием InMemoryUserRepository — полноценной реализации IUserRepository в памяти. Проверяем не вызвался ли метод add, а появился ли пользователь в "базе" после вызова сервиса. Естественно InMemoryUserRepository покрываем тестами как какой-нибудь LinqUserRepository.


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

                    Вот это в целом я считаю излишним для юнит или функционального тестирования.


                    Признаться, не уверен, стоит ли действительно тестировать такую логику. Я не припомню ни одного случая, когда у нас вызовы функций исчезали или дублировались

                    Такие случаи бывают когда сложные условия, циклы.


                    Что меня очень интересует, это то, можно ли с минимальными усилиями описать спецификацию «мы делаем Б и В (publish, add) только если условие А истинно, в противном случае возвращаем ошибку»

                    Очень похоже на формальную верификацию, так что вряд ли с минимальными усилиями получится. С другой стороны, на каком-нибудь gherkin в секции given можно написать что-то вроде "user with email test@example.com is(n't) registered", но нужно будет написать хэндлер для этого паттерна, приводящий систему в нужное состояние.

                      0
                      Проверяем не вызвался ли метод add, а появился ли пользователь в «базе» после вызова сервиса

                      Да, такое делали, в случае .NET просто через EF InMemory. Хороший подход и, в целом, более правильный, на мой взгляд.
                      Такие случаи бывают когда сложные условия, циклы.

                      Были куски логики посложнее, но там по итогу всё переписывалось на легковесную state machine и уже она тестилась. Традиционное «закинем моки и проверим вызовы» показалось не оптимальным.
                      Как пример: в зависимости от того, обращался пользователь ранее к сервису или нет, надо было вести себя по-разному. Сам вызов метода на получение информации о предыдущих обращениях не тестили, а вот логику в виде условной функции (currentState, userRequest) -> newState покрыли на ура. Пока что коллегам нравится.
                      Очень похоже на формальную верификацию, так что вряд ли с минимальными усилиями получится

                      Вот, у меня такие же мысли. Звучит хорошо, но на практике не так легко.
                      С другой стороны, на каком-нибудь gherkin в секции given можно написать что-то вроде «user with email test@example.com is(n't) registered», но нужно будет написать хэндлер для этого паттерна, приводящий систему в нужное состояние.

                      К сожалению, не работал с gherkin и вообще BDD не щупал на реальных проектах. Не исключаю, что в итоге оно может оказаться ещё более затратным, чем «тупой» тест с моками.
                        0
                        Как пример: в зависимости от того, обращался пользователь ранее к сервису или нет, надо было вести себя по-разному. Сам вызов метода на получение информации о предыдущих обращениях не тестили, а вот логику в виде условной функции (currentState, userRequest) -> newState покрыли на ура. Пока что коллегам нравится.

                        Оо, стейтмашины вместе с условиями типа «сюда может залезать только аутентифицированный юзер» — это ж канонический пример для легковесной верификации в завтипизированных языках. А если вам, например, нужно гарантировать, что юзер что-то там сделает ровно один раз (привет линейные типы), то это вообще прям одно удовольствие.

                      0
                      Что меня очень интересует, это то, можно ли с минимальными усилиями описать спецификацию «мы делаем Б и В (publish, add) только если условие А истинно

                      Проще всего это выразить добавлением в publish и add аргумента, требующего доказательство, что A истинно (а это доказательство произвести легко — достаточно на самом деле проверить A).


                      в противном случае возвращаем ошибку».

                      А вот это интереснее. Тут уже есть варианты, в зависимости от вашего стиля и философской школы, так сказать. ИМХО один из стандартных вариантов — набросать параметризуемый GADT тип-результат, вроде


                      data Result : Bool -> Type where
                        MkErrorResult : ... -> Result False
                        MkSuccessResult :... -> Result True

                      и использовать его как


                      getResult : (aCond : Bool) -> ... -> Result aCond
                        0

                        Я недавно для себя открыл SpecFlow очень даже интересная вещь- как раз-таки тестирование по спецификациям.

                      0
                      Мы бы каждый слой покрывали тестами отдельно. У вас 4 слоя:
                      1. Контроллер
                      2. Бизнес-слой
                      3. Слой работы с БД
                      4. Слой работы с очередью сообщений.

                      Контроллер работает только с бизнес-слоем (вызывает метод SignUpUser). Мокаем бизнес-слой. Тесты на контроллер проверяют только логику контроллера: аутентификацию, валидацию входящего DTO и поведение (реакцию контроллера) на разные варианты ответа бизнес-слоя (happy path, exception и т.п.)

                      Бизнес-слой работает со слоем БД и слоем очереди. Мокаем и то и другое и проверяем реакцию бизнес-слоя на различные варианты отклика от тех. слоев.

                      Слой работы с БД уже можно проверить на in-memory db. У вас две функции (поиск и создание пользователя), на каждую пишем свои тесты со своими данными.

                      Слой работы с очередью — по вкусу. Там явно будут вызовы функций какого-то фреймворка. Мокаем их и проверяем реакцию на описанные в доке исключения + happy path/
                        0

                        У Вас здесь только 1 тест — проверить наличие пользователя в БД.
                        Все остальное — действия, согласно описания, не содержащие логику. Они не требуют юнит тестов. Для них достаточно простых интеграционных тестов.
                        Мы же не хотим тестировать работу message queue? Нам достаточно на уровне интеграционных/automation/API тестов проверить happy path. Более детально проверит automation, который должен покрыть всю логику приложения, согласно ТЗ.

                    0
                    Соглашусь, и добавлю что иногда написание теста перед написанием функции само по себе облегчает жизнь
                      0
                      Я комбинирую оба подхода. Когда я уже точно знаю, что должна делать какая-нибудь функция или метод, я скорее сначала напишу пару тестов прежде чем писать реализацию.
                      +3

                      А вот мой опыт уже не согласится с вашим опытом.


                      Я юнит-тесты не пишу (по крайней мере, если считать юнитами отдельные функции, например), и полёт нормальный. Вместо этого пишу интеграционные тесты на то, что вся система целиком работает нормально. При этом очень помогают типы — почти всегда если я делаю относительно тупой рефакторинг, не меняющий «бизнес-логику», то сразу после того, как типы сошлись, интеграционные тесты зелёные.


                      Как пример — очередной транспилятор, который я сейчас ваяю, и тесты на него. Я начал с парсера типов входного языка (там не очень тривиальный язык), и просто начал писать тесты на парсинг входных выражений языка в порядке возрастания сложности этих самых выражений, при этом не тестируя отдельные функции парсера. Как только парсер написан, я начал писать транспиляцию типов и тесты на парсер + транспайлер, не тестируя сам транспайлер в отдельности. Как только это было написано, я начал писать парсер термов (и тесты на парсер типов + парсер термов), потом — тайпчекер (и тесты на парсер типов + парсер термов + тайпчекер), потом ­— транспайлер всего этого вместе (и тесты на всё вместе).


                      В идеале, конечно, когда я пойму, что мне надо от языка, я сформулирую всякие нужные утверждения (например) и докажу их формально в каком-нибудь коке или идрисе. Тогда все эти тесты вообще можно будет выкинуть, по большому счёту.

                        0
                        В идеале, конечно, когда я пойму, что мне надо от языка, я сформулирую всякие нужные утверждения (например) и докажу их формально в каком-нибудь коке или идрисе. Тогда все эти тесты вообще можно будет выкинуть, по большому счёту.
                        Ну да, останется только написать по 500 строк кода формальной верификации на каждые 10 строчек реализации. :)

                          +1

                          Ну я же там всё с нуля делал, вплоть до своих натуральных чисел и операций на них :]

                          0

                          Судя по описанию у вас как раз получается фрактальное тестирование: https://habr.com/ru/post/510824/

                            0

                            Да, с одной стороны, похоже. С другой стороны, там говорится об «уровне ниже», а я бы не сказал, что уровень парсера ниже уровня тайпчекера или кодогенератора, скажем. ИМХО это равноправные уровни, просто в общем пайплайне системы парсер идёт перед тайпчекером, и проще начинать с него (да и термы для тайпчекера проще писать естественным синтаксисом, а не выписывать их представление).

                              0

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

                                0

                                Да! И именно поэтому я сначала пишу парсер, чтобы не писать AST руками.

                        +2
                        Подписываюсь под первым комментарием.
                        Автор выдумываем сложные примеры, что бы доказать свои убеждения, хотя забывает (или не знает), что главная задача юнит-тестов не выполнить бестпрактикс для уменьшения количество ошибок, а сделать дешевле разработку(поддержку) за счет упрощения последующей доработки и рефакторинга кода.
                          +2
                          Угу, причём 80% пользы наносится даже не прогоном юнит-тестов, а самой возможностью их написать достаточно просто. Если тесты сложно писать – значит, архитектура сложная.
                            0
                            Если тесты сложно писать – значит, архитектура сложная.
                            Нет. Это просто значит что тесты сложно писать. Этого можно добиться и со сложной архитектурой и с простой.

                            А вот если тесты писать легко — то можно быть точно уверенным, что архитектура сложная.

                            Потому что если у вас типичная простая программа написанная в стиле «стурктурного программирования», то вы там ничего отюниттестировать не сможете. Функция регистрации пользователя будет ходить в базу, причём хорошо ещё если не в одну фиксированную базу… но это, несомненно, проще, чем все эти IoC, DI, позднее связывание и прочее. Во всём этом, представьте себе, тоже модно наделать кучу ошибок.

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

                            Угу, причём 80% пользы наносится даже не прогоном юнит-тестов, а самой возможностью их написать достаточно просто.
                            Когда вы пишите программу так, что она, с одной стороны, могла бы быть легко покрыта тестами, а с другой стороны — этого не делаете… вы получаете худший вариант из возможных.

                            Не надо так.
                              +1
                              все эти IoC, DI, позднее связывание и прочее

                              Помимо тестируемости они еще и гибкости очень неплохо добавляют. Уж как я настрадался от отсутствия всего этого когда разрабатывал на 1с и нужно было заметно изменить или расширить работу системы. Как пример были у нас два почти идентичных сценария в системе. Регистрация выявленного дефекта либо построение плана предупредительных ремонтов -> оформление заявки на ремонт -> оформление наряда на работы -> оформление акта выполненного ремонта. И построение плана регламентных мероприятий -> оформление наряда на регламентное мероприятие -> оформление акта о выполнении регламентного мероприятия. Цепочки, операции и формы были очень и очень похожи, очень много общего поведения (с кучей вариаций отличающихся в зависимости от разных условий). Как же не хватало возможности нормально модульность на уровне кода организовывать, с внедрением стратегий извне, валидаторов под разные сценарии, динамической диспетчеризацией под разное поведение вместо гроздей ифов и прочего. В итоге вносилась куча похожих правок прямо внутрь документов, часто однообразных, поскольку не было возможности создать какой нибудь один интерактор для общего поведения и внедрить во все участки, в лучшем случае выносились в общие функции и обмазывались условиями.
                                0
                                они еще и гибкости очень неплохо добавляют

                                Немедленно возникает вопрос: а она точно нужна?

                                  0
                                  Довольно часто нужна. Особенно когда есть коробочный продукт который на проектах дорабатывается под конкретных заказчиков, бывает с очень разными требованиями.
                                    +1

                                    В моем (весьма грустном) опыте регулярно оказывается так, что гибкость нужна не там, где ее заложили, когда юнит-тесты писали.

                                  0
                                  Помимо тестируемости они еще и гибкости очень неплохо добавляют.
                                  Это, извините, уже другая история.

                                  Я всего лишь указываю на фактическую ошибку. Код, который просто тестировать никак не может быть проще, чем код, который тестировать сложно. Просто потому что вы добавляете дополнительные требования на код — с какого перпугу он станет проще?

                                  Да, возможно, он станет гибче, да, возможно дополнительные тесты сделают его надёжнее. Всё может быть.

                                  Но само по себе такое действие — делает код однозначно сложнее. Просто потому что вы налагаете дополнительные требования к нему.
                                    +3
                                    Проще/сложнее — субъективные понятия, которые непонятно в чем измерять. Код, приспособленный для тестирования, иногда более читаем чем неприспособленный за счет накладываемых на него ограничений. Поэтому он проще. С другой стороны, чтобы написать такой код, нужно придумывать новые абстракции, их имена и связи, что делает написание сложнее.
                                    Например, под сложностью часто подразумевают количество сущностей, которые задействованы в юните кода и которыми нужно оперировать в процессе написания/чтения. Все эти IoC, DI, позднее связывание и прочее позволяют иногда размазать сущности по разным юнитам более равномерно, что снижает их количество в отдельно взятом юните, но скорее всего увеличивают их общее количество на всю программу.
                                    Наши суждения о простоте/сложности строятся на нашем разном опыте, в моем случае программа, написанная в стиле «структурного программирования», — это часто мешанина флагов, пятиэтажных ифов и глобальных переменных, с чем никак не проще работать, чем если то же самое написать с использованием ООП-баззвордов. Встречались и обратные примеры, похожие на FizzBuzz Enterprise Edition, но как-то реже.

                                    UPD. Еще хотелось бы добавить, что некоторые под сложностью подразумевают количество знаний, которое нужно применить для написания/чтения кода, но мне такое измерение не по душе, потому что субъективность здесь возводится в абсолют.
                                      0
                                      Все эти IoC, DI, позднее связывание и прочее позволяют иногда размазать сущности по разным юнитам более равномерно, что снижает их количество в отдельно взятом юните, но скорее всего увеличивают их общее количество на всю программу.
                                      Собственно не «иногда», а «почти всегда». В результате ваш код становится сложнее, однако удельная сложность может и уменьшиться.

                                      Типичные случаи, которые я наблюдаю у себя — код, не задуманный с целью «супергибкости» заметно сложнее удельно, в пересчёте на строку кода — но при этом его гораздо меньше. Часто — на порядок меньше.

                                      Поэтому он проще.
                                      Каждые его 100 строк кода обычно действительно проще. Однако суммарно — он содержит как всю неотъемлемую (essential) сложность (сложность-же должна где-то жить), так ещё, допольнительно, и привнесённую (accidental). Он ну никак не может быть проще.

                                      Например, под сложностью часто подразумевают количество сущностей, которые задействованы в юните кода и которыми нужно оперировать в процессе написания/чтения.
                                      Ну… «настоящая» сложность невычислима, так что да, приходится пользоваться какими-то оценками…

                                      UPD. Еще хотелось бы добавить, что некоторые под сложностью подразумевают количество знаний, которое нужно применить для написания/чтения кода, но мне такое измерение не по душе, потому что субъективность здесь возводится в абсолют.
                                      С таким определением вообще невозможно оперировать, так как оно попросту неконструктивно. Что такое «знания»? Как их мерить?
                                        0
                                        Типичные случаи, которые я наблюдаю у себя — код, не задуманный с целью «супергибкости» заметно сложнее удельно, в пересчёте на строку кода — но при этом его гораздо меньше. Часто — на порядок меньше.

                                        Аналогично, мне почти никогда не приходтся гнаться за размером кода, поэтому я чаще распределяю сложность, часто после того как возвращаюсь к ранее написаномму.
                                        Каждые его 100 строк кода обычно действительно проще. Однако суммарно — он содержит как всю неотъемлемую (essential) сложность (сложность-же должна где-то жить), так ещё, допольнительно, и привнесённую (accidental). Он ну никак не может быть проще.

                                        Имеет ли практический смысл эту суммарную сложность вообще рассматривать? Мы же в один момент времени чаще всего работаем с очень ограниченным количеством кода — и эта работа становится проще.
                                        С таким определением вообще невозможно оперировать, так как оно попросту неконструктивно. Что такое «знания»? Как их мерить?

                                        Мне встречаются люди, которые например говорят — «я не знаю как работает наследование в языке XXX, поэтому все что его использует — сложно». Это да — неконструктивно.

                                        P.S. Мне не нравится фраза "… код становится сложнее", потому что ее сейчас прочитает какой-нибудь студент, не вдаваясь в суть и будет считать, что это безусловное зло, а это не так. Но какая-нибудь альтернатива фразе в голову не приходт.
                                          0
                                          Имеет ли практический смысл эту суммарную сложность вообще рассматривать?
                                          Только в том случае, если вас волнует написание корректного кода, конечно.

                                          Мы же в один момент времени чаще всего работаем с очень ограниченным количеством кода — и эта работа становится проще.
                                          Зато программа, когда её запускают, работает сразу со всем кодом. И если у вас куча кода безошибочная и работоспособная, но одна проверка срабатывает неверно — вся конструкция в целом «разваливается».

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

                                          А вот как раз если код простой, монотонный, однотипный — там как раз ошибки, зачастую, могут «сидеть» годами.

                                          Мне встречаются люди, которые например говорят — «я не знаю как работает наследование в языке XXX, поэтому все что его использует — сложно»
                                          Это бывает. Вон, в соседней статье идёт чуть не битва за то, чтобы одну из конструкций Python «закопать» и «задавить авторитетом».

                                          Мне не нравится фраза "… код становится сложнее", потому что ее сейчас прочитает какой-нибудь студент, не вдаваясь в суть и будет считать, что это безусловное зло, а это не так.
                                          Ну дык тут вопрос, что вы не там копаете: сложность — это безусловное зло… тут особо и спорить не с чем. Однако всё не так просто… читайте следующую главу…

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

                                          Сложность — это плохо. Гибкость — это хорошо. А поскольку вы не можете сделать код одновременно и простым и гибким — то приходится выбирать.

                                          Увы. Нет в мире серебрянной пули.

                                          P.S. Да, я знаю — бывают ситуации, когда можно сделать код и проще и гибче одновременно. Тут спорить не о чем, нужно просто делать. К сожалению на практике — это, скорее, исключение, чем правило.

                                          P.S. Вообще всё программирование — это о компромиссах. У любого решения есть преимущества и недостатки. Если кто-то вам вообще хоть что-то рекламирует как абсолютное благо (или абсолютное зло) — гоните этих людей прочь: они не понимают о чём говорят. А вот когда вы знаете — какие параметры то или иное решение улучшает, а какие — ухудшает… уже можно выбирать… Просто есть вещи, которых нужно использовать почти всегда, а есть вещи, которые нужно, наоборот, использовать «в гомеопатических дозах». Но я затрудняюсь вообще хоть какое-то техническое решение назвать, которые было бы всегда полезно или, наоборот, всегда вредно…
                                            0
                                            А не нужна потому что никакая альтернатива. Нужно продолжение. Да — это делает код более сложным, но и, одновременно, более гибким.
                                            Сложность — это плохо. Гибкость — это хорошо. А поскольку вы не можете сделать код одновременно и простым и гибким — то приходится выбирать.

                                            Не могу полностью согласиться. Моя изначальная точка зрения в том, что распределение сложности — это не всегда ради гибкости, это иногда ради уменьшения локальной сложности ценой дополнительных точек отказа. При этом вероятность отказа в каждой отдельной точке снижается, поэтому вероятность отказа всей программы тоже иногда снижается.
                                            Ну да ладно, это больше спор о терминах, чем конструктивный, свою задачу предупредить студентов считаю выполенной.
                                            А вот ваш второй постскриптум у меня почти дословно на стене висел, когда менторством занимался.
                                              0
                                              При этом вероятность отказа в каждой отдельной точке снижается, поэтому вероятность отказа всей программы тоже иногда снижается.
                                              Тут мы уже начинаем упираться в психологию и нам начинает требоваться различать кажущуюся сложность и реальную.

                                              Потому что из моего опыта — сложный код, который выглядит сложно — ломается крайне редко. Даже реже, чем код, который и простой и выглядит просто. Просто потому что туда никакие «улучшайзеры» не лезут.

                                              А вот код, который выглядит просто — но при этом имеет какие-то хитрые зависимости, которые при взгляде на него неочевидны… тут полный караул — сколько бы там флажков и тестов не вешали, всё равно найдётся кто-нибудь, кто его «улучшит», а сломанные тесты «починит».

                                              Ну да ладно, это больше спор о терминах, чем конструктивный, свою задачу предупредить студентов считаю выполенной.
                                              Даже не о терминах, а о психологии: если технические вещи мы можем легко померить, то всякие психологические эффекты… сложно всё с ними, да.
                                      +1

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

                                        0
                                        Чем более код абстрактный, тем сложнее в нем допустить ошибки.

                                        Это утверждение нуждается в доказательстве.

                                          +1

                                          Возьмем крайний случай, функция принимает параметр типа T и возвращает параметр типа T. О типе T ничего не известно. Много ли вариантов написать такую функцию есть? Особенно если использовать языки, в которых сайд-эффекты будут частью сигнатуры функции?

                                            0

                                            Окей, вы доказали утверждение для частного случая бесполезного кода. А дальше?

                                              +1

                                              Это работает с любым кодом. Например, чуть сложнее, чем identity, пусть есть функция (List[Char], Char -> Char) -> List[Char]. Вариантов написать ее бесконечно много, как и вариантов ошибиться в них. Для функции (List[T], T -> U) -> List[U] вариантов ошибиться уже сильно-сильно меньше etc. Если отойти от полиморфизма и ФП, то будет +- то же самое. Возьмем репозиторий, возвращающем по id ошибку или искомый объект. Работать с ним без ошибок гораздо проще, чем с объектом соединения с БД, имеющим 100500 ручек.

                                                0
                                                Например, чуть сложнее, чем identity, пусть есть функция (List[Char], Char -> Char) -> List[Char]. Вариантов написать ее бесконечно много, как и вариантов ошибиться в них. Для функции (List[T], T -> U) -> List[U] вариантов ошибиться уже сильно-сильно меньше etc.

                                                Я подозреваю, что вы под "ошибиться в написании" понимаете не то же самое, что я. Для меня есть ровно один случай ошибки в первой функции, которого нет во втором — это не вызвана функция из второго аргумента. Ну… да. Но это пренебрежимо на фоне всех прочих ошибок (просто по вероятности ее возникновения).

                                                  0

                                                  В первой функции функция может быть (не)вызвана для части элементов. Во втором — нет, т.к. мы фиксируем, что тип листа разный. Еще более абстрактный вариант (Functor[T], T->U) -> Functor[U] будет иметь вообще только одну возможную реализацию.

                                                    0
                                                    В первой функции функция может быть (не)вызвана для каждого из элементов. Во втором — нет, т.к. мы фиксируем, что тип листа разный

                                                    Подождите, это ровно то, что я сказал. И?..

                                                      0

                                                      Не совсем. Первая функция это может быть, например, что-то вроде applyAll, а может быть applyFirst. Вторая функция подобных вариантов не имеет.

                                                        0

                                                        Не понимаю. Вот первая функция: (List[Char], Char -> Char) -> List[Char]. Вот вторая функция: (List[T], T -> U) -> List[U].


                                                        Почему первая может быть applyFirst, а вторая — нет?

                                                          0

                                                          Потому что первая Char->Char, а вторая T->U. В List[U] не могут содержаться T, только U.

                                                            0

                                                            Эм. applyFirst — это когда на входе был список 'q', 'w', а на выходе стал f('q'). Нет никакой разницы, ограничены у меня типы преобразования или нет.

                                                              0

                                                              Я имел ввиду на входе "qw" на выходе "Qw"

                                                                0

                                                                Понимаете ли, эту ошибку сложно совершить. Очень сложно.


                                                                Ну и да, возьмите функцию List[Char], Char -> Int) -> List[Int]. Казалось бы, ее уровень абстракции не изменился, а (ту же) ошибку совершить уже нельзя.


                                                                Поэтому лично я считаю, что дело тут в выразительной системе типов и их качественной проверке, а не в какой-то "абстракции".

                                                                  0

                                                                  Ну почему? Как раз очень легко, просто немного ошибившись в постановке задачи. Ибо такой сигнатуре соответствует больше возможных вариантов. Но в целом соглашусь, что пример я не удачный возможно выбрал.


                                                                  Выразительная система типов она потому и выразительная, что позволяет лучше описывать какие-то элементы предметной области, те абстракции. Сама по себе она бесполезна. В одной и той же системе типов возможны как функция addUser :: String, String -> String, так и функция addUser::UserId, UserName -> Result. И с первой можно совершить гораздо больше ошибок, чем со второй.

                                                                    0
                                                                    Как раз очень легко, просто немного ошибившись в постановке задачи.

                                                                    Если вы ошиблись в постановке, вас ничего не спасет, потому что сигнатура берется из постановки, а не наоборот.


                                                                    Выразительная система типов она потому и выразительная, что позволяет лучше описывать какие-то элементы предметной области, те абстракции.

                                                                    Вот это вот "т.е. абстракции" мне и не очевидно. Лучше описывать элементы предметной области — да. Более абстрактная? Не знаю.

                                                    0
                                                    Для меня есть ровно один случай ошибки в первой функции, которого нет во втором — это не вызвана функция из второго аргумента.

                                                    В первом случае возможна функция, которая возвращает ['a', 'b', 'c'], или например uppercase(f(list[i])), или ещё что-то завязанное именно на Char. Во втором — нет.
                                                      0

                                                      Это и есть "не вызвана функция из второго аргумента".

                                                        0
                                                        Почему не вызвана, если функция такая (второй пример из моего комментария): myfunc(list, f) = [uppercase(f(x)) for x in list]?
                                                          0

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

                                                            0
                                                            Если возражаете, то уж давайте хоть сколь-нибудь предметно. По-моему это весьма реалистичная ситуация — например, кто-то может считать, что в конкретном применении, для которого пишется функция, лучше символы «нормализовать» и привести к одному регистру. Кто-нибудь другой не согласится с этим — вот и две разные функции такого типа, где чтобы понять какой вариант фактически имеется нужно смотреть в реализацию. И это уже в таком простейшем примере.
                                                              0
                                                              Если возражаете, то уж давайте хоть сколь-нибудь предметно.

                                                              Да нет, я как раз не возражаю.

                                                0

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

                                                  0

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

                                                    0

                                                    И что же не правильного в рефлексии? Как вы без неё узнаете, например, размер, который структура занимает в памяти, чтобы выделить для кольцевого буфера, необходимого для реализации wait-free очереди, столько памяти, чтобы уложиться в одну страницу памяти?

                                                      0

                                                      А я говорил, что в ней есть что-то не правильное?
                                                      И, если уж на то пошло, то рефлексия не единственный способ определить размер структуры данных. Например, в каких-то случаях он может быть частью самого типа.

                                                        0

                                                        Давайте без переобуваний.
                                                        Эта информация всегда является свойством типа, достоверно известным лишь компилятору.

                                                          0

                                                          А как же динамические типы? Их размер только в рантайме известен.

                                                            0

                                                            Их размер задаётся как множитель для размера какого-либо статического типа.

                                                            0

                                                            Кто переобувается?
                                                            Никто не мешает в достаточно богатой системе типов реализовать вычисления размера структуры данных на уровне типа по крайней мере для определенного подмножества структур данных. Другое дело насколько это будет практично

                                                          0
                                                          И что же не правильного в рефлексии?

                                                          Она почти никогда не нужна в том виде, в котором она есть в условной джаве или сишарпе.


                                                          Как вы без неё узнаете, например, размер, который структура занимает в памяти

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

                                                        0

                                                        В компилтайме? Нет (мне известен ровно один язык, в котором вы можете паттерн-матчиться на типы, и там это явно видно в сигнатуре функции, и ещё один, в котором, похоже, если очень постараться, можно достичь непараметричности).
                                                        В рантайме? Это совсем другая история.

                                                          0

                                                          В компайл тайме, конечно же. Таких языков много: D, Nim и другие

                                                            0

                                                            D, nim и прочие не пытаются рассуждать о функциях, типах и полиморфизме в том стиле, в которой мы это делаем в этой ветке.

                                                  0
                                                  А где тут противоречие с моим утверждением? Да, добавляя в вашу задачу абстракции вы добавляете в него код, который легко понимать и тестировать. Но общую сложность решения это никоим образом уменьшить не может: этот код всё равно имеет дополнительные ограничения, которых исходная задача не имеет.

                                                  Почти всегда можно снизить удельную сложность в пересчёте на строку кода (но это редко когда важно). А вот бывает реально полезно — это абстрагировать части решения и использовать их для решения разных задач (возможно — решаемых разными людьми).

                                                  Вот в этом случае сложность решений всех задач, рассматриваемая совместно — может и реально снизиться. Сложность решения каждой отдельной задачи при этом, конечно, всё равно возрастает>

                                                  Но этим нужно заниматься очень аккуратно и банальным «шинкованием длинных функций в лапшу» — вы этого не сделаете…

                                                  Обычно всё это мотивируется наивными мантрами «да, здесь мне эта сложность не нужна, но „большие дяди“ так рекомендуют делать и скоро появится новая задача, где эта сложность себя окупит».

                                                  Вот в моей практике… это не работает. Если ваши задачи уже «висят в беклоге», уже известно — кто будет их делать и примерно когда… тогда добавление сложности может быть оправдано. Но если вы закладываете в код програмы для киоска по продаже сигарет код, который выстрелит, когда ваш магазин станет галактической империей и начнут проявлятся разные эффекты, связанные с парадоксом близнецов — то вы впустую тратите время и силы.
                                                    +1
                                                    Почти всегда можно снизить удельную сложность в пересчёте на строку кода (но это редко когда важно).

                                                    Все таки не согласен. Именно это и важно часто. Поскольку работаем мы не со всей кодовой базой сразу (там работа идет на уровне компонентов и функциональных блоков, бизнес процессов и т.п. а не кода), а с ее небольшой частью.
                                                      0
                                                      Поскольку работаем мы не со всей кодовой базой сразу (там работа идет на уровне компонентов и функциональных блоков, бизнес процессов и т.п. а не кода), а с ее небольшой частью.
                                                      Знаете, может быть мне и не повезло просто в жизни, но в моём опыте я никогда не встречал ситуации, чтобы я, долгое время, просто писал бы код или там, юниттесты. Большую часть времени занимает либо отладка, исправление багов, поступивших от пользователей.

                                                      В этом процессе мы работаем «со всей кодовой базой сразу». А оптимизацим, как мне кажется, должны делаться под то, чем мы занимаемся больше всего…

                                                      Но это, похоже, опять разговор жителей разных миров. Могу представить себе и мир, где баги никто не правит, только новые фичи лепятся друг на друга… собственно откуда берётся весь этот «хтонический легаси», о котором все стонут? Вот оттуда…

                                                      Я с этим миром стараюсь не пересекаться: если баги есть — их нужно править, а не «заметать под коврик».
                                                        0
                                                        Исправление ошибок чаще всего тоже происходит в одном, ну максимум 2-3 местах. В любом случае в 99% времени нам известен либо сценарий воспроизведения (а это уже позволяет неплохо место возникновения ошибки локализовать), либо у нас есть стектрейс. Вам не нужно анализировать все сотни тысяч, а то и миллионы строк кода.
                                                          0
                                                          Исправление ошибок чаще всего тоже происходит в одном, ну максимум 2-3 местах.
                                                          Исправление занимает вообще какую-то ничтожную часть времени. Даже меньше, чем первоначальное написание. Основное время уходит на поиск того места, где случилась проблема. И вот это вот — нифига не линейно и не локализовано.
                                                            0
                                                            Не знаю как у вас так выходит. Видимо мы разного рода системы разрабатываем. В моем случае все как я описал. Либо есть краш со стектрейсом, тогда идем в место краша, смотрим простой код и прикидываем какие условия могли к крашу привести, проверяем варианты, фиксим. Либо у нас есть описание пользователя/аналитика/сами наткнулись и поняли сценарий. И воспроизводим по этому сценарию баг, локализуем, смотрим какая часть кода за это отвечает и какой инвариант возможно нарушен, смотрим простой код который влияет на состояние, находим причину, фиксим. Вот серьезно, неужели вы зная что баг в расчете какой нибудь там ставки или валидации формы полезете весь код осматривать?
                                                              +1
                                                              Либо есть краш со стектрейсом, тогда идем в место краша, смотрим простой код
                                                              Ok, принято. Пусть простой код такой такой:
                                                                return order.item_info[item_id];
                                                              

                                                              и прикидываем какие условия могли к крашу привести
                                                              Тут и думать нечего: item_id у нас, допустим, отрицательный. -1 для простоты.

                                                              проверяем варианты
                                                              Ооо… вот тут-то собака и порылась. Мы ведь всё «упростили для тестирования». Потому у нас этот item_id приходит… неизвестно откуда.

                                                              Потому что у нас же всё феншуйно, удобно для тестирования. Конструктор помечен, как положено, @Inject, когда кто-то просит наш объект оно, в лучших традициях DI одному богу известно откуда вытаскивает этот item_id. А попасть он может из трёх мест в коде. А туда — ещё из трёх.

                                                              Гибкость неописуемая, но если вы сами, лично, не писали этот компонент — то фиг вы чего поймёте, потому что у вас в процессе от того места где засунули в Guice вместо item_id какой-нибудь reservation_id (который равен -1 когда товара нет на складе, а так-то, обычно, с ним всё хорошо) участвуют десяток классов… на каждом «этаже» возможны 2-3 варианта и ни один из них не виден в stack trace, потому что они отрабатывают асинхронно в других тредах, блин!

                                                              Либо у нас есть описание пользователя/аналитика/сами наткнулись и поняли сценарий.
                                                              Знаете — если вы уже добились воспроизводимости и задача перешла из стадии «мы имеем X крешей в наших логах каждый день и жалобы от пользователей» к стадии «у нас есть чёткий способ вопроизвести проблему» — то вы уже на 90% задачу решили.

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

                                                              Вот серьезно, неужели вы зная что баг в расчете какой нибудь там ставки или валидации формы полезете весь код осматривать?
                                                              А какие есть варианты? Если у вас код сделан «под тесты», в нём что угодно может вызывать что угодно через Guice (или любую другую подобную систему, «упрощающую тестирование») и вы понятия не имеете что и где у вас не так сконфигурировано?

                                                              Это если у вас тупой процедурный код — то вы всё увидите прямо по стектрейсу. А вот если всё разрезано на кусочки для юниттетсов, а те разложены по микросервисам для изоляции, а те общаются между собой асинхронно… о, вот тут-то понять откуда что и куда пришло и куда ушло — целый ребус «для ценителей жанра».

                                                              Зато всё тестируется легко и все тесты проходят… потому что вообще имеют мало отношения к тому, что в реальной программе исполняется!
                                                                0
                                                                Вы слишком демонизируете DI и прочие практики. Такое ощущение что вы предполагаете что они дают сильное зацепление и слабую связность, но по факту наоборот, их применение зацепление снижает, а связность повышает. По крайней мере это то что я наблюдаю в своей практике. В отличие от обычной процедурщины где со временем начинает все со всем переплетаться.
                                                                  0
                                                                  Всё-таки не практики как сами по себе, а определённый стиль их применения.
                                                                  В данном случае источник данных (как item_id) должен был проверить на выходе, что он не даёт чушь (как минимум залогать неверное значение). Но кто об этом думает, когда их пишет…
                                                                    0
                                                                    Всё-таки не практики как сами по себе, а определённый стиль их применения.
                                                                    Это, извините, как? Если у нас была типичная структурная программа, а мы её через Guice порезали — то это какой, я извиняюсь, стиль?

                                                                    В данном случае источник данных (как item_id) должен был проверить на выходе, что он не даёт чушь (как минимум залогать неверное значение).
                                                                    Ага, источник данных должен было проверить. И получатель тоже. И промежуточные классы — так, в идеале-то.

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

                                                                    Да, если добавить в вашу программу достаточно скотча — то какой-никакой устойчивости можно будет добиться, да.

                                                                    Но ведь когда падал «монолит» — там было достаточно по одной цепочке в stack trace пройтись и увидеть что и где у нас там происходит.

                                                                    Но кто об этом думает, когда их пишет…
                                                                    Кто-то думает, кто-то нет. Но тот факт, что об это вообще нужно думать и писать ещё код, сверх того, который нужен для DI — по-моему наглядно показывает что проще, а что сложнее.
                                                                0
                                                                Либо есть краш со стектрейсом, тогда идем в место краша, смотрим простой код [...]
                                                                Либо у нас есть описание пользователя/аналитика/сами наткнулись и поняли сценарий.

                                                                Ну вот описание последнего бага, который я чинил. Высоконагруженная система, кластер, 6 нод, которые общаются друг с другом. Две ноды читают из RabbitMQ поток примерно 20К сообщений в секунду. На остальных четырех нодах крутится примерно 400К эрланг-процессов, каждый отвечает за одну сущность, то есть когда появляется новое значение сущности, процесс просыпается и что-то там с ней делает.


                                                                Примерно раз в сутки-двое, в неопределенное время, не заметное ни на одной из метрик (без пиков по ресурсам, без алертов, без ничего) — консьюмеры раббита вдруг проседают и перестают успевать разгребать очередь. Спасибо реализованному back pressure — через несколько минут все восстанавливается, но даже с лимитом в 100К очередь на это время засоряется и часть данных мы теряем.


                                                                Подробное описание? — Да более чем. Сценарий понятен? — Ну как бы да. Код, который читает из раббита простой? — Да проще некуда.


                                                                Ваши действия?

                                                                  0
                                                                  От такого рода систем я все таки далек, но если те две ноды что читают из кролика не на эрланге — то подумал бы что где то проблемы с взаимоблокировками при работе с очередями. Или есть какой то процесс который что то делает по расписанию и опять же останавливает другие процессы на блокировках или IO отнимает возможно им нужный. Но я по большому счету фронтендщик (ранее 1с, сейчас android), и рассуждаю о своей сфере разработки прикладного софта. А чем в вашей ситуации усложнит жизнь модульность и DI?
                                                                    +1

                                                                    Ну, то есть, искать по всей кодовой базе. ЧТД ◁


                                                                    Там нет блокировок;

                                                                      0
                                                                      А чем в вашей ситуации усложнит жизнь модульность и DI?
                                                                      1. Увеличение количество кода примерно так на порядок усложняет попытки поиска банально тем, что кода много.
                                                                      2. Каждый переход «наверх, к источнику проблему» затруднён, так как там у нас нет однозначности в угоду простоте тестирования.
                                                                        +1
                                                                        На порядок это в десять раз, хотя по факту процентов на десять. Ну и DI не только для простоты тестирования применяется, у нас тестов нет почти, но DI используется и привносит больше удобства чем было без него, ибо бизнес логика отделена от конструирования и инициализации объектов. Плюс гибкости немного добавляет. Плюс вам один черт нужно пробрасывать многие вещи в глубину из наружных слоев, лучше это делать единообразно.
                                                                          0
                                                                          по факту процентов на десять.
                                                                          Это, извините, по какому факту? Как часто вы участвовали в «феншуйном переписывании» (или, наоборот, в «нефеншуйном»)?

                                                                          Я за этим слежу всю свою карьеру — ещё с тех пор когда сам, когда-то, переписал программу «по феншую» и она стала больше, чем оригинал в пять раз.

                                                                          Сейчас, я правда, «феншуй» стараюсь, по возможности, извести, но ситуация не меняется: минимальная разница — где-то два-три раза по объёму кода, типичная — пять (и люди, при этом плачут, что у них не хватает ресурсов «всё сделать правильно»), «полный феншуй» — десять.

                                                                          Ну и DI не только для простоты тестирования применяется, у нас тестов нет почти, но DI используется и привносит больше удобства чем было без него, ибо бизнес логика отделена от конструирования и инициализации объектов. Плюс гибкости немного добавляет
                                                                          Гибкость и удобство — это понятно. Если бы весь этот «феншуй» не давал вообще никаких премуществ — то было бы странно, согласитесь.

                                                                          Но отладку — он затрудняет. И написание корректного кода, без ошибок, на самом деле, затрудняет тоже. Позволяет гибко реагировать на все «колебания линии партии» — это да. Но это не везде нужно.

                                                                          Плюс вам один черт нужно пробрасывать многие вещи в глубину из наружных слоев, лучше это делать единообразно.
                                                                          А это уже другой вопрос. Во-первых уменьшение количества слоёв — зело помогает. Во-вторых — генераторы кода. Да, в бинарнике количество кода может остаться и неизменным, а иногда даже и вырости (редко), но зато если у вас вместо трёх классов написанных руками есть один кодогенератор — то вы можете быть уверены, что ошибка, если она и имеется, в этом кодогенераторе, а не в сгенерированных классах.

                                                                          Конечно иногда «и на старуху бывает проруха» и получается так, что кодогенератор всё генерирует правильно, но вот в одном случае из 100, из-за пересечения названий, порождается чушь… но в моей практике это случается куда реже, чем «скопировал класс, в 9 местах название поля поправил, в 10м — забыл, неделю сидел в отладчике, пока понял».
                                                                      0
                                                                      > Ваши действия?

                                                                      1. Лучшее: выкинуть Erlang и всё, что на нём написано. Нет, я серьёзно. Его разработчикам уже минимум с 2008 объясняют, что один-единственный mailbox на процесс, при том, что синхронное взаимодействие требует отправить сообщение и прочитать в ответ — это то преступление, что хуже ошибки, или та ошибка, что хуже преступления — всё едино. Но они не реагируют и отделываются 1/10-мерами типа пометки позиции прочитанного в очереди.
                                                                      Пока все старперы не уйдут из руководства и это не сдвинется — я на любое предложение применить Erlang для чего-то кроме чистой раздачи контента буду крутить пальцем у виска, а если это не поможет — бить ногами. Критерий непригодности системы к задаче — формальная возможность наличия более одного сообщения на входе у процесса, который выполняет gen_tcp:send(), gen_server:call() или аналоги.

                                                                      2. Вместо

                                                                      > крутится примерно 400К эрланг-процессов, каждый отвечает за одну сущность

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

                                                                      > Подробное описание? — Да более чем. Сценарий понятен? — Ну как бы да. Код, который читает из раббита простой? — Да проще некуда.

                                                                      И всё это не имеет никакого отношения к проблемам рантайма, в которых и зарыто целое кладбище собак.
                                                                        0
                                                                        Лучшее: выкинуть Erlang и всё, что на нём написано.

                                                                           


                                                                        Вы ещё только формирующееся, слабое в умственном отношении существо, все ваши поступки чисто звериные, и вы в присутствии двух людей с университетским образованием позволяете себе с развязностью совершенно невыносимой подавать какие-то советы космического масштаба и космической же глупости [...]
                                                                        — М. А. Булгаков, «Собачье сердце»

                                                                           


                                                                        Критерий непригодности системы к задаче — формальная возможность наличия более одного сообщения на входе у процесса, который выполняет gen_tcp:send(), gen_server:call() или аналоги.

                                                                        См. цитату выше. Я бы мог объяснить, почему один mailbox на процесс — это абсолютно правильно, но мне попросту лень связываться.


                                                                        Скажу только, что с мейлбоксами все в порядке, как, впрочем, и всегда, — когда код написан людьми, способными его писать.


                                                                        сделать лимитированное число процессов, каждый из которых отвечает за пару тысяч таких сущностей и держит вложенные машины состояний на каждую сущность

                                                                        Рыдаю. Давайте все нафиг переусложним в триста раз, потому что мы не умеем сделать диспетчиризацию по-человечески (да там ее и нет, по сути). Да-да.

                                                                          0
                                                                          Я бы мог объяснить, почему один mailbox на процесс — это абсолютно правильно

                                                                          Это как минимум требует блокировок и всех связанных с ними проблем. Я бы не назвал это хорошим решением.

                                                                            0
                                                                            Это как минимум требует блокировок и всех связанных с ними проблем.

                                                                            Не очень понял, что вы хотели этим сказать.


                                                                            Я бы не назвал это хорошим решением.

                                                                            А хорошее решение в модели акторов — это «мы хрен знаем, что тут когда пришло, разбирайтесь сами по таймстемпам», наверное. Ну или прикостыливайте свою наколеночную очередь, которая будет имитировать единый мейлбокс, только пишите ее сами, потому что вот тут человек не считает это хорошим решением.


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

                                                                              0

                                                                              Хорошее решение — использовать wait-free каналы для коммуникации между потоками. И уж точно не стартовать тысячи процессов, которые сожрут всю память.

                                                                                0
                                                                                И уж точно не стартовать тысячи процессов, которые сожрут всю память.

                                                                                WhatsApp’у расскажите, ага. Тысячи эрланг-процессов сами по себе ничего не сожрут, виртуальная машина легко поддерживает до двух миллионов процессов с незначительным оверхедом. Не нужно высказывать свое мнение в вопросах, в которых вы ничего не понимаете.


                                                                                Хорошее решение — использовать wait-free каналы для коммуникации между потоками.

                                                                                О, да!!! Ладно, все ясно.

                                                                                  0

                                                                                  Это не процессы.
                                                                                  Я не просто понимаю, а сам всё это реализовывал. Попробуйте и сами взять какой-нибудь си и реализовать межпоточную коммуникацию. Тогда поймёте все трейдофы. А сидеть на шее у эрланга или гошечки много ума не надо.

                                                                                    0
                                                                                    Попробуйте и сами взять какой-нибудь си и реализовать межпоточную коммуникацию.

                                                                                    Мне хватило в середине девяностых, когда я много писал околомедицинских приложений, работавших с разным нестандартным вводом: RS-232, недокументированные платы передачи данных от приборов, вот это вот все.


                                                                                    сидеть на шее у эрланга или гошечки много ума не надо

                                                                                    Это правда (ну, кроме того, что го вообще неработоспособен). Ну, покажите вашу реализацию, может быть перейду на нее в продакшене, у вас-то вот я вижу ума поболее — буду учиться у мэтров.

                                                                            0
                                                                            > См. цитату выше. Я бы мог объяснить, почему один mailbox на процесс — это абсолютно правильно, но мне попросту лень связываться.

                                                                            То есть аргументов у вас нет, и сказать что-то осмысленно тому, кто 5 лет писал на Erlang высоконагруженные приложения, вы не в состоянии, зато отделываетесь красивыми цитатами от неоднозначных персонажей. OK, понятно.

                                                                            > когда код написан людьми, способными его писать.

                                                                            Как минимум эти «люди, способные его писать» делают обход той же самой проблемы с gen_tcp:send() хаком во внутренности стандартной библиотеки, исключая проблемное ожидание. Но вы этого не хотите видеть.

                                                                            > Давайте все нафиг переусложним в триста раз

                                                                            Наоборот, упростим — по сравнению с нынешними кошмарами. Но вы можете продолжать упорствовать, ваше дело.
                                                                              0
                                                                              То есть аргументов у вас нет, и сказать что-то осмысленно тому, кто 5 лет писал на Erlang высоконагруженные приложения [...]

                                                                              Можно триста лет «программировать» высоконагруженные приложения, и ничему не научиться. Вон был один широко известный в узких кругах проект, который нанял эксперта такой же примерно ориентации: «хакнем внутренности стандартной библиотеки», «перепишем все на нифах», и так далее. Как закончилось — можно погуглить, вкратце: мнезия не выдержала четырех терабайт. И я не могу тут винить мнезию. Она не для того была сделана.


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


                                                                              [...] исключая проблемное ожидание. Но вы этого не хотите видеть.

                                                                              У меня нет никакого проблемного ожидания. Я умею в архитектуру с тем, что есть.


                                                                              вы можете продолжать упорствовать

                                                                              Я не упорствую; я в начале ветки привел пример, (почитайте, пример чего именно, кстати) только и всего. Потом пришли вы с безумными советами, которые все только поломают (или нет, я особо не вчитывался).


                                                                              Так вот, у меня нет никаких проблем с 200К процессами в одной виртуальной машине. Нет проблем, понимаете? Тех, которые вы предлагаете решать — их нет.

                                                                                0
                                                                                > Дело не в том, что у меня нет аргументов. Дело в том, что вы несете, простите, чушь

                                                                                «А если найду»? И таки нашёл, за пару минут:

                                                                                %% gen_tcp:send/2 does a selective receive of {inet_reply, Sock,
                                                                                %% Status} to obtain the result. That is bad when it is called from
                                                                                %% the writer since it requires scanning of the writers possibly quite
                                                                                %% large message queue.
                                                                                %%
                                                                                %% So instead we lift the code from prim_inet:send/2, which is what
                                                                                %% gen_tcp:send/2 calls, do the first half here and then just process
                                                                                %% the result code in handle_message/2 as and when it arrives.


                                                                                То есть авторы того средства, использованием которого же Вы и хвалитесь, проблему знают и её обошли в отдельном частном случае — хаком вокруг стандартной библиотеки, используя недокументированные средства. Ну, рассказывайте дальше про

                                                                                > Можно триста лет «программировать» высоконагруженные приложения, и ничему не научиться.

                                                                                пока что вы показали только свойства собственного отражения.

                                                                                > Я умею в архитектуру с тем, что есть.

                                                                                Ну да, с залезанием в потроха… я так тоже умею. И делал, и оно работало. Но больше не хочу, есть более благодарные средства, где не надо костылировать на каждом шагу.

                                                                                > Так вот, у меня нет никаких проблем с 200К процессами в одном виртуальной машине. Нет проблем, понимаете? Тех, которые вы предлагаете решать — их нет.

                                                                                Значит, вы их не нагружаете соответственно ресурсам.
                                                                                  0
                                                                                  авторы того средства, использованием которого же Вы и хвалитесь

                                                                                  Никогда в жизни ничего не делал с gen_tcp. Найденная вами «проблема» связана с тем, что не нужно в блокирующем вызове слушать сокет до победного конца. Как в этом виноват mailbox — тайна великая есть.


                                                                                  есть более благодарные средства, где не надо костылировать на каждом шагу

                                                                                  Да вот чего-то не приходится костылить-то, сколько ж раз повторить-то, чтоб дошло? Про благородные средства — знаем, слышали. Нет, спасибо.


                                                                                  Значит, вы их не нагружаете соответственно ресурсам.

                                                                                  Опять двадцать пять. Откуда вам-то знать? Что это вообще за манера — делать безапелляционые заявления про интимную жизнь собеседника, которого вы даже в глаза ни разу не видели?

                                                                                    0
                                                                                    > Как в этом виноват mailbox — тайна великая есть.

                                                                                    Извините, для ответа на это вам надо было всего лишь прочитать моё сообщение.

                                                                                    Я подожду с продолжением дискуссии, пока вы не покажете результат этого прочтения. Иначе просто нет смысла.
                                                                                      0

                                                                                      А, selective receive, и правда.


                                                                                      На нагруженном процессе не нужно вызывать selective receive, да. Нужно процессить все сообщения, пересылая их в зависимости от их типа другим процессам, каждый из которых умеет работать только с одним типом сообщений.


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

                                                                                        0
                                                                                        > А, selective receive, и правда.

                                                                                        Да. Спасибо, что прочли.

                                                                                        > На нагруженном процессе не нужно вызывать selective receive, да.

                                                                                        Вы можете сколько угодно избегать его в явном виде, но:
                                                                                        — Если вы зовёте gen_server:call (явно или неявно, через чей-то API), вы его применяете для получения ответа вызванного процесса.
                                                                                        — Если вы используете отправку по TCP через gen_tcp, вам приходит сообщение в ответ и вы его сразу и ищете в очереди.

                                                                                        > А так, как сделали эти чуваки — делать не нужно. А то так можно любую очередь заткнуть,

                                                                                        Если «эти чуваки» это авторы TCP драйвера Erlang, тут я согласен — так делать просто нельзя — пока это создаёт проблемы.

                                                                                        Если «эти чуваки» это авторы кода в rabbitmq_common — наоборот, они просто защищали себя от дебилизма Erlang, как раз заменив штатное чтение «пятьсоттысячного сообщения, от-nack-иваясь от всех не подошедших» на свой код, который этим не страдает.

                                                                                        Проблема моей задачи была в том, что я не имел возможности устранить косвенный selective receive на gen_call, и некуда было переместить длинную входную очередь — все меры приводили бы в лучшем случае к удлинению цепочки обрабатывающих процессов.

                                                                                        Ранее, я два раза это обошёл: первый раз через ETS, второй — через вот этот самый хак с явной port_command. На третий меня всё достало, обходов уже тупо не нашлось, проблема была обсуждена, решение выкинуть Erlang кхерам было принято и реализовано, и в итоге никто не пожалел.

                                                                                        А если бы были сделаны раздельные очереди — даже в простейшем варианте неизменяемого порядка выборки, но gen_server:reply отправлял бы в высокоприоритетную — всё могло бы работать и сейчас.
                                                                  +1
                                                                  Знаете, может быть мне и не повезло просто в жизни, но в моём опыте я никогда не встречал ситуации, чтобы я, долгое время, просто писал бы код или там, юниттесты. Большую часть времени занимает либо отладка, исправление багов, поступивших от пользователей.

                                                                  Я 90% процентов времени занимаюсь отладкой и исправлением багов, т.е. мы примерно из одного мира (полагаю уровень абстракции у нас сильно разный, у меня выше, у вас — ниже). И мне в мире исправления багов проще с большим количеством простых абстракций, чем с меньшим количеством более сложных.
                                                                0
                                                                Но общую сложность решения это никоим образом уменьшить не может: этот код всё равно имеет дополнительные ограничения, которых исходная задача не имеет.

                                                                Вполне может уменьшить сложность введение абстракций. Тупой пример: в задаче нужно несколько раз сложить натуранльное число само с собой. Вводим операцию умножения — абстракция над повторяющимся сложением и, внезапно, куча циклов/редьюсов заменяется одной операцией.


                                                                Если уж на то пошло, то любая переменная или константа вместо литерала — это абстракция. И, при условии нормального именования, очень часто они упрощают код.

                                                                  0
                                                                  Вводим операцию умножения — абстракция над повторяющимся сложением и, внезапно, куча циклов/редьюсов заменяется одной операцией.
                                                                  Здесь вы удачно воспользовались тем, что кто-то уже, за вас, умножение эффективно реализовал. Если бы его у вас в языке или CPU не было — его реализация была бы гораздо сложнее, чем просто несколько сложений.

                                                                  Собственно об этом факте говорит хотя бы то, что большинство компиляторов умеют превратить умножение обратно в несколько сложений (и вычитаний). Примерно так.

                                                                  Спасибо за «тупой пример», показавший, в очередной раз, мою правоту.

                                                                  Если уж на то пошло, то любая переменная или константа вместо литерала — это абстракция.
                                                                  Конечно.

                                                                  И, при условии нормального именования, очень часто они упрощают код.
                                                                  Нет. Замена одного литерала на одну константу — никогда не упрощает код.

                                                                  Упрощение начинается тогда, когда вы начинаете эту константу переиспользовать (хотя бы в коде и в тесте). И да — вот в этом случае вы можете получить упрощение… но это частный случай, про который я уже писал: абстрагировать части решения и использовать их для решения разных задач.

                                                                  Усложнение при заведении константы небольшое, выигрыш от использования хотя бы в двух местах — существенный (очень часто бывает важно, чтобы константы в этих двух местах совпадали), так что польза от такого действия может наблюдаться довольно быстро.

                                                                  Но вот, например, clang-tidy постоянно пытается предложить мне ввести именования вместо чисел 8, 16, 32 и 64. Которые в моём коде записаны явно там, где они указывают ширину регистра (8-битный, 16-битный и так далее). Вы вправду считаете, что какой-нибудь k8BitRegisterSizeWithInBits будет проще понять, чем число 8? Вас не удивляет, что типы char8_t, char16_t, char32_t пришли на замену типам char и wchar — которые как раз «абстрагировали» константы и, в результате, привели к дикой путанице?
                                                                    0
                                                                    Нет. Замена одного литерала на одну константу — никогда не упрощает код.

                                                                    И снова я с вами не согласен. Читаемое и осмысленное название всяко лучше магического числа которое иногда неясно по каким критериям выбрано, не всегда понятно что означает, и неизвестно а точно ли в одном месте действительно используется или нужно поиском все такие числа в программе менять.
                                                                      +1
                                                                      Забавно, что пафоса вы насыпали, а вот предложить на что заменить 8, 16 и 32 — так и не смогли.
                                                                        0
                                                                        А что я могу предложить если я не знаю контекста использования этих констант? Для чего вам ширина регистров в том месте, как используется? Впрочем даже банальный SIZE_8_REGISTER будет лучше магических чисел.
                                                                          0
                                                                          А что я могу предложить если я не знаю контекста использования этих констант?
                                                                          JIT-компилятор, банально.

                                                                          Для чего вам ширина регистров в том месте, как используется?
                                                                          Ну, например если нам нужно прибавить константу. Что-нибудь в духе:
                                                                            if (int32_t(immediate) == immediate) {
                                                                              as.mov(result, immediate);
                                                                            } else {
                                                                              auto tmp =
                                                                                alloc.AllocateVirtualRegister<GPRegister<64>>;
                                                                              as.mov(GPRegister<32>(result), immediate);
                                                                              as.mov(GPRegister<32>(tmp), immediate >> 32);
                                                                              as.shl(tmp, 32);
                                                                              as.or(result, tmp);
                                                                            }
                                                                          


                                                                          Впрочем даже банальный SIZE_8_REGISTER будет лучше магических чисел.
                                                                          Серьёзно? GPRegister<SIZE_8_REGISTER> более понятно, чем GPRegister<8>? Я вас умоляю. Это карго-культ в чистом виде.

                                                                          Как минимум потому что после такого «улучшения» банальный as.movsx(GPRegister<size * 2>(r1), GPRegister<size>(r2)); становится проблемой: если это у вас не просто числа, а магические имена, то чтобы перейти от SIZE_8_REGISTER к SIZE_16_REGISTER вам теперь, внезапно, нужна вспомогательная функция… и для обратного перехода — тоже… А вот с тем сдвигом наверху — как теперь быть? Можно там использовать SIZE_32_REGISTER или нужен отдельный SIZE_32_REGISTER_SHIFT?

                                                                          Ну да — можно всё это развести, компилятор умный, всё лишнее уберёт… Но чего вы этим, извините, добъётесь? Ну усложнения кода — это понятно. А кроме этого? Приятного ощущения на душе, что clang-tidy теперь не ругается? Какое-то сомнительное достижение, как по мне…
                                                                            0
                                                                            Да как раз упращение то. Если у вас есть несколько разных констант со значением 32 например — они все гарантированно будут разделены по смыслу.
                                                                            становится проблемой: если это у вас не просто числа, а магические имена, то чтобы перейти от SIZE_8_REGISTER к SIZE_16_REGISTER вам теперь, внезапно, нужна вспомогательная функция… и для обратного перехода — тоже…

                                                                            А тут то в чем проблема? Не говоря уж о том что да, в функцию вынести удобнее — но никто собственно не мешает вам и дальше as.movsx(GPRegister<size * 2>(r1), GPRegister(r2)); использовать, если size один из ее аргументов. Зато сразу в месте вызова не читая сигнатуру можно понять что в функцию передаем, именно размер регистра, а не просто число 8 неясно чего значащее.
                                                                              +1
                                                                              Да как раз упращение то. Если у вас есть несколько разных констант со значением 32 например — они все гарантированно будут разделены по смыслу.
                                                                              Где упрощение-то? Пока я вижу усложнение. Нет, я понимаю, если для вас «просто» = «феншуйно», «так как в умных книжках» написано — то тут уже обсуждать нечего. Нужно от такого работника избавляться.

                                                                              А если у вас какая-то другая метрика… ну можно что-то обсуждать.

                                                                              Так по какому, извините, критерию, этот код проще стал?

                                                                              А тут то в чем проблема?
                                                                              Проблема в том, что мы не знаем, в соотвествии с теми самыми умными книжками, что именно у нас SIZE_8_REGISTER обозначает. Вдруг у нас SIZE_8_REGISTER = 0, SIZE_16_REGISTER = 1, SIZE_4_REGISTER = 2 и SIZE_64_REGISTER = 3? Чтобы их удобнее было в LEA использовать?

                                                                              никто собственно не мешает вам и дальше as.movsx(GPRegister<size * 2>(r1), GPRegister(r2)); использовать, если size один из ее аргументов
                                                                              Мешает. Если я вижу в коде, где-то рядом, GPRegister<8&rt; — то я понимаю, что size — это просто размер регистра в битах, а не, скажем, в байтах.

                                                                              Убрав эту информацию с глаз пользователя — мы, тем самым, усложнили ему жизнь.

                                                                              Я ведь недаром с самого начала упомянул char и wchar. Там тоже «улучшайзеры» вроде вас решили «абстрагироваться от размера». В результате получили UTF-16 в Windows, UTF-32 в Linux/Unix и кучу проблем с переносимостью.

                                                                              Вот то же самое будет и с вашим «упрощающими» константами.

                                                                              Удивиельным образом константы могут упрощать жизнь только тогда, когда они могут осмысленно меняться! В разных версиях программы, разумеется.

                                                                              И да — M_E и M_PI не являются контрпримером: e и π — числа трансцендентные, в программе непредставимы, потому в разных программах, на разных системах, могут-таки отличаться.

                                                                              Зато сразу в месте вызова не читая сигнатуру можно понять что в функцию передаем, именно размер регистра, а не просто число 8 неясно чего значащее.
                                                                              То есть что значит 8 в uint8_t или char8_t — вам понятно «без слов»? А в GPRegister<8> вдруг стало непонятно?

                                                                              Я боюсь если вы обладаете настолько плохой памятью, то вам этот код лучше бы вообще не трогать…
                                                                                0
                                                                                Где упрощение-то? Пока я вижу усложнение. Нет, я понимаю, если для вас «просто» = «феншуйно», «так как в умных книжках» написано — то тут уже обсуждать нечего.

                                                                                Нет. Может для вас понимание из за адекватного нейминга усложняется, но для меня упрощается.
                                                                                А уж аргумент
                                                                                «феншуйно», «так как в умных книжках» написано — то тут уже обсуждать нечего. Нужно от такого работника избавляться.
                                                                                вообще странный. Код стайлы, паттерны и прочее придуманы в т.ч. и как общий знаменатель для разработчиков, чтобы было проще читать код написанный другими людьми, он по возможности должен быть написан единообразно, по общим принципам. А если каждый будет писать как ему вздумается и хочется — то получим мешанину стилей и подходов.
                                                                                Проблема в том, что мы не знаем, в соотвествии с теми самыми умными книжками, что именно у нас SIZE_8_REGISTER обозначает. Вдруг у нас SIZE_8_REGISTER = 0, SIZE_16_REGISTER = 1, SIZE_4_REGISTER = 2 и SIZE_64_REGISTER = 3? Чтобы их удобнее было в LEA использовать?

                                                                                Отражается в документации. В том же котлине я бы использовал enum с полем size внутри которое бы для каждого элемента обозначало его размер. И типобезопасно, и размер с собой таскать можно.

                                                                                Ну и опять же, речь не о плохой или хорошей памяти а о единообразии стиля и безопасном программировании.
                                                                                Можно всегда не забывать на null проверять, а можно взять язык в котором типы делятся на nullable и not nullable и для nullable компилятор заставляет проверять значение.

                                                                                Удивиельным образом константы могут упрощать жизнь только тогда, когда они могут осмысленно меняться!

                                                                                Вот вам константа 86400, изменяться она не будет. Очень понятная с ходу, угу.

                                                                                А уж попытка два раза перейти на личности с вашей стороны выглядит как то странно в обмене аргументами о кодстайлах.
                                                                                  0
                                                                                  Код стайлы, паттерны и прочее придуманы в т.ч. и как общий знаменатель для разработчиков, чтобы было проще читать код написанный другими людьми, он по возможности должен быть написан единообразно, по общим принципам.
                                                                                  Во всех местах, где я это наблюдал речь шла либо о вещах, которые мало на что влияют (типа int* p; вместо int *p;), либо, если речь шла о серьёзных ограничениях, там были обоснования и процедура получения исключения (waiver).

                                                                                  А если каждый будет писать как ему вздумается и хочется — то получим мешанину стилей и подходов.
                                                                                  Примерно как в самом популярном ядре OS? А чем это плохо?

                                                                                  Отражается в документации.
                                                                                  Это антитеза к упрощению. Если вам, для того, чтобы понять код, нужно читать документацию — то вы этот код, извините, не упростили, а усложнили. Потому что это значит, что вам теперь, собственно кода, для понимания, недостаточно. Нужна ещё и документация.

                                                                                  Да, иногда без этого не получается, не всегда можно упростить код настолько, что он будет поняет без дополнительной информации. Но вы же понимаете, что это — недостаток, а не достоинство?

                                                                                  Можно всегда не забывать на null проверять, а можно взять язык в котором типы делятся на nullable и not nullable и для nullable компилятор заставляет проверять значение.
                                                                                  Если что-то можно переложить с программиста «на бездушную машину» — это прекрасно. Но вы-то, вашим изменением, сделали обратное — заставили программиста, читающего код, делать больше работы, а не меньше.

                                                                                  А уж попытка два раза перейти на личности с вашей стороны выглядит как то странно в обмене аргументами о кодстайлах.
                                                                                  То есть Вам «передвигать ворота» и, внезапно, менять тему дискуссии — нормально, а больше — никому нельзя?

                                                                                  Слова про «кодстайл» вы произнесли, до вашего ответа мы, вроде как, обсуждали сложность программы.
                                                                                    0
                                                                                    А чем это плохо?

                                                                                    Тем что код становится сложнее читать, а соответственно и воспринимать.
                                                                                    Это антитеза к упрощению. Если вам, для того, чтобы понять код, нужно читать документацию — то вы этот код, извините, не упростили, а усложнили. Потому что это значит, что вам теперь, собственно кода, для понимания, недостаточно. Нужна ещё и документация.

                                                                                    На плюсах писал давно и совсем немного, нужно было по мелочам по работе, потому и не помню какие там есть средства написать это по человечески. В идеале сигнатура должна быть максимально упрощена и не принимать ничего кроме этих значений. В крайнем случае да, это должно быть отражено в комментарии к методу который вытащит IDE и покажет программисту что здесь допустима только одна из нескольких констант. Енам в этом плане как то лучше, документации не требует, но в плюсах насколько помню они грустные.

                                                                                    Слова про «кодстайл» вы произнесли, до вашего ответа мы, вроде как, обсуждали сложность программы

                                                                                    Мы обсуждали вынос магических чисел в константы. А это имеет прямое отношение к кодстайлу. Да и не только про кодстайлы, но и про другие общие рекомендации по написанию кода, кои вы обозвали феншуйными книжками. Большинство программистов о них хотя бы имеет представления и им легче будет код воспринимать чем в самобытном стиле и с самобытной организацией блоков и модулей.
                                                                                      0
                                                                                      Тем что код становится сложнее читать, а соответственно и воспринимать.
                                                                                      Нет. Не становится. Там где разница в стилях реально ведёт к проблемам и можно объяснить почему — можно ведь и, точечно, правила соотвествующие ввести. А если объяснить «почему» нельзя и нужно «давить авторитетом» — то, значит, и правило такое смысла не имеет. Style Guide ведь не зря выводит на вот этот вот, вполне конкретный, style guide. Где для большинства пунктов подробно описаны «за» и «против». И где правила «избегайте магических констант чего бы это ни стоило» в принципе нету.

                                                                                      На плюсах писал давно и совсем немного, нужно было по мелочам по работе, потому и не помню какие там есть средства написать это по человечески.
                                                                                      Для того, чтобы ответить на этот вопрос нужно прежде всего сформулировать что это такое — «по человечески».

                                                                                      В идеале сигнатура должна быть максимально упрощена и не принимать ничего кроме этих значений.
                                                                                      Зачем? Какую проблему это решит?

                                                                                      Енам в этом плане как то лучше, документации не требует, но в плюсах насколько помню они грустные.
                                                                                      Ну сделать этот параметр class enum — это не проблема. Он может быть параметром шаблона. Но какую проблему это решит? GPRegister<42> — это и так будет ошибка компиляции со вменяемым сообщением об ошибке.

                                                                                      Мы обсуждали вынос магических чисел в константы.
                                                                                      Вот только это не «магические числа». Это часть названия. 8 битный — 800 тысяч раз Гугл находит, 16 битный — 300 тысяч, 32 битный — 700 тысяч, 64 битный — почти 800…

                                                                                      Для этого понятия нет никаких недвусмысленных называний, не включающих в себя эти числа. WORD/DWORD/QWORD, предложенные VolCh, извините, неоднозначны.

                                                                                      Большинство программистов о них хотя бы имеет представления и им легче будет код воспринимать чем в самобытном стиле и с самобытной организацией блоков и модулей.
                                                                                      Ну то есть от тезиса «так код будет проще» мы уже отказались? И перешли к тезису «так будет лучше — потому что умные дяди плохого не посоветуют»? Так, что ли?
                                                                                        0
                                                                                        Ну то есть от тезиса «так код будет проще» мы уже отказались? И перешли к тезису «так будет лучше — потому что умные дяди плохого не посоветуют»? Так, что ли?

                                                                                        Где вы это прочитали? Впрочем я в любом случае уже спорить банально устал.
                                                                      +1
                                                                      const f = a => 6.2*a
                                                                      console.log(f(2))

                                                                      vs


                                                                      const PI = 3.1
                                                                      const f = r => 2 * PI * r
                                                                      console.log(f(2))

                                                                      Какую из двух "программ" вам проще понять? Какую будет проще поправить если заказчик скажет "результаты в целом верные, но нам нужно точнее"?

                                                                        0
                                                                        Для человека, который понятия не имеет о том, что такое π? Без разницы, на самом деле. Вторая версия только длиннее и всё.

                                                                        Вы тут выигрываете не на том, что программа стала проще и понятнее, а на том, что вы часть сложности разделили со множеством других задач.

                                                                        Про 8/16/32/64 вы так ничего и не сказали — что характерно.
                                                                          0

                                                                          С одной стороны, предполагается, что человек, читающий этот код, хотя бы неполное среднее получил. С другой, даже без этого, он хотя бы вопрос сможет задать "а что за константа PI у нас в коде?" и ему куда быстрее ответят, чем на вопрос "а что за 6.2 у нас?"


                                                                          Контекст был непонятен, с кодом выше чуть понятнее стало. Навскидку, я бы, минимум, сделал константы типа REGISTER_BYTE, REGISTER_WORD_SIZE, REGISTER_DWORD_SIZE, REGISTER_QWORD_SIZE — GPRegister<REGISTER_QWORD_SIZE> выглядит для меня проще, и исключает вопрос типа а можно ли сделать GPRegister<42>. И подумал бы о immediate >> 32 и as.shl(tmp, 32) — это сдвиги на половину ширины соответствующего регистра или какая-то другая логика. Если при смене GPRegister<64> на GPRegister<32> 32 в них нужно заменить на 16, то использовал бы REGISTER_QWORD_SIZE / 2

                                                                            0
                                                                            Навскидку, я бы, минимум, сделал константы типа REGISTER_BYTE, REGISTER_WORD_SIZE, REGISTER_DWORD_SIZE, REGISTER_QWORD_SIZE
                                                                            Ok, принято.

                                                                            GPRegister<REGISTER_QWORD_SIZE> выглядит для меня проще
                                                                            Проще выглядит? Вы это серьёзно? Что вы ответите «наивному чукотскому вьюноше», который спросит почему у вас 128-битная константа не лезет в ваш QWORD? И ссылку на документацию, где чётко говорится о «32-bit words», «64-bit doublewords» и «128-бит quadwords»? Что, дескать, программа у нас для AArch64, но названия мы используем другие обозначения «для простоты»?

                                                                            И да, мы, внезапно, поддерживаем x86-64 и AArch64. Второе — вообще самая популярная платформа в мире, первое — знаете, тоже ещё не отмерло.

                                                                            и исключает вопрос типа а можно ли сделать GPRegister<42>
                                                                            Попробуйте. Будет ошибка компиляции. Всё просто.
                                                                              0

                                                                              Названия я использовал по памяти о разработке более чем 20 лет тому назад. Вы хотели пример — я дал пример.

                                                                                0
                                                                                А — ну это пожалйста. Непонятно зачем приводить примеры, который показывавают вашу же неправоту… но хозяин — барин.
                                                                                  0

                                                                                  Какую мою неправоту? Что по памяти я неправильные обозначения выбрал?

                                                                                    +1
                                                                                    Похоже придётся разжевать…

                                                                                    Что по памяти я неправильные обозначения выбрал?
                                                                                    В том-то и дело, что память вас не подвела. И я думал что уж про это-то вам напоминать не нужно — раз вы так «упростить» код решили.

                                                                                    В том-то и дело, что в документации по x86 название «word» действительно используется для 16 бит, doubleword — 32 бит и так далее (там, правда, есть забавные разночтения когда то, что одни называют octaword другие называют double-quadword… но то такое).

                                                                                    Вот только в современном мире x86 — не единственная и, в общем, даже не главная, на сегодня, платформа. ARM, Power, RISC-V… у них у всех WORD — 32-битный.

                                                                                    У Alpha Alpha WORD вообще 64-битное число обозначал (хотя сегодня это не слишком актуально)!

                                                                                    Потому название типа REGISTER_DWORD_SIZE — это, извините, не «упрощение», а «лучший способ запутать неприятеля» (каковым вы, как я понял, чаете читателя вашей программы).
                                                                                      0

                                                                                      В таком случае, да, неудачные названия, из альтернативных интелу платформ (Z80 таким не считаю) я оооочень давно только с 6502 игрался и, вроде бы, "переучиваться" что называть байтом, а что словом не пришлось. Так что считайте, что пример исключительно в контексте интеловской экосистемы, хотя бы потому что другого вы не задали изначально.


                                                                                      Чтобы нормальные примеры, подходящие для столь разных платформ, привести, мне нужно погружаться в них.

                                                      +13
                                                      Хотя некоторым подобные изменения могут показаться усовершенствованиями, важно указать на то, что определённые нами интерфейсы не имеют практической пользы, за исключением возможности проведения юнит-тестирования.

                                                      Самое смешное, что в этом месте автор ошибается. Вынесение создания HttpClient за пределы его потребителей в .net имеет прямую и понятную ценность — у него, скажем так, нетривиальный жизненный цикл.


                                                      А дальше, внезапно, выясняется, что если оторвать только HttpClient, можно прекрасно протестировать связку из SolarCalculator и LocationProvider, не вводя ни одного интерфейса, и не заботясь о внутренностях их взаимодействия. Да, это не будет чистым юнит-тестом… ну так если нужные автору кейсы можно протестировать без выделения этой абстракции, то и какая разница? Правда, в этот момент становится не очень понятно, зачем эту абстракцию выделяли — ну и хорошо, нашли лишний код.

                                                        +9
                                                        Дочитал до этой цитаты про первый-второй пример кода:
                                                        Хотя некоторым подобные изменения могут показаться усовершенствованиями, важно указать на то, что определённые нами интерфейсы не имеют практической пользы, за исключением возможности проведения юнит-тестирования.

                                                        Нет, это не измения ради юнит-тестирования, это применение принципов инверсии зависимости и единой ответственности из SOLID. Хорошая практика проектирования, которая в перспективе экономит очень много времени и нервов при последующем изменении кода. И да, она же внезапно позволяет коду быть легко тестируемым, а значит более надежным. И сильно упрощает написание тестовых моков.

                                                        Я лично считаю, что юнит тесты — это самые простые для написания тесты, потому что для них нужно минимум инфраструктуры, и пишут их те же программисты, которые пишут код. По факту, это единственные тесты, которые могут тестировать внутренности реализации. Когда дело доходит до интеграционных тестов, оказывается, что заглушки для целых компонентов писать много сложнее. А c end-to-end тестами вообще часто беда, они выполняются долго, требуют сложной инфраструктуры и отдельных тестеров-автоматизаторов для поддержки.
                                                          +8
                                                          По факту, это единственные тесты, которые могут тестировать внутренности реализации.
                                                          То есть тестируют то, что не нужно ни пользователи, ни разработчику, ни вообще кому-либо.

                                                          Когда дело доходит до интеграционных тестов, оказывается, что заглушки для целых компонентов писать много сложнее. А c end-to-end тестами вообще часто беда, они выполняются долго, требуют сложной инфраструктуры и отдельных тестеров-автоматизаторов для поддержки.
                                                          Гениально просто. Вбухать кучу времени и сил в инфраструктуру поиска ключей под фонорями — потому что она, типа, очень просто делается.

                                                          Когда я поломал интеграционный тест — то это, в 90% случаев ошибка, которую нужно править. Если я поломал end-to-end — это это уже в 99% случаев ошибка, без исправления которой релиза не будет.

                                                          А в случае с юнит-тестами в 90% случаев — это ошибка, возникшая из-за того, что кто-то закодировал в них поведение, которое я, собственно, и хочу изменить.

                                                          В результате в тех 10% случаев, когда они таки срабатывают «по делу» — они, зачастую, точно так же механически «затыкаются» и свою функцию не исполняют.

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

                                                          Ядро современной операционки — это огромное количество кода, которое крайне тяжело тестировать. А какая-нибудь Enterprise-вермишель с методами в две-три строки — отлчино покрывается текстами в три слоя.

                                                          Тем не менее, чтобы получить «kernel oops» — вам нужно постараться, а завалить какую-нибудь эту «суперпротестированноу» бизнесс-програму часто можно парой кликов мышкой в неподходящий момент.
                                                            +4
                                                            Когда я поломал интеграционный тест — то это, в 90% случаев ошибка, которую нужно править. Если я поломал end-to-end — это это уже в 99% случаев ошибка, без исправления которой релиза не будет.

                                                            Кто-то поменял API, кто-то поменял внутренее поведение — и куча ваших интеграционных тестов требуют адаптации, все точно так же как и юнит тесты. На самом деле я ни разу не написал, что остальные тесты не нужны. Очень нужны и очень важны! Но чем ниже в иерархии находится тест, тем проще его писать. А правильный дизайн позволяет писать тесты на более низком уровне.
                                                            В результате в тех 10% случаев, когда они таки срабатывают «по делу» — они, зачастую, точно так же механически «затыкаются» и свою функцию не исполняют.

                                                            Это культура разработки в команде. В моем случае срабатывание юнит-теста как правило означает проблему в коде. И при рефакторинге юниты позволяют сильно упростить верификацию изменений. При разработке нового функционала низкого уровня они позволяют описать требования.
                                                            Тем не менее, чтобы получить «kernel oops» — вам нужно постараться, а завалить какую-нибудь эту «суперпротестированноу» бизнесс-програму часто можно парой кликов мышкой в неподходящий момент.

                                                            Я бы тоже не стал сравнивать код написанный лучшими умами мира и отлаженный миллионами юзеров, с говнокодом, покрытым каргокультовыми тестами, которые на самом деле ничего не проверяют.
                                                              0
                                                              Кто-то поменял API, кто-то поменял внутренее поведение [...]

                                                              И вот уже на следующий день кто-то ищет новую работу, вместе с тем, кто это пропустил на CR. В любом мало-мальски крупном проекте API не может быть изменено, оно может быть только дополнено, если команду не набирали на фестивале мазохистов, конечно.


                                                              А правильный дизайн позволяет писать тесты на более низком уровне.

                                                              FYI: языки, создатели которых отталкивались от научных работ по CS, а не от собственного извращенного понимания прекрасного, запрещают тестирование деталей реализации на уровне компилятора: в них не существует способа протестировать приватную функцию (как и задокументировать).


                                                              Это культура разработки в команде.

                                                              Культура разработки — это не менять API, не ломать обратную совместимость — и не тестировать детали реализации. А уж юнит ли этот тест, проперти, интеграционный, или его вообще нет — не имеет никакого вообще значения.

                                                                0
                                                                API — Это в том числе и интерфейсы внутренних компонентов сервиса, которые не публичны, могут менятся, и вполне себе тестируются интеграционным тестированием.

                                                                в них не существует способа протестировать приватную функцию

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

                                                                Культура разработки — это не менять API, не ломать обратную совместимость — и не тестировать детали реализации

                                                                Если у вас относительно молодой проект, то вы будете делать все три перечисленные вещи.
                                                                  –3
                                                                  Если у вас относительно молодой проект, то вы будете делать все три перечисленные вещи.

                                                                  Вы бы менторского нарративчика поубавили, а то и так смешно читать, а в утвердительной форме, не терпящей возражений — и подавно. Ни в одной из своих OSS библиотек я не сломал обратную совместимость с версией v0.1.0. Среди них есть и довольно замысловатые. Проект показать не могу, публиковать бизнес-логику в паблик домен мне пока не позволяют, но и там ситуация такая же (несмотря на то, что хайлоад появился только ко второй версии, все старые интерфейсы до сих пор поддерживаются).


                                                                  интерфейсы внутренних компонентов сервиса, которые не публичны, могут менятся

                                                                  За что ж вы так коллег-то ненавидите, а? Я бы пришиб любого, кто пришел бы ко мне с приветом «а теперь поменяй все свои вызовы нашего микросервиса, потому что мы переписали API». Ну, не пришиб бы, но переписать набело с нуля с сохранением обратной совместимости — заставил бы точно.


                                                                  как только ваш приватный код становится достаточно сложным, его приходится вытаскивать в видимую область

                                                                  Интерфейс вытащите и обтестируйтесь; детали реализации так и останутся деталями, будь они хоть трижды в библиотеке. В тестировании они не нуждаются просто потому, что иначе вы 90% времени будете тратить на озеленение тестов после каждой мини-правки, вместо работы.

                                                                    +2
                                                                    Вы бы менторского нарративчика поубавили, а то и так смешно читать, а в утвердительной форме, не терпящей возражений — и подавно.

                                                                    Это говорит человек, который совсем недавно написал "в любом мало-мальски крупном проекте API не может быть изменено, оно может быть только дополнено".


                                                                    Ну, не пришиб бы, но переписать набело с нуля с сохранением обратной совместимости — заставил бы точно.

                                                                    Это когда у вас есть способ "заставить". Которого, сюрприз, может и не быть.

                                                                      +1
                                                                      Это когда у вас есть способ «заставить». Которого, сюрприз, может и не быть.
                                                                      Да даже если есть. Во всех крупных публичных проектах, с которыми я сталкивался, (как то: ядро Linux, GCC, Clang, Chromium, Android, далее везде) API всегда чётко разбиты на два класса:
                                                                      1. Внешние API (1%-10% всех API) — стабильны как скала, не меняются годами, покрыты тестами по самое… в общем хорошо покрыты.
                                                                      2. Внутренние API (90%-99%) — нестабильны и меняются в любой момент, легко могут оказаться несовместимыми между версиями и т.д. и т.п.
                                                                      Объяснение просто: поддерживать стабильность — сложно и дорого (как не меряй — хоть в деньгах, хоть во времени, хоть в «потерянных конрибуторах» — всё равно дорого), но это необходимо, чтобы вашим продуктом кто-нибудь мог пользоваться. А вот подерживать стабильность внутренних API — тут сплошные минусы: ими никто, кроме разработчиков не пользуется, но потенциальных котрибутовров вы всё равно потеряете.

                                                                      Не могу вспомнить ни одного популярного проекта более 100'000 строк (или, тем более, более 1'000'000 строк), который бы имел внутренние стабильные API.
                                                                        +1
                                                                        Внешние API (1%-10% всех API) — стабильны как скала, не меняются годами, покрыты тестами по самое… в общем хорошо покрыты.

                                                                        То то в андроид постоянно что то становится deprecated, выпиливается из внешнего api, добавляются новые… Для решения этой проблемы несовместимости апи даже либу отдельную гугл запилил которая пытается как то скрыть под капотом эти изменения, но выходит так себе.

                                                                        По второму пункту полностью согласен.
                                                                          0
                                                                          То то в андроид постоянно что то становится deprecated, выпиливается из внешнего api, добавляются новые…
                                                                          Знаете — если бы я в этом всём не варился, то так бы вам и поверил. Да, конечно, там что-то выпиливается регулярно… только этот процесс занимает лет так пять-семь обычно. Фича, которую ввели максимально быстро (из известного мне) — это поддержка PIE. в Android 4.1 добавили, в Android 5.0 поддежку non-PIE убрали. Но это, во-первых, экстремальный случай (я вообще не могу припомнить никакой другой фичи, которую так форсированно вводили бы) — но и даже в этом случае процесс, всё-таки, занял два года.

                                                                          Можете назвать что-нибудь что было введено быстрее, чем за пару лет?
                                                                            0
                                                                            Да вот например недавние изменения в работе с файлами. В десятке только начали отказываться, просто флаг ввели для легаси, а в 11 уже все.
                                                                              0
                                                                              Это вы про что конкретно? Про то, что приложения должны были бы, уже давно, перейти на новинку десятилетней давности?

                                                                              Или о чём?
                                                                                0
                                                                                Тащем то если бы они депрекейтнули прямой доступ и спустя лет 5 его выпилили — я бы согласился что норм. Но они депрекейтнули его только в десятке. А ведь на это была куча либ завязана и софта. Куча всего завязано именно на использование файловых дескрипторов которые с 11 насколько помню станут недоступны (если это не свои файлы приложения, там, опять же если не ошибаюсь, они останутся). Но всяким там видеоплеерам и прочему подлянка та еще.
                                                                                  0
                                                                                  Куча всего завязано именно на использование файловых дескрипторов которые с 11 насколько помню станут недоступны (если это не свои файлы приложения, там, опять же если не ошибаюсь, они останутся).
                                                                                  Конкретику можно? Я знаю только вот про это: To give developers additional time for testing, apps that target Android 10 (API level 29) can still request the requestLegacyExternalStorage attribute.

                                                                                  Если судить по текущим полиси ещё годик у вас есть.
                                                                                    +1
                                                                                    Ну, годик конечно лучше чем ничего. Про конкретику к сожалению не отвечу поскольку в нашем приложении файлы почти не используются (а там где используется давно от прямой работы с файлами отказались), потому сильно эту тему не изучал. Но в Android Dev Podcast неплохо эту тему раскрывали в одном из выпусков недавних.
                                                                                      0
                                                                                      Ну, годик конечно лучше чем ничего.
                                                                                      Два годика. Год назад ввели дополнительный permission, ещё год — им можно будет пользоваться.

                                                                                      Собственно то, что вы воспринимаете это как «предупреждение за год» и показывает, что «депрекейтнуть и лет через 5 выпилить» — не работает.

                                                                                      Сколько бы ни было предупреждений и описаний — для большинства разработчиков «обратный отсчёт» начинается тогда, когда чётко становится известна дата «полного и окончательного выпиливания».

                                                                                      И многие начинают вопить вообще в тот день, когда это случается. Думаете когда в конце 2020 года Flash окончательно перестанет поддерживаться — не найдётся куча горе-разработчиков Web-сайтов который только тогда и начнут вопить, что им ничего вовремя не сказали? Я уверен, что найдутся…

                                                                                      Но в Android Dev Podcast неплохо эту тему раскрывали в одном из выпусков недавних.
                                                                                      А почему эту тему раскрывали «в одном из выпусков недавних», а не «в одном из выпусков прошлого года»?
                                                                                        0
                                                                                        А почему эту тему раскрывали «в одном из выпусков недавних», а не «в одном из выпусков прошлого года»?

                                                                                        В прошлом году тоже обсуждали, но переход на десятку с соответствующими изменениями и гадали что будет в 11. В этом году уже известно что в 11.
                                                                                        По поводу того что все на старом апи сидели, так ведь он и не депрекейтед был до десятки насколько помню.
                                                                                          0
                                                                                          Кстати еще немного в тему совместимости api — как раз сейчас сижу, думаю как обойти нерабочие ACTION_CREATE_DOCUMENT и ACTION_OPEN_DOCUMENT на сяоми и ванпласах. Которые между прочем рекомендуемый подход для выбора файла и сохранения файла.
                                                                                0

                                                                                Есть нюанс. Даже 5 лет не так уж много. Android 4.4 вполне живой даже у меня. Ну и теперь разработчикам нужно поддерживать два API для одного приложения.

                                                                                  0
                                                                                  Android 4.4 вполне живой даже у меня.
                                                                                  А у меня есть комп с живой Windows XP. И AmigaOS 3.5. Дальше? Подо всё это софт разрабатывать? Никто так не делает.

                                                                                  И даже «бессмертный» MS IE 6 уже прекратили поддерживать.

                                                                                  Даже 5 лет не так уж много.
                                                                                  Это огромный срок. Процент «живых» смартфонов старше 5 лет сравним с процентом пользователей Windows XP.

                                                                                  Ну и теперь разработчикам нужно поддерживать два API для одного приложения.
                                                                                  Нет, не нужно. Люди пользующие устаревшую версию Android могут пользовать и устаревшую версию приложения.
                                                                                    0
                                                                                    Люди пользующие устаревшую версию Android могут пользовать и устаревшую версию приложения.

                                                                                    Вот это не работает очень часто, увы. Мобильные приложения больше частью, по-моему, это клиенты для онлайн сервисов. Сервис меняет API — нужен новый клиент.

                                                                                      0
                                                                                      Ну мы в своем приложении даже 4.2 поддерживаем. И примерно 4% пользователей до сих пор на 4.2-4.4 сидит.
                                                                                  0

                                                                                  Тут лучше взять clang, у которого есть сишная стабильная libclang, в которой толком нифига сделать нельзя, и плюсовая нестабильная библиотека (или набор библиотек), в которой API меняют регулярно, почти что с каждым релизом.

                                                                              +2
                                                                              За что ж вы так коллег-то ненавидите, а? Я бы пришиб любого, кто пришел бы ко мне с приветом «а теперь поменяй все свои вызовы нашего микросервиса, потому что мы переписали API». Ну, не пришиб бы, но переписать набело с нуля с сохранением обратной совместимости — заставил бы точно.

                                                                              Если вы действительно проектируете API так, что потом вы его никогда не изменяете, а только дополняете, то я вам искренне завидую, ибо я и большинство других программистов так не умеют. Сколько сталкивался — все развивающиеся API имеют версии и иногда ломают обратную совместимость. Например в Google Maps API фраза «removes deprecated features, and/or introduces backwards-incompatibilities» встречается чуть ли ни на каждую версию. Даже Windows, на мой взгляд образец обратной совместимости, иногда так делает, особенно в драйверах.
                                                                                +4
                                                                                Ни в одной из своих OSS библиотек я не сломал обратную совместимость с версией v0.1.0. Среди них есть и довольно замысловатые.

                                                                                Либо вы гениальный дизайнер интерфейсов, либо вам повезло и последующие версии были просто расширением первоначальной, либо вы теперь поддержваете кучу легаси. Одно из трех.

                                                                                Я бы пришиб любого, кто пришел бы ко мне с приветом «а теперь поменяй все свои вызовы нашего микросервиса, потому что мы переписали API».

                                                                                Это вы своих коллег не любите :-) Кроме внешних интерфейсов с версионированием и обратной совместимостью бывают еще внутренние API сервисов, которые активно живут и меняются, вот интерфейсы классов, например. Они просто обязаны меняться, иначе проект либо обрастает адапторами-костылями, либо дизайн становится неуправляемым спагетти с подпорками.
                                                                                  0
                                                                                  вот интерфейсы классов, например

                                                                                  У нас нет классов, простите. И адапторов нет. И вообще ООП нет. И связанных с ним проблем — тоже нет.

                                                                                    +1
                                                                                    Как будто отсутствие ООП спасает от проблем расширения API?
                                                                                    Наоборот, ООП способно их лечить.
                                                                                      0

                                                                                      А как builder помогает расширять и менять API?

                                                                                        0
                                                                                        Установка всяких начальных параметров это тоже часть API, местами очень существенная.
                                                                                      0
                                                                                      Замените интерфейсы классов на сигнатуры функций и структуры данных.
                                                                                        +1

                                                                                        Как только заменим — проблема сама собой исчезнет. Во второй версии API появится новая функция, старая никуда не денется, а структура данных просто расширится, без потери обратной совместимости.


                                                                                        Да, так можно было.

                                                                              0
                                                                              чем ниже в иерархии находится тест, тем проще его писать

                                                                              Это не так. В модульных тестах нужно много мокать в каждом тесте, а в компонентных достаточно просунуть тестовый контекст и всё. Например: https://github.com/hyoo-ru/todomvc.hyoo.ru/blob/master/todomvc.test.ts

                                                                                0
                                                                                Мой опыт показывает, что если это не так, то у вас проблемы с дизайном.
                                                                                  0

                                                                                  Какие проблемы? Вот давайте на конкретном приведённом мной примере.

                                                                                    0
                                                                                    Это означает, что ваши зависимости становятся сложными, функции перестают быть чистыми, и начинают что-то подозревать о своих зависимостях. Ваш конкретный пример, если я правильно понимаю, уже содержит слой моков, контекст. Это далеко не всегда возможно обеспечить. В моем проекте моки для юнит тестов намного проще, чем моки для интеграционных.
                                                                                      0

                                                                                      Что значит "сложные зависимости"? Причём тут чистые функции, когда у нас ООП в полный рост?


                                                                                      Ну вот, смотрите, тривиальный мок, который пишется один раз, а не для каждого теста: https://github.com/eigenmethod/mol/blob/master/state/arg/arg.web.test.ts#L3

                                                                                        0
                                                                                        В случае юниов такие же тривиальные моки пишутся все же на тест, но на сюиту. Но часто поведение сервиса более сложное, чем поведение маленького компонента. Соответственно и моки становятся все сложнее и сложнее.
                                                                                          0

                                                                                          Замечательно, вот вы и подтвердили, что написание моков для модульных тестов — сложно и долго.

                                                                                            0
                                                                                            При редактировании потерялось «не», прошу прощения. Я имел в виду «тривиальные моки пишутся все же не на тест, но на сюиту». А так я и писал, что чем больше модулей и связей охватывает тест, чем выше в иерархии он находится, тем сложнее моки. У юнита самые простые, у модуля сложнее и так далее.

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