Статьи

Синхронизация многопоточных интеграционных тестов

Тестирование потоков сложно, очень сложно, и это делает написание хороших интеграционных тестов для тестируемых многопоточных систем … сложным. Это связано с тем, что в JUnit нет встроенной синхронизации между тестовым кодом, тестируемым объектом и любыми потоками. Это означает, что проблемы обычно возникают, когда вам нужно написать тест для метода, который создает и запускает поток. Одним из наиболее распространенных сценариев в этом домене является вызов тестируемого метода, который запускает новый поток, выполняющийся перед возвратом. В какой-то момент в будущем, когда работа с потоком будет завершена, вам нужно будет утверждать, что все прошло хорошо. Примеры этого сценария могут включать в себя асинхронное чтение данных из сокета или выполнение длинного и сложного набора операций с базой данных.

Например, класс ThreadWrapper ниже содержит единственный открытый метод: doWork() . Вызов doWork() процесс, и в какой-то момент в будущем, по усмотрению JVM, поток запускает добавление данных в базу данных.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class ThreadWrapper {
 
  /**
   * Start the thread running so that it does some work.
   */
  public void doWork() {
 
    Thread thread = new Thread() {
 
      /**
       * Run method adding data to a fictitious database
       */
      @Override
      public void run() {
 
        System.out.println("Start of the thread");
        addDataToDB();
        System.out.println("End of the thread method");
      }
 
      private void addDataToDB() {
        // Dummy Code...
        try {
          Thread.sleep(4000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
 
    };
 
    thread.start();
    System.out.println("Off and running...");
  }
 
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ThreadWrapperTest {
 
  @Test
  public void testDoWork() throws InterruptedException {
 
    ThreadWrapper instance = new ThreadWrapper();
 
    instance.doWork();
 
    Thread.sleep(10000);
 
    boolean result = getResultFromDatabase();
    assertTrue(result);
  }
 
  /**
   * Dummy database method - just return true
   */
  private boolean getResultFromDatabase() {
    return true;
  }
}

В приведенном выше коде есть простой Thread.sleep(10000) между двумя вызовами методов. Преимущество этой техники в том, что она невероятно проста; Однако это также очень рискованно. Это потому, что он вводит условие гонки между тестом и рабочим потоком, поскольку JVM не дает никаких гарантий относительно того, когда потоки будут работать. Часто он будет работать на компьютере разработчика только для того, чтобы постоянно выходить из строя на компьютере сборки. Даже если он работает на сборочной машине, он искусственно увеличивает продолжительность теста; помните, что быстрые сборки важны. Единственный верный способ получить это право — синхронизировать два разных потока, и один из способов сделать это — CountDownLatch простой CountDownLatch в CountDownLatch экземпляр. В приведенном ниже примере я изменил ThreadWrapper doWork () класса ThreadWrapper, добавив в качестве аргумента CountDownLatch .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class ThreadWrapper {
 
  /**
   * Start the thread running so that it does some work.
   */
  public void doWork(final CountDownLatch latch) {
 
    Thread thread = new Thread() {
 
      /**
       * Run method adding data to a fictitious database
       */
      @Override
      public void run() {
 
        System.out.println("Start of the thread");
        addDataToDB();
        System.out.println("End of the thread method");
        countDown();
      }
 
      private void addDataToDB() {
 
        try {
          Thread.sleep(4000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
 
      private void countDown() {
        if (isNotNull(latch)) {
          latch.countDown();
        }
      }
 
      private boolean isNotNull(Object obj) {
        return latch != null;
      }
 
    };
 
    thread.start();
    System.out.println("Off and running...");
  }
}

API Javadoc описывает защелку обратного отсчета следующим образом: Средство синхронизации, которое позволяет одному или нескольким потокам ожидать завершения набора операций, выполняемых в других потоках. CountDownLatch инициализируется с заданным количеством. Методы await блокируются до тех пор, пока текущий счетчик не достигнет нуля из-за вызовов метода countDown (), после чего все ожидающие потоки освобождаются и любые последующие вызовы await немедленно возвращаются. Это одноразовое явление — счет не может быть сброшен. Если вам нужна версия, которая сбрасывает счет, рассмотрите возможность использования CyclicBarrier.

CountDownLatch — это универсальный инструмент синхронизации, который можно использовать для различных целей. CountDownLatch, инициализированный счетчиком единицы, служит в качестве простой защелки включения / выключения, или шлюза: все вызывающие потоки ожидают ожидания в вентиле, пока он не будет открыт потоком, вызывающим countDown (). CountDownLatchinitialized для N можно использовать, чтобы один поток ожидал, пока N потоков не выполнит какое-либо действие или какое-либо действие будет выполнено N раз. Полезное свойство CountDownLatch состоит в том, что он не требует, чтобы потоки, вызывающие countDown, ожидали, пока счетчик достигнет нуля, прежде чем продолжить, он просто предотвращает прохождение любого потока через ожидание, пока все потоки не смогут пройти.

Идея заключается в том, что тестовый код никогда не будет проверять базу данных на предмет результатов, пока метод run() рабочего потока не latch.countdown() . Это связано с тем, что поток тестового кода блокируется при вызове latch.await() . latch.countdown() уменьшает число защелок, и как только оно становится равным нулю, блокирующий вызов latch.await() возвращает, и тестовый код продолжает выполняться, при latch.await() что любые результаты, которые должны быть в базе данных, находятся в базе данных. Затем тест может получить эти результаты и сделать правильное утверждение. Очевидно, что приведенный выше код просто подделывает соединение с базой данных и операции. Дело в том, что вы, возможно, не захотите или не захотите внедрить CountDownLatch непосредственно в ваш код; в конце концов это не используется в производстве, и это не выглядит особенно чистым или изящным. Один из быстрых способов обойти это — просто сделать пакет метода doWork(CountDownLatch latch) закрытым и предоставить его через публичный doWork() .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class ThreadWrapper {
 
  /**
   * Start the thread running so that it does some work.
   */
  public void doWork() {
    doWork(null);
  }
 
  @VisibleForTesting
  void doWork(final CountDownLatch latch) {
 
    Thread thread = new Thread() {
 
      /**
       * Run method adding data to a fictitious database
       */
      @Override
      public void run() {
 
        System.out.println("Start of the thread");
        addDataToDB();
        System.out.println("End of the thread method");
        countDown();
      }
 
      private void addDataToDB() {
 
        try {
          Thread.sleep(4000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
 
      private void countDown() {
        if (isNotNull(latch)) {
          latch.countDown();
        }
      }
 
      private boolean isNotNull(Object obj) {
        return latch != null;
      }
 
    };
 
    thread.start();
    System.out.println("Off and running...");
  }
}

Приведенный выше код использует аннотацию Google Guava @VisibleForTesting чтобы сообщить нам, что видимость метода doWork(CountDownLatch latch) немного ослаблена в целях тестирования.

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

Имея это в виду, следующая итерация ThreadWrapper определяет необходимость метода, помеченного как @VisibleForTesting вместе с необходимостью вставки CountDownLatch в ваш производственный код. Идея заключается в том, чтобы использовать шаблон стратегии и отделить реализацию Runnable от Thread . Следовательно, у нас есть очень простой ThreadWrapper

01
02
03
04
05
06
07
08
09
10
11
12
public class ThreadWrapper {
 
  /**
   * Start the thread running so that it does some work.
   */
  public void doWork(Runnable job) {
 
    Thread thread = new Thread(job);
    thread.start();
    System.out.println("Off and running...");
  }
}

и отдельная работа:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public class DatabaseJob implements Runnable {
 
  /**
   * Run method adding data to a fictitious database
   */
  @Override
  public void run() {
 
    System.out.println("Start of the thread");
    addDataToDB();
    System.out.println("End of the thread method");
  }
 
  private void addDataToDB() {
 
    try {
      Thread.sleep(4000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

Вы заметите, что класс DatabaseJob не использует CountDownLatch . Как это синхронизировано? Ответ лежит в тестовом коде ниже …

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class ThreadWrapperTest {
 
  @Test
  public void testDoWork() throws InterruptedException {
 
    ThreadWrapper instance = new ThreadWrapper();
 
    CountDownLatch latch = new CountDownLatch(1);
 
    DatabaseJobTester tester = new DatabaseJobTester(latch);
    instance.doWork(tester);
    latch.await();
 
    boolean result = getResultFromDatabase();
    assertTrue(result);
  }
 
  /**
   * Dummy database method - just return true
   */
  private boolean getResultFromDatabase() {
    return true;
  }
 
  private class DatabaseJobTester extends DatabaseJob {
 
    private final CountDownLatch latch;
 
    public DatabaseJobTester(CountDownLatch latch) {
      super();
      this.latch = latch;
    }
 
    @Override
    public void run() {
      super.run();
      latch.countDown();
    }
  }
}

Тестовый код выше содержит внутренний класс DatabaseJobTester , который расширяет DatabaseJob . В этом классе метод run() был переопределен для включения вызова latch.countDown() после обновления нашей поддельной базы данных с помощью вызова super.run() . Это работает, потому что тест передает экземпляр DatabaseJobTester doWork(Runnable job) добавляя необходимые возможности тестирования потоков. Идея подклассификации тестируемых объектов — это то, о чем я упоминал ранее в одном из моих блогов по методам тестирования, и это действительно мощный метод.

Итак, сделаем вывод:

… и что ослабление видимости метода для тестирования может или не может быть хорошей идеей, но об этом позже …

Приведенный выше код доступен на Github в репозитории отладки капитана (git: //github.com/roghughe/captaindebug.git) в рамках проекта unit-testing-threads.

Ссылка: синхронизация многопоточных интеграционных тестов от нашего партнера по JCG Роджера Хьюза в блоге Captain Debug’s Blog .