Як використовувати JUnit для тестування асинхронних процесів


204

Як ви перевіряєте методи, які запускають асинхронні процеси з JUnit?

Я не знаю, як змусити тест чекати, коли процес закінчиться (це не зовсім одиничний тест, він більше схожий на тест інтеграції, оскільки він включає кілька класів, а не один).


Ви можете спробувати JAT (Java Asynchronous Test): bitbucket.org/csolar/jat
cs0lar

2
JAT має 1 спостерігача і не оновлювався протягом 1,5 років. Очікування було оновлено лише 1 місяць тому і знаходиться на версії 1.6 на момент написання цього повідомлення. Я не пов'язаний ні з одним із проектів, але якби я збирався інвестувати в доповнення до свого проекту, я би наділив більше уваги на це в цей час.
Les Hazlewood

JAT досі не має оновлень: "Остання зміна 2013-01-19". Просто заощадіть час, щоб перейти за посиланням.
Деймон

@LesHazlewood, один спостерігач поганий для JAT, але про оновлення протягом багатьох років ... Лише один приклад. Як часто ви оновлюєте низькорівневий стек TCP своєї ОС, якщо він просто працює? Альтернатива JAT відповідає нижче stackoverflow.com/questions/631598 / ... .
користувач1742529

Відповіді:


46

IMHO - це погана практика створювати одиничні тести або чекати на потоках тощо. Ви б хотіли, щоб ці тести виконувались за секунди. Ось чому я хотів би запропонувати двоступеневий підхід до тестування асинхронних процесів.

  1. Перевірте, чи правильно подано ваш процес асинхронізації. Ви можете знущатися над об’єктом, який приймає ваші запити на асинхронізацію, і переконайтеся, що подане завдання має правильні властивості тощо.
  2. Перевірте, що ваші зворотні дзвінки з асинхронністю роблять правильні дії. Тут ви можете знущатись над оригінально відправленою роботою та припускати, що вона ініціалізована належним чином, і перевірити правильність зворотного зв'язку.

148
Звичайно. Але іноді потрібно перевірити код, який спеціально повинен керувати потоками.
брак

77
Для тих із нас, хто використовує Junit або TestNG для тестування інтеграції (а не лише тестування одиниць) або тестування прийняття користувачем (наприклад, w / огірок), очікування завершення асинхронізації та перевірки результату є абсолютно необхідним.
Les Hazlewood

38
Асинхронні процеси - це один із найскладніших кодів, щоб виправитись, і ви кажете, що не слід використовувати для них тестування одиниць, а тестувати лише одним потоком? Це дуже погана ідея.
Чарльз

18
Макет-тести часто не вдається довести, що функціональність працює від кінця до кінця. Функціональність Async потрібно перевірити асинхронно, щоб переконатися, що вона працює. Назвіть це тест інтеграції, якщо ви хочете, але це тест, який все ще потрібен.
Скотт Боргінг

4
Це не має бути прийнятою відповіддю. Тестування виходить за межі одиничного тестування. ОП розглядає це як більше інтеграційний тест, ніж тест на одиницю.
Єремія Адамс

190

Альтернативою є використання класу CountDownLatch .

public class DatabaseTest {

    /**
     * Data limit
     */
    private static final int DATA_LIMIT = 5;

    /**
     * Countdown latch
     */
    private CountDownLatch lock = new CountDownLatch(1);

    /**
     * Received data
     */
    private List<Data> receiveddata;

    @Test
    public void testDataRetrieval() throws Exception {
        Database db = new MockDatabaseImpl();
        db.getData(DATA_LIMIT, new DataCallback() {
            @Override
            public void onSuccess(List<Data> data) {
                receiveddata = data;
                lock.countDown();
            }
        });

        lock.await(2000, TimeUnit.MILLISECONDS);

        assertNotNull(receiveddata);
        assertEquals(DATA_LIMIT, receiveddata.size());
    }
}

ПРИМІТКА, ви не можете просто використовувати синхронізований звичайний об'єкт як замок, оскільки швидкі зворотні виклики можуть звільнити замок до виклику методу очікування блокування. Дивіться цю публікацію в блозі Джо Уолнес.

