Статьи

Юнит Лямбда: Прототип

Вы слышали о Юнит Лямбда ? Я надеюсь на это, потому что эти ребята определяют будущее тестирования на JVM. Слишком преувеличено? Может быть, но не намного. Это следующая версия нашего любимого JUnit, безусловно, наиболее используемой библиотеки Java , в процессе создания.

Я экспериментировал с новым прототипом и представляю свои результаты здесь. В настоящее время проект собирает отзывы, так что это наш шанс оценить.

обзор

После быстрого погружения в фон проекта я представлю документированные функции. Каждый закончится парой строк моего личного мнения по теме. Не стесняйтесь громко соглашаться или не соглашаться.

Я создал небольшой демонстрационный проект, из которого взято большинство примеров кода. Существует демонстрационный класс для каждой из представленных функций, и ссылки на них указаны в заголовках.

Обратите внимание, что прототип находится в стадии интенсивной разработки, поэтому он является движущейся целью. Из-за этого все ссылки на мой демонстрационный код, а также код проекта привязаны к их текущей версии. Со времени, когда я писал и вы читали это, все могло значительно измениться, и эти версии могли быть довольно устаревшими. Будьте лектором.

Фон

JUnit Lambda — проект группы энтузиастов тестирования Java, включая основной коммиттер JUnit.

Цель состоит в том, чтобы создать современную основу для тестирования на стороне разработчика в JVM. Это включает в себя фокусировку на Java 8 и выше, а также включение множества различных стилей тестирования.

Сайт проекта JUnit Lambda

Они собирали средства с июля по октябрь, начали свою работу над этим на совещании с 20 по 22 октября и выпустили свой прототип в прошлую среду (18 ноября).

Если вы хотите внести свой вклад в промежуточный период, пожалуйста, используйте трекер проблем проекта или отправьте нам комментарии через Twitter .

JUnit Lambda Wiki

Проект собирает отзывы до 30 ноября, а затем начнет работу над альфа-версией. Это также подразумевает, что текущая версия даже не альфа. Имейте это в виду при формировании вашего мнения.

Характеристики

Настройка, Тест, Разрушение

Наиболее важной частью API JUnit является аннотация @Test, и здесь ничего не меняется: только методы, аннотированные этим, будут рассматриваться как тесты.

Испытанные и проверенные аннотации для установки и разборки тестов практически не меняются, но имеют новые названия:

  • @Before и @After, которые выполняются до и после каждого метода тестирования, теперь называются @BeforeEach и @AfterEach.
  • @BeforeClass и @AfterClass, которые запускаются до первого и после последнего теста класса, теперь называются @BeforeAll и @AfterAll

Мне нравятся новые имена. Они больше раскрывают намерения и, следовательно, легче понять — особенно для начинающих.

Затем есть новое @Name, которое можно использовать, чтобы дать более удобочитаемые имена для тестирования классов и методов. Пример из документации :

@Name("A special test case")
class CanHaveAnyNameTest {

  @Test
  @Name("A nice name, isn't it?")
  void testWithANiceName() {}

}

Мои имена методов испытаний следуют шаблону unitOfWork_stateUnderTest_expectedBehavior , как представленный Рой Osherove и я не собираюсь повторять любое из этого где — нибудь еще. Мое необразованное предположение состоит в том, что большинство разработчиков, которым небезразличны названия их методов тестирования, думают одинаково, а те, кто их не использует, так или иначе. Так что, с моей точки зрения, это не добавляет особой ценности.

Утверждения

Если @Before …, @After … и @Test являются каркасом набора тестов, утверждения являются его сердцем. Прототип здесь проходит тщательную эволюцию.

Сообщения с утверждениями теперь идут последними и могут создаваться лениво. То, что assertTrue и assertFalse могут напрямую оценивать BooleanSupplier, является хорошей настройкой.

