RKDEEP

Тестируем java приложение

Kirill Rybkin2021-06-04

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

Для чего необходимо тестирование

  1. Тестирование устраняет страх очищать кодовую базу и исправлять ошибки в коде. Т.е. придает уверенность разработчику.
  2. Писать код продукта в объеме необходимом для прохождения текущего отказаного теста. Это правило устраняет воронку времени под названием перфекционизм, т.к. существует очевидное условие, выполнение которого свидетельствует о решении задачи.
  3. Тесты - один из лучших способов документации кода.
  4. Тестирование повышает качество кода. т.к. разработчику необходимо изначально написать слабо сопряженный код (Low Coupling), чем думать как протестировать код с высоким сопряжением.
  5. Убераются side effect влияния изменений в одной части системы на другую.

Практические рекомендации тестирования

Given, When, Then

Тест должен содержать 3 блока кода

  • Given (Input): Блок кода для подготовки данных, конфигурации mock объектов.

  • When (Action): Вызов методов или действий который собираемся тестировать.

  • Then (Output): Проверяем правильность вывода или поведение действий. Каждый блок кода должен быть лаконичный и тестировать одину фунциональность.

    // Do
    @Test
    public void findProduct() {
      insertIntoDatabase(new Product(100, "Smartphone"));
    
      Product product = dao.findProduct(100);
    
      assertThat(product.getName()).isEqualTo("Smartphone");
    }

Используйте префиксы “actual” и “expected

// Don't
ProductDTO product1 = requestProduct(1);
ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(product1).isEqualTo(product2);

Если используете переменные в Assert на равенство, добавляйте префиксы “actual” и “expected”. Это проясняет код и повышает читаемость, и сложнее допустить ошибку в Assert.

// Do
ProductDTO actualProduct = requestProduct(1);
ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(actualProduct).isEqualTo(expectedProduct); // nice and clear.

Не используйте случайные данные в тесте

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

// Don't
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

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

// Do
Instant ts1 = Instant.ofEpochSecond(1550000001);
Instant ts2 = Instant.ofEpochSecond(1550000002);
int amount = 50;
UUID uuid = UUID.fromString("00000000-000-0000-0000-000000000001");

Поддерживайте тесты тонкими

Не злоупотреблять переменными

Дополнительные переменные усложняют чтение кода, делают тест объемнее и менее читабельным.

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);
}
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");
}

Определяйте тесты с единственной ответственностью

Нужно помнить какая функциональность уже протестирована и не повторять проверку в каждом тесте. Это помогает держать тесты лаконичными.

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

-- Роберт Мартин

На пример если тестируем Http endpoint который возвращает коллекцию продуктов. Наш набор тестов мог бы содержать следующие тесты: Один большой "mapping test", который проверяет корректность возвращенных значений из базы данных в json.

String responseJson = requestProducts();

ProductDTO expectedDTO1 = new ProductDTO("1", "evelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED));
ProductDTO expectedDTO2 = new ProductDTO("2", "evelope", new Category("smartphone"), List.of(States.ACTIVE));
assertThat(toDTOs(responseJson))
        .containsOnly(expectedDTO1, expectedDTO2);

Тесты которые проверяют кореектность фильтрации по параметру. Т.к. мы проверили маппинг всех параметров, то достаточно сравнить id.

String responseJson = requestProductsByCategory("Office");

assertThat(toDTOs(responseJson))
        .extracting(ProductDTO::getId)
        .containsOnly("1", "2");

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

assertThat(actualProduct.getPrice()).isEqualTo(100);

Проблема в том, что тест проверяет более одной концепции. Так что, вероятно, лучше всего сформулировать это правило так: количество директив assert на концепцию должно быть минимальным, и в тестовой функции должна проверяться только одна концепция.

-- Роберт Мартин


Автономные тесты

Не выносите релевантные параметры в спомогательные функции

// Don't
insertIntoDatabase(createProduct());
List<ProductDTO> actualProducts = requestProductsByCategory();
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));

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

// Do
insertIntoDatabase(createProduct("1", "Office"));
List<ProductDTO> actualProducts = requestProductsByCategory("Office");
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));

Предпочитайте композицию наследованию

Нет необходимости использовать иерархию наследования в тестовых классах.

// Don't
class SimpleBaseTest {}
class AdvancedBaseTest extends SimpleBaseTest {}
class AllInklusiveBaseTest extends AdvancedBaseTest {}
class MyTest extends AllInklusiveBaseTest {}

Наиболее вероятно базовый класс будет развиваться и содержать код не используемый в тесте. Вместо, используйте композицию. Добавьте части кода которые отвечают за специфический участок работы напр.: старт базы данных, создание схемы, вставка данных, создание мокового сервера. Переиспользуйте этот код в @BeforeAll метода или сохраниете возвращаемое значение в полях тестового класса. Таким образом можно переиспользовать части кода в каждом тесте.

