Тестирование GWT приложений архитектуры MVP

  • Tutorial
Добрый день!

В этой статье я рассмотрю unit/integration тестирование в GWT с использованием UI компонентов GWT и GXT и MVP (с Passive View) архитектуры для разделения логики и внешнего вида приложения.
GWT и GXT здесь выделены не случайно — Google разработал несколько framework'ов, которые облегчают поддержку паттерна MVP (точнее, более общее — разделение логики и представления) в GWT. Это Activity and Place для разделения логики приложения на модули, GWT Editor для автоматического мапинга POJO объектов на widget, UiBinding для декларативного описания интерфейса.
Всё это поддерживается ещё и UI framework'ом GXT. Т.ч. по сути не будет большой разницы в использовании UI компонентов GWT или GXT.

В результате получим легко тестируемое приложение без поднятия тяжёлого framework'а GWT.

Цель


Просто писать тесты на GWT приложение с покрытием максимального функционала. В идеале — простое написание интеграционных тестов (интеграционных, что бы сразу покрывать тестами все слои приложения минимальным количеством тестов, тестируя тем самым ещё и взаимосвязь слоёв). Нужно, т.к. много логики находится на клиенте и хотелось бы её тоже по максимуму покрыть простыми легко запускающимися тестами, которые можно быстро запускать на машине разработчика.

Используемые технологии и framework'и


1) MVP (Model View Presenter) с Passive View — краеугольный камень в тестировании приложений с UI. Вкратце — это паттерн разделения логики и отображения в котором View отводится минимальная роль. Благодаря чему View становится прост и в нём сложнее будет сделать ошибки. А так как его сложно тестировать (для этого придётся поднять всё окружение GWT), то это будет только на руку — его и не будем тестировать.

За подробностями о MVP смотреть сюда:
MVP в GWT. Официальная документация: www.gwtproject.org/articles/mvp-architecture.html
Passive View. М. Фаулер: martinfowler.com/eaaDev/PassiveScreen.html
MVP в GWT: www.javabeat.net/what-is-model-view-presenter-mvp-in-gwt-application
Различные MV* паттерны: outcoldman.com/ru/archive/2010/02/22/паттерны-mvc-mvp-и-mvvm

2) DI (Dependence Injection) — реализация для GWT — GIN, для тестирования без запуска GWT — Guice. Все зависимости в Presenter'ах вместо GWT.create() определяются с помощью аннотации Inject. Т.е. при использовании GWT RPC вместо
 ServiceAsync serviceAsync = GWT.create(ServiceAsync.class);

писать
 @Inject ServiceAsync serviceAsync;

Так же нужно при тестировании без полного поднятия окружения GWT — при этом зависимости, которые не возможно создать (например, реализация View) будут заменены mock'ами. Плюс использования GIN и Guice в паре — они используют одни аннотации для определения DI (Inject, Provide) благодаря чему код не нужно менять под другой DI framework и ничего дополнительно не нужно настраивать — только корректно за'bind'ить зависимости.

3) Mocking — эмулирование объектов. Нужно для замены, как минимум View, чтобы корректно работали Presenter'ы. В качестве framework'а для mocking'а используем Mockito.

4) SyncProxy — framework для выполнения GWT RPC запросов из Java. Нужен для тестирования, чтобы предоставить возможность совершать GWT RPC вызовы без поднятия GWT окружения.
SyncProxy code.google.com/p/gwt-syncproxy

5) Jetty — servlet container. Нужен для запуска web приложения.

6) GWT Editor — GWT framework который позволяет автоматически маппить POJO объекты на UI компоненты. Нужен, чтобы разгрузить View от маппинга и тем самым приблизить его к Passive View.

GWT Editor. Официальная документация: www.gwtproject.org/doc/latest/DevGuideUiEditors.html
GWT Editor in GXT: docs.sencha.com/gxt/3.1.0-beta/data/Editors.html

7) Activity and Place — framework от Google для удобной history механизма (история посещения страницы в браузере — кнопки назад и вперёд). В принципе здесь не обязателен, но позволяет легко реализовать поддержку истории браузера, т.ч. тоже рассмотрим.

Activity and Place. Официальная документация. www.gwtproject.org/doc/latest/DevGuideMvpActivitiesAndPlaces.html

8) UiBinder — GWT механизм декларативного описания UI. По сути GWT UI элементы определяются как в html/jsp станицах — с помощью тегов. Тоже не обязательно, но позволяет разгрузить View, оставив там только event handler'ы и минимум логики.

