Салют, коллеги.

В рамках пятничной статьи предлагаю посмотреть на интересный способ создания моков в Kotlin, без использования сторонних библиотек.

Я занимаюсь разработкой аддонов для Atlassian-стека в компании Stiltsoft и, из-за технических ограничений, до сих пор (да в 2021 году и, скорее всего, в ближайшие пару лет) вынужден использовать Java 8. Но, чтоб не отставать от прогрессивного человечества, внутри компании мы пробуем Kotlin, пишем на нем тесты и разные экспериментальные продукты.

Однако, вернемся к тестам. Часто у нас есть интерфейс из предметной области, нам не принадлежащий, но который активно используется нашим кодом. Причем у самого интерфейса много разных методов, но в каждом сценарии используем их буквально по паре штук. Например, интерфейс ApplicationUser.

public interface ApplicationUser {
    String getKey();
 
    String getUsername();          
     
    String getEmailAddress();
 
    String getDisplayName();       
     
    long getDirectoryId();
 
    boolean isActive();
}

В разных тестах нам нужен объект типа ApplicationUser с разным набором предустановленных полей, где-то надо displayName и emailAddress, где-то только username и так далее. 

В общем случае, нам нужен способ "на лету" создавать объекты, реализующие определенный интерфейс, с возможностью произвольного переопределения методов этого объекта. 

Самое простое решение - анонимные классы

ApplicationUser user = new ApplicationUser() {
    @Override
    public String getDisplayName() {
        return "John Doe";
    }
 
    @Override
    public String getEmailAddress() {
        return "jdoe@example.com";
    }
 
    @Override
    public String toString() {
        return getDisplayName() + " <" + getEmailAddress() + ">";
    }
 
    @Override
    public String getKey() {
        return null;
    }
 
    @Override
    public String getUsername() {
        return null;
    }
 
    @Override
    public long getDirectoryId() {
        return 0;
    }
 
    @Override
    public boolean isActive() {
        return false;
    }
};

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

public abstract class AbstractApplicationUser implements ApplicationUser {
    @Override
    public String getKey() {
        return null;
    }
 
    @Override
    public String getUsername() {
        return null;
    }
 
    @Override
    public long getDirectoryId() {
        return 0;
    }
 
    @Override
    public boolean isActive() {
        return false;
    }
 
    @Override
    public String getEmailAddress() {
        return null;
    }
 
    @Override
    public String getDisplayName() {
        return null;
    }
}

и потом использовать его.

ApplicationUser user = new AbstractApplicationUser() {
    @Override
    public String getDisplayName() {
        return "John Doe";
    }
 
    @Override
    public String getEmailAddress() {
        return "jdoe@example.com";
    }
 
    @Override
    public String toString() {
        return getDisplayName() + " <" + getEmailAddress() + ">";
    }
};

Это улучшит ситуацию со строками, но класс-обертку придется написать на каждую сущность такого плана.

Более продвинутый вариант - использовать специализированную библиотеку.

ApplicationUser user = mock(ApplicationUser.class);
when(user.getDisplayName()).thenReturn("John Doe");
when(user.getEmailAddress()).thenReturn("jdoe@example.com");
 
String toString = user.getDisplayName() + " <" + user.getEmailAddress() + ">";
when(user.toString()).thenReturn(toString);

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

Я предлагаю альтернативный план: собрать решение из существующих фич Kotlin. Но сначала, небольшое теоретическое отступление про делегаты.

Один из юзкейсов делегирования, навесить какой-то дополнительный функционал на "чужой" объект, причем незаметным для конечного пользователя способом.