// Do
public class MyTest {
    // composition instead of inheritance
    private JdbcTemplate template;
    private MockWebServer taxService;

    @BeforeAll
    public void setupDatabaseSchemaAndMockWebServer() throws IOException {
        this.template = new DatabaseFixture().startDatabaseAndCreateSchema();
        this.taxService = new MockWebServer();
        taxService.start();
    }
}

// In a different File
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;
    }
}

Не переиспользуйте production код

Тесты должны тестировать, а не использовать production код. Это может привести к тому что вы упустите ошибку которая содержится в переиспользуемом коде, потому что вы не тестируете этот код.

// Don't
boolean isActive = true;
boolean isRejected = true;
insertIntoDatabase(new Product(1, isActive, isRejected));
ProductDTO actualDTO = requestProduct(1);
// production code reuse ahead
List<State> expectedStates = ProductionCode.mapBooleansToEnumList(isActive, isRejected);
assertThat(actualDTO.states).isEqualTo(expectedStates);

Вместо этого думайте о входных и выходных данных в тесте. Тест устанавливает входные данные и сравнивает их c hard-coded выходными данными.

Не переписывайте production логику

Мапперы сущности на dto частый пример когда логика сущности переписывается. Если предположить что тест содержит метод mapEntityToDto(), результат которого используется для утверждения, что возвращаемый dto содержит те же значения, что и сущность. В этом случае мы переписываем производственную логику в тестовом коде, которая может содержать ошибки.

// Don't
ProductEntity inputEntity = new ProductEntity(1, "evelope", "office", false, true, 200, 10.0);
insertIntoDatabase(input);
ProductDTO actualDTO = requestProduct(1);
 // mapEntityToDto() contains the same mapping logic as the production code
ProductDTO expectedDTO = mapEntityToDto(inputEntity);
assertThat(actualDTO).isEqualTo(expectedDTO);

Решение сравнивать actualDTO с созданым объектом с hard-coded значениями.

// Do
ProductDTO expectedDTO = new ProductDTO("1", "evelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED))
assertThat(actualDTO).isEqualTo(expectedDTO);

Не пишите слишком много логики в тестах

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


Тестируй близко к реальности

Сосредоточтесь на тестированиий вертикального среза

Тестирование каждого класса изолированно, используя mocks частая рекомендация. Однако это имеет недостатки: мы не тестируем все классы в интеграции и рефакторинг внутренних сломает все тесты, потому что есть тест для каждого внутреннего класса. В конечном счете вы должны поддерживать множество тестов. Вместо этого возможно сосредоточится на интеграционных тестах (или компонентных). Я имею ввиду собрать все классы вместе и тестировать всю вертикать проходя через все слои (HTTP, бизнес логика, база данных). Таким образом мы тестируем поведение вместо реализации. Эти тесты точны, близки к production и устойчивы к рефакторингу внутренних классов. Идеально мы должны написать только один тестовый класс. По-прежнему юнит тесты удобны в ситуациях когда это лучший выбор или имеет смысл совместить 2 подхода.

Не используйте in-memory базу данных в тестах

Использование базы данных в памяти снижает надежность тестов. Т. к. разные базы данных могут вести себя по-разному и возвращать разный результат. Таким образом зеленый тест in-memory базы данных не гарантирует корректное поведение вашего приложения в production. Более того, часто может получаться ситуация в которой in-memory база данных не поддерживает функционал реализованый персистентной базой. Решение использовать для тестирования базу данных, на которой будет работать production система.


Java/JVM

Используйте флаги -noverify -XX:TieredStopAtLevel=1

Всегда добавляйте опции -noverify -XX:TieredStopAtLevel=1 в конфигурации старта. Это сохранить 1-2 сек. при старте jvm до того как тест будет выполнен. Это оссобенно полезно в разработке, когда тесты запускаются часто через IDE Вы можете добавить аргументы к Junit для запуска шаблонов в Intellij IDEA, поэтому нет необходимости добавлять опции к каждой конфигурации.

Используйте AssertJ

AssertJ мощная и зрелая библиотека с типобезопасным API. Есть Assertion для всего что можно пожелать. Это предостережет от комплексной логики с циклами и ветвлениями в тестах, оставляя тестовый код лаконичным.

assertThat(actualProduct)
        .isEqualToIgnoringGivenFields(expectedProduct, "id");

assertThat(actualProductList).containsExactly(
        createProductDTO("1", "Smartphone", 250.00),
        createProductDTO("1", "Smartphone", 250.00)
);

assertThat(actualProductList)
        .usingElementComparatorIgnoringFields("id")
        .containsExactly(expectedProduct1, expectedProduct2);

assertThat(actualProductList)
        .extracting(Product::getId)
        .containsExactly("1", "2");

assertThat(actualProductList)
        .anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));