UiBinder. Официальная документация: www.gwtproject.org/doc/latest/DevGuideUiBinder.html

Не используемые технологии


1) Selenium — тесты написанные с помощью Selenium трудно поддерживать, писать и запускать. Хотя они и позволяют провести полностью интеграционное тестирование, но для них нужен отдельный человек, который будет поддерживать их в рабочем состоянии и отдельная машина, на которой их можно быстро запустить. В маленьких командах если и можно выделить и настроить сервер для запуска Selenium тестов, то отдельного человека для их поддержки найти очень сложно. Т.ч. его я не рассматриваю, хотя раньше на других проектах успешно использовал (с QA инженером, который писал тесты).

2) GWTTestCase — GWT JUnit runner, который позволяет поднять GWT окружение в JUnit тестах и эмулировать браузер с помощью HtmlUnit. Не использовал здесь, т.к. запускается долго (по сути, это запуск GWT в режиме разработки), а из за использования HtmlUnit эмуляция некоторых JavaScript может происходит некорректно. Вообще это такое половинчатое решение — с одной стороны и запускается GWT, но другой — запускается долго и не во всех случаях будет работать корректно. Т.ч. тоже не использовал, хотя для некоторых случаев может и подойти.

Архитектура приложения и основные моменты


Повторюсь, основной паттерн облегчающий тестирование в GWT — MVP с Passive View. Суть его — сосредоточить всю логику в Presenter'е, сделать максимально простой View (чем проще код, тем меньше в нём ошибок, а так как View не будем покрывать тестами, потому что сложно, то это очень актуально). При этом в Presenter'е не должно быть UI кода — никакого использования UI элементов, вызовов GWT.create() (вместо этого используется внедрение зависимостей с помощью DI) и всего того, что не может выполнится на JVM. Всё это нужно, чтобы код Presenter'а можно было выполнить на JVM без компиляции в JS или поднятия GWTDevMode, которые происходят долго.

Этапы запуска тестов


1) Поднимаем Jetty с нашим приложением.
2) Настраиваем SyncProxy на наши GWT RPC сервисы.
3) С помощью Guice определяем реализации интерфейсов GWT RPC сервисов, Presenter'ов и View (с mock'ом View).
4) Доп. в случае использования Activity and Place настроить ActivityMapper, ActivityManager, PlaceControler.
5) Тестировать Presenter'ы и (если есть) Activity и Place.

Пример приложения


В качестве примера я сделал приложение редактирования студентов и групп. Студент может принадлежать только одной группе. Всего есть 4 формы — список студентов, список групп, просмотр/редактирование студента, просмотр/редактирование группы.

Исходники расположены здесь: github.com/TimReset/example.gwt.gxt.test
Приложение используем maven. В папке с проектом есть папка repo в которой находится SyncProxy (так как его нет в maven) и annotation.jar от IDEA (т.к. я использую аннотации NotNull и Nullable от IDEA).

В качестве базового Presenter'а и View используем следующий:
/**
 * Общий интерфейс для presenter'ов. Сделан по <a href="http://www.gwtproject.org/articles/mvp-architecture.html">офф
 * дока по MVP от Google</a> и <a href="http://www.gwtproject.org/doc/latest/DevGuideMvpActivitiesAndPlaces.html">офф
 * дока по Activity и Place</a>. А так же с учётом статьи Фаулера про MVP. Но отличается от канонической реализации тем,
 * что View имеет ссылку на интерфейс Presenter, что бы сообщать ему об изменениях (например, в случае нажатия на кнопку
 * в View). Это удобно тем, что не нужно делать передачу callback'ов в View, что бы она могла сообщить Presenter'у о
 * своих изменениях.
 * <p/>
 * <p/>
 * Use case использования Presenter'ов:
 * <p/>
 * Presenter'ы являются максимально независимой единицей - всё взаимодействие с другими Presenter'ами должно происходить
 * через EventBus, который устанавливается Presenter'у явно (не через inject). Нужно это, т.к. EventBus может быть
 * локальным и работать только с этим Presenter'ов. Свять Presenter'ов между собой происходит в Activity. ({@link
 * BaseActivity ).
 */
public interface BasePresenter<V extends BasePresenter.View> {

  /**
   * Возвращает {@link View} для отображения. Повторный вызов этого метода должен возвратить тот же View. Инициализацией
   * View Presenter должен заниматься в другом месте, но не в этом методе. Это нужно, чтобы можно было вернуть к
   * отображению этого View, если был переход на другой View. Возвращает именно {@link View}, а не {@link IsWidget}, что
   * бы можно использовать типизированные View в других View.
   *
   * @return View.
   */
  @NotNull
  V getWidget();