Например, мы отдаем объект ApplicationUser`a наружу, но хотим отправлять какое-то событие, каждый раз как у него вызовут метод getEmailAddress(). Для этого делаем свой объект, реализующий интерфейс ApplicationUser

public class EventApplicationUser implements ApplicationUser {
 
    private ApplicationUser delegate;
 
    public EventApplicationUser(ApplicationUser delegate) {
        this.delegate = delegate;
    }
 
    @Override
    public String getEmailAddress() {
        System.out.println("send event");
        return delegate.getEmailAddress();
    }
 
    @Override
    public String getDisplayName() {
        return delegate.getDisplayName();
    }
 
    @Override
    public String getKey() {
        return delegate.getKey();
    }
 
    @Override
    public String getUsername() {
        return delegate.getUsername();
    }
 
    @Override
    public long getDirectoryId() {
        return delegate.getDirectoryId();
    }
 
    @Override
    public boolean isActive() {
        return delegate.isActive();
    }
}

Используется такая конструкция следующим образом

public ApplicationUser method() {
    ApplicationUser user = getUser();
    return new EventApplicationUser(user);
}

Так вот, в Kotlin есть встроенная поддержка для такого использования делегата. И вместо простыни кода в стиле 

@Override
public String someMethod() {
    return delegate.someMethod();
}

Можно сделать так

class EventApplicationUser(private val user: ApplicationUser) : ApplicationUser by user {
    override fun getEmailAddress(): String {
        println("send event")
        return user.emailAddress
    }
}

И этот код будет работать точно так же как и его джавовский собрат. Важный момент, синтаксис делегирования работает и для анонимных классов, т.е. можно делать вот так, без предварительной подготовки классов-оберток

val user = object : ApplicationUser by originalUser {
    override fun getEmailAddress(): String {
        println("send event")
        return originalUser.emailAddress
    }
}

Теперь надо лишь как-то подготовить объект originalUser, реализующий дефолтное поведение. Тут нам пригодится возможность создать динамический прокси

Написав простую инлайн функцию 

inline fun <reified T> proxy() = Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java), { _, _, _ -> null }) as T

мы получаем возможность писать так 

val user1 = proxy<ApplicationUser>()
val user2: ApplicationUser = proxy()

Обе строки делают одно и то же, создают динамический прокси для интерфейса ApplicationUser.

Разница, чисто синтаксическая, в первом случае мы явно параметризуем нашу функцию proxy() и компилятор понимает, что результат будет типа ApplicationUser, во втором случае мы откровенно говорим, что хотим переменную типа ApplicationUser и компилятор понимает чем надо параметризовать функцию proxy().

Остается только свести все вместе

val user = object : ApplicationUser by proxy() {
    override fun getDisplayName() = "John Doe"
    override fun getEmailAddress() = "jdoe@example.com"
    override fun toString() = "$displayName <$emailAddress>"
}

Здесь мы создаем анонимный объект с интерфейсом ApplicationUser, тут же все методы делегируем в свежесозданный мок и переопределяем только нужное, без всяких оберток/заготовок под каждую сущность, естественным образом. 

p. s. Идеально, конечно было бы снять ограничение на интерфейсы и разрешить делать что-то в таком духе, но тут уже нужна поддержка со стороны компилятора 

val user = proxy<ApplicationUser>() {
    override fun getDisplayName() = "John Doe"
    override fun getEmailAddress() = "jdoe@example.com"
    override fun toString() = "$displayName <$emailAddress>"
}

Комментарии (9)


  1. Borz
    19.11.2021 23:28
    +11

    а можно просто использовать mockk:

    val user = mockk<ApplicationUser>{
      every { displayName } returns "John Doe"
      every { emailAddress } returns "jdoe@example.com"
    }

    или вообще иерархию замокировать: https://mockk.io/#hierarchical-mocking

    UPD: и обратите ещё внимание на kotest и faker


    1. dimskiy
      20.11.2021 09:25

      Автор предлагает не использовать моковый фреймворк, а вы предлагаете использовать другой моковый фреймворк, еще более многословный и поощряющий говнокод (тест статики, например)


      1. Borz
        20.11.2021 11:11
        +2

        предлагаю в Kotlin использовать mockk вместо mockito. И ни слова не написал что подход автора плохой - просто у автора свой путь. Когда понадобится не просто замокировать объект, а сделать ещё множество всего, тогда и будет видна разница предлагаемого решения или использования "многословного" фреймворка.

        статику тоже иногда надо замокировать - когда вы тестируете код выше её. Тот же "object" вполне себе статика на класс но при этом, вполне себе законная конструкция в Kotlin

        ЗЫ: на примерах уровня HelloWorld любое использование фреймворков кажется избыточным


        1. dimskiy
          20.11.2021 12:44
          -1

          Я подчеркнул только бессмысленность вашего примера. Очевидно, что фреймворк доя тестов удобнее чем его отсутствие. Что касается статики - имхо, если для тестов вам приходится ее мокать, то почти наверняка вы сделали что-то не так


          1. Borz
            20.11.2021 13:01
            +2

            смысл в примере в том, что как аргументация для статьи была озвучена фраза `код стал более "тяжелым" для восприятия` при использовании сторонней библиотеки, которая изначально не была создана под Kotlin


    1. e_Hector Автор
      20.11.2021 09:34
      +1

      Да, но в таком варианте растет количество "не естественного" синтаксиса, имею в виду конструкции библиотеки every, returns вместо "родных" языковых override

      Плюс, сходу не понятно, как сделать собственное поведение для мокируемого метода?

      Например, как будет выглядет вот такой мок, но сделанный на mockk

      fun repository(repositoryId: Int, origin: Repository? = null, project: Project? = null) = object : Repository by proxy() {
          override fun getId() = repositoryId
          override fun getState() = AVAILABLE
          override fun getOrigin() = origin
          override fun isFork() = getOrigin() != null
          override fun getProject() = project!!
          override fun equals(other: Any?) = (other as? Repository)?.id == id
          override fun hashCode() = id.hashCode()
          override fun getName() = "Repository #$id"
          override fun getSlug() = "repository-$id"
          override fun toString() = name
      }

      p.s. Вариант moсkk выглядит лучше, чем mockito, но концептуальные претензии такие же


      1. Borz
        20.11.2021 11:13
        +4

        как в вашем подходе проверить, что метод, к примеру isFork, был вызван не более одного раза?


        1. loltrol
          20.11.2021 15:15
          +4

          Очевидно же что писать свой велосипед с еще более весёлыми конструкциями. С вероятностью 99% при переходе проекта с уровня hello world на уровень решения прикладных задач выйдет или каша, или решение ничем не "уступающее" в концептуальном плане mokk.

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


        1. e_Hector Автор
          20.11.2021 17:27
          +1

          Никак :)

          На всякий случай, я не предлагаю выкинуть Mockito и ему подобные инструменты и заменить на подход из статьи.

          Я за то, чтобы не тащить к себе хоть и крутой, но избыточный инструмент.