Привет! Меня зовут Сергей, я работаю фронтенд-разработчиком в Тинькофф на одном из внутренних приложений в направлении Compliance. Последние полгода я активно занимался повышением стабильности и качества продукта, в том числе увеличивал покрытие приложения юнит-тестами. За это время я написал более 500 юнит-тестов, а тестовое покрытие удалось увеличить примерно на 30% с учетом того, что бизнес-задачи продолжали выполняться. В ходе работы над тестами я получил новый опыт и пришел к интересным выводам, которыми хочу поделиться с вами.

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

1. Тесты помогают найти баги

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

2. Тесты — это документация

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

3. Код должен быть тестируемым

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

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

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

Cервис получения данных о пользователях:

export class UserService {
  constructor(private http: HttpClient) {}
  
  getUsers$(): Observable<User> {
    const url = 'https://example.com/getUsers';

    return this.http.get(url);
  }
}

Компонент для отображения:

export class UsersComponent implements OnInit {
  users$: Observable<User>;

  constructor(private errorService: ErrorService, private userService: UserService) {}

  ngOnInit(): void {
    this.users$ = this.userService.getUsers$.pipe(
      map(users => ...)),
      catchError(error => {
        this.errorService.showNotification('get users error');

        return of([]);
       }),
    );
  }
}

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

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

Код сервиса:

export class UserService {
  constructor(private http: HttpClient, private errorService: ErrorService) {}
  
  getUsers$(): Observable<User> {
    const url = 'https://example.com/getUsers';

    return this.http.get(url).pipe(
      catchError(error => {
        this.errorService.showNotification('get users error');
        
        return of([]);
      }),
      map(users => ...)),
    );
  }
}

Код компонента:

export class UsersComponent implements OnInit {
  users$: Observable<User>;

  constructor(private userService: UserService) {}

  ngOnInit(): void {
    this.users$ = this.userService.getUsers$();
  }
}

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

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

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

Навык писать тестируемый код приходит с опытом, так же как и навык писать хороший код. Единственный путь — писать тесты. Много тестов.

4. Теория тестирования = хорошие тесты 

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

Код сервиса:

export class UserService {
  constructor(private http: HttpClient) {}
  
  getUserById$(userId: string’): Observable<User> {
    const url =`https://example.com/getUsers/${userId}`;
    
    return this.http.get(url);
   }
}

Здесь можно выделить несколько кейсов:

  1. Формирование url с подстановкой туда userId из аргументов.

  2. Данные мы получаем через метод get у httpClient.

  3. Полученный результат нужно вернуть.

При сильном желании можно найти множество кейсов, которые будут требовать проверки, но в рамках этой статьи достаточно и этого. Сейчас мы попытаемся написать один тест, который проверит нам все эти кейсы. Для этого теста понадобится не только Jest, но и ts-mockito и rxjs-marble. Чтобы никого не пугать, расшифруем тест.

Код теста:

it('should return user when call method getUserById with "userId"', () => {
  // формируем url, который мы знаем заранее, с учетом тестовых данных
  const testUrl = 'https://example.com/getUsers/userId';
  
  // мокируем вызов метода get с тестовым url и возвратом нужных нам данных
  when(httpClientMock.get(testUrl)).thenReturn(of(userModel));

  // указываем ожидаемый результат
  const expected = cold('(a|)', { a: userModel });
  
  // сравниваем полученный результат с ожидаемым
  expect(service.getUserById$('userId')).toBeObservable(expected);
});

В итоге мы получим результат, только если у нас будет вызван метод get с правильно сформированным url с подстановкой userId. Часто можно делать такие пассивные проверки различных частей кода: это позволит уменьшить количество тестов, не влияя на качество кода. Но нужно точно знать, где можно сократить проверки таким образом, а где нет. Например, если вы делаете POST-запрос со сложной логикой формирования тела запроса, лучше написать отдельный тест или даже несколько.

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

5. Используй инструменты

В Typescript есть различные настройки компилятора. Одна из них — строгая проверка на null. Если не включен флаг strictNullCheck, функция может принимать null как аргумент, несмотря на то что тип указан как строка.

Пример функции:

function someFunction(someArg: string): void {...}

Если вызвать функцию someFunction с аргументом null при выключенной проверке strictNullCheck, IDE и компилятор не будут ругаться. Можно легко забыть про такой кейс при тестировании. Включение строгой проверки на null позволяет избежать кейсов, когда у нас нет значения. Зачастую мы возвращаем null, чтобы показать, что что-то пошло не так. А когда проверяем код, делаем акцент на успешных кейсах, забывая те, что приводят к ошибкам. Такой кейс довольно распространен, особенно на legacy-проектах, где включать настройки строгих проверок может быть очень затратно. Лучше заранее настройте компилятор.

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