  /**
   * Интерфейс View для Presenter'ов. Нужен, т.к. содержит шаблонный метод {@link #setPresenter(BasePresenter)} который
   * должна вызывать каждая реализация Presenter'а чтобы сообщить View с каким Presenter'ом он общается.
   */
  public interface View<T extends BasePresenter> extends IsWidget {

    /**
     * View имеет ссылку на Presenter, чтобы в случае событий со стороны View сообщать об этом Presenter'у. Ссылка
     * передаётся напрямую через set метод, а не с помощью Inject, т.к. 1) GIN не поддерживает циклические ссылки -
     * когда View ссылается на Presenter и Presenter ссылается на View. Поэтому ссылку нужно передавать каким-то другим
     * способом. 2) Нужно, что бы ссылка View Presenter и наоборот были один к одному, т.к. View ссылался на тот же
     * Presenter, который ссылается на этот же View, поэтому Presenter должен сам инициализировать View, который есть у
     * него.
     *
     * @param presenter Ссылка на Presenter для этого View.
     */
    void setPresenter(@NotNull T presenter);
  }
}


Суть базового Presenter'а — определить основные методы по работе с Presenter'ами и View: у Presenter'а — получить View. Метод нужен, т.к. напрямую с View не работаем (потому что за состояние View отвечает Presenter и он сам должен решать при отдаче этого View, в каком состоянии его отдать), а только через Presenter. У View есть метод для установки Presenter'а. Метод нужен, т.к. нельзя в GIN/Guice сделать циклические зависимости — экземпляр Presenter'а ссылается на View и View ссылается на Presenter.

UML Class диаграмма



На диаграмме видна взаимосвязь между классам — Presenter общается с View и шлёт сообщения в EventBus. Activity общается с Presenter'ами (на диаграмме показан только один Presenter но их может быть больше) и работает с EventBus — слушает сообщения от Presenter'ов и остальные сообщения.

Основные нюансы взаимодействия между объектами:

1) Сообщения от Presenter'а в Activity передаются через EventBus. Как альтернативу можно использовать callback'и, которые Activity будет устанавливать в Presenter. Но у этого решения есть один минус — callback'и нужно не забывать стирать, чтобы не было утечек ресурсов из за того, что Presenter singleton, а Activity нет. Т.е. Activity установил Presenter'у callback. Перешли на новый Activity и не установили callback (а Presenter забыл его обнулить) И тогда будет висеть ссылка на callback старого Activity или того хуже, этот callback будет вызван. В механизме Activity and Place GWT при создании Activity передаётся ResettableEventBus, который очищается при переходе на новый Activity. Т.ч. Activity может установить сколько нужно event handler'ов, не беспокоясь о их удалении. При смене Activity эти handler'ы автоматически удалятся. А Presenter'у не нужно беспокоиться о наличии callback'ов — он просто шлёт сообщения в EventBus.

2) Сообщения от Activity в Presenter можно напрямую слать через методы интерфейса Presenter'а.

3) Presenter с View взаимодействуют через интерфейсы друг друга. Нужно чтобы при тестировании заменить реализацию View mock'ом.

4) О View знает только Presenter — все остальные общаются только с Presenter'ом. Опять же — удобно при замене View mock'ом и последующем отслеживании зависимостей (когда о View никто не знает и его легко поменять).

Настройки тестового окружения


Jetty настраиваем программно аналогично как в web.xml — т.е. всё то, что написано в web.xml переносим в вызов методов в Jetty. Я сделал именно так, а не явно указал web.xml потому что у меня не получилось использовать относительный путь к web.xml, а писать заточенные под конкретные пути тесты не хотелось — пришлось бы их каждый раз менять. Т.ч. если у вас получится сделать запуск Jetty с относительным путём к web.xml, то будет отлично.

Для настройки SyncProxy используем Map со списком интерфейсов асинхронных GWT RPC сервисов, их реализаций (нужно для удобного запуска GWT RPC сервлетов в Jetty) и путей к этим сервлетам.

/**
 * Список GWT RPC сервлетов. Ключ - класс GWT RPC сервлета. Значение - асинхронный интерфейс сервлета и строка - его
 * маппинг из web.xml. Нужно для удобного добавления GWT RPC сервлетов для Jetty и последующего создания асинхронных
 * интерфейсов - чтобы каждый раз не прописывать мапинг для Jetty, создание экземпляра интерфейса в SyncProxy и
 * последующего биндинга в Guice.
 */
