Как стать автором
Обновить
1158.58
OTUS
Цифровые навыки от ведущих экспертов

Параллельные тесты JUnitPlatform. Как победить в гонке?

Время на прочтение9 мин
Количество просмотров5.5K

Начиная с версии 5.3 JUnit Platform предоставило возможность параллельного запуска тестов, что может существенно увеличить скорость прохождения тестовых сценариев. Но в то же время, если сценарии используют какие-либо разделяемые ресурсы, общие для всех тестов, без использования механизмов синхронизации можно обнаружить неустойчивое выполнение тестов из-за возможного переключения потоков выполнения во время исполнения тестируемого кода ("состояние гонки"). В этой статье мы рассмотрим как настроить параллельное тестирование и как обнаружить (и преодолеть) потенциальные проблемы доступа к общим ресурсам.

Рассматривать мы будет на простых синтетические примерах со счетчиком значений (увеличивается при вызове метода класса) и некоторого виртуального счета в банке, куда могут зачисляться или с которого могут переводиться деньги. Это классическая задача про транзакции (целостные операции, которые должны быть выполнено либо целиком, либо полностью отменены), но она помогает увидеть возможные проблемы и предусмотреть механизмы для их решения. Для разработки мы будем использовать язык Java, но похожие ситуации могут возникнуть на любом языке программирования, где поддерживается многопоточное выполнение с использованием Thread'ов (например, Kotlin, Scala или Groovy).

Начнем со счетчика. Создадим новый проект для Java 11 в IntelliJ IDEA (или VSCode) с поддержкой тестового фреймворка JUnitPlatform (содержимое build.gradle приведено ниже):

plugins {
    id 'java'
}

group 'tech.dzolotov'
version '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
}

test {
    useJUnitPlatform()
}

И создадим простой класс счетчика, который мы в дальнейшем будем тестировать и дорабатывать:

public class SimpleCounter {
    private int i = 0;
    public void increment() {
        i++;
    }

    public int getCounter() {
        return i;
    }
}

Сделаем простой тест для тестирования функциональности счетчика:

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assertions;

public class ParallelTest {

    @Test
    public void testCounter() {
        var counter = new SimpleCounter();
        System.out.println("Counter = "+counter.hashCode());
        System.out.println(Thread.currentThread().getName());
        counter.increment();
        Assertions.assertEquals(1, counter.getCounter());
        counter.increment();
        Assertions.assertEquals(2, counter.getCounter());          
    }
}

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

Counter = 672618889
Test worker
2 actionable tasks: 1 executed, 1 up-to-date

Теперь скопируем тест (создадим метод testCounter2) и запустим повторно. Мы увидим, что оба теста выполняются на одном Worker-процессе (Test worker) и для каждого теста создается свой экземпляр SimpleCounter. Выделим общий счетчик в статическое поле, инициализируем его в методе с аннотацией @BeforeAll и будем проверять в каждом тесте изменения значения до ожидаемого. Для предсказуемости выполнения тестов в нужной последовательности, добавим аннотацию

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)

для выполнения в указанном порядке и аннотацию @Order(N) для определения последовательности (чем меньше, тем раньше):

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class ParallelTest {
    static SimpleCounter counter;

    @BeforeAll
    static void setupCounter() {
        counter = new SimpleCounter();
    }
    @Test
    @Order(1)
    public void testCounter() {
        System.out.println("Counter = "+counter.hashCode());
        System.out.println(Thread.currentThread().getName());
        counter.increment();
        Assertions.assertEquals(1, counter.getCounter());
    }

    @Test
    @Order(2)
    public void testCounter2() {
        System.out.println("Counter = "+counter.hashCode());
        System.out.println(Thread.currentThread().getName());
        counter.increment();
        Assertions.assertEquals(2, counter.getCounter());
    }
}

При запуске мы увидим, что тесты выполняются корректно (последовательно testCounter и testCounter2) и состояния гонки не возникает, поскольку они работают в одном и том же потоке выполнения.

Counter = 1827171553
Test worker
Counter = 1827171553
Test worker
4 actionable tasks: 2 executed, 2 up-to-date

Теперь разрешим параллельное выполнение тестов, для этого создадим файл junit-platform.properties в src/test/resources и разрешим параллельное выполнение и по умолчанию будем использовать конкурентный режим (допускающий запуск тестов на разных потоках выполнения).

junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.config.fixed.parallelism = 4