Некоторые IDE содержат расчет этих метрик «из коробки». Например, в WebStorm достаточно включить их в настройках. Для других существуют плагины и расширения. Например, для VS Code есть расширение codemetrics. Автор утверждает, что реализовал не чистую цикломатическую сложность, а ее приближенные вычисления. Если у вас нет никаких инструментов, для начала подойдет и его использование.

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

6. Тестовое покрытие может врать

Еще один полезный инструмент для написания тестов — отчет code coverage, который может показать, все ли строки/условия/функции вашего кода были задействованы в тестах. Именно задействованы, а не протестированы. Code coverage не дает 100%-й гарантии того, что код покрыт нужными функциональными тестами. Его можно использовать как отчет о том, что в текущей функциональности еще не успели протестировать и не пропустили ли какие-то места.

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

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

Пример функции сортировки:

function userSort(users: User[]) => {
  return users.sort((user1, user2) => user1.age - user2.age))
};

Если вы настроили сборку данных о code coverage, например на файлы с расширением *.ts, вполне возможна ситуация, когда в отчет попадет неиспользуемый код. Например, части dto-модели, которые не используются в коде, но были добавлены в тесте. Рассмотрим интерфейс данных пользователей. В dto-модели есть поле «Гражданство», которое мы можем не использовать в приложении. Но если мы напишем тест на получение всей модели с бэкенда, перечисление Citizen будет засчитано полностью или частично как покрытое тестами. Так мы поднимем уровень тестового покрытия, ничего фактически не проверив.

Пример dto:

export interface UserDto {
  name: string;
  age: number;
  citizen: Citizen;
} 

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

7. Тесты — это инвестиции в светлое будущее

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

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

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

8. Нужны договоренности

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

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

9. Пиши тесты сразу

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

Если задача не дождалась тестов и разработку по ней закончили, а может быть, она даже успела дойти до релиза, психологически проще считать ее завершенной. К таким задачам обычно не хочется возвращаться. Лучший способ избежать этого — начать писать тесты в рамках выполнения задачи и заложить время на это в ее оценку. У представителей бизнеса будет меньше вопросов в духе «А нельзя ли как-то без тестов/побыстрее?». Но такие решения лучше заранее обговорить с командой. 

Заключение

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

Если у вас есть какие-то мысли или подходы к тестированию, которые вы применяете, или вы просто хотите поделиться своим опытом, смело пишите свои комментарии — с удовольствием отвечу.

