Basic Java Tools for Building & Testing Apps
Скачать 4.76 Mb.
|
Используйте префиксы “actual*” и “expected*” // Неправильно ProductDTO product1 = requestProduct( 1 ); ProductDTO product2 = new ProductDTO( "1" , List.of(State.ACTIVE, State.REJECTED)) assertThat(product1).isEqualTo(product2); Если вы собираетесь использовать переменные в проверке на совпадение значений, добавьте к этим переменным префиксы “actual” и “expected”. Так вы улучшите читаемость кода и проясните назначение переменных. Кроме того, так их сложнее перепутать при сравнении. // Правильно ProductDTO actualProduct = requestProduct( 1 ); ProductDTO expectedProduct = new ProductDTO( "1" , List.of(State.ACTIVE, State.REJEC TED)) assertThat(actualProduct).isEqualTo(expectedProduct); // ясно и красиво Используйте заданные значения вместо случайных Избегайте подавать случайные значения на вход тестов. Это может привести к «морганию» тестов, что чертовски сложно отлаживать. Кроме того, увидев в сообщении об ошибке случайное значение, вы не сможете проследить его до того места, где ошибка возникла. // Неправильно Instant ts1 = Instant.now(); // 1557582788 Instant ts2 = ts1.plusSeconds( 1 ); // 1557582789 int randomAmount = new Random().nextInt( 500 ); // 232 UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad Используйте для всего подряд разные заранее заданные значения. Так вы получите идеально воспроизводимые результаты тестов, а также быстро найдёте нужное место в коде по сообщению об ошибке. // Правильно Instant ts1 = Instant.ofEpochSecond( 1550000001 ); Instant ts2 = Instant.ofEpochSecond( 1550000002 ); int amount = 50 ; UUID uuid = UUID.fromString( "00000000-000-0000-0000-000000000001" ); Вы можете записать это ещё короче, используя вспомогательные функции (см. ниже). Пишите краткие и конкретные тесты Где можно, используйте вспомогательные функции Вычленяйте повторяющийся код в локальные функции и давайте им понятные имена. Так ваши тесты будут компактными и легко читаемыми с первого взгляда. // Неправильно @Test public void categoryQueryParameter () throws Exception { List products = List.of( new ProductEntity().setId( "1" ).setName( "Envelope" ).setCategory( "Office " ).setDescription( "An Envelope" ).setStockAmount( 1 ), new ProductEntity().setId( "2" ).setName( "Pen" ).setCategory( "Office" ).se tDescription( "A Pen" ).setStockAmount( 1 ), new ProductEntity().setId( "3" ).setName( "Notebook" ).setCategory( "Hardwa re" ).setDescription( "A Notebook" ).setStockAmount( 2 ) ); for (ProductEntity product : products) { template.execute(createSqlInsertStatement(product)); } String responseJson = client.perform(get( "/products?category=Office" )) .andExpect(status().is( 200 )) .andReturn().getResponse().getContentAsString(); assertThat(toDTOs(responseJson)) .extracting(ProductDTO::getId) .containsOnly( "1" , "2" ); } // Правильно @Test public void categoryQueryParameter2 () throws Exception { insertIntoDatabase( createProductWithCategory( "1" , "Office" ), createProductWithCategory( "2" , "Office" ), createProductWithCategory( "3" , "Hardware" ) ); String responseJson = requestProductsByCategory( "Office" ); assertThat(toDTOs(responseJson)) .extracting(ProductDTO::getId) .containsOnly( "1" , "2" ); } используйте вспомогательные функции для создания данных (объектов) ( createProductWithCategory() ) и сложных проверок. Передавайте во вспомогательные функции только те параметры, которые релевантны в этом тесте, для остальных используйте адекватные значения по умолчанию. В Kotlin для этого есть дефолтные значения параметров, а в Java можно использовать цепочки вызова методов и перегрузку для имитации дефолтных параметров список параметров переменной длины сделает ваш код ещё изящнее ( ìnsertIntoDatabase() ) вспомогательные функции также можно использовать для создания простых значений. В Kotlin это сделано ещё лучше через функции-расширения // Правильно (Java) Instant ts = toInstant( 1 ); // Instant.ofEpochSecond(1550000001) UUID id = toUUID( 1 ); // UUID.fromString("00000000-0000-0000-a000-000000000001") // Правильно (Kotlin) val ts = 1 .toInstant() val id = 1 .toUUID() Вспомогательные функции на Kotlin можно реализовать так: fun Int toInstant (): Instant = Instant.ofEpochSecond( this .toLong()) fun Int toUUID (): UUID = UUID.fromString( "00000000-0000-0000-a000- ${this.toString( ).padStart( 11 , '0' )} " ) Не злоупотребляйте переменными Условный рефлекс у программиста — вынести часто используемые значения в переменные. // Неправильно @Test public void variables () throws Exception { String relevantCategory = "Office" ; String id1 = "4243" ; String id2 = "1123" ; String id3 = "9213" ; String irrelevantCategory = "Hardware" ; insertIntoDatabase( createProductWithCategory(id1, relevantCategory), createProductWithCategory(id2, relevantCategory), createProductWithCategory(id3, irrelevantCategory) ); String responseJson = requestProductsByCategory(relevantCategory); assertThat(toDTOs(responseJson)) .extracting(ProductDTO::getId) .containsOnly(id1, id2); } Увы, это очень перегружает код. Хуже того, увидев значение в сообщении об ошибке, — его будет невозможно проследить до места, где ошибка возникла. «KISS важнее DRY» // Правильно @Test public void variables () throws Exception { insertIntoDatabase( createProductWithCategory( "4243" , "Office" ), createProductWithCategory( "1123" , "Office" ), createProductWithCategory( "9213" , "Hardware" ) ); String responseJson = requestProductsByCategory( "Office" ); assertThat(toDTOs(responseJson)) .extracting(ProductDTO::getId) .containsOnly( "4243" , "1123" ); } Если вы стараетесь писать тесты максимально компактно (что я, в любом случае, горячо рекомендую), то переиспользуемые значения хорошо видны. Сам код становится более убористым и хорошо читаемым. И, наконец, сообщение об ошибке приведёт вас точно к той строке, где ошибка возникла. Не расширяйте существующие тесты, чтобы «добавить ещё одну маленькую штучку» // Неправильно public class ProductControllerTest { @Test public void happyPath () { // здесь много кода... } } Всегда есть соблазн добавить частный случай к существующему тесту, проверяющему базовую функциональность. Но в результате тесты становятся больше и сложнее для понимания. Частные случаи, раскиданные по большой простыне кода, легко не заметить. Если тест сломался, вы не сразу поймёте, что именно послужило причиной. // Правильно public class ProductControllerTest { @Test public void multipleProductsAreReturned () {} @Test public void allProductValuesAreReturned () {} @Test public void filterByCategory () {} @Test public void filterByDateCreated () {} } Вместо этого напишите новый тест с наглядным названием, из которого сразу будет понятно, какого поведения он ожидает от тестируемого кода. Да, придётся набрать больше букв на клавиатуре (против этого, напомню, хорошо способствуют вспомогательные функции), но зато вы получите простой и понятный тест с предсказуемым результатом. Это, кстати, отличный способ документировать новый функционал. Проверяйте только то, что хотите протестировать Думайте о том функционале, который тестируется. Избегайте делать лишние проверки просто потому, что есть такая возможность. Более того, помните о том, что уже проверялось в ранее написанных тестах и не проверяйте это повторно. Тесты должны быть компактными и их ожидаемое поведение должно быть очевидным и лишённым ненужных подробностей. Предположим, что мы хотим проверить HTTP-ручку, возвращающую список товаров. Наш тестовый набор должен содержать следующие тесты: 1. Один большой тест маппинга, который проверяет, что все значения из БД корректно возвращаются в JSON-ответе и правильно присваиваются в нужном формате. Мы легко можем написать это при помощи функций isEqualTo() (для единичного элемента) или containsOnly() (для множества элементов) из пакета AssertJ, если вы правильно реализуете метод equals() String responseJson = requestProducts(); ProductDTO expectedDTO1 = new ProductDTO( "1" , "envelope" , new Category( "office" ), List.of(States.ACTIVE, States.REJECTED)); ProductDTO expectedDTO2 = new ProductDTO( "2" , "envelope" , new Category( "smartphone " ), List.of(States.ACTIVE)); assertThat(toDTOs(responseJson)) .containsOnly(expectedDTO1, expectedDTO2); 2. Несколько тестов, проверяющих корректное поведение параметра ?category. Здесь мы хотим проверить только правильную работу фильтров, а не значения свойств, потому что мы сделали это раньше. Следовательно, нам достаточно проверить совпадения полученных id товаров: String responseJson = requestProductsByCategory( "Office" ); assertThat(toDTOs(responseJson)) .extracting(ProductDTO::getId) .containsOnly( "1" , "2" ); 3. Ещё пару тестов, проверяющих особые случаи или особую бизнес-логику, например что определённые значения в ответе вычислены корректно. В этом случае нас интересуют только несколько полей из всего JSON-ответа. Тем самым мы своим тестом документируем именно эту специальную логику. Понятно, что ничего кроме этих полей нам здесь не нужно. assertThat(actualProduct.getPrice()).isEqualTo( 100 ); Самодостаточные тесты Не прячьте релевантные параметры (во вспомогательных функциях) // Неправильно insertIntoDatabase(createProduct()); List actualProducts = requestProductsByCategory(); assertThat(actualProducts).containsOnly( new ProductDTO( "1" , "Office" )); Использовать вспомогательные функции для генерации данных и проверки условий удобно, но их следует вызывать с параметрами. Принимайте на вход параметры для всего, что значимо в рамках теста и должно контролироваться из кода теста. Не заставляйте читателя переходить внутрь вспомогательной функции, чтобы понять смысл теста. Простое правило: смысл теста должен быть понятен при взгляде на сам тест. // Правильно insertIntoDatabase(createProduct( "1" , "Office" )); List actualProducts = requestProductsByCategory( "Office" ); assertThat(actualProducts).containsOnly( new ProductDTO( "1" , "Office" )); Держите тестовые данные внутри самих тестов Всё должно быть внутри. Велик соблазн перенести часть данных в метод @Before и переиспользовать их оттуда. Но это вынудит читателя скакать туда-сюда по файлу, чтобы понять, что именно тут происходит. Опять же, вспомогательные функции помогут избежать повторений и сделают тесты более понятными. Используйте композицию вместо наследования Не выстраивайте сложных иерархий тестовых классов. // Неправильно class SimpleBaseTest {} class AdvancedBaseTest extends SimpleBaseTest {} class AllInklusiveBaseTest extends AdvancedBaseTest {} class MyTest extends AllInklusiveBaseTest {} Такие иерархии усложняют понимание и вы, скорее всего, быстро обнаружите себя пишущим очередного наследника базового теста, внутри которого зашито множество хлама, который текущему тесту вовсе не нужен. Это отвлекает читателя и приводит к трудноуловимым ошибкам. Наследование не гибко: как вы сами думаете, можно ли использовать все методы класса AllInclusiveBaseTest , но ни одного из его родительского AdvancedBaseTest? Более того, читателю придётся постоянно прыгать между различными базовыми классами, чтобы понять общую картину. «Лучше продублировать код, чем выбрать неправильную абстракцию» (Sandi Metz) Вместо этого я рекомендую использовать композицию. Напишите маленькие фрагменты кода и классы для каждой задачи, связанной с фикстурами (запустить тестовую базу данных, создать схему, вставить данные, запустить мок-сервер). Переиспользуйте эти запчасти в методе @BeforeAll или через присвоение созданных объектов полям тестового класса. Таким образом, вы сможете собирать каждый новый тестовый класс из этих заготовок, как из деталей Лего. В результате каждый тест будет иметь свой собственный понятный набор фикстур и гарантировать, что в нём не происходит ничего постороннего. Тест становится самодостаточным, потому что содержит в себе всё необходимое. // Правильно public class MyTest { // композиция вместо наследования private JdbcTemplate template; private MockWebServer taxService; @BeforeAll public void setupDatabaseSchemaAndMockWebServer () throws IOException { this .template = new DatabaseFixture().startDatabaseAndCreateSchema(); this .taxService = new MockWebServer(); taxService.start(); } } // В другом файле public class DatabaseFixture { public JdbcTemplate startDatabaseAndCreateSchema () throws IOException { PostgreSQLContainer db = new PostgreSQLContainer( "postgres:11.2-alpine" ); db.start(); DataSource dataSource = DataSourceBuilder.create() .driverClassName( "org.postgresql.Driver" ) .username(db.getUsername()) .password(db.getPassword()) .url(db.getJdbcUrl()) .build(); JdbcTemplate template = new JdbcTemplate(dataSource); SchemaCreator.createSchema(template); return template; } } И ещё раз: «KISS важнее DRY» Прямолинейные тесты — это хорошо. Сравнивайте результат с константами Не переиспользуйте продакшн-код Тесты должны проверять продакшн-код, а не переиспользовать его. Если вы переиспользуете боевой код в тесте, вы можете пропустить баг в этом коде, потому что больше не тестируете его. // Неправильно boolean isActive = true ; boolean isRejected = true ; insertIntoDatabase( new Product( 1 , isActive, isRejected)); ProductDTO actualDTO = requestProduct( 1 ); // переиспользование боевого кода List assertThat(actualDTO.states).isEqualTo(expectedStates); Вместо этого при написании тестов думайте в терминах ввода и вывода. Тест подаёт данные на вход и сравнивает вывод с предопределёнными константами. Бóльшую часть времени переиспользование кода не требуется. // Do assertThat(actualDTO.states).isEqualTo(List.of(States.ACTIVE, States.REJECTED)); Не копируйте бизнес-логику в тесты Маппинг объектов — яркий пример случая, когда тесты тащат в себя логику из боевого кода. Предположим наш тест содержит метод mapEntityToDto() , результат выполнения которого используется для проверки, что полученный DTO содержит те же значения, что и элементы, которые были добавлены в базу в начале теста. В этом случае вы, скорее всего, скопируете в тест боевой код, который может содержать ошибки. // Неправильно ProductEntity inputEntity = new ProductEntity( 1 , "envelope" , "office" , false , true , 200 , 10.0 ); insertIntoDatabase(input); ProductDTO actualDTO = requestProduct( 1 ); // mapEntityToDto() содержит ту же логику, что и продакшн-код ProductDTO expectedDTO = mapEntityToDto(inputEntity); assertThat(actualDTO).isEqualTo(expectedDTO); Правильным будет решение, при котором actualDTO сравнивается с созданным вручную эталонным объектом с заданными значениями. Это предельно просто, понятно и защищает от потенциальных ошибок. // Правильно ProductDTO expectedDTO = new ProductDTO( "1" , "envelope" , new Category( "office" ), L ist.of(States.ACTIVE, States.REJECTED)) assertThat(actualDTO).isEqualTo(expectedDTO); Если вы не хотите создавать и проверять на совпадение целый эталонный объект, можете проверить дочерний объект или вообще только релевантные тесту свойства объекта. Не пишите слишком много логики Напомню, что тестирование касается в основном ввода и вывода. Подавайте на вход данные и проверяйте, что вам вернулось. Нет необходимости писать сложную логику внутри тестов. Если вы вводите в тест циклы и условия, вы делаете его менее понятным и более неустойчивым к ошибкам. Если ваша логика проверки сложна, пользуйтесь многочисленными функциями AssertJ, которые сделают эту работу за вас. Запускайте тесты в среде, максимально похожей на боевую Тестируйте максимально полную связку компонентов Обычно рекомендуется тестировать каждый класс изолированно при помощи моков. У этого подхода, однако, есть и недостатки: таким образом не тестируется взаимодействие классов между собой, и любой рефакторинг общих сущностей сломает все тесты разом, потому что у каждого внутреннего класса свои тесты. Кроме того, если писать тесты для каждого класса, то их будет просто слишком много. Изолированное юнит-тестирование каждого класса Вместо этого я рекомендую сосредоточиться на интеграционном тестировании. Под «интеграционным тестированием» я подразумеваю сбор всех классов воедино (как на продакшене) и тестирование всей связки, включая инфраструктурные компоненты (HTTP-сервер, базу данных, бизнес-логику). В этом случае вы тестируете поведение вместо реализации. Такие тесты более аккуратны, близки к реальному миру и устойчивы к рефакторингу внутренних компонентов. В идеале, вам будет достаточно одного класса тестов. Интеграционное тестирование (= собрать все классы вместе и тестировать связку) |