private static final Map<Class<? extends BaseRemoteService>, Pair<Class<?>, String>> gwtRpcServlets = Collections
        .unmodifiableMap(new HashMap<Class<? extends BaseRemoteService>, Pair<Class<?>, String>>() {
            private static final long serialVersionUID = -2126682232601937926L;

            {
                put(StudentsServiceImpl.class, new Pair<Class<?>, String>(StudentsServiceAsync.class, "/GxtModule/students"));
                put(GroupsServiceImpl.class, new Pair<Class<?>, String>(GroupsServiceAsync.class, "/GxtModule/groups"));
            }
        });


Собственно сам мапинг происходит в этом цикле:

for (Map.Entry<Class<? extends BaseRemoteService>, Pair<Class<?>, String>> entry : gwtRpcServlets
        .entrySet()) {
    // Cookie явно не используются, но по умолчанию SyncProxy используем свои куки, т.ч. http сессия корректно работает.
    // Используем синхронный вызов асинхронного кода (waitForInvocation = true). Нужно, что бы проще писать тесты - в них методы будут выполняться по порядку.
    gwtRpcAsyncInstances.put(entry.getValue().getA(), SyncProxy.newProxyInstance(entry.getValue().getA(),
            URL_TO_SERVER, entry.getValue().getB(), true));
}


Обращу Ваше внимание на следующие моменты при использовании SyncProxy:

1) Инстансы асинхронных интерфейсов сохраняются в Map gwtRpcAsyncInstances. Это нужно что в последствие универсально их получать при bind'инге.

2) Используется синхронный механизм в асинхронных интерфейсах. Это означает, что при вызове асинхронного GWT RPC метода следующая строчка кода не будет выполняться, пока не придёт ответ от сервера (т.е. не выполнятся методы com.google.gwt.user.client.rpc.AsyncCallback#onFailure или com.google.gwt.user.client.rpc.AsyncCallback#onSuccess. Это очень облегчает написание тестов — фактически тесты будут писаться по шагам, как синхронные и не нужно будет дополнительно обрабатывать ожидание ответа сервера.

3) Нужно в пакете с тестами создать копию класса com.google.gwt.user.server.rpc.impl.LegacySerializationPolicy и переопределить метод com.google.gwt.user.server.rpc.impl.LegacySerializationPolicy#isInstantiable, чтобы он поддерживал классы наследники Serializable. Т.ч. код метода в итоге должен быть таким:
	private boolean isInstantiable(Class<?> clazz) {
		if (clazz.isPrimitive()) {
			return true;
		}
		if (clazz.isArray()) {
			return isInstantiable(clazz.getComponentType());
		}
		// Здесь добавилось Serializable.class.isAssignableFrom(clazz)
		if (IsSerializable.class.isAssignableFrom(clazz) || Serializable.class.isAssignableFrom(clazz)) {
			return true;
		}
		return SerializabilityUtil.hasCustomFieldSerializer(clazz) != null;
	}


Это нужно, т.к. GWT RPC по умолчанию, если нет файла с GWT Policy (файл с описанием сериализуемых типов), использует LegacySerializationPolicy для проверки того, может ли объект сериализоваться. А в этом классе нет поддержки интерфейса Serializable. Т.ч. придётся добавить её вручную. Используется именно такое переопределение метода (с полной заменой класса), т.к. GWT RPC напрямую создаёт экземпляр этого класс и указать GWT RPC свой Serializable Policy нельзя.

И последний момент настройки — определение bind'ингов в Guice. Для определения Presenter'ов так же используется Map с их классом Presenter'а, классом реализации Presenter'а и классов View этого Presenter'а.

    /**
     * Список Presenter'ов, их реализации и View. Нужен для автоматического биндинга.
     */
    private static final Map<Class<? extends BasePresenter>, Pair<Class<? extends BasePresenter>, Class<? extends BasePresenter.View>>> presenters =
            Collections.unmodifiableMap(new HashMap<Class<? extends BasePresenter>, Pair<Class<? extends BasePresenter>, Class<? extends BasePresenter.View>>>() {
                private static final long serialVersionUID = 3512350621073004110L;

                private <T extends BasePresenter> void addPresenter(Class<T> presenterInterface, Class<? extends T> implementPresenter, Class<? extends BasePresenter.View<T>> viewClass) {
                    if (!presenterInterface.isInterface()) {
                        throw new IllegalArgumentException("Should be interface " + presenterInterface.getName());
                    }
                    if (implementPresenter.isInterface()) {
                        throw new IllegalArgumentException("Should be class " + implementPresenter.getName());
                    }
                    put(presenterInterface, new Pair<Class<? extends BasePresenter>, Class<? extends BasePresenter.View>>(implementPresenter, viewClass));
                }

                {
                    addPresenter(StudentPresenter.class, StudentPresenterImpl.class, StudentPresenter.View.class);
                    addPresenter(StudentsListPresenter.class, StudentsListPresenterImpl.class, StudentsListPresenter.View.class);
                    addPresenter(MainWindowPresenter.class, MainWindowPresenterImpl.class, MainWindowPresenter.View.class);
                }
            });


