Unit-тесты проверяют одну функцию или класс, widget-тесты рендерят один виджет или экран без устройства, а интеграционные тесты гоняют всё приложение на эмуляторе или телефоне. Unit-тестов пишут много, widget-тестов меньше, а интеграционных пишут единицы.
Unit: обычный test(), без рендеринга, миллисекунды на тест; для доменной логики, маперов, блоков (bloc) и нотифаеров
Widget: testWidgets() строит дерево виджетов на фейковых часах; вы тапаете, вводите текст и проверяете то, что отрисовано. Оптимальный уровень для поведения экрана
Интеграционные: integration_test запускает настоящее приложение с настоящими плагинами; самые медленные и хрупкие, поэтому только для критичных сценариев вроде логина и оплаты
Запуск: flutter test прогоняет unit-, widget- и golden-тесты на вашей машине; flutter test integration_test требует устройство или эмулятор
FlutterMiddle
Тестирование
Юнит-тесты, тесты виджетов, интеграционные тесты, мокирование
Закрыто вопросов
0 / 25
- 0Повторить
- 6Часто спрашивают
Вопросы
В тестовом файле есть main() с вызовами test('описание', body), сгруппированными через group; setUp выполняется перед каждым тестом своей группы, tearDown выполняется после каждого, а проверка в каждом тесте делается через expect(actual, matcher).
**Свежие объекты в setUp**: объявляйте late-переменные в группе и присваивайте их в setUp, тогда ни один тест не увидит состояние, оставленное предыдущим
Матчеры: equals, isA<T>(), contains, hasLength, isEmpty, throwsA(...); упавший матчер печатает, что ожидалось и что пришло
Одно поведение на тест: arrange, act, assert, а название теста описывает поведение: 'returns 0 for an empty cart'
testWidgets даёт вам WidgetTester: строите виджет через pumpWidget, находите элементы через find, действуете через tap или enterText, вызываете pump для перестроения и проверяете через expect(finder, findsOneWidget).
**Оборачивайте в MaterialApp**: большинству виджетов нужны Directionality, Theme и MediaQuery, которые он даёт
Finder-ы: find.text, find.byType, find.byKey, find.byIcon, find.byTooltip; для элементов, чей текст может поменяться, лучше ValueKey
**pump после каждого действия**: tap только отправляет событие указателя; ничего не перестроится, пока не прокачан следующий кадр
Stub возвращает заготовленные ответы, mock ещё и записывает вызовы, чтобы их можно было проверить через verify, а fake служит маленькой рабочей реализацией, например репозиторием в памяти. Fake пишут, когда тесту нужно поведение, а mock пишут, когда результатом является сам вызов.
mocktail: class MockRepo extends Mock implements Repo {}, стаб через when(() => repo.load()).thenAnswer((_) async => user), проверка через verify(...).called(1); без кодогенерации, обычный выбор для новых проектов
mockito: та же идея с @GenerateNiceMocks([MockSpec<Repo>()]) и шагом build_runner; оставляйте его там, где проект уже на нём
Fake переживают рефакторинг: InMemoryCartRepo всё равно, какие его методы вызывает ваш код, поэтому проверки результата остаются верными после переписывания
**any() в mocktail**: для своего типа аргумента нужен registerFallbackValue(...) в setUpAll, иначе он бросает ошибку Bad state с именем типа
Не мокайте то, что вам не принадлежит: мок Dio или его Response хрупкий; оберните его в свой интерфейс клиента и подменяйте fake-ом уже этот интерфейс
logPurchase не застаблен, а незастабленный метод в mocktail возвращает null, который нельзя вернуть там, где объявлен non-nullable Future<void>. Застабьте его до действия: when(() => analytics.logPurchase(42)).thenAnswer((_) async {}).
**Откуда null**: Mock отвечает на каждый вызов через noSuchMethod; без подходящего стаба вернуть нечего, кроме null, и проверка объявленного типа возврата падает
**thenAnswer для future и стримов**: thenReturn с Future или Stream бросает ArgumentError; thenAnswer строит future на каждый вызов, внутри зоны вызова
Стаб должен совпасть: when(() => analytics.logPurchase(41)) не покрывает вызов с 42; если аргумент неважен, используйте any()
Fake снимает проблему: для зависимости «отправил и забыл» вроде аналитики FakeAnalytics, складывающий события в список, не требует стабов и его проще проверять
У Order нет равенства по значению, поэтому Order('p1', 1), созданный внутри verify, оказывается другим объектом, не тем, что отправил buyNow. mocktail сравнивает аргументы через ==, а без переопределения это сравнение по ссылке, и подходящий вызов не находится.
Как mocktail сопоставляет аргументы: обычное значение в when или verify оборачивается в equals, который вызывает ==; без переопределения это identical, и он истинен только для того же самого экземпляра. Сообщение об ошибке намекает на это: All calls: MockOrderApi.send(Instance of 'Order')
Исправление: дайте Order равенство по значению: переопределите == и hashCode или используйте Equatable, freezed либо record. Value-объект и в продакшене должен сравниваться по значению (в Set, как ключ мапы, в Stream.distinct)
Если класс менять нельзя: сопоставляйте по полям: verify(() => api.send(any(that: isA<Order>().having((o) => o.qty, 'qty', 1)))) или захватите аргумент через captureAny и проверьте его поля
Как поймать в следующий раз: переопределяйте toString у value-классов, чтобы упавший verify печатал поля, и закрепите равенство однострочным тестом: expect(Order('p1', 1), Order('p1', 1))
Нужно дать окружение, из которого виджет читает, и обычно это значит обернуть его в MaterialApp: если сверху ничего нет, MediaQuery.of(context) бросает исключение, а AppLocalizations.of(context)! падает на null.
**MaterialApp** даёт Theme, Directionality, MediaQuery, локализации по умолчанию и Navigator; для строк самого приложения добавьте свои localizationsDelegates и locale
Размер экрана: задайте tester.view.physicalSize и devicePixelRatio, затем addTearDown(tester.view.reset), чтобы размер не утёк в следующие тесты
Масштаб текста: tester.platformDispatcher.textScaleFactorTestValue = 2, сбрасывается через clearTextScaleFactorTestValue
Платформа: variant: TargetPlatformVariant.only(TargetPlatform.iOS) в testWidgets вместо ручной установки debugDefaultTargetPlatformOverride
Scope состояния: ProviderScope(overrides: ...) или BlocProvider.value с fake, в той же обёртке
**Один хелпер pumpApp**: десятистрочная обёртка, скопированная в сорок тестов, становится верным путём к гниению тестов
Widget-тесты идут на фейковых часах: pump рисует один кадр и может сдвинуть время, pumpAndSettle качает кадры, пока ни один не запланирован, pumpFrames проходит фиксированный отрезок, а runAsync уходит на настоящие часы ради реального I/O.
**pump([duration])**: выполняет микрозадачи и рисует кадр; с длительностью двигает фейковые часы и срабатывают таймеры из этого окна, поэтому пятисекундный Future.delayed ничего не стоит
**pumpAndSettle()**: качает шагами по 100 мс, пока ничего не запланировано; подходит для переходов между маршрутами и конечных анимаций, но бесконечная анимация роняет его с pumpAndSettle timed out
**pumpFrames(widget, maxDuration): качает с фиксированным интервалом заданный отрезок, чтобы записать или проверить кадры анимации
runAsync(() async {...})**: выполняет по-настоящему асинхронную работу вроде файлового или сокетного I/O, которая на фейковых часах никогда не завершится; future, созданные внутри него, pump не завершает, поэтому реальную работу делайте там, а pump вызывайте снаружи
Тест анимации: качайте точные длительности: через 150 мс линейного затухания длиной 300 мс прозрачность должна быть около 0.5
Бесконечный (indeterminate) CircularProgressIndicator бесконечно планирует новый кадр, поэтому pumpAndSettle никогда не видит простоя и сдаётся после десяти минут фейкового времени. Спиннер уже на экране после pumpWidget, так что проверяйте сразу или сделайте один pump().
Что значит «settle»: ни одного запланированного кадра; спиннер, shimmer-заглушка, контроллер, запущенный через repeat(), или зацикленная Lottie этого никогда не допустят
Исправление: уберите здесь pumpAndSettle; чтобы проверить загруженное состояние, завершите future и сделайте ещё один pump()
Та же ловушка дальше: одна зацикленная анимация где угодно на странице ломает каждый pumpAndSettle на ней; качайте там явные длительности
Не поднимайте таймаут: анимация бесконечна, больше времени лишь замедлит падение
Ограничьте finder тем поддеревом или виджетом, который вы на самом деле имеете в виду. tester.tap и findsOneWidget требуют ровно одного совпадения, а .first просто берёт то, что вёрстка поставила первым.
**find.descendant(of: ..., matching: ...): ограничивает поиск одним поддеревом; обычное решение, когда диалог повторяет подпись со страницы под ним
find.widgetWithText(FilledButton, 'Save'): говорит «кнопка с этим текстом», а не заголовок в app bar
ValueKey и find.byKey: надёжный ответ для строк списка, которые все выглядят одинаково
Почему их было два**: find.text находит каждый Text с равными данными; offstage-виджеты уже отброшены, значит оба действительно на экране
Назовите число: findsExactly(2) документирует намеренное дублирование, а .first его прячет и молча меняет цель в тот день, когда кто-то переставит дерево
ListView.builder строит только строки, которые помещаются в тестовую поверхность 800×600 плюс небольшой кэш, поэтому строки 40 в дереве нет и ни один finder её не увидит. Сначала прокрутите, как сделал бы пользователь.
**tester.scrollUntilVisible(finder, 200, scrollable: ...)**: тянет шагами, пока finder не найдёт совпадение, с ограничением maxScrolls
**tester.drag(find.byType(ListView), const Offset(0, -600))** и затем pump(): фиксированная прокрутка, когда нужен контроль над кадрами
Строки в cache extent: построены, но вне вьюпорта, поэтому finder-ы считают их offstage и пропускают, если не передать skipOffstage: false; тап по ним промахивается и лишь печатает предупреждение
**ensureVisible**: работает только для уже построенного виджета, например поля внутри SingleChildScrollView
Или увеличьте поверхность: tester.view.physicalSize с addTearDown(tester.view.reset), когда тест про длинную форму, а не про прокрутку
Проверьте, что после тапа на экране целевая страница: прокачайте MaterialApp с маршрутами, тапните, вызовите pumpAndSettle и сделайте expect(find.byType(CheckoutPage), findsOneWidget). Это проверка поведения, она переживёт переход с Navigator.push на именованные маршруты.
С go_router: соберите GoRouter только с тестируемыми маршрутами и вашим настоящим redirect, прокачайте MaterialApp.router и проверьте целевой экран
Когда целевой экран слишком тяжёлый: зарегистрируйте mocktail-мок NavigatorObserver и verify(() => observer.didPush(any(), any())); сначала registerFallbackValue с fake-Route
Дизайн: виджет, который принимает колбэк onContinue, а не вызывает Navigator сам, тестируется тривиально: проверяете, что колбэк вызван
**Не тестируйте, что Navigator работает**: тестируйте, что ваш виджет просит правильный маршрут
Делайте тело теста async и просто ждите future через await; для стрима навесьте expectLater(stream, emitsInOrder([...])) до того, как запустить работу, а потом дождитесь его. Матчер потребляет события по мере прихода, поэтому задержки не нужны.
Future: expect(await repo.load(), user) или await expectLater(repo.load(), throwsA(isA<TimeoutException>())) для ошибки
Матчеры стримов: emits, emitsInOrder, emitsThrough (пропускает до совпадения), emitsInAnyOrder, emitsError, emitsDone, neverEmits
Сначала подписка, потом действие: cubit.stream устроен как broadcast-стрим, он не повторяет то, что было выпущено до подписки
**Зачем await expectLater**: раннер и так дождётся незавершённого асинхронного матчера, но без await строки после него выполнятся раньше, чем стрим совпадёт
**emitsDone требует закрытого стрима: закройте контроллер или кубит, иначе матчер прождёт до 30-секундного таймаута теста
StreamQueue** из package:async читает по одному событию, когда длинный список матчеров становится трудно читать
Не вставляйте в тесты реальные паузы. Запускайте код внутри fakeAsync и сами двигайте время через elapse: тогда пятиминутный backoff занимает микросекунды.
**fakeAsync((async) { ... })**: таймеры и отложенные future, созданные внутри, не сработают, пока вы не вызовете async.elapse(...)
**async.flushMicrotasks(): выполняет ожидающие микрозадачи, не двигая часы
Widget-тесты**: tester.pump(duration) уже двигает фейковые часы, так что fake_async там нужен редко
**pumpAndSettle ждёт кадры, а не таймеры**: периодический таймер, который постоянно вызывает setState (или повторяющаяся анимация), не даст ему успокоиться; качайте явные длительности
Внедряйте время: читайте его через clock.now() из package:clock вместо DateTime.now(); внутри fakeAsync он идёт по фейковым часам, в остальных местах его фиксирует withClock
Ловушка: реальный I/O внутри fakeAsync никогда не завершается, потому что его результат не приходит на фейковую шкалу времени; проверки после него могут вообще не выполниться
Для widget- и bloc-тестов подменяйте репозиторий fake-ом, а сам HTTP-клиент подменяйте, только когда тестируемый код и есть HTTP-слой. Тест, который ходит на настоящий сервер, медленный, нестабильный и может менять реальные данные.
Fake-репозиторий: написанный руками класс, реализующий интерфейс вашего источника данных; widget-тест так и не узнаёт, что HTTP существует
**package:http**: внедрите Client и передайте MockClient из package:http/testing.dart, который отвечает на каждый Request собранным вами Response
Dio: поставьте в dio.httpClientAdapter DioAdapter из http_mock_adapter или добавьте Interceptor, который отвечает на запросы заготовками
Если забыть: внутри testWidgets биндинг устанавливает HttpOverrides, и каждый реальный запрос получает статус 400 и предупреждение; падение выглядит как ошибка сервера
Оставляйте продовые интерсепторы: тест репозитория, проверяющий заголовок авторизации, должен проходить через тот же стек интерсепторов, что и приложение
Подменяйте то, от чего зависит слой состояния, а не виджеты: переопределите провайдеры в ProviderScope или отдайте экрану замоканный блок через BlocProvider.value, а затем проверяйте отрисованное.
Riverpod: ProviderScope(overrides: [repoProvider.overrideWithValue(FakeRepo())]) вокруг экрана; для одной логики хватит ProviderContainer с теми же overrides и без дерева виджетов
BLoC: MockBloc из bloc_test плюс whenListen(bloc, stream, initialState: ...) подаёт экрану точные состояния, не запуская настоящий блок
Разделяйте работу: переходы состояний в быстрых unit-тестах (blocTest, ProviderContainer), отрисовку каждого состояния в widget-тестах
Свежий scope на каждый тест: контейнер или блок, переиспользуемый между тестами, переносит состояние
Проверяйте то, что видит пользователь: тексты, активные кнопки, отправленные события; а не приватные поля нотифаера
Golden-тест рендерит виджет, сравнивает пиксели с закоммиченным PNG через matchesGoldenFile и падает при любом расхождении; flutter test --update-goldens перегенерирует картинку после намеренного изменения.
Одна операционная система: растеризация шрифтов различается на macOS, Linux и Windows, поэтому генерируйте и проверяйте goldens на одной ОС, обычно на Linux-образе CI или в Docker-контейнере
Настоящие шрифты: тесты рисуют текст шрифтом FlutterTest, где каждый глиф рисуется прямоугольником; загрузите свои шрифты (или используйте alchemist), когда важен сам текст
Детерминизм: фиксированный размер поверхности, никаких сетевых картинок, замороженные часы, завершённые анимации
Допуск: унаследуйтесь от LocalFileComparator и принимайте небольшой diffPercent, когда шум сглаживания неизбежен
Уберите их из быстрого прогона: пометьте файл @Tags(['golden']), объявите тег в dart_test.yaml, запускайте flutter test --exclude-tags golden локально и --tags golden в отдельной CI-джобе
Инструменты: alchemist для матриц тем и устройств; golden_toolkit больше не развивается
integration_test запускает обычный код testWidgets против настоящего приложения на эмуляторе или устройстве, с настоящими плагинами и движком; patrol строится поверх него и умеет управлять ещё и нативным UI вроде диалогов разрешений и уведомлений.
Настройка: файлы в integration_test/, первым делом IntegrationTestWidgetsFlutterBinding.ensureInitialized(), затем запуск приложения через тестовую точку входа с fake-бэкендом; запуск через flutter test integration_test -d <device>
Ограничение: integration_test видит только виджеты Flutter: ни системного диалога разрешений, ни нативного пикера, ни запроса на пуши
patrol: patrolTest плюс нативный автоматизатор как раз для этих диалогов и лучшая изоляция между тестами; настраивать тяжелее, поэтому его берут для сценариев, которые трогают нативный UI
Детерминизм: fake или локально поднятый бэкенд, очистка хранилища между тестами и ожидание условий вместо фиксированных пауз Future.delayed
Где запускать: джоба с эмулятором в CI и Firebase Test Lab для матрицы устройств; держите в наборе только критичные сценарии, потому что каждый тест стоит минуты
flutter test --coverage пишет coverage/lcov.info; уберите из него сгенерированные файлы, а в CI ставьте порог на покрытие изменённых строк, а не на фиксированный общий процент.
Как смотреть: genhtml строит HTML-отчёт локально; Codecov или SonarQube пишут цифры комментарием в pull request
Исключайте сгенерированный код: *.g.dart, *.freezed.dart и вывод l10n не содержат вашей логики, а тысячи их строк заглушают сигнал
**// coverage:ignore-line**, ignore-start / ignore-end и ignore-file: для кода, который действительно нельзя выполнить в тесте
Порог на патч: например 80% на новые строки; глобальный порог наказывает команду за легаси и поощряет пустые тесты
Непротестированные файлы невидимы: файла, который не импортирует ни один тест, в lcov.info нет вовсе, поэтому процент выглядит лучше, чем есть
Чего покрытие не скажет: строка выполнилась, но не то, что про неё что-то проверили
Они проверяли, как код работает, а не что он делает, поэтому рефакторинг (то самое, что тесты должны делать безопасным) их сломал.
**verify(() => repo.fetchPage(1)) повсюду**: фиксирует последовательность вызовов; оставляйте verify для эффектов без другого следа, например события аналитики, а в остальных местах проверяйте видимый результат
**find.byType(Padding).at(3): зашивает форму дерева виджетов; поставьте ключ на тот единственный узел, о котором тест
Проверяйте результат: смотрите, что показывает экран или что возвращается, а не какие методы вызвали
Один хелпер pumpApp и билдеры тестовых данных: новая зависимость или новое обязательное поле тогда правится в одном месте, а не в тридцати
Называйте тесты по поведению**: «показывает пустое состояние, когда корзина пуста»; тест, который так не назвать, обычно проверяет структуру
Red, green, refactor: пишете падающий тест на следующее поведение, делаете так, чтобы он прошёл, самым простым кодом, затем чистите. Главная выгода проявляется в дизайне: код, написанный от теста, получает внедрённые зависимости и маленькие модули, которым не нужно дерево виджетов.
Зависимости приходят через конструктор: тест не может подменить Dio(), созданный внутри метода, поэтому код, написанный от теста, принимает интерфейсы параметрами
Логика уходит из виджета: валидация, форматирование и переходы состояний переезжают в обычные Dart-классы, нотифаеры или блоки, до которых unit-тест достаёт напрямую
Время и случайность становятся входами: часы или Random передаются снаружи, а не DateTime.now(), зарытый в логике
Где подходит лучше всего: доменные правила, парсеры, стейт-машины и исправление багов: сначала падающий тест, воспроизводящий баг
Где подходит хуже: исследовательская работа над UI и вёрсткой; там команды обычно сначала делают, а потом фиксируют результат widget- или golden-тестами
Честный ответ на собеседовании: скажите, где вы его применяете, а где нет; догма в любую сторону служит тревожным сигналом
Заберите его через tester.takeException(). Тестовый биндинг запоминает ошибку, о которой фреймворк сообщил во время теста, и в конце роняет тест, если вы её не забрали.
Как она туда попадает: исключение в build, layout или paint ловит фреймворк и сообщает через FlutterError.onError; в build виджет заменяется на ErrorWidget
**expect(tester.takeException(), isA<FormatException>())**: возвращает ожидающее исключение и очищает его; expect(tester.takeException(), isNull) проверяет, что ничего не сломалось, например нет переполнения
Хранится только одно: второе исключение превращает ожидающее в ошибку Multiple exceptions (2) were detected…, поэтому тест должен вызывать ровно одно
**Обычному падающему Future** биндинг не нужен: await expectLater(repo.load(), throwsA(isA<TimeoutException>()))
Проверка своего fallback-UI: задайте ErrorWidget.builder в тесте, верните его в finally в конце тела теста, а не через addTearDown (биндинг проверяет его раньше, чем выполняются tear-down); проверьте свой виджет и всё равно заберите исключение
**Никогда не глушите FlutterError.onError на весь набор**: незабранное исключение означает, что фреймворк сообщает о реальном баге
Подозревайте утёкшее общее состояние раньше, чем тайминги: тесты внутри одного файла делят глобальные переменные, поэтому тест, который проходит один и падает после другого, читает то, что оставил предыдущий.
Воспроизведите порядок: flutter test --test-randomize-ordering-seed=random перемешивает тесты внутри каждого файла и печатает seed; перезапустите с этим seed локально
Изолируйте подозреваемого: запустите его один через --plain-name; если он зелёный в одиночку и красный внутри файла, значит, утечка от более раннего теста
Типичные утечки: service locator, заполненный в setUpAll, статические кэши и синглтоны, оставленный mock-обработчик канала, переопределённый instance платформы, не сброшенный размер tester.view; отменяйте каждую через addTearDown прямо рядом с настройкой
Между файлами: тестовые файлы идут параллельными процессами, поэтому фиксированный временный путь, порт или файл фикстуры, в который пишут два файла, конфликтуют; используйте Directory.systemTemp.createTempSync на каждый тест
Настоящие тайминги: DateTime.now(), часовой пояс и локаль в CI другие; внедряйте часы и фиксируйте Intl.defaultLocale
**retry: прячет баг**: только вместе с открытым тикетом, иначе пайплайн зелёный, а флак навсегда
Колбэк fakeAsync помечен async, поэтому он останавливается на await session.start() и возвращает future, которую никто не ждёт. Фейковая зона выполняет микрозадачи только на elapse или flushMicrotasks, так что остаток не выполняется: ни один expect не срабатывает, и тест зелёный.
Почему он останавливается: fakeAsync выполняет колбэк синхронно в зоне, которая придерживает таймеры и микрозадачи, пока вы её не продвинете; код после await как раз и становится такой микрозадачей, а когда колбэк уже вернул управление, зону никто не продвигает
Почему он зелёный: тело test синхронное и завершается, как только fakeAsync вернул управление; тест без упавшего expect проходит, в том числе тот, что ничего не проверил
Исправление: синхронный колбэк: вызовите session.start() без await, доведите его до конца через async.flushMicrotasks(), затем elapse. Внутри fakeAsync время двигается только через async, а не через await
Проверяйте и состояние «до»: expect(session.isExpired, isFalse) перед elapse доказывает, что флаг переключил таймер, а не подготовка теста
Как поймать в следующий раз: смотрите, как каждый новый тест хотя бы раз падает (сломайте код или ожидаемое значение), прежде чем верить зелёному; тест, который не может покраснеть, и есть баг
Переносите большую часть проверок вниз, в быстрые unit-тесты доменного слоя и слоя состояния, покройте все состояния каждого экрана widget-тестами против fake, защитите дизайн-систему goldens и держите небольшой набор интеграционных тестов для сценариев, которые приносят деньги.
Уровни
Unit, основная масса: репозитории поверх fake HTTP-клиента, маперы, use case-ы, блоки и нотифаеры; они идут секунды и фиксируют бизнес-правила
Widget: каждый экран в состояниях загрузки, пустом, ошибки и с данными, на fake-репозиториях; навигация и валидация форм тестируются здесь
Golden: компоненты дизайн-системы и несколько ключевых экранов в светлой и тёмной темах и при крупном шрифте, отрисованные на одной ОС
Интеграционные: логин, покупка, онбординг; на эмуляторе на каждый PR или ночью, на ферме устройств перед релизом
Что подменять
Подменяйте на своих границах: репозитории, обёртки над плагинами, часы; сам фреймворк не подменяйте никогда
Держите общие fake и билдеры тестовых данных в одном пакете test_helpers, чтобы сорок фич не писали каждая свои
Записанные JSON-фикстуры для API-слоя ловят изменения бэкенда, которые рукописные fake спрятали бы
Как сохранить скорость и доверие
Хелпер pumpApp, чтобы новая общая зависимость правилась в одном месте
Теги отделяют быстрый прогон на PR от golden- и интеграционных джоб; в монорепозитории запускайте только пакеты, которые изменение может задеть
Считайте нестабильные тесты багами с владельцами: набор, который перезапускают до зелёного, перестал быть сигналом
Покрытие изменённых строк служит подспорьем для ревью, а не целью