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



Предлагаем вам перевод поста «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. Используйте заглушки только в крайних случаях

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

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

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

  • 33,0%Однозначно — да71
  • 18,1%Однозначно — нет39
  • 48,8%Сильно зависит от ситуации105




Комментарии (297):

  1. vdem
    /#21839472 / +19

    Не читал (многабукаф, только пролистал) но осуждаю :) Мне лично юнит-тесты очень помогают не поломать что-нибудь, особенно когда общее представление о проекте еще не оформилось и приходится часто вносить правки. Я уверен (вернее даже знаю), что они сэкономили мне кучу времени. Другое дело, что не надо это возводить в абсолют, конечно, и стремиться к 100% покрытию или менять архитектуру в угоду тестируемости, которая может зависеть от используемого фреймворка для тестов.

    • VIkrom
      /#21839678 / +5

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

      • vdem
        /#21839708 / +8

        Я отдельные модули по одному пишу. И в процессе покрываю тестами (это конечно не best practices, когда сначала пишут тесты, а потом код). Да, часть времени уходит на переписывание и тестов тоже, но дальше, когда модуль уже более-менее готов и уже может работать с другими (которые я начинаю писать, когда предыдущий почти оформился). И вот здесь тесты начинают очень помогать, так как теперь уже приходится вносить небольшие изменения в уже почти готовый код.
        P.S. Это я рассказываю, как я над своим собственным проектом работаю. А заказчики как правило на тестах экономят. Но попадаются и такие, для которых тесты чуть ли не важнее кода.

        • Guzergus
          /#21839748 / +2

          (это конечно не best practices, когда сначала пишут тесты, а потом код)

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

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

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

          • vdem
            /#21839800 / +2

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

            • Guzergus
              /#21839868

              P.S. А дальше извините, у меня работа а я и так уже три часа тут торчу.

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

            • onets
              /#21844038

              То, что вы описали — это не юнит-тест. О чем в статье и говорится:

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

          • VolCh
            /#21840336

            Какой-то 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 и проверяем, что выбросилось исключение, а репозиторий и шина пустые (вариант — не вызывались)

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

            • Guzergus
              /#21840858 / +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# для меня пока открыт.

              • VolCh
                /#21841144

                Приходится заглядывать в реализацию, дабы знать, какой именно метод IUserRepository вызывается и какой надо мокать.

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


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

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


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

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


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

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

                • Guzergus
                  /#21841256

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

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

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

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

                  К сожалению, не работал с gherkin и вообще BDD не щупал на реальных проектах. Не исключаю, что в итоге оно может оказаться ещё более затратным, чем «тупой» тест с моками.

                  • 0xd34df00d
                    /#21841960

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

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

              • 0xd34df00d
                /#21841948

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

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


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

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


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

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


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

              • HackerDelphi
                /#21850822

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

          • Artem_7
            /#21844108

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

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

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

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

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

          • /#21845510

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

    • ImLoaD
      /#21840108

      Соглашусь, и добавлю что иногда написание теста перед написанием функции само по себе облегчает жизнь

      • vdem
        /#21840114

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

    • 0xd34df00d
      /#21841894 / +3

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


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


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


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

      • defuz
        /#21843296

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

        • 0xd34df00d
          /#21843312 / +1

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

      • nin-jin
        /#21843942

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

        • 0xd34df00d
          /#21846332

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

          • nin-jin
            /#21846362

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

            • 0xd34df00d
              /#21846382

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

  2. rsashka
    /#21839582 / +1

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

    • aamonster
      /#21841340 / +1

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

      • khim
        /#21841940

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

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

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

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

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

        Не надо так.

        • Neikist
          /#21842212

          все эти IoC, DI, позднее связывание и прочее

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

          • lair
            /#21842414

            они еще и гибкости очень неплохо добавляют

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

            • Neikist
              /#21842742

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

              • lair
                /#21842782 / +2

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

          • khim
            /#21843096

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

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

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

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

            • v2kxyz
              /#21844264 / +2

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

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

              • khim
                /#21844838

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

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

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

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

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

                • v2kxyz
                  /#21845146

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

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

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

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

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

                  • khim
                    /#21846032

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

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

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

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

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

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

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

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

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

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

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

                    • v2kxyz
                      /#21846214

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

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

                      • khim
                        /#21846582

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

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

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

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

            • joyfolk
              /#21844644 / +1

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

              • lair
                /#21844804

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

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

                • joyfolk
                  /#21844830 / +1

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

                  • lair
                    /#21844844

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

                    • joyfolk
                      /#21844964 / +1

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

                      • lair
                        /#21845024

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

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

                        • joyfolk
                          /#21845060

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

                          • lair
                            /#21845102

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

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

                            • joyfolk
                              /#21845176

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

                              • lair
                                /#21845424

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


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

                                • joyfolk
                                  /#21845440

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

                                  • lair
                                    /#21845456

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

                                    • joyfolk
                                      /#21845472

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

                                      • lair
                                        /#21845486

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


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


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

                                        • joyfolk
                                          /#21845550

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


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

                                          • lair
                                            /#21845584

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

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


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

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

                        • chersanya
                          /#21845670

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

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

                          • lair
                            /#21845674

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

                            • chersanya
                              /#21845766

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

                              • lair
                                /#21845790

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

                                • chersanya
                                  /#21845834

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

                                  • lair
                                    /#21845840

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

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

                  • nin-jin
                    /#21845054

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

                    • joyfolk
                      /#21845164

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

                      • nin-jin
                        /#21845248

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

                        • joyfolk
                          /#21845278

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

                          • nin-jin
                            /#21845768

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

                            • VolCh
                              /#21845914

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

                              • nin-jin
                                /#21845970

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

                            • joyfolk
                              /#21846178

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

                        • 0xd34df00d
                          /#21846360

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

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


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

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

                    • 0xd34df00d
                      /#21846348

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

                      • nin-jin
                        /#21846384

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

              • khim
                /#21844910

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

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

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

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

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

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

                • Neikist
                  /#21845754 / +1

                  Почти всегда можно снизить удельную сложность в пересчёте на строку кода (но это редко когда важно).

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

                  • khim
                    /#21846086

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

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

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

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

                    • Neikist
                      /#21846192

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

                      • khim
                        /#21846596

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

                        • Neikist
                          /#21846792

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

                          • khim
                            /#21847046 / +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 (или любую другую подобную систему, «упрощающую тестирование») и вы понятия не имеете что и где у вас не так сконфигурировано?

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

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

                            • Neikist
                              /#21847240

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

                              • netch80
                                /#21847314

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

                                • khim
                                  /#21848584

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

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

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

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

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

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

                          • chapuza
                            /#21847168

                            Либо есть краш со стектрейсом, тогда идем в место краша, смотрим простой код [...]
                            Либо у нас есть описание пользователя/аналитика/сами наткнулись и поняли сценарий.

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


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


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


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

                            • Neikist
                              /#21847258

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

                              • chapuza
                                /#21848120 / +1

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


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

                              • khim
                                /#21848626

                                А чем в вашей ситуации усложнит жизнь модульность и DI?
                                1. Увеличение количество кода примерно так на порядок усложняет попытки поиска банально тем, что кода много.
                                2. Каждый переход «наверх, к источнику проблему» затруднён, так как там у нас нет однозначности в угоду простоте тестирования.

                                • Neikist
                                  /#21848644 / +1

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

                                  • khim
                                    /#21848934

                                    по факту процентов на десять.
                                    Это, извините, по какому факту? Как часто вы участвовали в «феншуйном переписывании» (или, наоборот, в «нефеншуйном»)?

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

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

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

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

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

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

                            • netch80
                              /#21847342

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

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

                              2. Вместо

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

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

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

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

                              • chapuza
                                /#21847814

                                Лучшее: выкинуть Erlang и всё, что на нём написано.

                                   


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

                                   


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

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


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


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

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

                                • nin-jin
                                  /#21848052

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

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

                                  • chapuza
                                    /#21848378

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

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


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

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


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

                                    • nin-jin
                                      /#21848810

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

                                      • chapuza
                                        /#21848996

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

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


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

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

                                        • nin-jin
                                          /#21849278

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

                                          • chapuza
                                            /#21850708

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

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


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

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

                                • netch80
                                  /#21849012

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

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

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

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

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

                                  Наоборот, упростим — по сравнению с нынешними кошмарами. Но вы можете продолжать упорствовать, ваше дело.

                                  • chapuza
                                    /#21849246

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

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


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


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

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


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

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


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

                                    • netch80
                                      /#21849264

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

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

                                      %% 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К процессами в одном виртуальной машине. Нет проблем, понимаете? Тех, которые вы предлагаете решать — их нет.

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

                                      • chapuza
                                        /#21849458

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

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


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

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


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

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

                                        • netch80
                                          /#21849482

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

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

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

                                          • chapuza
                                            /#21850134

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


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


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

                                            • netch80
                                              /#21850838

                                              > А, 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 отправлял бы в высокоприоритетную — всё могло бы работать и сейчас.

                    • v2kxyz
                      /#21846268 / +1

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

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

                • VolCh
                  /#21845932

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

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


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

                  • khim
                    /#21846132

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

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

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

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

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

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

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

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

                    • Neikist
                      /#21846200

                      Нет. Замена одного литерала на одну константу — никогда не упрощает код.

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

                      • khim
                        /#21846598 / +1

                        Забавно, что пафоса вы насыпали, а вот предложить на что заменить 8, 16 и 32 — так и не смогли.

                        • Neikist
                          /#21846798

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

                          • khim
                            /#21847076

                            А что я могу предложить если я не знаю контекста использования этих констант?
                            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 теперь не ругается? Какое-то сомнительное достижение, как по мне…

                            • Neikist
                              /#21847274

                              Да как раз упращение то. Если у вас есть несколько разных констант со значением 32 например — они все гарантированно будут разделены по смыслу.

                              становится проблемой: если это у вас не просто числа, а магические имена, то чтобы перейти от SIZE_8_REGISTER к SIZE_16_REGISTER вам теперь, внезапно, нужна вспомогательная функция… и для обратного перехода — тоже…

                              А тут то в чем проблема? Не говоря уж о том что да, в функцию вынести удобнее — но никто собственно не мешает вам и дальше as.movsx(GPRegister<size * 2>(r1), GPRegister(r2)); использовать, если size один из ее аргументов. Зато сразу в месте вызова не читая сигнатуру можно понять что в функцию передаем, именно размер регистра, а не просто число 8 неясно чего значащее.

                              • khim
                                /#21848716

                                Да как раз упращение то. Если у вас есть несколько разных констант со значением 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> вдруг стало непонятно?

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

                                • Neikist
                                  /#21848768 / +1

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

                                  Нет. Может для вас понимание из за адекватного нейминга усложняется, но для меня упрощается.
                                  А уж аргумент
                                  «феншуйно», «так как в умных книжках» написано — то тут уже обсуждать нечего. Нужно от такого работника избавляться.
                                  вообще странный. Код стайлы, паттерны и прочее придуманы в т.ч. и как общий знаменатель для разработчиков, чтобы было проще читать код написанный другими людьми, он по возможности должен быть написан единообразно, по общим принципам. А если каждый будет писать как ему вздумается и хочется — то получим мешанину стилей и подходов.
                                  Проблема в том, что мы не знаем, в соотвествии с теми самыми умными книжками, что именно у нас 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, изменяться она не будет. Очень понятная с ходу, угу.

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

                                  • khim
                                    /#21848982

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

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

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

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

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

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

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

                                    • Neikist
                                      /#21849034

                                      А чем это плохо?

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

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

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

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

                                      • khim
                                        /#21849324

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

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

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

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

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

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

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

                                        • Neikist
                                          /#21849360

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

                                          Где вы это прочитали? Впрочем я в любом случае уже спорить банально устал.

                    • VolCh
                      /#21846480 / +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))

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

                      • khim
                        /#21846610

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

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

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

                        • VolCh
                          /#21847386

                          С одной стороны, предполагается, что человек, читающий этот код, хотя бы неполное среднее получил. С другой, даже без этого, он хотя бы вопрос сможет задать "а что за константа 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

                          • khim
                            /#21848774

                            Навскидку, я бы, минимум, сделал константы типа 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>
                            Попробуйте. Будет ошибка компиляции. Всё просто.

                            • VolCh
                              /#21848858

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

                              • khim
                                /#21848988

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

                                • VolCh
                                  /#21849014

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

                                  • khim
                                    /#21849386 / +1

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

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

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

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

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

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

                                    • VolCh
                                      /#21849432

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


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

  3. lair
    /#21839774 / +13

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

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


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

  4. dbagaev
    /#21839806 / +9

    Дочитал до этой цитаты про первый-второй пример кода:

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

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

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

    • khim
      /#21842000 / +8

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

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

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

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

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

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

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

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

      • dbagaev
        /#21842262 / +4

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

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

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

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

        • chapuza
          /#21843388

          Кто-то поменял API, кто-то поменял внутренее поведение [...]

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


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

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


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

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

          • dbagaev
            /#21844556

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

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

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

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

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

            • chapuza
              /#21844672 / -3

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

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


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

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


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

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

              • lair
                /#21844808 / +2

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

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


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

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

                • khim
                  /#21844968 / +1

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

                  Не могу вспомнить ни одного популярного проекта более 100'000 строк (или, тем более, более 1'000'000 строк), который бы имел внутренние стабильные API.

                  • Neikist
                    /#21845772 / +1

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

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

                    По второму пункту полностью согласен.

                    • khim
                      /#21846164

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

                      Можете назвать что-нибудь что было введено быстрее, чем за пару лет?

                      • Neikist
                        /#21846174

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

                        • khim
                          /#21846632

                          Это вы про что конкретно? Про то, что приложения должны были бы, уже давно, перейти на новинку десятилетней давности?

                          Или о чём?

                          • Neikist
                            /#21846808

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

                            • khim
                              /#21847086

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

                              Если судить по текущим полиси ещё годик у вас есть.

                              • Neikist
                                /#21847278 / +1

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

                                • khim
                                  /#21848836

                                  Ну, годик конечно лучше чем ничего.
                                  Два годика. Год назад ввели дополнительный permission, ещё год — им можно будет пользоваться.

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

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

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

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

                                  • Neikist
                                    /#21848846

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

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

                      • VolCh
                        /#21847406

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

                        • khim
                          /#21848868

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

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

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

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

                          • VolCh
                            /#21848888

                            Люди пользующие устаревшую версию Android могут пользовать и устаревшую версию приложения.

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

                          • Neikist
                            /#21848918

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

                    • 0xd34df00d
                      /#21846376

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

              • v2kxyz
                /#21845010 / +2

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

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

              • dbagaev
                /#21845612 / +4

                Ни в одной из своих OSS библиотек я не сломал обратную совместимость с версией v0.1.0. Среди них есть и довольно замысловатые.

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

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

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

                • chapuza
                  /#21846274

                  вот интерфейсы классов, например

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

                  • netch80
                    /#21847298 / +1

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

                  • dbagaev
                    /#21849478

                    Замените интерфейсы классов на сигнатуры функций и структуры данных.

                    • chapuza
                      /#21860210

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


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

        • nin-jin
          /#21844018

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

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

          • dbagaev
            /#21844500

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

            • nin-jin
              /#21845086

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

              • dbagaev
                /#21845642

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

                • nin-jin
                  /#21845786

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


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

                  • dbagaev
                    /#21849492

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

                    • nin-jin
                      /#21849690

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

                      • dbagaev
                        /#21849880

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

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

    • Andrey_Solomatin
      /#21850378

      Было плохо, стало плохо. Но с интерфейсами и инверсией зависимостей.
      Можно ли сказать что метод который читает данные из провайдера и проводит вычисления следует принципу единой ответственности?

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

      Писать тесты к таким функциям одно удовольствие. Ведь у неё нет зависимости и сайдэфектов. И моков никаких не надо.

      Клас с бизнес логикой будет простым как пробка. Взять из сети координаты, передать их в вычисление. Тут без моков не протестить. Но и тестить то особо нечего: тест на позитивный сценарий и пара тестов на обработку ошибок. Тут будет прямо как в статье «Юнит-тесты зависят от подробностей реализации». Если функция имеет сайдэфекты, то их надо тестировать.

  5. Guzergus
    /#21839822 / +6

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

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

    С другой стороны, это сильно зависит от домена и характера разработки. Когда я ради интереса набрасывал простейший лексер в относительно ФП стиле с чистыми функциями, тестировать его было одно удовольствие. Никаких километровых моков, простые чистые функции input-output. Можно пойти дальше и попробовать описать некоторые свойства системы не в виде простого юнит-теста, а в виде property-based testing. Если пойти и ещё дальше, то вместо тестов у нас появятся типы и compile-time доказательства корректности программы.

    • andreyverbin
      /#21841464 / -1

      но в типичной enterprise LoB разработке они выглядели именно так, как описывает автор — хрупкими, не очень полезными и достаточно дорогими в разработке.


      Это также согласуется с моим опытом — такие тесты хрупкие, баги не ловят, стоят как чугунный мост. Гораздо лучше дергать живую систему, с живой БД. Тесты пишутся один раз, покрытие близко к 100% с минимум тестов, они ловят реальные баги и меняются только если изменилось API.

      • lair
        /#21841544 / +1

        покрытие близко к 100% с минимум тестов

        Вот как вы этого добиваетесь, мне очень интересно, конечно.

        • khim
          /#21842034

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

          Если вы написали какую-нибудь проверку, которую вы ну никак, кроме как с помощью юниттеста, не можете «покрыть» — то проверку следуюет удалить и всё.

          У вас будет меньше кода в программе, будет меньше тестов и они будут быстрее отрабатывать, а если ошибку никак по другому нельзя вызвать — то и пользователь её не увидит.

          • lair
            /#21842056

            Меня волнует не столько покрытие, близкое к 100%, сколько утверждение о "минимуме тестов".

            • khim
              /#21842296 / +1

              А. Это я не знаю. Знаю что у нас, скажем, подсистема графики покрыта почти на 100% чисто интеграционными тестами… но там этих тестов — больше двухсот тысяч. Они пачками по тысяче прогоняются на ботах.

              Так что про «минимум тестов» — было бы интересно узнать…

        • andreyverbin
          /#21842916

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


          Есть сложные кейсы, типа «сломался коннект к БД», для этого есть тестовые коннекторы к внешним ресурсам, которые можно заставить имитировать такие ошибки. Обычно нужно иметь HttpClient, DbConnection и все.

          • lair
            /#21842956

            Главное понимать классы эквивалентности на параметрах апи и состоянии системы и проверять каждый.

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

            • andreyverbin
              /#21843106

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


              Другой вариант — комплект тестов для интерфейса работы с БД, который гоняем для всех поддерживаемых БД. Мы так делаем для проверки работы с разными блокчейнами (которых около 20) и это удобно, потому что у этого интерфейса всего 3 метода.

              • lair
                /#21843856

                Можно комплект тестов гонять на двух БД.

                … ну то есть шли тесты 26 часов, будут идти 52. Хмм.


                Другой вариант — комплект тестов для интерфейса работы с БД, который гоняем для всех поддерживаемых БД.

                Но тогда же надо как-то проверить, что то, что работает с этим интерфейсом, делает то же, что и тесты?

                • andreyverbin
                  /#21844074 / -1

                  … ну то есть шли тесты 26 часов, будут идти 52. Хмм.

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


                  • можно заказать 260 спот инстансов на aws и прогнать их за 5 минут
                  • можно в тестах и пользовать in-memory sqlite и делать вариант 2 (см. ниже)

                  Если 26 или 52 часа это проблема, то занимайтесь оптимизацией тестов. Но заменить медленные тесты, которые проверяют поведение реальной системы быстрыми, которые проверяют моки это так себе идея.


                  Но тогда же надо как-то проверить, что то, что работает с этим интерфейсом, делает то же, что и тесты?

                  Два комплекта тестов


                  1. Проверяет API, в нем либо реальная БД1, либо in-memory sqlite
                  2. Комплект тестов для БД, который запускается для БД1, БД2, in-memory SQLite и подтверждает, что они одинаково работают.

                  • lair
                    /#21844098 / +1

                    можно заказать 260 спот инстансов на aws и прогнать их за 5 минут

                    Не, нельзя. Денег нет.


                    Если 26 или 52 часа это проблема, то занимайтесь оптимизацией тестов.

                    Ну так замена медленных тестов быстрыми — это и есть оптимизация, не так ли?


                    Два комплекта тестов
                    1. Проверяет API, в нем либо реальная БД1, либо in-memory sqlite
                    2. Комплект тестов для БД, который запускается для БД1, БД2, in-memory SQLite и подтверждает, что они одинаково работают.

                    Но как гарантируется, что тесты из (2) покрывают все, что нужно в (1)?

                    • andreyverbin
                      /#21844332

                      Не, нельзя. Денег нет.

                      Ага, точно. Есть команда из десятков или сотен человек, которые эту систему пишут и как-то написали сотни тысяч тестов, которые идут 26 часов. Но денег нет. Ню ню.


                      Но как гарантируется, что тесты из (2) покрывают все, что нужно в (1)?

                      Есть интерфейс с методами, есть комплект тестов, который этот контракт проверяет. Этот комплект тестов запускается для каждой реализации интерфейса. В реальной системе используется одна из реализации, которая в силу тестов контракта эквивалентна всем остальным.

                      • joyfolk
                        /#21844694

                        Ага, точно. Есть команда из десятков или сотен человек, которые эту систему пишут и как-то написали сотни тысяч тестов, которые идут 26 часов. Но денег нет. Ню ню.

                        На прошлом проекте именно так и было.

                      • lair
                        /#21844802

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

                        Ну да, а что вас удивляет? Деньги — они в какой-то момент заканчиваются.


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


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

                        Проблема в том, что она эквивалентна в объеме тестов. Но откуда вы знаете — и это именно то, что я спросил выше — что тесты эквивалентны использованию?


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


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

                        • andreyverbin
                          /#21844976

                          Ну да, а что вас удивляет? Деньги — они в какой-то момент заканчиваются.

                          Если нет денег добавить поддержку новой БД в систему, то не добавляйте. О чем мы тут вообще говорим?


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

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


                          Вы хотели знать как получается высокое покрытие с минимумом тестов, не знаю зачем мы тут про часы и разные БД говорим. Покрытие хорошее получается когда программист корректно выделил классы эквивалентности на параметрах и состоянии системы и написал тесты. Тесты дергают внешнее апи и заодно проверяют все внутренности. Когда внутренности изменятся тесты упадут, если не упали, значит поведение апи не изменилось и не о чем беспокоится. Иногда будут неприятные сюрпризы, для этого есть другие инструменты вроде ручного тестирования, мониторинга ошибок и т.п.

                          • lair
                            /#21845046 / +1

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

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


                            Вы хотели знать как получается высокое покрытие с минимумом тестов, не знаю зачем мы тут про часы и разные БД говорим.

                            Вот только пока минимума тестов я так и не увидел. Увидел удвоение тестов при добавлении второй БД.

                            • andreyverbin
                              /#21845058

                              Вариант 1 вам не нравится, оказывается долго или дорого. Вариант 2 (внезапно) удвоение тестов, хотя я об этом не говорил. Ну значит судьба такая — страдать.

                              • lair
                                /#21845066

                                Да нет, удвоение тестов — это вариант 1. А вариант 2, доведенный до логического завершения, как раз и станет юнит-тестами.

                                • andreyverbin
                                  /#21845134

                                  Вариант 1 это удвоение времени тестов, не их количества или ресурсов на их поддержку. Вариант 2 никогда изолированным юнит тестом не станет.


                                  • система все ещё тестируется с живой БД
                                  • все ещё тестируется внешнее апи системы, внутренности не трогаем

                                  То есть количество тестов пропорционально количеству вызовов АПИ, а не количеству классов в системе. В случае изолированных юнит тестов все наоборот.

                                  • lair
                                    /#21845418 / +1

                                    Вариант 1 это удвоение времени тестов, не их количества или ресурсов на их поддержку.

                                    Вот тут и вопрос, как вы считаете "количество тестов". Если только по их числу, то дальше можно долго спорить о том, как их делить, и придти к идее, что один тест покрывает систему полностью.


                                    все ещё тестируется внешнее апи системы, внутренности не трогаем

                                    Вы уже тестируете внутреннее API — то, которое между БД и системой, ее потребляющей.

                      • 0xd34df00d
                        /#21846388 / +2

                        Ага, точно. Есть команда из десятков или сотен человек, которые эту систему пишут и как-то написали сотни тысяч тестов, которые идут 26 часов. Но денег нет. Ню ню.

                        Да, например, если бюджет на отдел выделяется раз в год, и на амазоны денег не запланировано.


                        Или, например, если вы работаете в месте, где AWS запрещён по соображениям секурности и отдачи данных левым лицам.

  6. VIkrom
    /#21839942 / +6

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

    • Guzergus
      /#21840300 / +3

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

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

    • khim
      /#21842060 / -1

      Покрывать юнитами надо то, что имеет смысл тестировать именно юнитами.
      А поподробнее можно? Ну потому что эта фраза — она же не объясняет ничего!

      Я встречал объяснение, что нужно покрывать юниттестами то, что нельзя покрыть другими видами тестов… но это бред!

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

      И потом, вдобавок к тому коду, который просто не нужно было писать вообще — вы собрались писать ещё больше кода? Чего вы этим добиваетесь, чёрт побери?

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

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

      • VIkrom
        /#21842456

        Пример из практики. Простой сервис, занимающийся обезличиванием персональных данных. По сути, если отбросить I/O, набор конвертеров. Все конвертеры покрыты юнит-тестами.

        • khim
          /#21843104 / -1

          И где вы тут видите юнит-тесты? Если все эти форматы заданы «снаружи» (как чаще всего и бывает) — то тут вообще ни одного юнит-теста нет. Чистая проверка требований стороннего сервиса.

          • VIkrom
            /#21843534

            А кто сказал, что границы тестов определяются источником требований?

            • khim
              /#21845002 / -1

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

  7. /#21839962 / +1

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

    • khim
      /#21842090 / -1

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

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

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

  8. tendium
    /#21840194 / +1

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


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


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

    • khim
      /#21842142

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

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

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

      Зачем вы хотите это делать? Какова цель?

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

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

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

      • tendium
        /#21842472

        Тесты, проверяющие API между компонентами обычно называются интеграционными. И да, разумеется они нужны.

        А я не про них говорил. Вот вам пример: на входе имеем формат (а), на выходе имеем формат (б). Отличный кандидат на юнит-тесты.


        Просто не нужно пытаться тестировать отдельные его части — и всё.

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


        Зачем вы хотите это делать? Какова цель?

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


        Вам, на самом деле, тоже наплевать.

        Не надо за меня говорить… Мне не наплавать, как мой код написан и организован. Наплевать обычно тем, кто работает на короткой дистанции. Но это не про меня.

        • khim
          /#21843112 / -1

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

          Хотите переиспользовать код — оформите его в отдельный компонент с продуманным и документированным API. После чего уже его и протестируйте.

          Наплевать обычно тем, кто работает на короткой дистанции. Но это не про меня.
          Это таки про вас. Потому если бы вы работали с кодом, которому 5-10 лет и который всё это время активно правился бы — то вы бы прекрасно понимали, что смешать в кучу два совершенно разных куска кода, только потому что у них логика процентов на 60 совпадает на сегодня — лучший способ порождения жёсткого легаси, которое, через не слишком большое время, можно будет только выкинуть.

  9. Gorthauer87
    /#21840294 / +1

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

    • defuz
      /#21840780 / +1

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

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

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

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

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

    • tendium
      /#21841070 / +2

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

      • VolCh
        /#21841146

        Зависит от того, что именно понимается под интеграционными тестами.

        • Gorthauer87
          /#21842730

          Я же не просто так тесты назвал псевдоинтеграционными. Использование моков как раз делает юнит тесты похожими на интеграционные.

    • 0xd34df00d
      /#21842152

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

      Интересно, что как раз в этих случаях отлично подходит и доказательство всего что надо в типах.

      • Gorthauer87
        /#21842744

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

        • 0xd34df00d
          /#21842826

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

          • v2kxyz
            /#21844506 / +1

            Доказательство тоже нужно правильно написать, что может быть ни разу не просто, а иногда вообще не понятно — возможно ли. Зато, если я правильно понимаю, его состояние часто более детерминировано — либо доказано, либо нет, в отличии от юнит-тестов, которые могут быть неполными. Не забыть обновить помогают административные меры в виде TDD и средства автоматизации, которые не дадут запушить в мастер, если тест сломался.
            Кстати вопрос, как выразить в типах корректность функции, которая к моменту времени прибавляет рабочее время в часах и получается момент времени в будущем, отвечающий следующим требованиям: момент времени находится в промежутке с 9:00 до 18:00 в рабочий день, согласно производственному календарю, если момент времени попадает на конец рабочего времени(18:00 например), то его нужно перенести на начало следующего с учетом выходных и праздников(например на 9:00), если добавляемое количество времени меньше либо равно количеству рабочих часов в день(например 8), то добавлять час обеда, если точка отсчета находится в нерабочее время(ночь, выходной), то приводить ее к началу ближайшего рабочего дня, если день сокращенный, то учитывать его как полный. Может еще что-то забыл, давно было, но как вы догадались, это про расчет сроков некоего бизнес-действия.
            Эту задачу с наскока пыталось решить три разных человека, все время находились какие-нибудь новые граничные случаи, которые забыли учитывать или ломали старые. Я написал классический юнит-тест и наконец победил ее, но не с первой итерации, потому что как раз не все описывал в юнит-тесте. Но чем тут могут типы помочь — я пока к сожалению даже приблизительно не понимаю, особенно учитывая наличие завимости в виде «производственного календаря».

            • 0xd34df00d
              /#21846408

              Не забыть обновить помогают административные меры в виде TDD и средства автоматизации, которые не дадут запушить в мастер, если тест сломался.

              Но средства автоматизации не могут проверить полноту.


              Кстати вопрос, как выразить в типах корректность функции, которая к моменту времени прибавляет рабочее время в часах [...]

              Вот ровно так и выразить, как вы написали. Если у вас есть


              futureTP : Calendar -> TimePoint -> TimeDelta -> TimePoint

              то вы просто пишете по дополнительной функции на каждое атомарное условие:


              -- предполагаем существование isWorkday : Calendar -> TimePoint -> Bool
              
              futureTPIsWorkDay : (cal : Calendar)
                               -> (tp : TimePoint)
                               -> (td : TimeDelta)
                               -> isWorkday cal (futureTP cal tp td) = True

              Это по факту означает «для любого календаря, любой точки времени и любого временного сдвига isWorkday cal (futureTP cal tp td) должно возвращать True».


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

  10. pankraty
    /#21841312 / +4

    Юнит-тесты, конечно, не панацея, но они имеют два важных преимущества, которых не дают другие виды тестов:


    • простота тестирования граничных случаев. Иногда, чтобы протестировать поведение системы в каком-то очень специфичном случае может потребовать тонкая подгонка входных параметров — зачастую очень нетривиальная, требующая знания деталей реализации каждого из звеньев цепочки вызовов. В случае юнит-теста мы просто передаем граничные параметры, на которых хотим проверить работу определенного метода, в этот метод.
    • устранение комбинаторного взрыва. Представим модельный пример, в котором есть 4 метода, вызывающих друг друга, в каждом из которых выполнение может идти по 4 разным путям. В случае, если мы захотим качественно покрыть e2e тестами все пути выполнения, нам придется подобрать 44=256 различных сочетаний входных параметров, что может оказаться весьма непростой задачей, и очевидно, выполнено в полном объеме не будет — разработчик покроет тестами наиболее вероятные сценарии, понадеявшись, что и остальные работают корректно. В случае юнит тестов, нам может хватить 4*4=16 тестов, чтобы проверить все ветви исполнения кода — и это уже выполнимая задача. Это, конечно, все еще не гарантирует, что система работает корректно (одних юнит-тестов недостаточно), но сильно повышает нашу уверенность в ней.

    • andreyverbin
      /#21841482 / +1

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


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

      • pankraty
        /#21841516 / +1

        Это неверно, на множестве входных параметров есть классы эквивалентности, нужно их проверить.

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


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

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

        • JustDont
          /#21841792

          Условно, для int32 у нас 4 миллиарда варианта входных параметров, но с т.з. конкретного метода могут быть различия для категорий «все отрицательные», «ноль», «положительные до тысячи», «положительные от тысячи и более» — их-то мы и проверяем.

          Для этого вам нужно знать детали реализации, писать тест в соответствии с ними, и поддерживать соответствие между тем и другим. Если завтра у вас «положительные до тысячи» превратились в «положительные до 1001», а тест вы не изменили — то всё, ничего особо хорошего вы уже не тестируете.

          • VolCh
            /#21841814

            Вот наиболее ярко плюсы TDD в таких случаях проявляются. Прежде чем изменять в коде 1000 на 1001 — нужно написать тест, который упадёт на 1000, а после изменения пройдёт.

          • pankraty
            /#21841832

            При изменении бизнес-требований надо менять бизнес-логику И тесты. Ваш КО.


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

            • JustDont
              /#21841842

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

              • pankraty
                /#21841860 / +1

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

              • VolCh
                /#21843368

                Ну вот было в тесте 500 для "положительные до 1000", тест не упал, когда изменили поведение на "положительные до 1001". Тест как фиксировал поведение на 500, так и фиксирует. Ничего не случилось. Ничего не сломалось.

                • nin-jin
                  /#21844880

                  Положительные до 1000 имеют 2 граничных условия: 1 и 999 — их и надо проверять, а не 500.

                  • khim
                    /#21845036 / +1

                    Далеко не всегда это возможно. Например если ваша функция имеет в 1000 особую точку, то может так получиться что время её вычисления быстро растёт. И попытка прогнать тест на 999 закончится тем, что он будет работать сутки. А если чисел больше 300-400 не ожидается — то проверить на 500 будет достаточно.

                    В общем случае тесты — это тоже часть написанного вами кода и они тоже участвуют в определении сложности того, что вы сделали…

                    • nin-jin
                      /#21845138

                      Если вам не нужна поддержка чисел больше 500, то значение выше этого значения должны просто кидать ошибку или хотя бы варнинг. Если же вы хотите их поддерживать, то придётся-таки проверить и 999 и 1000. Если же вам не важно что там творится на 999, то ваш диапазон — "положительные до 500", что должно быть отражено в документации с указанием того, что значения выше 500 могут быть некорректными, ибо никем никогда не проверялись.

                  • VolCh
                    /#21846170

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


                    Далеко не всегда такая задача стоит.

                    • nin-jin
                      /#21846312 / -1

                      А для чего ещё тесты писать?

                      • VolCh
                        /#21846504

                        Зафиксировать поведение кода на основных сценариях.

                        • nin-jin
                          /#21846660

                          А на не основных пусть хоть в бесконечном цикле крутится?

                          • VolCh
                            /#21847430

                            Ага, пока баг-репорта не будет. :) Тогда зафиксируем желаемое поведение, увидим упавший тест, и поправим код, чтобы все тесты проходили.

                      • chersanya
                        /#21846682

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

                        • nin-jin
                          /#21846724

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

                          • 0xd34df00d
                            /#21846826 / +2

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

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

                          • chersanya
                            /#21846966 / +1

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

                            А для чего ещё тесты писать?

                            на сообщение
                            Это если вы пытаетесь тестами доказать безошибочность кода…

                            Далеко не всегда такая задача стоит.


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

                            • nin-jin
                              /#21846990 / -2

                              Ребят, вам вот заняться что-ли больше не чем, как доёбываться до формулировок? Учитесь понимать, что имел ввиду автор, а не триггериться на слово "доказательство".

        • andreyverbin
          /#21842140

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

          • pankraty
            /#21842326

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


            Из наиболее эпичных примеров, где плотное использование модульных тестов было настоящим life saver -ом — у меня был расчет перестраховочной премии. На входе всего три параметра — даты начала и окончания периода, да номер договора перестрахования, а на выходе — всего одно число. Зато неявных параметров — вагон и маленькая тележка. Поэтому отдельно проверяется логика (несамая тривиальная) отбора действующих договоров прямого страхования за период, отдельно — учет кумуляции в рамках застрахованного лица (там тоже кхм, интересные выверты в требованиях были), отдельно — логика преобразования валют (с учетом, что произвольный курс может быть зашит в договор перестрахования — а может не быть, и тогда надо брать курс ЦБ), отдельно — отнесение прямых договоров к тому или иному лееру договора перестрахования, отдельно — логика отбора бордеро предыдущих периодов, которые тоже участвуют в кумуляции и т.д.и т. п. E2e тесты просто непрерывно были бы красными, не давая никакой информации, на каком же этапе возникла ошибка. (Что не умаляет их нужности; они тоже нужны, но их одних тоже недостаточно).

            • pankraty
              /#21842366

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

              • andreyverbin
                /#21842838

                На простых примерах это работает.

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


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

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


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

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

                • VolCh
                  /#21843862

                  Какая разница положить в базу нужные данные или заставить репозиторий возвращать нужные данные? Кажется строчек кода будет столько же.

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

                  • andreyverbin
                    /#21844130

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

                    • VolCh
                      /#21844188

                      От них этого и не требуется, от них требуется зафиксировать логику работу конкретного юнита, а не всей системы.

                      • andreyverbin
                        /#21844344

                        Если я зафиксировал поведение внешнего апи, зачем мне фиксировать поведение внутренностей?

                        • VolCh
                          /#21846180

                          Внешнего апи чего? Я про тестирование внешнего апи юнита, например, публичных методов класса.

                          • andreyverbin
                            /#21846326

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

                            • VolCh
                              /#21846528

                              В моей практике часто наоборот: поведение "кирпичиков" редко меняется, а часто меняются процессы, из них составленные, и/или их параметры (в широком смысле слова). Вплоть до А/Б тестирования различных гипотез.

                              • vdem
                                /#21847440

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

                                • VolCh
                                  /#21847458

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

                                  • vdem
                                    /#21847498

                                    Я это уже заметил, потому и перестал здесь дальше спорить :)

                    • Neikist
                      /#21844200 / +1

                      Ну как сказать. Вот есть какой нибудь интерактор со своей логикой который что то из репозитория читает, обрабатывает как то и делает несколько записей. Мы можем удостовериться что при чтении определенных данных он для этих данных отправляет корректные запросы на запись в репозиторий и защищаем себя от того что какое то условие могло неправильно сработать и не записать данные, либо записать лишние данные. Плюс проверка что данные были корректно обработаны для конкретных входных данных.
                      З.Ы. в приложении над которым работаю тестов почти нет, но это потому что либо логика слишком тривиальная чтобы ее тестировать, либо слишком сложная для этого (UI), и для небольшого приложения которое разрабатывают полтора землекопа тесты не настолько нужны чтобы тратить процентов 30-40 времени на разработку тестов для этих сложных элементов.

    • 0xd34df00d
      /#21842164

      В случае юнит тестов, нам может хватить 4*4=16 тестов, чтобы проверить все ветви исполнения кода — и это уже выполнимая задача.

      Как вы при этом проверите, что выходное значение одного метода соответствует ожиданиям на входное значение другого метода?


      Проверять индивидуальные кусочки лего — это интересно, но как проверить, что вы их правильно собрали?

      • pankraty
        /#21842506

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


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

        • 0xd34df00d
          /#21842836

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

      • tendium
        /#21842516 / +1

        Но это же другая задача. Есть даже мем на эту тему: 2 unit tests, 0 integration tests (про дверные ручки). Просто юнит-тесты и интеграционные тесты решают разные задачи. На разных уровнях. Если перевести язык в плоскость аналогий, то то, что машина может ехать на 4-х колесах, и что они не цепляют в ней другие детали, и что они монтируются хорошо (= интеграционные тесты), не отменяет важности проверки надежности колеса самого по себе (= юнитовые тесты), причем отдельно резину, отдельно диск.

  11. andreyverbin
    /#21841520

    Забавно, что отцы основатели XP и TDD совсем не такие unit тесты имели в виду. Тут Фаулер об этом пишет, в его терминологии они имели в виду Sociable unit test, это ближе к тому, что обычно именуется интеграционным тестированием. А потом набежали «толкователи книжек» и мы получили моки и solitary unit tests, которые хрупкие, дорогие, багов не ловят, зато канонические, соответствуют установившейся догме.

    Indeed when xunit testing began in the 90's we made no attempt to go solitary unless communicating with the collaborators was awkward (such as a remote credit card verification system).

    Это цитата из статьи Фаулера.

    • khim
      /#21842278 / +1

      Ну то есть, в результате, приходим туда, с чего начали:
      1. То, что «отцы-основатели» называли «социальные юнит-тесты» — сегодня мы называем интеграционными — и их, вроде как, все считают полезными.
      2. «Solitary unit tests» (то, что сегодня и называется юнит-тестами) — есть типичное порождение «архитектурной астронавтики» и являются бессмысленной тратой времени и сил.

      А дальше — да, не договорившись о терминах можно спорить «мимо» друг друга вечно…

    • tendium
      /#21842574

      Отец-основатель PHP — Rasmus Lerdorf — тоже видел будущее PHP иначе (если вообще видел). ;) Но это ведь не повод слепо следовать тому, что он говорит сейчас.

  12. numitus2
    /#21842110 / +1

    Только ситхи возводят все в абсолют

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

  13. AlexunKo
    /#21842796 / -1

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

  14. vdem
    /#21842952 / -1

    В общем, конкретный пример. Есть у меня простенький самописный QueryBuilder (на PHP, пара десятков классов всего). Сложные запросы он не умеет, там надо уже SQL писать, но SELECT по какому-то полю, INSERT одной или нескольких записей, UPDATE и DELETE он умеет, — часто мне только это и нужно. Примеры (считаем, что $db уже создан, он наследует PDO):

    $user = $db->users->id[12]; // получили пользователя, или null если нет
    
    $db->users[] = [ // вставляем пользователя
        'name' => $name,
        'email' => $email,
        'password_hash' => password_hash($password)
    ];
    
    $db->users->id[12] = [ // обновляем
        'name' => $anotherName
    ];
    
    unset($db->users->id[12]); // удаляем
    

    Так вот, мне надо протестировать всё это (на самом деле намного больше), для того чтобы при работе над очередным проектом не начали невовремя лезть непонятные баги. Объясните, какие тут интеграционные тесты я должен написать. Или не писать вообще? Или может таки юнит-тесты? За каждый тип запроса отвечает отдельный класс, а $db их в себе просто вызывает. И да, не советуйте мне уже существующие супернавороченные билдеры — мне не нужны пара десятков зависимостей и сотни файлов с ними впридачу.

    • Vilaine
      /#21843192

      Query Builder выдаёт SQL, его как конечный результат и проверяйте.

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

      • vdem
        /#21843458

        0. Это не совсем чисто Query Builder. И не надо здесь про «чистый код», мне нужен был инструмент, я его написал. Я знаю, что там не все в порядке с архитектурой, но я и цели не ставил перед собой — написать код 100% соответствующий всем нынешним хайповым трендам.
        1. Я не про дисковое пространство, очевидно же.
        2. Laravel для небольшого скрипта командной строки, которому дается на вход CSV и по определенным критериям тот должен дописать/обновить БД? Или для веб-страницы с тремя формами, которая нужна только на несколько раз? Вы шутите? А если сам заказчик против использования чего-то тяжеловесного (и такое бывало)?
        3. Вообще-то здесь как бы про юнит-тесты спор. А Вы о чем?

        • Vilaine
          /#21845818

          1. Я не про дисковое пространство, очевидно же.
          Тогда я не понимаю в чем проблема. Количество файлов еще влияет на inode#, но навряд ли в наше время их не хватает.
          Laravel для небольшого скрипта командной строки
          Можно не весь фреймворк, а тот кусок, который для БД. Например, composer require doctrine/dbal.
          А если сам заказчик против использования чего-то тяжеловесного (и такое бывало)?
          Едва ли бизнес может так говорить в настоящий момент. Скорее всего, его неправильно информировали.
          А Вы о чем?
          О вашем странном комментарии про сотни файлов.

    • Ogra
      /#21843484 / +1

      Объясните, какие тут интеграционные тесты я должен написать.

      — Если вы сделали DELETE, то SELECT должен вернуть NULL
      — Если вы сделали INSERT, то SELECT должен вернуть точно такой же объект (за вычетом id)
      — Если вы сделали UPDATE одного или нескольких полей, то SELECT должен вернуть объект в котором изменены эти и только эти поля
      По-моему неплохой стартовый набор для property-based-testing. Пусть тестовый фреймворк вам генерирует тесты.

    • andreyverbin
      /#21844166

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


      Опишите комплект тестов типа «вот исходное состояние БД, вот набор вызовов QueryBuilder, проверить, что в результате из БД получили нужные данные» и запускать этот комплект для каждой БД. Так вы будете знать, что целевая БД действительно может ваш SQL переварить. Если же вы генерите, по большей части ANSI SQL, и уверены в том, что целевые БД его осилят, то генерите строки и проверяйте их. Но как только ваш QueryBuilder начнёт делать CTE, CONNECTED BY и прочие advanced штуки без реальной БД вам не обойтись.

      • vdem
        /#21844194

        Тесты давно написаны. Всё работает как минимум на SQLite, MySQL и PostgreSQL. И да, все запросы это ANSI SQL, там нет каких-то продвинутых штук для которых бы еще и драйверы для каждой БД писать.

  15. Vilaine
    /#21843188

    Моки — это дешевый способ воспроизводства среды выполнения или состояния, окружающей метод/ограниченный граф вызовов. Когда моки перестают быть дешевыми, разумеется, придётся что-то менять. Скорее всего, тестируемый граф перерос возможности изолированного тестирования, или был изначально слишком широк.
    Взаимодействие со сторонними сервисами, внешние контракты, не входящих в тестируемое приложение, стоит тестировать в отдельном контуре. И запускать тесты, к примеру, по крону, а не в общем CI. Потому что проблемы с сетью (они будут) или с самим сервисом не должны блокировать отдельный деплой, который с большой вероятностью никак не менял взаимодействие со внешним сервисом. Не стоит вводить крупные элементы индетерминизма в CI. Иначе будут регулярные красные билды и повышенный уровень кортизола у команды (мы сильнее стрессуем от факторов, которые не под нашим контролем).

  16. Sayonji
    /#21843202

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

  17. /#21843376 / +1

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

  18. HellWalk
    /#21843600 / +1

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

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

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

  19. asm0dey
    /#21843778

    Огромная статья, вся суть которой сводится к следующим мыслям:


    1. Не используйте юнит-тесты для тестирования не-юнитов
    2. Не забывайте что пирамида состоит из нескольких уровней
    3. Люди забывают и получается плохо — не будьте как эти люди

  20. Andrey_Rogovsky
    /#21843872 / +1

    Юнит-тесты прекрасная вещь… если у вас продуманная архитектура. Но если у вас хаос — то они вам не помогут.

    • VolCh
      /#21846182 / +1

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

  21. Throwable
    /#21843952

    в большинстве случаев упор на юнит-тесты является пустой тратой времени.

    Алилуйя! Не прошло и 10 лет, чтобы осознать это. Еще в недалеком прошлом адепты секты TDD буквально растерзали бы за подобного рода заявления.


    Добавлю.


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


    .6. Согласно Lean, юнит тесты не привносят value в конечный продукт. Это внутренняя инициатива разработчика, однако их написание и поддержка связаны с затратами.
    Как правило проект состоит из компонентов, которые связываются в функциональные модули, которые в свою очередь используются в необходимых бизнес сценариях. С точки зрения заказчика именно правильное выполнение сценариев является изначальной задачей и критерием приемки работы. И, как правило если не работает какой-то компонент, бизнес сценарий также должен вальнутся. Юниты иногда помогают быстрее локализовать этот сбой.

    • VolCh
      /#21844100 / +1

      Согласно Lean, юнит тесты не привносят value в конечный продукт.

      Это где такое написано? Если это ваше мнение, то тогда это не "Согласно Lean", а что-то вроде "мне кажется, что юнит тесты не привносят value в конечный продукт, а это противоречит Lean"


      Это внутренняя инициатива разработчика

      Ох, далеко не всегда.


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

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

      • andreyverbin
        /#21844310

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

        • Подняли систему
        • Пульнули в неё 200 запросов с неверной формой, проверили, что все 200 раз апи ответило адекватной ошибкой.
        • Вырубили систему.

        По сравнению с юнит тестами потратили 10 сек на запуск системы, но проверили ещё


        • Cериализацию и десериализацию, вдруг кто-то навешал странные атрибуты на модель?
        • Error handling middleware, а правильно ли ошибки валидации превращаются в ответы API?
        • Убедились, что апи вообще доступно по указанному адресу.

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


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

        • lair
          /#21844826

          По сравнению с юнит тестами потратили 10 сек на запуск системы, но проверили ещё

          Вот тут и зарыта собака. Пока у вас поднять систему — это 10 секунд, все неплохо. А когда "поднять систему" — это несколько минут, и сами тесты тоже на порядок или два медленнее (т.е., каждый тест проходит секунд пять вместо полусекунды), разница в длине итераций становится ощутимой.

          • andreyverbin
            /#21844892

            Это повод не тестировать публичное апи системы? А если у вас публичное апи покрыто тестами, то что вам добавят юнит тесты?

            • lair
              /#21844900

              А если у вас публичное апи покрыто тестами, то что вам добавят юнит тесты?

              Они добавят возможность быстро протестировать какие-то части системы.

              • andreyverbin
                /#21845100

                И ради этого вы предлагаете тестировать каждый класс в системе изолируя его с помощью моков?

                • lair
                  /#21845108

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

                  • andreyverbin
                    /#21845312

                    Чем это отличается, по вашему мнению, от моей позиции?

                    • lair
                      /#21845432

                      А я, вроде, ничего не успел сказать про вашу "позицию". Я всего лишь сказал, что тесты, поднимающие систему, удлинняют итерацию по сравнению с теми, которые этого не делают.

          • Throwable
            /#21845412

            Именно поэтому и появилась вся эта хрень про юниты — в эпоху серверов приложений, когда билда и деплоя приходилось ждать по нескольку минут. Поэтому люди начали извращаться — мокать сервисы контейнера, чтобы хоть как-то тестировать куски кода локально без деплоя. И отсюда же пошло большинство современных (анти-)паттернов проектирования типа Repository, Context Object, etc. Более того, большинство кейсов вообще невозможно протестировать юнитами, ибо их логика зависит от контейнера и контекста выполнения (Transactional, RequestScoped, etc...).


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

            • lair
              /#21845438

              Система должна стартовать локально за 1-2 секунды в тестовой конфигурации, причем по возможности без моков и с персистенцией.

              Ну вот когда к этому придем, тогда и поговорим. А сейчас она собирается дольше.

      • Throwable
        /#21845288

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

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

        • VolCh
          /#21846204

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

  22. Pavel1114
    /#21844144

    Очередной не такой как все и не согласный со всеми + громкий заголовок

  23. onets
    /#21844210

    Тоже пришел к примерно таким же выводам, как и в статье.

    Юнит-тесты слишком мелкие и хрупкие, но они отлично подходят для тестирования каких-нибудь алгоритмов и расчетов. Т.е. там где чистый код на языке программирования, без вызовов внешних систем/БД и так далее. Для таких случаев я их и использую. Моки в 95% там и не нужны.

    В основном я пишу интеграционные (функциональные).

    На одном из проектов видел следующий подход — утилита, которая кидается http запросами к web api. Утилита самописная, тесты оформляются в виде внешних json файлов. Оставляя удобство за скобками — мне этот подход не очень понравился. Это было больше похоже на smoke тестирование, нежели проверку конкретных сложносоставных кейсов.

    Я же выбрал несколько другой — интеграционные тесты на бизнес-логику и все что ниже (обращения к БД). БД я подготавливаю специальным образом. Раньше делал восстановление БД перед тестами, в последнее время перешел на запуск тестов в транзакциях с откатом. Активно использую атрибут TestCase из NUnit, чтобы натравить на один блок кода несколько различных кейсов. Вызовы внешних чужих сервисов (погода, гео-кодинг и тому подобные) обычно делаю через моки. При таком подходе не надо поднимать целый веб-сервис. Достаточно зарезолвить из контейнера экземпляр тестируемого бизнес-кода. Но минусы все равно есть — такие тесты менее хрупкие, но не защищены от изменений структуры моделей и dto. Зато отлично защищают при рефакторинге и доработках.

    Для тестирования обращений к внешним вызовам — пишу отдельные интеграционные тесты. Там непосредственно вызывается некий сервис по http. Эти тесты в отдельной сборке и не включены CI/DC, чтобы не тратить бабло платных внешних сервисов при каждом прогоне.

    И еще надо взять за основу правило: пришел баг в техподдержку — дописал тест.

    • joyfolk
      /#21844680

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

      • onets
        /#21845012

        Обращение к внешнему сервису я оборачиваю классом оберткой. Внутри либо try catch с возвратом null/default, либо проброс исключения наверх.

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

        А вот как бизнес-логика работает с хорошим/плохим ответом от внешнего сервиса — там да. Но там у меня тоже интеграционные + моки на вот эти классы-обертки.

        • joyfolk
          /#21845088

          Это работает только для тех случаев, если обработка отказов тривиальна. А если надо протестировать ретраи, переключение на другой инстанс, откат каких-то состояний?

          • onets
            /#21845194

            Эти штуки я не делал. Потому что по ту сторону сервисы, которые обещают 99% SLA. А если сеть упала на проде — то у меня проблемы посерьезней, чем недоступность сервиса погоды.

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

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

            • joyfolk
              /#21845236

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

  24. JordanoBruno
    /#21844230

    Рассуждайте критически и подвергайте сомнению best practices

    Это, конечно, хорошо, что автор начал мыслить критически и перестал тупо применять все известные best practices. Юнит-тестирование, безусловно, является средством из раздела best practices, но это не означает, что внедрение его в проект безусловно принесет пользу.

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

  25. BlackSCORPION
    /#21844776

    image

  26. Peter1010
    /#21847794

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


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


    А для вненших сервисов куча МОКОВ но только с позитивными сценариями.


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

    • Vilaine
      /#21849532

      все системные типы и классы на свои обёртки и делигаты, только что бы их замочить
      Прям все? Например?