Статьи

100% покрытие кода, Hibernate Validator и дизайн по контракту

Покрытие кода в модульном тестировании было одной из первых вещей, которые я узнал в своей карьере программиста. Компания, в которой я работал, научила меня, что вы должны иметь 100% охват в качестве цели, но ее достижение не означает, что в системе нет ошибок. В то время я работал в компании, которая имела большое значение в том, что они поставляли очень надежное программное обеспечение для выставления счетов операторам связи. Раньше мы тратили столько же времени на написание модульных тестов, сколько и на написание кода. Если вы включили модульные тесты, интеграционные тесты, системные тесты и приемочные тесты, то больше времени было потрачено на тестирование, чем на разработку и реализацию кода, который работал в производстве. Это было впечатляюще, и с тех пор я никогда не работал с такой моделью и не работал в такой компании, хотя я уверен, что есть много компаний, которые так работают.

На днях я вспоминал те дни и читал о модульном тестировании, чтобы освежиться в процессе подготовки к курсу модульного тестирования, который я скоро посещаю (не хочу, чтобы инструктор знал больше, чем я!), И я удивился о том, какой код может быть полностью покрыт во время модульного тестирования, но который все еще может содержать ошибку. Хотя я узнал, что целью должно быть 100% покрытие, за более чем 10 лет я никогда не работал над проектом, который достиг этого. Поэтому я никогда не удивлялся, обнаружив в коде ошибку, которая, по моему мнению, была «полностью» протестирована.

Забавно писать дурацкий код — обычно вы не пытаетесь писать ошибки ? Я начал со спецификации:

    /**
     * @return String "a" or "b" depending upon the values
     * passed to the init method. If variable "a" is true,
     * then string "a" is returned. If variable "b" is true,
     * then "b" is returned. If neither is true, then "" is
     * returned. Variable "b" is important than "a", so if
     * both are true then return "b".
     */

Сложнее было написать код, который содержал ошибку, но ее было легко покрыть неполными тестами. Я придумал следующее. Вышеприведенная спецификация предназначена для метода «getResult ()».

public class SomeClass {

    private boolean a;
    private boolean b;

    public SomeClass init(boolean a, boolean b) {
        this.a = a;
        this.b = b;
        return this;
    }

    public String getResult() {
        String s = "";
        if(a) {
            s = "a";
        }
        if(b){
            s += "b"; // <-- oops!
        }
        return s;
    }
}

«Ошибка» находится в методе «getResult», и проблема в том, что вместо просто назначения был использован оператор «плюс равно». «Else», вероятно, сделает код немного более безопасным. Но я думаю, что такой код довольно типичен для ошибочного кода, который находит свое применение в продуктивных системах. Даже у лучших программистов есть недостатки в концентрации, когда они пишут опечатки, подобные этой (особенно в офисах с открытой планировкой!).

Модульный тест, который программист пытается достичь 100% покрытия, будет выглядеть примерно так:

    @Test
    public void test() {
        assertEquals("a", new SomeClass().init(true, false).getResult());
        assertEquals("b", new SomeClass().init(false, true).getResult());
    }

Используя Эмму для Eclipse, я измерил полный охват. Но ждать! Есть еще ошибка. Код не делает то, что говорится в спецификации Javadoc. Если я инициализирую объект «true, true», то результатом будет «ab» из-за оператора «плюс равно». Так что, хотя у меня есть 100% охват, у меня все еще есть ошибка.

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

Один из способов взглянуть на проблему — сказать, что есть ошибка, потому что код не выполняет свой контракт. Хорошо, я использую «контракт» в широком смысле этого слова, но Javadoc — это, по сути, контракт. Он сообщает любому, вызывающему этот метод, что ожидать, но коды не делают то, что ожидает пользователь.

