Написание тестируемого кода
Опубликовано: 2022-11-03Модульное тестирование является важным инструментом в наборе инструментов любого разработчика программного обеспечения. Писать модульные тесты относительно легко, когда имеешь дело с кодовой базой, которая следует лучшим практикам, шаблонам и принципам проектирования программного обеспечения. Настоящая проблема возникает при попытке модульного тестирования плохо спроектированного, нетестируемого кода.
В этом блоге мы обсудим, как писать больше кода, пригодного для модульного тестирования, и каких шаблонов и неправильных практик следует избегать, чтобы улучшить тестируемость.
Тестируемый и нетестируемый код
При работе с крупномасштабными приложениями, которые необходимо поддерживать в долгосрочной перспективе, мы должны полагаться на автоматические тесты, чтобы поддерживать общее качество системы на высоком уровне. По сравнению с интеграционными тестами, когда вы тестируете несколько модулей как единое целое, модульные тесты имеют то преимущество, что они быстрые и стабильные. Быстрый, потому что в идеале мы создаем экземпляр только тестируемого класса, и стабильный, потому что мы обычно моделируем внешние зависимости, например, базу данных или сетевое соединение.
Если вы не знакомы с точным различием между модульными и интеграционными тестами, вы можете узнать больше об этой теме в нашем блоге Введение в тестирование.
Код, который можно тестировать, можно изолировать от остальной части нашей кодовой базы. Другими словами, самые маленькие устройства могут быть протестированы независимо друг от друга. Нетестируемый код написан таким образом, что для него сложно или даже невозможно написать хороший модульный тест.
Давайте рассмотрим некоторые анти-шаблоны и плохие практики, которых следует избегать при написании тестируемого кода.
Примеры написаны на Java, но упомянутые здесь соглашения о кодировании применимы к любому объектно-ориентированному языку программирования и среде тестирования. Мы будем использовать assertj и JUnit5 для примеров в этом сообщении блога.
Внедрение зависимости
Внедрение зависимостей — один из наиболее важных шаблонов проектирования для достижения изоляции тестов. Внедрение зависимостей — это шаблон проектирования, в котором один объект получает другие объекты (зависимости) через параметры конструктора или сеттеры вместо того, чтобы создавать их самостоятельно.
С внедрением зависимостей мы можем легко изолировать тестируемый класс, имитируя зависимости объекта.
Давайте посмотрим на пример без внедрения зависимостей:

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

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

Теперь возможно изолированное тестирование, потому что мы можем создать фиктивную реализацию абстракции Engine и передать ее нашему классу Car:

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


По сути, этот метод считывает текущую системную дату и время и возвращает результат на основе этого значения. Было бы очень сложно написать правильный модульный тест на основе состояния для этого метода, потому что статический вызов LocalDateTime.now() будет давать разные результаты во время выполнения наших тестов. Написание тестов для этого метода невозможно без изменения системной даты и времени.
Чтобы исправить это, мы передадим дату и время методу timeOfDay в качестве аргумента:

Статический метод timeOfDay теперь чистый — одни и те же входные данные всегда дают одинаковые результаты. Теперь мы можем легко передавать изолированные объекты dateTime в качестве аргументов в наших тестах:

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

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

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

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

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

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