Далее bind'им объекты асинхронных интерфейсов GWT RPC, которые создал SyncProxy, Presenter'ы, с автоматическим созданием mock'а для View, EventBus и, в нашем случае, — поднимаем объекты для работы Activity and Place. Обращаю Ваше внимание на то, что всё здесь определено как Singleton. Для Presenter'ов и View это не критично, а EventBus и классы, связанные с Activity and Place должны быть именно такими — т.к. они используются во многих местах и имеют ссылки друг на друга.

     injector = Guice.createInjector(new AbstractModule() {
            @Override
            protected void configure() {
                // Автоматически биндим интерфейс и реализацию.
                for (Class gwtRpcAsyncClass : gwtRpcAsyncInstances.keySet()) {
                    bind(gwtRpcAsyncClass).toInstance(getGwtRpc(gwtRpcAsyncClass));
                }
                for (Map.Entry<Class<? extends BasePresenter>, Pair<Class<? extends BasePresenter>, Class<? extends BasePresenter.View>>> entry : presenters.entrySet()) {
                    log.info("Bind View {}", entry.getValue().getB().getName());
                    bindMock(entry.getValue().getB());
                    log.info("Bind Presenter {} to implementation {} ", entry.getKey().getName(), entry.getValue().getA().getName());
                    bind(entry.getKey()).to((Class) entry.getValue().getA()).in(Singleton.class);
                }

                EventBus eventBus = new SimpleEventBus();
                bind(EventBus.class).toInstance(eventBus);

				
                com.google.gwt.place.shared.PlaceController placeController = new com.google.gwt.place.shared.PlaceController(
                        eventBus, new com.google.gwt.place.shared.PlaceController.Delegate() {
// простая реализация Delegate, нужна, т.к. Delegate по умолчанию использует UI.
                });
                bind(com.google.gwt.place.shared.PlaceController.class).toInstance(placeController);

                bind(ActivityMapper.class).to(ru.timreset.example.gxt.client.ActivityMapper.class).in(Singleton.class);

                bind(AcceptsOneWidget.class).to(ru.timreset.example.test.base.AcceptsOneWidget.class).in(Singleton.class);
            }

            /**
             * Автоматическое создание mock объекта и биндинг его в этот же класс.
             * @param bindClass Класс mock объекта.
             */
            private void bindMock(Class bindClass) {
                Object bindObject = Mockito.mock(bindClass);
                bind(bindClass).toInstance(bindObject);
            }
        });

        ActivityManager activityManager = new ActivityManager(getInstance(ActivityMapper.class), injector.getInstance(
                EventBus.class));

        activityManager.setDisplay(getInstance(AcceptsOneWidget.class));

        final PlaceHistoryHandler historyHandler = new PlaceHistoryHandler(
                new PlaceHistoryMapper(), new PlaceHistoryHandler.Historian() {
           // простая реализация Historian, нужна, т.к. Historian по умолчанию использует UI и информацию о браузере.
        });

        historyHandler.register(injector.getInstance(com.google.gwt.place.shared.PlaceController.class),
                injector.getInstance(EventBus.class), Place.NOWHERE);

        historyHandler.handleCurrentHistory();


Пример Presenter'а StudentPresenter


/**
 * Presenter формы с полями Студента. Используется для просмотра/создания/редактирования Студента.
 */
public interface StudentPresenter extends BasePresenter<StudentPresenter.View> {

    /**
     * Метод инициализации Presenter'а перед показом.
     *
     * @param mode      Тип работы Presenter'а.
     * @param studentId id Студента.
     * @param eventBus  EventBus.
     * @param onReady   Callback который будет вызываться при успешной инициализации Presenter'а.
     */
    void init(@NotNull Mode mode, @Nullable Integer studentId, @NotNull EventBus eventBus, @NotNull Command onReady);

    /**
     * Сохранить указанного студента.
     *
     * @param student Студент.
     */
    void saveStudent(Student student);

