Я обнаружил, что по сути есть два стереотипных шаблона с многопоточным кодом:
- Ориентация на задачи — множество краткосрочных однородных задач, часто выполняемых в среде Java 5 executor,
- Ориентированный на процесс — несколько долгосрочных неоднородных задач, часто основанных на событиях (ожидание уведомления) или опрос (спящий между циклами), часто выражаемых с использованием потока или запуска.
Тестирование любого типа кода может быть трудным; работа выполняется в другом потоке, и поэтому уведомление о завершении может быть непрозрачным или скрыто за уровнем абстракции.
Код есть на 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()); } } |
В этом есть плюсы и минусы:
Плюсы:
- Создает полезный код для прослушивания объекта.
- Может воспользоваться существующим кодом уведомления, что делает его хорошим выбором там, где он уже существует.
- Является более гибким, может применяться как к задачам, так и к процессно-ориентированному коду.
- Это более сплоченно, чем извлечение работы.
Минусы:
- Код слушателя может быть сложным и создавать свои собственные проблемы, создавая дополнительный производственный код, который должен быть протестирован.
- Раздельное представление от уведомления.
- Требует от вас разобраться со сценарием, что уведомление не отправляется (например, из-за ошибки).
- Тестовый код может быть довольно многословным и поэтому может содержать ошибки.
Ссылка: 5 советов по модульному тестированию потокового кода от нашего партнера по JCG Алекса Коллинза в блоге Алекса Коллинза .