Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Салют, коллеги.
В рамках пятничной статьи предлагаю посмотреть на интересный способ создания моков в 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>"
}