Последняя строка в файле конфигурации определяет количество потоков (по умолчанию значение рассчитывается из количества ядер процессора и опции junit.jupiter.execution.parallel.config.dynamic.factor). При запуске теста мы обнаружим, что тесты по прежнему выполняются на одном потоке и это связано с тем, что мы явно определили последовательность выполнения тестов. Но если мы ее закомментируем, тестовый фреймворк будет запускать методы в произвольном порядке и ожидаемых значений 1 и 2 можно не получить.

Закомментируем аннотацию TestMethodOrder и немножко усложнил логику теста - теперь мы будем очищать значение при запуске функции, делать паузу в 50 миллисекунд и увеличивать на единицу (и затем будем проверять на равенство 1). Суть эксперимента состоит в том, что счетчик является полем класса, который разделяется между несколькими тестами (которые запускаются параллельно в нескольких потоках выполнения) и есть вероятность при увеличении значения получить уже увеличенное другим потоком значение. Методы теста testCounter и testCounter2 будут идентичными.

public class SimpleCounter {
    private int i = 0;
    public void setOne() {
        i = 0;
        try {
            Thread.sleep(50);
        } finally {
            i++;

        }
    }

    public int getCounter() {
        return i;
    }
}

Код метода теста testCounter (и testCounter2):

    @Test
    public void testCounter() {
        System.out.println("Counter = "+counter.hashCode());
        System.out.println(Thread.currentThread().getName());
        counter.setOne();
        Assertions.assertEquals(1, counter.getCounter());
    }

Запустим несколько раз тест и обнаружим, что в ряде случаев выполнение завершается успешно, но иногда возникает ошибка org.opentest4j.AssertionFailedError: expected: <1> but was: <2>, вызванное тем, что два потока одновременно увеличивают значение счетчика, считая что оно было равно нулю (но в действительности уже было изменено другим потоком), т.е. мы имитировали состояние гонки. Здесь может быть две или более последовательности выполнения операторов:

  1. T1 i=0; T1 i++; T1 get; T2 i=0 ; T2 i++; T2 get - в это случае ошибки не возникает (T1, T2 - потоки выполнения)

  2. T1 i=0; T2 i=0; T1 i++; T1 get; T2 i++; T2 get - здесь T2 получит значение 2