@Test
void interestingAssertions() {
    String mango = "Mango";

    // message comes last
    assertEquals("Mango", mango, "Y U no equal?!");

    // message can be created lazily
    assertEquals("Mango", mango,
            () -> "Expensive string, creation deferred until needed.");

    // for 'assert[True|False]' it is possible
    // to directly test a supplier that exists somewhere in the code
    BooleanSupplier existingBooleanSupplier = () -> true;
    assertTrue(existingBooleanSupplier);
}

Более интересной является добавленная функция захвата исключений …

@Test
void exceptionAssertions() {
    IOException exception = expectThrows(
            IOException.class,
            () -> { throw new IOException("Something bad happened"); });
    assertTrue(exception.getMessage().contains("Something bad"));
}

… и эти утверждения можно сгруппировать, чтобы проверить их все сразу.

@Test
void groupedAssertions() {
    assertAll("Multiplication",
            () -> assertEquals(15, 3 * 5, "3 x 5 = 15"),
            // this fails on purpose to see what the message looks like
            () -> assertEquals(15, 5 + 3, "5 x 3 = 15")
    );
}

Обратите внимание, что у группы может быть имя (в данном случае «Умножение»), и что содержащиеся в ней утверждения даются в виде лямбд, чтобы задержать выполнение.

Что касается утверждений в целом, я всегда ценил расширяемость JUnit. Это позволило мне игнорировать встроенные утверждения и стеганографический шедевр, который является Хэмкрестом в пользу AssertJ . Поэтому у меня нет никаких реальных мнений по этому поводу, за исключением того, что изменения, кажется, немного улучшают ситуацию.

видимость

Возможно, вы уже заметили: тестовые классы и методы больше не должны быть публичными. Я думаю, что это отличные новости! На одно бесполезное ключевое слово меньше.

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

Жизненные циклы

JUnit 4 всегда создает новый экземпляр класса теста для каждого метода тестирования. Это сводит к минимуму вероятность того, что отдельные тесты будут тонко взаимодействовать и нежелательно зависеть друг от друга.

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

@TestInstance(Lifecycle.PER_CLASS)
class _2_PerClassLifecycle {

    /** There are two test methods, so the value is 2. */
    private static final int EXPECTED_TEST_METHOD_COUNT = 2;

    /** Is incremented by every test method
        AND THE STATE IS KEPT ACROSS METHODS! */
    private int executedTestMethodCount = Integer.MIN_VALUE;

    // Note that the following @[Before|After]All methods are _not_ static!
    // They don't have to be because this test class has a lifecycle PER_CLASS.

    @BeforeAll
    void initializeCounter() {
        executedTestMethodCount = 0;
    }

    @AfterAll
    void assertAllMethodsExecuted() {
        assertEquals(EXPECTED_TEST_METHOD_COUNT, executedTestMethodCount);
    }

    @Test
    void oneMethod() { executedTestMethodCount++; }

    @Test
    void otherMethod() { executedTestMethodCount++; }

}

Я думаю, что это типичный случай функции, которая вредна в 99% случаев, но обязательна в остальных 1%. Я искренне боюсь того, как может выглядеть набор тестов, если бы неопытные разработчики распространяли на него такие зависимости между тестами. Но наличие таких разработчиков, которые делают все, что они хотят, без проверок и противовесов, таких как парное программирование и обзоры кода, само по себе является проблемой, поэтому наличие жизненного цикла для каждого класса не станет намного хуже.

What do you think? Ship it or scrap it?

Inner Classes

Some people use inner classes in their test suites. I do it to inherit interface tests, others to keep their test classes small. To have them run in JUnit 4 you have to either use JUnit’s @Suite or NitorCreations’ more elegant NestedRunner. Still you have to do something.

With JUnit Lambda this is not necessary anymore! In the following example, all of the printing methods are executed:

class _4_InnerClasses {

    @Nested
    class InnerClass {

        @Test
        void someTestMethod() { print("Greetings!"); }

    }

    @Nested
    static class StaticClass {

        @Test
        void someTestMethod() { print("Greetings!"); }

    }

    class UnannotatedInnerClass {

        @Test
        void someTestMethod() { throw new AssertionError(); }

    }

    static class UnannotatedStaticClass {

        @Test
        void someTestMethod() { print("Greetings!"); }

    }

}

