Статьи

5 советов по модульному тестированию потокового кода

Вот несколько советов о том, как выполнить проверку кода на логическую правильность (в отличие от многопоточной корректности).

Я обнаружил, что по сути есть два стереотипных шаблона с многопоточным кодом:

  1. Ориентация на задачи — множество краткосрочных однородных задач, часто выполняемых в среде Java 5 executor,
  2. Ориентированный на процесс — несколько долгосрочных неоднородных задач, часто основанных на событиях (ожидание уведомления) или опрос (спящий между циклами), часто выражаемых с использованием потока или запуска.

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

Код есть на GitHub .

Совет 1 — Жизненный цикл управления вашими объектами

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

01
02
03
04
05
06
07
08
09
10
11
public class Foo {
 private ExecutorService executorService;
 
 public void start() {
  executorService = Executors.newSingleThreadExecutor();
 }
 
 public void stop() {
  executorService.shutdown();
 }
}

Совет 2 — Установите тайм-аут на ваших тестах

Ошибки в коде (как вы увидите ниже) могут привести к тому, что многопоточный тест не будет завершен, так как (например) вы ожидаете какой-то флаг, который никогда не будет установлен. JUnit позволяет вам установить таймаут на вашем тесте.

1
2
3
4
...
@Test(timeout = 100) // in case we never get a notification
public void testGivenNewFooWhenIncrThenGetOne() throws Exception {
...

Совет 3 — Запуск задач в той же теме, что и ваш тест

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class Foo {
 private final AtomicLong foo = new AtomicLong();
...
 public void incr() {
  executorService.submit(new Runnable() {
   @Override
   public void run() {
    foo.incrementAndGet();
   }
  });
 }
...
 public long get() {
  return foo.get();
 }
}
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public class FooTest {
 
 private Foo sut; // system under test
 
 @Before
 public void setUp() throws Exception {
  sut = new Foo();
  sut.start();
 }
 
 @After
 public void tearDown() throws Exception {
  sut.stop();
 }
 
 @Test
 public void testGivenFooWhenIncrementGetOne() throws Exception {
  sut.incr();
  Thread.sleep(1000); // yuk - a slow test - don't do this
  assertEquals("foo", 1, sut.get());
 }
}

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

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

1
2
3
4
5
6
7
8
9
public class Foo {
...
 public Foo(ExecutorService executorService) {
  this.executorService = executorService;
 }
...
 public void stop() {
     // nop
}

Затем вы можете использовать службу синхронного исполнителя (по концепции похожую на SynchronousQueue) для тестирования:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
public class SynchronousExecutorService extends AbstractExecutorService {
 private boolean shutdown;
 
 @Override
 public void shutdown() {shutdown = true;}
 
 @Override
 public List<Runnable> shutdownNow() {shutdown = true; return Collections.emptyList();}
 
 @Override
 public boolean isShutdown() {shutdown = true; return shutdown;}
 
 @Override
 public boolean isTerminated() {return shutdown;}
 
 @Override
 public boolean awaitTermination(final long timeout, final TimeUnit unit) {return true;}
 
 @Override
 public void execute(final Runnable command) {command.run();}
}

Обновленный тест, который не нужно спать:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class FooTest {
 
 private Foo sut; // system under test
 private ExecutorService executorService;
 
 @Before
 public void setUp() throws Exception {
  executorService = new SynchronousExecutorService();
  sut = new Foo(executorService);
  sut.start();
 }
 
 @After
 public void tearDown() throws Exception {
  sut.stop();
  executorService.shutdown();
 }
 
 @Test
 public void testGivenFooWhenIncrementGetOne() throws Exception {
  sut.incr();
  assertEquals("foo", 1, sut.get());
 }
}

Обратите внимание, что вам необходимо в течение жизненного цикла управлять исполнителем внешне по отношению к Foo.

Совет 4 — Извлечение работы из потоков

Если ваш поток ожидает событие или время, прежде чем он выполнит какую-либо работу, извлеките работу в свой собственный метод и вызовите ее напрямую. Учти это:

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 FooThread extends Thread {
 private final Object ready = new Object();
 private volatile boolean cancelled;
 private final AtomicLong foo = new AtomicLong();
 
 @Override
 public void run() {
  try {
   synchronized (ready) {
    while (!cancelled) {
     ready.wait();
     foo.incrementAndGet();
    }
   }
  } catch (InterruptedException e) {
   e.printStackTrace(); // bad practise generally, but good enough for this example
  }
 }
 
 public void incr() {
  synchronized (ready) {
   ready.notifyAll();
  }
 }
 
 public long get() {
  return foo.get();
 }
 
 public void cancel() throws InterruptedException {
  cancelled = true;
  synchronized (ready) {
   ready.notifyAll();
  }
 }
}

И этот тест:

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
public class FooThreadTest {
 
 private FooThread sut;
 
 @Before
 public void setUp() throws Exception {
  sut = new FooThread();
  sut.start();
  Thread.sleep(1000); // yuk
  assertEquals("thread state", Thread.State.WAITING, sut.getState());
 }
 
 @After
 public void tearDown() throws Exception {
  sut.cancel();
 }
 
 @After
 public void tearDown() throws Exception {
  sut.cancel();
 }
 
 @Test
 public void testGivenNewFooWhenIncrThenGetOne() throws Exception {
  sut.incr();
  Thread.sleep(1000); // yuk
  assertEquals("foo", 1, sut.get());
 }
}

Теперь извлеките работу:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
@Override
 public void run() {
  try {
   synchronized (ready) {
    while (!cancelled) {
     ready.wait();
     undertakeWork();
    }
   }
  } catch (InterruptedException e) {
   e.printStackTrace(); // bad practise generally, but good enough for this example
  }
 }
 
 void undertakeWork() {
  foo.incrementAndGet();
 }

Повторный анализ теста:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class FooThreadTest {
 
    private FooThread sut;
 
    @Before
    public void setUp() throws Exception {
        sut = new FooThread();
    }
 
    @Test
    public void testGivenNewFooWhenIncrThenGetOne() throws Exception {
        sut.incr();
        sut.undertakeWork();
        assertEquals("foo", 1, sut.get());
    }
}

Совет 5 — Уведомлять об изменении состояния через события

Альтернативой предыдущим двум советам является использование системы уведомлений, чтобы ваш тест мог прослушивать резьбовой объект.

Вот пример, ориентированный на задачу:

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
public class ObservableFoo extends Observable {
 private final AtomicLong foo = new AtomicLong();
 private ExecutorService executorService;
 
 public void start() {
  executorService = Executors.newSingleThreadExecutor();
 }
 
 public void stop() {
  executorService.shutdown();
 }
 
 public void incr() {
  executorService.submit(new Runnable() {
   @Override
   public void run() {
    foo.incrementAndGet();
    setChanged();
    notifyObservers(); // lazy use of observable
   }
  });
 }
 
 public long get() {
  return foo.get();
 }
}

И соответствующий ему тест (обратите внимание на использование таймаута):

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
public class ObservableFooTest implements Observer {
 
 private ObservableFoo sut;
 private CountDownLatch updateLatch; // used to react to event
 
 @Before
 public void setUp() throws Exception {
  updateLatch = new CountDownLatch(1);
  sut = new ObservableFoo();
  sut.addObserver(this);
  sut.start();
 }
 
 @Override
 public void update(final Observable o, final Object arg) {
  assert o == sut;
  updateLatch.countDown();
 }
 
 @After
 public void tearDown() throws Exception {
  sut.deleteObserver(this);
  sut.stop();
 }
 
 @Test(timeout = 100) // in case we never get a notification
 public void testGivenNewFooWhenIncrThenGetOne() throws Exception {
  sut.incr();
  updateLatch.await();
  assertEquals("foo", 1, sut.get());
 }
}

В этом есть плюсы и минусы:

Плюсы:

  1. Создает полезный код для прослушивания объекта.
  2. Может воспользоваться существующим кодом уведомления, что делает его хорошим выбором там, где он уже существует.
  3. Является более гибким, может применяться как к задачам, так и к процессно-ориентированному коду.
  4. Это более сплоченно, чем извлечение работы.

Минусы:

  1. Код слушателя может быть сложным и создавать свои собственные проблемы, создавая дополнительный производственный код, который должен быть протестирован.
  2. Раздельное представление от уведомления.
  3. Требует от вас разобраться со сценарием, что уведомление не отправляется (например, из-за ошибки).
  4. Тестовый код может быть довольно многословным и поэтому может содержать ошибки.

Ссылка: 5 советов по модульному тестированию потокового кода от нашего партнера по JCG Алекса Коллинза в блоге Алекса Коллинза .