    /**
     * Можно ли редактировать Студента.
     *
     * @return true - редактировать можно, false - редактировать нельзя.
     */
    boolean isEdit();

    /**
     * Нажатие на кнопку Редактировать.
     */
    void onEditStudent();

    /**
     * Получить текущий режим работы.
     *
     * @return Режим работы.
     */
    Mode getMode();

    /**
     * Были ли изменения редактируемого Студента.
     *
     * @return true - изменения были, false - изменений не было.
     */
    boolean isDirty();

    /**
     * Режим работы Presenter'а.
     */
    enum Mode {
        /**
         * Просмотр.
         */
        VIEW,
        /**
         * Редактирование.
         */
        EDIT,
        /**
         * Создание.
         */
        CREATE;
    }


    interface View extends BasePresenter.View<StudentPresenter> {

        /**
         * Загрузить Студента на форму.
         *
         * @param student Студент.
         */
        void setStudent(Student student);

        /**
         * Были ли изменения загруженного ранее Студента.
         *
         * @return true - изменения были, false - изменений не было.
         */
        boolean isDirty();
    }
}


Здесь описаны методы, которые должны реализовывать View и Presenter.

Сообщения от Presenter'а к View идут через интерфейс StudentPresenter.View. Это обычная схема MVP. Сообщения от View в Presenter идут через интерфейс StudentPresenter. Во многих примерах MVP так не делают — обычно делают в интерфейсе View методы по установки callback'ов (e.g. setEditClick(Callback c) ) и Presenter при инициализации View устанавливает ей callback'и (e.g. view.setEditClick(new Callback(){/*функция-обработчик*/})). View же должен в реализации методов по установки callback'ов сделать их сохранение у себя и в нужный момент должен их вызвать. Это решение мне не понравилось из за того, что будет много однотипного кода по сохранению callback'ов и во View будет много полей с этими callback'ами. А в Presenter'е будет много анонимных классов с кодом этих callback'ов, которые читаются не очень хорошо.

По этому я выбрал вариант, когда View имеет ссылку на интерфейс Presenter'а и когда нужно передать сообщение из View в Presenter, то View просто вызывает методы Presenter'а. В данной реализации минус этого решения в том, что интерфейс Presenter'а содержит фактически 2 типа методов — методы, которые нужно для взаимодействия между View и Presenter'ом (в примере выше это методы saveStudent, onEditStudent, getMode, isEdit) и методы, которые нужны для взаимодействия Presenter'а с внешним миром (это методы init, isDirty). Чтобы избежать этого минуса нужно методы взаимодействия View с Presenter'ом вынести в отдельный интерфейс и View передавать экземпляр этого интерфейса. Фактически реализовывать этот интерфейс может тот же класс что и реализует интерфейс StudentPresenter.

Пример с дополнительным интерфейсом

public interface StudentPresenter extends BasePresenter<StudentPresenter.View> {

    void init(@NotNull Mode mode, @Nullable Integer studentId, @NotNull EventBus eventBus, @NotNull Command onReady);

    boolean isDirty();

    enum Mode {
        VIEW,
        EDIT,
        CREATE;
    }
	
	/**
	* Дополнительный интерфейс для взаимодействия View с Presenter'ом.
	*/
	interface ViewPresenter extends BasePresenter.ViewPresenter{

		void saveStudent(Student student);

		boolean isEdit();

		void onEditStudent();

		Mode getMode();
	}

    interface View extends BasePresenter.View<StudentPresenter.ViewPresenter> {

        void setStudent(Student student);
		
        boolean isDirty();
    }
}


В этом случае потребители Presenter'а будут видеть только предназначенные им методы, а не внутренние методы для взаимодействия с View.

Насколько я понял, в C# задача с передачей сообщения из View в Presenter хорошо решается с помощью event'ов — при их использовании кода пишется меньше.

Пример теста


Тестировать будем список студентов и форму редактирования студента

Список студентов


Форма редактирования студента

В тесте дополнительно используются следующие методы:
/**
* Переход на указанный Place с проверкой.
*
* @param newPlace Новый Place.
*/
void goToWithAssert(Place newPlace)
/**
* Проверка, что находимся на указанном Place. Внимание! Place должны корректно реализовывать метод {@link
* Object#equals(Object)}. Иначе сравнение будет по ссылке объекта.
*
* @param expectedWhere Ожидаемое местоположение (Place).
*/
void assertWhere(Place expectedWhere);

/**
* Проверка, что сейчас отображается View указанного Presenter'а. Т.е. по сути, что сейчас на экране находится этот
* Presenter.
*
* @param presenter Presenter, View которого должна сейчас отображаться на экране.
*/
void assertWhere(BasePresenter presenter);


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