assertThat(actualProductList)
        .filteredOn(product -> product.getCategory().equals("Smartphone"))
        .allSatisfy(product -> assertThat(product.isLiked()).isTrue());

Избегать assertTrue() and assertFalse()

Предпочитайте не использовать assertTrue() и assertFalse() т.к эти методы выводят сообщения трудные для восприятия например:

// Don't 
assertTrue(actualProductList.contains(expectedProduct));
assertTrue(actualProductList.size() == 5);
assertTrue(actualProduct instanceof Product);
expected: <true> but was: <false>

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

// Do
assertThat(actualProductList).contains(expectedProduct);
assertThat(actualProductList).hasSize(5);
assertThat(actualProduct).isInstanceOf(Product.class);
Expecting:
 <[Product[id=1, name='Samsung Galaxy']]>
to contain:
 <[Product[id=2, name='iPhone']]>
but could not find:
 <[Product[id=2, name='iPhone']]>

Используйте JUnit5

Используйте Awaitility для тестирования асинхронного кода

Awaitility библиотека для тестирования асинхронного кода. Можете установить как часто проверять утверждение до того момента как тест провалится.

private static final ConditionFactory WAIT = await()
        .atMost(Duration.ofSeconds(6))
        .pollInterval(Duration.ofSeconds(1))
        .pollDelay(Duration.ofSeconds(1));
@Test
public void waitAndPoll(){
    triggerAsyncEvent();
    WAIT.untilAsserted(() -> {
        assertThat(findInDatabase(1).getState()).isEqualTo(State.SUCCESS);
    });
}

Это помогает избежать Thread.sleep() в коде. Однако тестирование синхронного кода легче, поэтому следует разделять синхронный и асинхронный код и тестировать отдельно.


Нет необходимости подымать DI (Spring, Guice)

Старт контекста Spring перед стартом теста занимает несколько секунд и замедляет обратную связь, тесты становятся медленнее. Альтернатива, написание тестов без DI. Возможно создавать объекты через new и связывать их через конструктор. В большинстве случаев для тестирования бизнес логики, нет необходимости в DI.

Пишите тестируемый код

Не используйте статический доступ

Статический доступ это антипатерн, он усложняет понимание и тестируемость кода из-за использование side эффектов. При тестирование у вас нет возможности изменить реализацию объекта, например DAO слой доступа к базе данных.

// Don't 
public class ProductController {
    public List<ProductDTO> getProducts() {
        List<ProductEntity> products = ProductDAO.getProducts();
        return mapToDTOs(products);
    }
}
// Do 
public class ProductController {
    private ProductDAO dao;
    public ProductController(ProductDAO dao) {
        this.dao = dao;
    }
    public List<ProductDTO> getProducts() {
        List<ProductEntity> products = dao.getProducts();
        return mapToDTOs(products);
    }
}

К счастью Spring создает и связывает объекты за нас, что помогает избежать использования static доступа.

Не используй Instant.now() или new Date()

Не делай вызовы Instant.now() или new Date() для получения текущего timestamp в production коде, если необходимо тестировать поведение.

// Don't
public class ProductDAO {
    public void updateDateModified(String productId) {
        Instant now = Instant.now(); // !
        Update update = Update()
            .set("dateModified", now);
        Query query = Query()
            .addCriteria(where("_id").eq(productId));
        return mongoTemplate.updateOne(query, update, ProductEntity.class);
    }
}

Проблема в том что созданный timastamp не контроллируется тестом. Вы не можете проверить точное значение времени, т. к. оно меняется в каждом тесте. Используйте java Clock класс.

// Do
public class ProductDAO {
    private Clock clock; 

    public ProductDAO(Clock clock) {
        this.clock = clock;
    }

    public void updateProductState(String productId, State state) {
        Instant now = clock.instant();
        // ...
    }
}

Теперь есть возможность создать mock для Clock, за инжектить в Dao и сконфигурировать clock класс для возврата фиксированого значения.

Разделяй Асинхронное выполнение и логику

Тестировать асинхронный код сложно. Библиотеки на подобии Awaitility могут помочь, но это обходной путь. Если возможно следует разделять бизнес логику и асинхронное выполнение. Например реализуя бизнес логику в ProductController, мы можем тестировать эту часть синхронно что легче. Асинхронную часть и паралерризм сконцентрировать в ProductScheduler, который может быть протестирован в своем окружении.

// Do
public class ProductScheduler {

    private ProductController controller;

    @Scheduled
    public void start() {
        CompletableFuture<String> usFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.US));
        CompletableFuture<String> germanyFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.GERMANY));
        String usResult = usFuture.get();
        String germanyResult = germanyFuture.get();
    }
}

Материалы

  1. "Best practice testing in java" (https://phauer.com/2019/modern-best-practices-testing-java/).
  2. "Clean Code" Robert Martin 2019.
  3. "Refactoring improving the design of existing code". Martin Fowler 2019.