Что я понял, когда написал много тестов

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

da49e52d0ac17d97c2ab11f8d0255272.png

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

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

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

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

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

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

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

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

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

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

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

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

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

export class UsersComponent implements OnInit {
  users$: Observable;

  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 {
    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;

  constructor(private userService: UserService) {}

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

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

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

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

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

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

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

Код сервиса:

export class UserService {
  constructor(private http: HttpClient) {}
  
  getUserById$(userId: string’): Observable {
    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. Пиши тесты сразу

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

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

Заключение

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

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

© Habrahabr.ru