Так что, возможно, одним из решений является не только полное использование кода, но и полное выполнение контракта? Есть ли способ перевести контракт Javadoc в нечто более конкретное, что мне помогут проверить инструменты тестирования? Да, именно я использую какую-то структуру (или программу) по контракту (DBC или PBC). JSR-303не строго DBC, но близко. Он позволяет вам использовать аннотации для определения ваших ожиданий относительно параметров, передаваемых методам, а также ваших ожиданий относительно возвращаемого результата. Вы можете создать свои собственные сложные ограничения довольно легко. Я добавил следующие аннотации к своему коду, чтобы помочь в поисках кода без ошибок:

    @Valid
    @NotNull
    @Size(min=0, max=1)
    public String getResult() {
        ...

Теперь валидация метода (проверка вызовов метода, а не проверка самого компонента) — это нечто дополнительное в Hibernate Validator, которое на самом деле не является частью JSR-303 — оно описано только в Приложении C как необязательное. Чтобы проверить это, я использовал Google Guice для добавления перехватчика AOP к любым методам, помеченным аннотацией @Valid, и в этом перехватчике я вызываю средство проверки Hibernate. Я получаю что-то вроде этого:

.
.
.
    Injector injector = Guice.createInjector(new AbstractModule(){
        protected void configure() {
            bindInterceptor(Matchers.any(),
                    Matchers.annotatedWith(Valid.class),
                    new MethodValidatorInterceptor());
        }
    });
    SomeClass someClass = injector.getInstance(SomeClass.class);
    someClass.init(true, false);
    assertEquals("a", someClass.getResult());

    someClass = injector.getInstance(SomeClass.class);
    someClass.init(false, true);
    assertEquals("b", someClass.getResult());
.
.
.
public class MethodValidatorInterceptor<T> implements MethodInterceptor {

    public Object invoke(MethodInvocation invocation) throws Throwable {

        //validate all inputs
        Set<MethodConstraintViolation<T>> mcvs =
            methodValidator.validateAllParameters((T)invocation.getThis(),
               invocation.getMethod(), invocation.getArguments());
        if(mcvs.size() != 0){
            throw new IllegalArgumentException(String.valueOf(mcvs));
        }

        //call through to the actual method being called
        Object ret = invocation.proceed();

        //validate result
        mcvs = methodValidator.validateReturnValue((T)invocation.getThis(),
            invocation.getMethod(), ret);
        if(mcvs.size() != 0){
            throw new IllegalArgumentException(String.valueOf(mcvs));
        }

        return ret;
    }
}
.
.
.

Вышесказанное — это то, что контейнер (Java EE) должен сделать для меня — я просто возился с простыми классами в простом Java-проекте. Теперь, это не совсем завершено, потому что у меня все еще есть 100% охват, и у меня все еще есть та ошибка, потому что новые аннотации действительно не сделали ничего полезного. Ну, это не совсем верно — читатель кода знает, что контракт немного более строг, чем это было, когда это был простой Javadoc. Читатель может предположить, что система проверит эти ограничения при вызове метода. Но пока есть еще ошибка, я проложил путь для добавления некоторых предварительных или постусловий. Следующим шагом было добавить новую аннотацию, интерфейс и использовать их в перехватчике.

@Target( { ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PreCondition {
    Class<? extends ConditionChecker> implementation();
}

/** Any pre- or post-condition is written in
  * an implementation of this interface
  */
public interface ConditionChecker {
    void checkCondition()
        throws ConstraintViolationException;
}

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

    @Valid
    @NotNull
    @Size(min=0, max=1)
    @PreCondition(implementation=MyPreAndPostCondition.class)
    public String getResult() {
        ...

Перехватчик может проверить наличие такой аннотации предварительного условия перед вызовом вызываемого метода. Если аннотация найдена, перехватчик пытается создать экземпляр класса реализации и вызывает его метод «checkCondition».

Итак, заключительная часть этой головоломки состоит в том, чтобы написать предварительное условие, которое поможет мне не получить 100% охват при тестировании с помощью теста, показанного в верхней части этого поста. Здесь он реализован как статический финальный внутренний класс внутри класса SomeClass, так что он имеет доступ к полям «a» и «b»:

public class MyPreAndPostCondition implements ConditionChecker {
    @Override
    public void checkCondition()
            throws ConstraintViolationException {

        //im going to list all combinations of
        //a and b which I support
        if(!a && b) return;
        if(!b && a) return;
        if(!b && !a) return;
        if(a && b) return;
    }
}

Когда я сейчас тестирую свой код, я больше не получаю 100% покрытия, потому что последние две строки в предварительном условии не покрываются. Ура! Инструменты теперь могут сказать мне, что мне еще нужно проверить …

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

Итак, подведем итог: DBC не поможет мне решить проблему, заключающуюся в том, что 100% покрытие кода может содержать ошибки. Я думаю, что DBC-фреймворки (а их там много, некоторые делают именно то, что я сделал здесь с помощью аннотации @PreCondition) помогают сделать контракты более конкретными. Если вы используете валидацию метода из Hibernate Validator, вам не нужно писать столько Javadoc, потому что читатель знает, что контейнер откажется перед вызовом метода, если что-то не получится проверить. Для меня как для программиста это гораздо приятнее, чем молитва о том, что некоторые Javadoc отражают то, что я действительно реализовал.

У меня есть знакомые программисты, которые не пишут тесты, потому что есть структура DBC, и это заставляет их чувствовать себя в безопасности. Но то, что вы объявляете контракт, не означает, что код действительно работает. Вне зависимости от того, будет ли код терпеть неудачу с исключением из-за проверки или через какое-то время из-за того, что ваш код содержит ошибки, не имеет значения — оба они неприемлемы! С этой точки зрения контракты DBC — это просто подсказки для тестировщика о том, какие тесты могут быть полезны, и они гарантируют, что код рано или поздно завершится неудачно.

В то время как я обновлял свои навыки тестирования, я также узнал разницу между макетами и заглушками. В течение многих лет я всегда думал, что это одно и то же, но оказывается, что заглушки возвращают предварительно загруженные ответы, в результате чего издевательства проверяют последовательность обращений к ним. В другом потоке в DZone кто-то подчеркнул, что модульное тестирование бессмысленно, потому что оно никогда не помогало им находить ошибки, и это приводило к большой работе при рефакторинге, потому что все, что делал, это ломало их тесты. Я бы сказал, что это просто вопрос тестирования черного ящика против белого ящика. Модульное тестирование черного ящика никогда не должно прерываться, если вы реорганизуете свой код — тесты — это просто клиенты для кода, который вы реорганизуете, и такие инструменты, как Eclipse, изменят вызывающий код, если вы измените вызываемые интерфейсы, включая тесты.Вы можете получить довольно хорошие результаты тестирования, просто используя тесты черного ящика — подавляющее большинство тестов, которые я пишу, являются тестами черного ящика, и когда я пишу такие тесты, у меня, как правило, код без ошибок.

Я говорил о написании контрактов, чтобы помочь читателю определить, чего ожидать от использования вашего кода. Сами модульные тесты работают аналогично, потому что они показывают читателю примеры того, как вызывать ваш код и что ожидать, когда ваш код меняет состояние системы. Они намекают читателю, как автор предполагал использовать код. Хотя я не защищаю TDD (возможно, только потому, что я никогда не участвовал в проекте, который использовал его, или в компании, которая оценивала качество достаточно, чтобы гарантировать TDD), я действительно рекомендую писать тесты и использовать валидацию и предварительные / постусловия, потому что они помогают документировать код с дополнительным бонусом при обнаружении случайной ошибки. В то же время я архитектор, и нам нужно помнить о бюджетах. Когда вы оцениваете, вы оказываете влияние на свой бюджет, предполагая, что вам разрешено это делать.Ваш клиент может подтолкнуть вас к снижению ваших оценок, и это свидетельствует об их приверженности качеству, потому что они не будут ограничивать объем или сроки поставки! Поэтому напишите как можно больше тестов в рамках своего бюджета и начните с кода, который является наиболее сложным и который вызывается чаще всего. и помните, что 100% охват не совсем ваш лучший друг, потому что ошибки все еще могут скрываться.
 

От http://blog.maxant.co.uk/pebble/2011/11/28/1322465377854.html