The new annotation @Nested guides JUnit and the reader to understand the tests as part of a larger suite. An example form the prototype’s codebase demonstrates this well.

In the current version, it is also required to trigger the execution of tests in non-static inner classes but that seems to be coincidental. And while the documentation discourages the use on static classes, I guess because it interacts badly with a per-class lifecycle, this does not lead to an exception.

Assumptions

Assumptions got a nice addition utilizing the power of lambda expressions:

@Test
void assumeThat_trueAndFalse() {
    assumingThat(true, () -> executedTestMethodCount++);
    assumingThat(false, () -> {
        String message = "If you can see this, 'assumeFalse(true)' passed, "
                + "which it obviously shouldn't.";
        throw new AssertionError(message);
    });
}

I think I never once used assumptions, so what can I say? Looks nice. 🙂

Custom Annotations

When JUnit Lambda checks a class or method (or anything else really) for annotations, it also looks at the annotations’ annotations and so forth. It will then treat any annotation it finds during that search as if it were directly on the examined class or method. This is the hamstrung but common way to simulate inheritance of annotations, which Java does not support directly.

We can use this to easily create custom annotations:

/**
 * We define a custom annotation that:
 * - stands in for '@Test' so that the method gets executed
 * - gives it a default name
 * - has the tag "integration" so we can filter by that,
 *   e.g. when running tests from the command line
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Test
@Name("Evil integration test!   ")
@Tag("integration")
public @interface IntegrationTest { }

We can then use it like this:

@IntegrationTest
void runsWithCustomAnnotation() {
    // this is run even though 'IntegrationTest' is not defined by JUnit
}

This is really neat! A simple feature with great implications.

Conditions

Now it gets really interesting! JUnit Lambda introduces the concept of conditions, which allows the creation of custom annotations that decide whether a test should be skipped or not. The decision whether to run a specific test method is made as follows:

  1. the runner looks for any annotation on the test method that is itself annotated with @Conditional(Class<? extends Condition> condition)
  2. it creates an instance of condition and a TestExecutionContext, which contains a bunch of information for the current test
  3. it calls the condition’s evaluate-method with the context
  4. depending in the call’s return value it decides whether the test is run or not

One condition that comes with JUnit is @Disabled, which replaces @Ignore. Its DisabledCondition simply checks whether the annotation is present on the method or on the class and creates a matching message.

Let’s do something more interesting and much more useful: We’ll create an annotation that skips tests if it’s Friday afternoon. For all those tricky ones that threaten your weekend.

Let’s start with the date check:

static boolean itsFridayAfternoon() {
    LocalDateTime now = LocalDateTime.now();
    return now.getDayOfWeek() == DayOfWeek.FRIDAY
            && 13 <= now.getHour() && now.getHour() <= 18;
}

Now we create the Condition implementation that evaluates a test:

class NotFridayCondition implements Condition {

    @Override
    public Result evaluate(TestExecutionContext testExecutionContext) {
        return itsFridayAfternoon()
                ? Result.failure("It's Friday afternoon!")
                : Result.success("Just a regular day...");
    }

}

We can see that we don’t need the TestExecutionContext at all and simply check whether it is Friday afternoon or not. If it is, the result is a failure (unlucky naming) so the test will be skipped.

We can now hand this class to @Conditional and define our annotation:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Conditional({NotFridayCondition.class})
public @interface DontRunOnFridayAfternoon { }

And to get rid of that pesky test and ride off into the Friday afternoon sunset:

@Test
@DontRunOnFridayAfternoon
void neverRunsOnFridayAfternoon() {
    assertFalse(itsFridayAfternoon());
}

Very nice. Great feature!

Injection

Last but not least, there is great support for injecting instances into tests.

This is done by simply declaring the required instances as test method parameters. For each of the parameters, JUnit will then look for a MethodParameterResolver that supports its type. Such resolvers are either shipped with JUnit or listed in the new @ExtendWith annotation on the test class.

If JUnit finds an applicable resolver, it uses it to create an instance of the parameter. Otherwise the test fails.

A simple example is @TestName which, if used on a string, injects the test’s name:

@Test
void injectsTestName(@TestName String testName) {
    // '@TestName' comes with JUnit.
    assertEquals("injectsTestName", testName);
}

Creating a resolver is easy. Let’s say there is a Server class, which we need to preconfigure for different tests. Doing that is as simple as writing this class:

public class ServerParameterResolver implements MethodParameterResolver {

    @Override
    public boolean supports(Parameter parameter) {
        // support all parameters of type 'Server'
        return parameter.getType().equals(Server.class);
    }

    @Override
    public Object resolve(Parameter parameter, TestExecutionContext context)
            throws ParameterResolutionException {
        return new Server("http://codefx.org");
    }
}

Assuming the test class is annotated with @ExtendWith( { ServerParameterResolver.class } ), any parameter of type Server is initialized by the resolver:

@Test
void injectsServer(Server server) {
    int statusCode = server.sendRequest("gimme!");
    assertEquals(200, statusCode);
}

Let’s look at a slightly more complicated example.

Say we want to provide a resolver for strings that should contain E-Mail addresses. Now, analog to above we could write a StringParameterResolver but it would be used for all strings which is not what we want. We need a way to identify those strings that should contain addresses. For that we introduce an annotation…

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface EMailParameter { }

… which we use to limit the supported parameters.

public class Resolver implements MethodParameterResolver {

    @Override
    public boolean supports(Parameter parameter) {
        // support strings annotated with '@EmailParameter'
        return parameter.getType().equals(String.class)
                && isAnnotated(parameter, EMailParameter.class);
    }

    @Override
    public Object resolve(Parameter parameter, TestExecutionContext context)
            throws ParameterResolutionException {
        return "nipa@codefx.org";
    }
}

Makes sense, right? Now we can use it like @TestName:

@Test
void injectsEMailAddress(@EMailParameter String eMail) {
    assertTrue(eMail.contains("@"));
}

Did I say that I like this feature? I do. In fact I think it’s awesome! I’m sure third-party test libraries can make great use of this.

But I wonder whether supports should also receive a TestExecutionContext for more fine-grained decision-making.

Misc

Extensibility

JUnit Lambda Logo

The project lists a couple of core principles, one of them is to “prefer extension points over features”. This is a great principle to have and I think especially the last features we discussed follow this very well.

The ability to create custom annotations, conditions and injections and that they are treated exactly as if they were shipped with the library is really cool. I am sure this will lead to interesting innovations in third-party test libraries.

Java Version

This is still a little unclear (at least to me). While the API is lambda-enabled it seems to do that mostly without requiring types or features from Java 8. It is also being considered to avoid using Java 8 features inside JUnit so that the project can be compiled against older versions. If so, it could be used in environments that did not or can not upgrade (e.g. Android).

Compatibility With JUnit 4

The project dedicates a separate page to address this important topic.

Instead, JUnit 5 provides a gentle migration path via a JUnit 4 test engine which allows existing tests based on JUnit 4 to be executed using the JUnit 5 infrastructure. Since all classes and annotations specific to JUnit 5 reside under a new org.junit.gen5 base package, having both JUnit 4 and JUnit 5 in the classpath does not lead to any conflicts. It is therefore safe to maintain existing JUnit 4 tests alongside JUnit 5 tests.

Reflection

We have seen the prototype’s basic features:

  • setup, test, teardown: better naming
  • assertions: slight improvements
  • visibility: no more public!
  • lifecycles: a per-class lifecycle keeping state between tests
  • inner classes: direct support for tests in inner classes
  • assumptions: slight improvements
  • custom annotations: enables fully compatible custom annotations
  • conditions: enables fanciful skipping of tests
  • injection: support for injecting instances via test parameters

Especially the last items show the core principle of extensibility. We also discussed the focus on migration compatibility, which will allow projects to execute tests using both JUnit 4 and JUnit 5.

With all of this you are prepared to make up your opinion about the details and (important step!) give feedback to the great people doing this for us:

By the way, if you’d follow me on Twitter, you would’ve known most a while ago.