EDIT Видалено синхронізовані блоки навколо CountDownLatch завдяки коментарям від @jtahlborn та @Ring


8
будь ласка, не слідкуйте за цим прикладом, це неправильно. вам не слід синхронізувати на CountDownLatch, оскільки він обробляє безпеку потоку всередині.
jtahlborn

1
Це була корисна порада до синхронізованої частини, яка з'їла, ймовірно, близько 3-4 годин часу налагодження. stackoverflow.com/questions/11007551/…
Кільце

2
Вибачте за помилку. Я відповідь відповідним чином відредагував.
Мартін

7
Якщо ви перевіряєте, що onSuccess викликався, вам слід стверджувати, що lock.await повертає значення true.
Гілберт

1
@Martin це було б правильно, але це означало б, що у вас є інша проблема, яку потрібно вирішити.
Джордж Арісті

75

Ви можете спробувати скористатися бібліотекою « Чекання» . Це дозволяє легко протестувати системи, про які ви говорите.


21
Доброзичлива відмова: Йохан є головним учасником проекту.
dbm

1
Страждає від основної проблеми: чекати (одиничні тести потрібно виконувати швидко ). В ідеалі ви дійсно не хочете чекати мілісекунди довше, ніж потрібно, тому я думаю, що використання CountDownLatch(див. Відповідь від @Martin) краще в цьому плані.
Джордж Арісті

Дійсно приголомшливий.
Р. Карлус

Це ідеальна бібліотека, яка відповідає моїм вимогам тестування інтеграції процесу асинхронізації. Дійсно приголомшливий. Здається, бібліотека є доглянутою і має функції, що розширюються від базових до просунутих, які, на мою думку, є достатніми для задоволення більшості сценаріїв. Дякуємо за чудову довідку!
Танвір

Дійсно дивовижна пропозиція. Спасибі
RoyalTiger

68

Якщо ви використовуєте CompletableFuture (представлений в Java 8) або SettableFuture (від Google Guava ), ви можете зробити тестовий фініш, як тільки це буде зроблено, а не чекати заздалегідь встановленої кількості часу. Ваш тест виглядав би приблизно так:

CompletableFuture<String> future = new CompletableFuture<>();
executorService.submit(new Runnable() {         
    @Override
    public void run() {
        future.complete("Hello World!");                
    }
});
assertEquals("Hello World!", future.get());

4
... і якщо ви застрягли в Java-менше, ніж вісім, спробуйте guavas SettableFuture, який робить майже те саме
Markus T


18

Один із методів, який я знайшов досить корисним для тестування асинхронних методів, - це введення Executorекземпляра в конструктор об'єкта для тестування. У виробництві екземпляр виконавця налаштований на запуск асинхронно, в той час як в тесті його можна знущатися виконувати синхронно.

Отже, припустимо, я намагаюся протестувати асинхронний метод Foo#doAsync(Callback c),

class Foo {
  private final Executor executor;
  public Foo(Executor executor) {
    this.executor = executor;
  }

  public void doAsync(Callback c) {
    executor.execute(new Runnable() {
      @Override public void run() {
        // Do stuff here
        c.onComplete(data);
      }
    });
  }
}

У виробництві я буду конструювати Fooз Executors.newSingleThreadExecutor()екземпляром Виконавця, тоді як у тесті я, мабуть, будував би його з синхронним виконавцем, який виконує наступні дії

class SynchronousExecutor implements Executor {
  @Override public void execute(Runnable r) {
    r.run();
  }
}

Тепер мій тест JUnit на асинхронний метод досить чистий -

@Test public void testDoAsync() {
  Executor executor = new SynchronousExecutor();
  Foo objectToTest = new Foo(executor);

  Callback callback = mock(Callback.class);
  objectToTest.doAsync(callback);

  // Verify that Callback#onComplete was called using Mockito.
  verify(callback).onComplete(any(Data.class));

  // Assert that we got back the data that we expected.
  assertEquals(expectedData, callback.getData());
}

Не працює, якщо я хочу тестувати інтеграцію на те, що передбачає асинхронний виклик бібліотеки, як Spring'sWebClient
Стефан Хаберль,