А теперь рассмотрим пример теста в котором проверяем список студентов. Приводить пример Presenter'а для списка студентов я не буду. Его можно найти по полному имени ru.timreset.example.gxt.client.presenter.StudentPresenter
	
    @Test
    public void listStudent() {
		// Получаем Presenter главного меню.
        MainWindowPresenter mainWindowPresenter = getInstance(MainWindowPresenter.class);
		// Получаем Presenter списка студентов.
        StudentsListPresenter studentsListPresenter = getInstance(StudentsListPresenter.class);
        //Нажимаем на Перейти на список студентов.
        mainWindowPresenter.goToStudentsList();
        // Проверяем, что список открыт.
        assertWhere(studentsListPresenter);		
		// Получается список студентов.
        studentsListPresenter.getStudents(new PagingLoadConfigBean(0, 999), new ru.timreset.example.gxt.client.AsyncCallback<PagingLoadResult<Student>>() {
            @Override
            public void onSuccess(PagingLoadResult<Student> result) {
                //Проверяем, что данные есть.
                Assert.assertFalse(result.getData().isEmpty());
            }
        });
    }


Это тривиальный пример, но в нём уже проверяется работа главного меню и списка студентов.

Теперь рассмотрим пример посложнее — создание студента. При создании студента проверяем полную цепочку
1) Нажимаем на «Список студентов» в меню.
2) Нажимаем на «Создать студента» в списке студентов.
3) Заполняем поля в окне создания студентов.
3) Сохраняем студента.
4) Проверяем, что сохранённый студент есть в списке.

При этом на каждых шагах проверяем, что отображаются нужные формы и списки.

@Test
public void editTest() {
		// Получаем Presenter главного меню.
        MainWindowPresenter mainWindowPresenter = getInstance(MainWindowPresenter.class);
		// Получаем Presenter списка студентов.
        StudentsListPresenter studentsListPresenter = getInstance(StudentsListPresenter.class);
		// Получаем Presenter окна студента.
        StudentPresenter studentPresenter = getInstance(StudentPresenter.class);
		
		// View окна студента.
        StudentPresenter.View studentView = getInstance(StudentPresenter.View.class);

        //Переходим на список
        mainWindowPresenter.goToStudentsList();
        // Проверяем, что список открыт. Проверяем и Place и View.
        assertWhere(StudentsListPlace.buildList());
        assertWhere(studentsListPresenter);
        // Нажимаем на Создать студента.
        studentsListPresenter.onCreateStudent();
        // Должны перейти на Создание студента.
        assertWhere(StudentPlace.buildCreate());
        // Должно быть открыто окно Редактирования студента.
        assertWhere(studentPresenter);
        //Окно должно быть в режиме создания.
        Assert.assertEquals(StudentPresenter.Mode.CREATE, studentPresenter.getMode());
        // Получаем Студента
        ArgumentCaptor<Student> studentArgumentCaptor = ArgumentCaptor.forClass(Student.class);
        Mockito.verify(studentView).setStudent(studentArgumentCaptor.capture());

        // Заполняем поля.
        Student student = studentArgumentCaptor.getValue();
        student.setName("TEST_NAME");
        student.setSurname("TEST_SURNAME");
        student.setPatronymic("TEST_PATRONYMIC");
        student.setBirthday(new Date());
        student.setStudentType(StudentType.ABSENTED);
        // Сохраняем
        studentPresenter.saveStudent(student);

        // Должно быть открыто окно Список студентов.
        assertWhere(StudentsListPlace.buildList());
        assertWhere(studentsListPresenter);
        //Получаем список студентов и проверяем, что созданный студент есть в списке.
        studentsListPresenter.getStudents(new PagingLoadConfigBean(0, 999), new ru.timreset.example.gxt.client.AsyncCallback<PagingLoadResult<Student>>() {
            @Override
            public void onSuccess(PagingLoadResult<Student> result) {
                Collection<Student> coll = Collections2.filter(result.getData(), new Predicate<Student>() {
                    @Override
                    public boolean apply(Student input) {
                        return "TEST_NAME".equals(input.getName());
                    }
                });
                //Проверяем, что данные есть.
                Assert.assertEquals(1, coll.size());
            }
        });
    }


Здесь так же как и в первом тесте переходим на список студентов. Проверяем что перешли на список. Там нажимаем на «Создать студента». Проверяем, что перешли на окно «создание студента». Проверяем и наличие самого окна (проверка, что отображается нужный View) и наличие нужно Place. Далее заполняем поля и создаём студента. И проверяем, что созданный студент есть в списке.