Комментарии (17)


  1. dprotopopov
    11.08.2023 08:28

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

    Не верю!!! (с) Станиславский .. 5 тестов в рабочий день (возможно по неизвестной области) ...

    Либо это жёская галера ...


    1. panzerfaust
      11.08.2023 08:28
      +1

      Это сарказм? Посмотрел свою статистику в сонаре - 950 тестов с начала 2023 года. Это же юниты. Они по самой идее маленькие, простые и многочисленные.


    1. ws233
      11.08.2023 08:28

      Юнит-тесты очень просты и состоят обычно из трех строчек кода:

      1. assign

      2. act

      3. assert

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

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

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

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

      Если у Вас так не выходит, задумайтесь, где Вы ошибаетесь.


    1. Newbilius
      11.08.2023 08:28

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


    1. Goodzonchik Автор
      11.08.2023 08:28

      Как уже сказал @ws233 тесты часто похожи, например, если у нас есть два сервиса суть которых только делать запрос на бэкенд, но с разными url-ами, то их тесты будут практически копиями. Также часть тестов могут быть параметризированными, если считать их за несколько. А некоторые тесты делаются копированием, например, если проверяем функцию, которая на вход может принять строку, null и undefined, в таком случае два последних кейса будут копией, только разные входные аргументы.

      Могут быть и сложные legacy-моменты, когда проще переписать код и только потом написать тесты, или же при написании тестов зависимости настолько запутанны, что их невозможно замокать. В таких случаях написать 5 тестов за день может быть рекордом, но в остальных случаях, можно запросто написать 15-20 тестов без проблем.


  1. ws233
    11.08.2023 08:28
    +1

    1. Тесты – это не просто документация. Это 100% копия технического задания, переведенная с человеческого на язык машинных кодов. Если у вас есть тест, но нет соответствующего описания на человеческом языке в ТЗ, то стоит это описание туда добавить. И наоборот. Любое изменение в ТЗ приводит к гарантированному изменению в тестов. В итоге у вас 1 и тот же документ записан на двух языках: человеческом (ТЗ) и машинном (тесты) – и оба перевода должны постоянно синхронизироваться в обе стороны.

    2. Проблемы с покрытием у Вас из-за отсутствия изоляции. Изоляция – необходимое условие юнит-тестов (принципы FIRST). Если вернете им изоляцию, то проблем с подсчетом покрытия для них не будет. С интеграционными и e2e-тестами это не сработает. Там изоляция быть не может, а значит, что и покрытие для этих тестов как метрика быть использована не может. Другими словами – покрытие можно считать ТОЛЬКО для юнит-тестов (которые являются копией ТЗ, помните?), для всех других тестов эту метрики использовать нельзя.


  1. nin-jin
    11.08.2023 08:28
    +2

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

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

    export class UserComponent extends IoC {
      
      @mem user() {
        reurn this.$.get( UserService ).getUser()
      }
    
    }

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

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

    я написал более 500 юнит-тестов, а тестовое покрытие удалось увеличить примерно на 30%

    А могли бы написать 50 компонентных тестов и увеличить покрытие до 99%: https://page.hyoo.ru/#!=2jggfw_at1ily

    PS: дизайнер, который рисует грязно жёлтым по мрачно серому, кажется пытается сказать нам: "Спасите меня отсюда, меня держат в заложниках и заставляют рисовать унылые картинки!".


  1. i360u
    11.08.2023 08:28
    +5

    Сейчас выскажу непопулярное мнение: тесты - это побочный артефакт разработки, совсем не ее ЦЕЛЬ. Поймите это наконец. Чем больше тесты отнимают времени и ресурсов, тем выше оверхед вашего подхода к процессам и ниже их эффективность. Часто тесты - это просто попытка снять с себя ответственность. Тесты - это условность, и очень часто, множество кейсов реального использования пересекается со множеством тест-кейсов весьма... незначительно. Иначе в энтерпрайз приложениях никогда не было бы багов. И чем сложнее ваша система, тем этот эффект проявляется более явно. Идеально покрыть тестами сложный код - просто математически невозможно. Соответственно, вы всегда должны понимать где пора остановиться и где более применимы принципы хаос-инжиниринга. Код нужно писать не так, чтобы его было легко тестами покрывать, а так, чтобы максимально снизить стоимость последствий возможной ошибки и максимально удешевить, упростить и ускорить ее исправление.

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


    1. noodles
      11.08.2023 08:28
      +1

      Как на счёт стратегии покрывать тестами только баги - которые уже вылезли на продакшене или тестировании? Т.е. флоу такой:

      - пилим себе "свободно" проект\фичу
      - выкатываем в тест\прод среду, он там крутиться
      - споймали баг, пофиксили, покрыли его тестами
      - и так на протяжении всей жизни проекта

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

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


      1. i360u
        11.08.2023 08:28

        Такой подход мне нравится. Мы примерно так и делаем у себя в команде: пишем юнит-тесты на ключевые элементы систем и остальное уже по репортам. Но в таком подходе система репортов должна быть очень хорошо выстроена.


    1. Goodzonchik Автор
      11.08.2023 08:28

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

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


  1. AnyKey80lvl
    11.08.2023 08:28
    +1

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

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


    1. panzerfaust
      11.08.2023 08:28

      нашел там нужный текст и из него понял бизнес-подоплёку

      А что именно вам тут кажется невероятным? В инструментах вроде кукумбера у каждого теста есть текстовое описание типа "подаем на вход А - получаем на выходе Б" или "подаем на вход Г - получаем ошибку". Если это не документация, то что? Та же история на более низком уровне юнит-тестов.


      1. Ant0ha
        11.08.2023 08:28

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


        1. panzerfaust
          11.08.2023 08:28

          Ок, пишем тесты так, что "подаем на вход А - получаем на выходе Б" или "подаем на вход Г - получаем ошибку". Готова документация для другого разработчика.


    1. Goodzonchik Автор
      11.08.2023 08:28

      Именно бизнес-полопёку лучше получать из интеграционных тестов, или е2е-тестов, так как они отображают юзер-кейсы. А вот суть того, как работает конкретный метод/функция можно узнать из юнит-теста. Конечно, в первую очередь стоит смотреть на документацию/ТЗ и на сам код. Самый частый кейс, когда тест рассматривается в качестве документации, если он упадет, тогда описание теста как раз скажет, как работал код.


  1. Lukerman
    11.08.2023 08:28

    Приветствую! Спасибо за статью !

    Один тест все же не дописали.

    Кейс: заходим в МП тинькофф

    1.Платежи

    2.По номеру телефону

    3. Вставить из буфера обмена телефонный номер формата +7xxx xxx xx xx

    4. СОВПАДЕНИЙ НЕТ (ЭКРАН ПУСТ )

    5.Жмакаем на крестик. Повторяем п.3

    6.СОВПАДЕНИЕ НАЙДЕНО .ЮЗЕР И ФОРМА ПЕРЕВОДА ДОСТУПНЫ

    Android v.13

    Kernel 5.10.136