Я подумал о том, чтобы дать этому заголовку кликбейт, что-то вроде «Ожидаемый атрибут исключения в JUnit 4 ужасен, никогда не используйте его!»

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

Допустим, вы работаете над тестовым классом для программы Java, используя JUnit 4.12. Допустим, вам никогда не приходилось писать тест для модуля, который должен генерировать конкретное исключение при данных обстоятельствах.

Если вы используете NetBeans для запуска шаблона тестового класса, вы, вероятно, очень хорошо знакомы с JUnit Assert.fail(). Поскольку это не проходит тест независимо ни от чего, вам приходит в голову, что вы можете поместить его в заданную ветвь выполнения программы, чтобы он был более полезным.

Мой пример для такого рода вещей — деление на ноль в классе Fraction. Например, попытка разделить 3/4 на 7/2 должна дать в результате 3/14. Но любая дробь, деленная на 0, должна вызывать какое-то исключение во время выполнения.

Итак, вы можете написать что-то вроде этого:

    @Test
    public void testDivisionByZero() {
        Fraction dividend = new Fraction(3, 4);
        Fraction zero = new Fraction(0, 1);
        try {
            Fraction result = dividend.divides(zero);
            fail(dividend.toString() + " divided by 0 gave result "
                                               + result.toString());
        } catch (ArithmeticException ae) {
            System.out.println("\"" + ae.getMessage() + "\"");
        } catch (Exception e) {
            fail("Expected ArithmeticException but got " +
                                   e.getClass().getName());
    }

Таким образом, если divides() дает неверный результат (любое число Fraction может представлять) или выдает нерелевантное исключение (например, PrinterException), этот тест должен завершиться неудачно.

Но тогда вы можете беспокоиться, что это не идиоматическое использование JUnit. Таким образом, вы можете обратиться к Google. Если вы используете IntelliJ, вы, вероятно, обратились бы к Google гораздо раньше.

И тогда Google расскажет вам об атрибуте expected аннотации @Test, который принимает в качестве параметра класс исключения. Проверка деления на ноль будет переписана следующим образом:

    @Test(expected = ArithmeticException.class)
    public void testDivisionByZero() {
        Fraction dividend = new Fraction(3, 4);
        Fraction zero = new Fraction(0, 1);
        Fraction result = dividend.divides(zero);
        System.out.println(dividend.toString() +
                 "divided by 0 is said to be " + result.toString());
    }

Это выглядит намного чище и работает почти так же, как и предыдущая версия: тест завершается неудачно, если divides() дает неверный результат или выдает любое исключение, кроме ArithmeticException.

Также обратите внимание, что он выводит на консоль любой неверный результат, который может дать divides(). Если вы занимаетесь разработкой через тестирование, у вас может быть заглушка divides(), которая дает результат 0 независимо от фактических задействованных чисел.

Но эта версия теста не выводит сообщение ArithmeticException. Это небольшой недостаток, если только вы не заинтересованы в каких-либо утверждениях о сообщении (например, о том, что это не пустое String).

А что, если вы считаете допустимыми два или три разных исключения? Например, в случае деления на ноль я думаю, что IllegalArgumentException будет лучшим выбором, но ArithmeticException приемлемо.

Для меня ArithmeticException указывает на проблему, которую можно решить с помощью дополнительных ресурсов. Например, если Math.addExact() переполняет int, выдается ArithmeticException, предлагая, возможно, вам использовать long или BigInteger вместо int.

Я думаю, что деление на ноль обычно происходит из-за глупой ошибки со стороны программиста. Какая-то глупая ошибка, которую обычно можно устранить, не переключаясь на другие типы данных.

Аннотация @Test в TestNG имеет атрибут, указывающий, что должно произойти одно из двух или более исключений. У него также есть атрибут, чтобы указать, каким должно быть сообщение об исключении.

Но в JUnit вам нужна конструкция try-catch-fail, если вы хотите перехватить любое из нескольких допустимых исключений и протестировать сообщения.

Гораздо реже вы можете захотеть утверждать, что определенное исключение является оболочкой для другого исключения. Затем ваша тестовая процедура должна поймать исключение упаковки, а затем использовать getCause() для проверки исключения исключения.

Другой вариант — использовать класс JUnit ExpectedException (с аннотацией @Rule), а затем использовать процедуру expectCause(). Вот пример из документации JUnit:

    @Test
    public void throwsExceptionWhoseCauseCompliesWithMatcher() {
        NullPointerException expectedCause = new
                                             NullPointerException();
        thrown.expectCause(is(expectedCause));
        throw new IllegalArgumentException("What happened?", cause);
    }

Ваш тестовый класс должен импортировать org.junit.rules.ExpectedException и аннотацию org.junit.Rule. Так что вам также нужно это:

    @Rule
    public ExpectedException thrown = ExpectedException.none();

Это дословно скопировано из документации JUnit, за исключением добавленного пробела. И оказывается, вам также нужно будет импортировать org.hamcrest.core.Is, если вы еще этого не сделали.

Итак, вот как выглядит мой тест деления на ноль с использованием expectCause():

    @Test
    public void testDivisionByZero() {
        ArithmeticException expectedCause = new
                                              ArithmeticException();
        thrown.expectCause(Is.is(expectedCause));
        Fraction dividend = new Fraction(3, 4);
        Fraction zero = new Fraction(0, 1);
        Fraction result = dividend.divides(zero);
        System.out.println(dividend.toString() +
                " divided by 0 is said to be " + result.toString());
    }

Мне пришлось написать «Is.is», потому что я не импортировал is() статически. Ничего страшного.

С другой стороны, заставить ArithmeticException в тесте соответствовать ArithmeticException из Fraction.divides() было немного головной болью.

Разница в том, что в попытке поймать-ошибиться я бы просто написал проверку instanceof для getCause(), в то время как сопоставитель Hamcrest, вероятно, ожидает, что исключения будут точно такими же, с идентичными сообщениями и идентичными трассировками стека.

У ExpectedException также есть expectMessage(), который может оказаться для вас гораздо полезнее, чем expectCause(). Я решил, что обернутое исключение в данном случае нецелесообразно и меня больше волнует сообщение об исключении.

Итак, со статически импортированным org.hamcrest.core.StringStartsWith.startsWith я переписал тест следующим образом:

    @Test
    public void testDivisionByZero() {
        thrown.expectMessage(startsWith("Dividing 3/4 by 0"));
        Fraction dividend = new Fraction(3, 4);
        Fraction zero = new Fraction(0, 1);
        Fraction result = dividend.divides(zero);
        System.out.println(dividend.toString() +
                " divided by 0 is said to be " + result.toString());
    }

Было намного проще заставить это сначала провалиться, а потом пройти.

Поскольку вы уже импортируете из JUnit JAR аннотацию @Test и различные утверждения, а также добавляете Hamcrest (что можно проверить с помощью панели «Проекты NetBeans», заглянув в «Тестовые библиотеки»), вы также можете использовать ExpectedException.

Но стоит ли это дополнительных усилий по сравнению с классическим методом «попробуй-поймал-не удалось»? Это вопрос, на который вы должны будете ответить сами.

Я думаю, что вы согласитесь со мной, что либо классический try-catch-fail, либо класс ExpectedException в большинстве случаев были бы гораздо полезнее, чем атрибут expected аннотации @Test.

Наконец, я бы не рекомендовал пытаться использовать атрибут expected и класс ExpectedException одновременно. Это может привести к вводящим в заблуждение ошибкам тестирования из-за противоречивых ожиданий.