Заключение


Подытожим, что было сделано — разработан пример MVP архитектуры с базовыми интерфейсами и описанием взаимодействия между ними. В дополнение использованы GWT Editor, UiBinder, Activity and Place. Создано тестовое окружение. Написано несколько тестов для демонстрации подхода. К сожалению из за недостатка времени (и лени), я не реализовал полный пример с созданием групп и использованием Presenter'а списка студентов как списка для выборы студентов для добавления в группы.

Теперь отменим плюсы и ограничения этого подхода

Плюсы:
1) Можно быстро запустить тесты с покрытием цепочки вызовов «UI логика — Взаимодействие с сервером — Серверная логика — Взаимодействие с СУБД».
2) Лёгкость написания тестов (пусть объём их Вас не пугает — пишутся они легко и последовательно по шагам).
3) Вообще отделение логики и представления полезно при написании больших UI приложений. А тут

Ограничения:
1) Нельзя тестировать UI элементы. Например, если есть какая нибудь кнопка на форме то нельзя проверить, что по клику на нём происходит нужное действие, нельзя проверить что существует связь между click handler'ом для этой кнопки и вызовом соответствующего метода в Presenter'е. Частично эти ошибки предупреждаются использованием максимального простого View, но исключать их нельзя.
2) MVP больше подходит для больших приложений со сложной логикой — если есть всего несколько формочек с простой логикой, то будет большой overhead по коду и количеству классов при использовании MVP.

Вообще для себя я решил использовать этот подход — при это разработка становится даже проще, т.к. есть чёткое разделение обязанностей по классам (из за MVP) быстрая проверка работоспособности кода (из за того, что не нужно подниматься тяжёлое GWT окружение).

Дополнительная литература


GWT Mockito
www.objectpartners.com/2013/11/07/testing-gwt-with-gwtmockito

Unit and Integration Testing for GWT Applications
www.infoq.com/articles/gwt_unit_testing
Share post

Comments 4

    +1
    Чтобы не поднимать все GWT окружение для тестов, придумали вот это code.google.com/p/gwt-test-utils/

    Вполне неплохо работает.
      0
      Интересная вещь. Я её встречал, когда искал информацию по тестированию в GWT. Но меня смутило несколько моментов:
      1) Давно не поддерживается — последний релиз в сентября 2013 года для версии GWT 2.5.0.
      2) Используется reflection для получения элементов, что не отлавливается при компиляции.
      3) Далеко не все UI элементы можно корректно создать в JVM (например, все элемент SmartGWT). Так что лучше полностью отказаться от работы с UI и разделять логику и UI (не зря же придумали паттерны MV* ?!) и тестировать всё кроме UI.
      4) Там нет тестирования GWT RPC (только с mock'ами). Хотя теоретически можно прикрутить SyncProxy.

      А Вы использовали эту либу в реальных проектах? На какой версии GWT работаете? Вообще спасибо за инфу, ещё посмотрю поподробнее.
        +1
        Вы, видимо, на гитхаб не заглянули, там вполне себе есть для 2.6 github.com/gwt-test-utils/gwt-test-utils/wiki/GWT-compatibility

        Да, не все можно сымитировать, согласен, но с другой стороны такое и с классическими GWT тестами скорее всего не протестишь. Никто не спорит с тем, что нужно придерживаться разделения, но не всегда получается.

        Например я активно использую SelectionModel из GWT поставки, но при этом её нельзя использовать в junit тестах, ибо внутренняя реализация использует Scheduler.get, который нельзя замокать никакими стандартными средствами. Здесь эта библиотечка подходит идеально.

        Я всегда считал, что моков RPC сервисов вполне достаточно, а все остальное тестируется server side unit тестами (если конечно не пихать всю реализацию прямо в RPC сервлеты GWT)

        Мы используем эту штуку как часть всей системы тестирования. То есть у нас MVP (в той мере в какой это возможно) и обычные junit тесты, потом gwtmockito и вот эта библиотечка. Работаем вообще на 2.4.0 по той простой причине, что много самописных компонентов и переезжать на новую версию больно.

          0
          Посмотрел поподробнее — штука интересная. Но у нас в текущем проекте используется SmartGwt, а его не сэмулируешь — там только JS с тонкой Java-обёрткой. А так, для классического GWT или GXT gwt-test-utils получается хорош :) Кстати, не хотите написать статью об этом тестировании?

    Only users with full accounts can post comments. Log in, please.