8

З тестуванням потокового / асинхронного коду немає нічого по суті, особливо якщо введення теми - це тест коду, який ви тестуєте. Загальним підходом до тестування цього матеріалу є:

  • Блокуйте основну тестову нитку
  • Захоплення невдалих тверджень з інших потоків
  • Розблокуйте основну тестову тему
  • Перезавантажте будь-які невдачі

Але це дуже багато котлован для одного тесту. Кращий / простіший підхід - просто використовувати ConcurrentUnit :

  final Waiter waiter = new Waiter();

  new Thread(() -> {
    doSomeWork();
    waiter.assertTrue(true);
    waiter.resume();
  }).start();

  // Wait for resume() to be called
  waiter.await(1000);

Перевага цього CountdownLatchпідходу полягає в тому, що він є менш дослідним, оскільки збої у твердженні, які трапляються в будь-якій нитці, належним чином повідомляються в основній нитці, тобто тест не завершується, коли повинен. Рецензія , який порівнює CountdownLatchпідхід до ConcurrentUnit знаходиться тут .

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


подібне рішення, яке я використовував у минулому, - це github.com/MichaelTamm/junit-toolbox , яке також представлене як сторонне
dschulten

4

Як щодо виклику SomeObject.waitта notifyAllяк описано тут АБО за допомогою методу Robotiums Solo.waitForCondition(...) АБО використовуйте клас, про який я писав (див. Коментарі та тестовий клас щодо використання)


1
Проблема з підходом очікування / повідомлення / переривання полягає в тому, що код, який ви тестуєте, може потенційно заважати потокам очікування (я бачив, що це відбувається). Ось чому ConcurrentUnit використовує приватну ланцюг, на яку потоки можуть чекати, яка не може бути ненавмисно перешкоджена перериваннями на основний тестовий потік.
Джонатан

3

Я знаходжу бібліотеку socket.io для перевірки асинхронної логіки. Це виглядає просто і коротко, використовуючи LinkedBlockingQueue . Ось приклад :

    @Test(timeout = TIMEOUT)
public void message() throws URISyntaxException, InterruptedException {
    final BlockingQueue<Object> values = new LinkedBlockingQueue<Object>();

    socket = client();
    socket.on(Socket.EVENT_CONNECT, new Emitter.Listener() {
        @Override
        public void call(Object... objects) {
            socket.send("foo", "bar");
        }
    }).on(Socket.EVENT_MESSAGE, new Emitter.Listener() {
        @Override
        public void call(Object... args) {
            values.offer(args);
        }
    });
    socket.connect();

    assertThat((Object[])values.take(), is(new Object[] {"hello client"}));
    assertThat((Object[])values.take(), is(new Object[] {"foo", "bar"}));
    socket.disconnect();
}

Використовуючи LinkedBlockingQueue, приймайте API для блокування, поки ви не отримаєте результат так само, як синхронний шлях. І встановіть тайм-аут, щоб не припускати занадто багато часу для очікування результату.


1
Дивовижний підхід!
амінографія


2

Це я зараз використовую, якщо результат тестування створюється асинхронно.

public class TestUtil {

    public static <R> R await(Consumer<CompletableFuture<R>> completer) {
        return await(20, TimeUnit.SECONDS, completer);
    }

    public static <R> R await(int time, TimeUnit unit, Consumer<CompletableFuture<R>> completer) {
        CompletableFuture<R> f = new CompletableFuture<>();
        completer.accept(f);
        try {
            return f.get(time, unit);
        } catch (InterruptedException | TimeoutException e) {
            throw new RuntimeException("Future timed out", e);
        } catch (ExecutionException e) {
            throw new RuntimeException("Future failed", e.getCause());
        }
    }
}

Використовуючи статичний імпорт, тест читає якось приємно. (зауважте, у цьому прикладі я починаю нитку для ілюстрації ідеї)

    @Test
    public void testAsync() {
        String result = await(f -> {
            new Thread(() -> f.complete("My Result")).start();
        });
        assertEquals("My Result", result);
    }

Якщо f.completeйого не викликати, тест закінчиться після закінчення тайм-ауту. Ви також f.completeExceptionallyможете скоріше відмовитись.


2

Тут є багато відповідей, але простий - просто створити завершений файл CompletableFuture і використовувати його:

CompletableFuture.completedFuture("donzo")

Тож у моєму тесті:

this.exactly(2).of(mockEventHubClientWrapper).sendASync(with(any(LinkedList.class)));
this.will(returnValue(new CompletableFuture<>().completedFuture("donzo")));

Я просто переконуюсь, що все це називається так чи інакше. Ця методика працює, якщо ви використовуєте цей код:

CompletableFuture.allOf(calls.toArray(new CompletableFuture[0])).join();

Він буде прошивати прямо через нього, як всі завершені майбутні завершено!


2

Уникайте тестування з паралельними нитками, коли це можливо (це більшість часу). Це лише зробить ваші тести розмитими (іноді проходять, іноді виходять з ладу).

Тільки коли вам потрібно зателефонувати в якусь іншу бібліотеку / систему, можливо, доведеться чекати на інших потоках , у такому випадку завжди використовуйте бібліотеку « Чекання» замість Thread.sleep().

Ніколи не телефонуйте get()або join()в своїх тестах, інакше ваші тести можуть працювати назавжди на вашому сервері CI у випадку, якщо майбутнє ніколи не завершиться. Завжди запевняйте isDone()в своїх тестах, перш ніж дзвонити get(). Для CompletionStage, тобто .toCompletableFuture().isDone().

Коли ви перевіряєте такий метод, який не блокує:

public static CompletionStage<String> createGreeting(CompletableFuture<String> future) {
    return future.thenApply(result -> "Hello " + result);
}

тоді ви не повинні просто тестувати результат, передаючи завершене майбутнє в тесті, ви також повинні переконатися, що ваш метод doSomething()не блокується за допомогою виклику join()або get(). Це особливо важливо, якщо ви використовуєте неблокуючий фреймворк.

Для цього протестуйте з незавершеним майбутнім, яке ви встановили для завершення вручну:

@Test
public void testDoSomething() throws Exception {
    CompletableFuture<String> innerFuture = new CompletableFuture<>();
    CompletableFuture<String> futureResult = createGreeting(innerFuture).toCompletableFuture();
    assertFalse(futureResult.isDone());

    // this triggers the future to complete
    innerFuture.complete("world");
    assertTrue(futureResult.isDone());

    // futher asserts about fooResult here
    assertEquals(futureResult.get(), "Hello world");
}

Таким чином, якщо ви додасте future.join()до doSomething (), тест не вдасться.

Якщо у Вашій службі використовується ExecutorService, наприклад в thenApplyAsync(..., executorService), тоді у ваші тести вставляють однопоточну програму ExecutorService, таку як guava:

ExecutorService executorService = Executors.newSingleThreadExecutor();

Якщо ваш код використовує forkJoinPool, наприклад thenApplyAsync(...), перезапишіть код, щоб використовувати ExecutorService (є багато вагомих причин) або використовувати Awaitility.

Щоб скоротити приклад, я зробив BarService аргументом методу, реалізованим як лямбда Java8 в тесті, як правило, це буде введена посилання, яку ви знущаєтеся.


Привіт, @tkruse, можливо, у вас є публічний git repo з тестом за допомогою цієї методики?
Кріштіано

@Christiano: це було б проти філософії ТА Натомість я змінив методи компіляції без будь-якого додаткового коду (увесь імпорт - java8 + або junit), коли ви вставляєте їх у порожній тестовий клас. Не соромтеся звертати увагу.
tkruse

Я зрозумів зараз. Дякую. Моя проблема зараз полягає в тому, щоб перевірити, коли методи повертають CompletableFuture, але приймають інші об'єкти як параметри, відмінні від CompletableFuture.
Кріштіано

У вашому випадку, хто створює CompletableFuture, який метод повертає? Якщо це ще одна послуга, з якої можна знущатися, і моя техніка все ще застосовується. Якщо метод сам по собі створює CompletableFuture, ситуація дуже сильно змінюється, тому ви можете задати нове запитання щодо цього. Тоді залежить від того, який потік завершить майбутнє, яке поверне ваш метод.
tkruse

1

Я вважаю за краще використовувати зачекати та сповістити. Це просто і зрозуміло.

@Test
public void test() throws Throwable {
    final boolean[] asyncExecuted = {false};
    final Throwable[] asyncThrowable= {null};

    // do anything async
    new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                // Put your test here.
                fail(); 
            }
            // lets inform the test thread that there is an error.
            catch (Throwable throwable){
                asyncThrowable[0] = throwable;
            }
            // ensure to release asyncExecuted in case of error.
            finally {
                synchronized (asyncExecuted){
                    asyncExecuted[0] = true;
                    asyncExecuted.notify();
                }
            }
        }
    }).start();

    // Waiting for the test is complete
    synchronized (asyncExecuted){
        while(!asyncExecuted[0]){
            asyncExecuted.wait();
        }
    }

    // get any async error, including exceptions and assertationErrors
    if(asyncThrowable[0] != null){
        throw asyncThrowable[0];
    }
}

В основному нам потрібно створити остаточну посилання на масив, яке буде використовуватися всередині анонімного внутрішнього класу. Я б скоріше створив булевий [], тому що я можу поставити значення для управління, якщо нам потрібно почекати (). Коли все буде зроблено, ми просто випускаємо asyncExecuted.


1
Якщо ваше твердження не вдасться, головний тестовий потік про це не дізнається.
Джонатан

Дякуємо за рішення, допомагає мені налагоджувати код підключенням до веб-сокетів.
Назар Сахаренко

@Jonathan, я оновив код, щоб знайти будь-яке твердження та виняток і повідомив його в основний тестовий потік.
Пауло

1

Для всіх користувачів Spring, саме зараз я зазвичай роблю свої інтеграційні тести, в яких бере участь асинхронна поведінка:

Запустіть подію програми у виробничому коді, коли завдання асинхронізації (наприклад, дзвінок вводу-виводу) завершено. Більшу частину часу ця подія так чи інакше потрібна для обробки відповіді на операцію асинхронізації у виробництві.

Коли ця подія відбулася, ви можете використовувати таку стратегію у своєму тестовому випадку:

  1. Виконати перевірену систему
  2. Прослухайте подію та переконайтеся, що подія почалася
  3. Зробіть свої твердження

Щоб вирішити цю проблему, спочатку вам знадобиться якась подія домену для запуску. Я тут використовую UUID, щоб визначити завдання, яке було виконано, але ви, звичайно, можете використовувати щось інше, поки це унікально.

(Зауважте, що наведені нижче фрагменти коду також використовують анотації Lombok, щоб позбутися коду пластини котла)

@RequiredArgsConstructor
class TaskCompletedEvent() {
  private final UUID taskId;
  // add more fields containing the result of the task if required
}

Сам виробничий код зазвичай виглядає так:

@Component
@RequiredArgsConstructor
class Production {

  private final ApplicationEventPublisher eventPublisher;

  void doSomeTask(UUID taskId) {
    // do something like calling a REST endpoint asynchronously
    eventPublisher.publishEvent(new TaskCompletedEvent(taskId));
  }

}

Тоді я можу використовувати Spring, @EventListenerщоб зловити опубліковану подію в тестовому коді. Слухач події дещо більше задіяний, тому що йому належить безпечно обробляти два випадки:

  1. Виробничий код швидший, ніж тестовий випадок, і подія вже запустилася до перевірки тестового випадку на подію, або
  2. Тестовий випадок швидше, ніж виробничий код, і тестовий випадок повинен дочекатися події.

A CountDownLatchвикористовується для другого випадку, як згадується в інших відповідях тут. Також зауважте, що @Orderанотація про метод обробника подій гарантує, що цей метод обробника подій викликається після всіх інших слухачів подій, які використовуються у виробництві.

@Component
class TaskCompletionEventListener {

  private Map<UUID, CountDownLatch> waitLatches = new ConcurrentHashMap<>();
  private List<UUID> eventsReceived = new ArrayList<>();

  void waitForCompletion(UUID taskId) {
    synchronized (this) {
      if (eventAlreadyReceived(taskId)) {
        return;
      }
      checkNobodyIsWaiting(taskId);
      createLatch(taskId);
    }
    waitForEvent(taskId);
  }

  private void checkNobodyIsWaiting(UUID taskId) {
    if (waitLatches.containsKey(taskId)) {
      throw new IllegalArgumentException("Only one waiting test per task ID supported, but another test is already waiting for " + taskId + " to complete.");
    }
  }

  private boolean eventAlreadyReceived(UUID taskId) {
    return eventsReceived.remove(taskId);
  }

  private void createLatch(UUID taskId) {
    waitLatches.put(taskId, new CountDownLatch(1));
  }

  @SneakyThrows
  private void waitForEvent(UUID taskId) {
    var latch = waitLatches.get(taskId);
    latch.await();
  }

  @EventListener
  @Order
  void eventReceived(TaskCompletedEvent event) {
    var taskId = event.getTaskId();
    synchronized (this) {
      if (isSomebodyWaiting(taskId)) {
        notifyWaitingTest(taskId);
      } else {
        eventsReceived.add(taskId);
      }
    }
  }

  private boolean isSomebodyWaiting(UUID taskId) {
    return waitLatches.containsKey(taskId);
  }

  private void notifyWaitingTest(UUID taskId) {
    var latch = waitLatches.remove(taskId);
    latch.countDown();
  }

}

Останнім кроком є ​​виконання тестової системи в тестовому випадку. Тут я використовую тест SpringBoot з JUnit 5, але це має працювати однаково для всіх тестів, що використовують Spring-контекст.

@SpringBootTest
class ProductionIntegrationTest {

  @Autowired
  private Production sut;

  @Autowired
  private TaskCompletionEventListener listener;

  @Test
  void thatTaskCompletesSuccessfully() {
    var taskId = UUID.randomUUID();
    sut.doSomeTask(taskId);
    listener.waitForCompletion(taskId);
    // do some assertions like looking into the DB if value was stored successfully
  }

}

Зауважте, що на відміну від інших відповідей тут, це рішення також буде працювати, якщо ви будете виконувати свої тести паралельно і кілька потоків одночасно здійснювати код асинхронізації.


0

Якщо ви хочете перевірити логіку, не перевіряйте її асинхронно.

Наприклад, для перевірки цього коду, який працює на результатах асинхронного методу.

public class Example {
    private Dependency dependency;

    public Example(Dependency dependency) {
        this.dependency = dependency;            
    }

    public CompletableFuture<String> someAsyncMethod(){
        return dependency.asyncMethod()
                .handle((r,ex) -> {
                    if(ex != null) {
                        return "got exception";
                    } else {
                        return r.toString();
                    }
                });
    }
}

public class Dependency {
    public CompletableFuture<Integer> asyncMethod() {
        // do some async stuff       
    }
}

У тесті висміюють залежність із синхронною реалізацією. Тест блоку повністю синхронний і працює за 150 мс.

public class DependencyTest {
    private Example sut;
    private Dependency dependency;

    public void setup() {
        dependency = Mockito.mock(Dependency.class);;
        sut = new Example(dependency);
    }

    @Test public void success() throws InterruptedException, ExecutionException {
        when(dependency.asyncMethod()).thenReturn(CompletableFuture.completedFuture(5));

        // When
        CompletableFuture<String> result = sut.someAsyncMethod();

        // Then
        assertThat(result.isCompletedExceptionally(), is(equalTo(false)));
        String value = result.get();
        assertThat(value, is(equalTo("5")));
    }

    @Test public void failed() throws InterruptedException, ExecutionException {
        // Given
        CompletableFuture<Integer> c = new CompletableFuture<Integer>();
        c.completeExceptionally(new RuntimeException("failed"));
        when(dependency.asyncMethod()).thenReturn(c);

        // When
        CompletableFuture<String> result = sut.someAsyncMethod();

        // Then
        assertThat(result.isCompletedExceptionally(), is(equalTo(false)));
        String value = result.get();
        assertThat(value, is(equalTo("got exception")));
    }
}

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

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.