Одним из самых простых способов решить эту проблему является использование блоков синхронизации. Можно пометить весь метод setOne: public synchronized void setOne() {}, либо обернуть в synchronized(obj) { } код, который не должен быть вызван одновременно из двух потоков (критическая секция). Obj - это объект синхронизации (может быть пустым объектом, по нему отслеживается факт входа в критическую секцию, также можно использовать this для гарантированно однопоточного выполнения (остальные вызовы приостанавливаются до выхода первого потока из критической секции).

В действительности переключение может произойти и перед get и для исключения такой ситуации можно использовать атомарные типы (в нашем случае AtomicInteger), которые могут выполнять операции по модификации и одновременно возвращать результат (или предыдущее значение) с исключением переключения потоков между изменением и возвратом. В этом случае проверять мы будем результат метода setOne (мы могли и раньше сделать в немreturn i; но это не решило бы проблему переключения между i++ и return. Будем использовать такую реализацию счетчика:

public class SimpleCounter {
    private AtomicInteger atomicInteger = new AtomicInteger(0);
    public int setOne() {
        atomicInteger.set(0);
        try {
            Thread.sleep(50);
        } finally {
            return atomicInteger.incrementAndGet();
        }
    }
}

И методы тестирования будут выглядеть таким образом:

    @Test
    public void testCounter() {
        System.out.println("Counter = "+counter.hashCode());
        System.out.println(Thread.currentThread().getName());
        counter.setOne();
        Assertions.assertEquals(1, counter.setOne());
    }

При запуске теста мы увидим ошибку (случайно или в testCounter или в testCounter2). С чем связано такое поведение?

AtomicInteger позволяет избежать переключение контекста между увеличением значения и возвратом результата, но все же контекст может переключиться между установкой начального значения 0 и его увеличением (во время выполнения Thread.sleep). В результате в atomicInteger будет дважды записан 0 и потом дважды увеличен и один из тестов получит значение 2. Можно также использовать примитив синхронизации, но рассмотрим альтернативный вариант с отложенной инициализацией при чтении значения, для этого будем использовать лямбда-выражение инициализации и класс Supplier из библиотеки функционального программирования java.util.function:

    static <T> Supplier<T> reinitialize(Supplier<T> supplier) {
        return () -> supplier.get();
    }
    private Supplier<AtomicInteger> atomicInteger;
    public int setOne() {
        atomicInteger = reinitialize(() -> new AtomicInteger(0));
        try {
            Thread.sleep(50);
        } finally {
            return atomicInteger.get().incrementAndGet();
        }
    }

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

    @ResourceLock(value = "resources")
    @Test
    public void testCounter() {
        System.out.println("Counter = "+counter.hashCode());
        System.out.println(Thread.currentThread().getName());
        Assertions.assertEquals(1, counter.setOne());
    }

При запуске видно, что тесты запускаются в отдельных потоках, но срабатывают последовательно и это помогает избежать состояния гонки:

Counter = 1719356840
ForkJoinPool-1-worker-3
Counter = 1719356840
ForkJoinPool-1-worker-5
BUILD SUCCESSFUL in 1s
4 actionable tasks: 2 executed, 2 up-to-date

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

import java.util.ArrayList;
import java.util.Optional;

class UserBalance {
    double amount;
}

public class Finance {

    ArrayList<UserBalance> balances = new ArrayList<>();

    void register(UserBalance balance) {
        balances.add(balance);
    }

    void transfer(UserBalance from, UserBalance to, double amount) {
        try {
            Thread.sleep(30);
            double fromAmount = from.amount;
            Thread.sleep(30);
            from.amount = fromAmount - amount;
            Thread.sleep(30);
            double toAmount = to.amount;
            Thread.sleep(30);
            to.amount = toAmount + amount;
            Thread.sleep(30);
        } catch (InterruptedException e) {
        }
    }

    Optional<Double> balance() {
        return balances.stream().map(b -> b.amount).reduce(Double::sum);
    }
}

Для тестирования запустим 4 одновременных теста:

  • перевод от пользователя user1 пользователю user2 суммы в 500 единиц

  • перевод от пользователя user2 пользователю user1 суммы в 200 единиц

  • перевод от пользователя user1 пользователю user3 суммы в 100 единиц

  • перевод от пользователя user3 пользователю user1 суммы в 800 единиц

Код одного из тестов:

public class ParallelTest {

    static Finance finance;

    static UserBalance user1;
    static UserBalance user2;
    static UserBalance user3;

    @BeforeAll
    static void setupFinance() {
        finance = new Finance();
        user1 = new UserBalance();
        user2 = new UserBalance();
        user3 = new UserBalance();
        finance.register(user1);
        finance.register(user2);
        finance.register(user3);
        user1.amount = 1000.0;
        user2.amount = 2000.0;
        user3.amount = 3000.0;
    }

    @Test
    void transfer1() {
        System.out.println(Thread.currentThread().getName());
        finance.transfer(user1, user2, 500);
        Assertions.assertEquals(3000, finance.balance().get());
    }
  //здесь еще 3 теста
}

Большинство запусков тестов завершатся успешно, но возможно и возникновение состояния гонки, при котором суммарный баланс будет отличаться от ожидаемых 3000 единиц. Здесь также можно использовать блоки синхронизации, но тут необходимо отслеживать два объекта (счет отправителя и счет получателя) двумя вложенным блоками и при встречных переводах может возникнуть ситуация, когда в одном потоке создана синхронизация по счету A и ожидается счет B, который заблокирован в другом потоке (где ожидается счет A после синхронизации по объекту счета B) и при запуске теста он никогда не будет завершен. Такая ситуация называется DeadLock (взаимная блокировка). Очевидного и простого решения здесь нет (кроме маркировки transfer как synchronized), самое очевидное решение - использовать два метода перевода денег с разной последовательностью синхронизации для ситуаций перевода встречного перевода финансов (например, A-B для user1 к user3, B-A от user3 к user1).

Мы рассмотрели основные проблемы, которые могут возникнуть при многопоточном запуске тестов, и способы их решения. Здесь были рассмотрены синтетические ситуации, которые могут быть легко исправлены через изолированное тестирование объектов без состояния (без хранения в статическом поле) и избегать переиспользуемых объектов (как, например, User1-User4 в тестировании финансовых транзакций) и заполнять их непосредственно в коде теста. Но в реальных условиях deadlock и race condition может встретится в сложных приложениях, в которых так или иначе содержится явно или косвенно хранится состояние.

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

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 7: ↑6 и ↓1+5
Комментарии2

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS