Skip to content

Add unit testing for commands, JDA mock system #182

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Oct 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions application/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ dependencies {
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.13.0'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.0'

testImplementation 'org.mockito:mockito-core:3.12.4'
testRuntimeOnly 'org.mockito:mockito-core:3.12.4'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package org.togetherjava.tjbot.commands.basic;

import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
import org.jetbrains.annotations.NotNull;
import org.jooq.Record1;
import org.jooq.Result;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.togetherjava.tjbot.commands.SlashCommand;
import org.togetherjava.tjbot.db.Database;
import org.togetherjava.tjbot.db.generated.tables.Storage;
import org.togetherjava.tjbot.db.generated.tables.records.StorageRecord;
import org.togetherjava.tjbot.jda.JdaTester;

import java.sql.SQLException;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

final class DatabaseCommandTest {

private Database database;

private static SlashCommandEvent createGet(@NotNull String value, @NotNull SlashCommand command,
@NotNull JdaTester jdaTester) {
return jdaTester.createSlashCommandEvent(command)
.subcommand("get")
.option("key", value)
.build();
}

private static SlashCommandEvent createPut(@NotNull String key, @NotNull String value,
@NotNull SlashCommand command, @NotNull JdaTester jdaTester) {
return jdaTester.createSlashCommandEvent(command)
.subcommand("put")
.option("key", key)
.option("value", value)
.build();
}

@BeforeEach
void setupDatabase() throws SQLException {
// TODO This has to be done dynamically by the Flyway script, adjust gradle test settings
database = new Database("jdbc:sqlite:");
database.write(context -> {
context.ddl(Storage.STORAGE).executeBatch();
});
}

@Test
void getNoKey() {
SlashCommand command = new DatabaseCommand(database);
JdaTester jdaTester = new JdaTester();

SlashCommandEvent event = createGet("foo", command, jdaTester);
command.onSlashCommand(event);

verify(event, times(1)).reply("Nothing found for the key 'foo'");
}

@Test
void getValidKey() {
SlashCommand command = new DatabaseCommand(database);
JdaTester jdaTester = new JdaTester();

putIntoDatabase("foo", "bar");

SlashCommandEvent event = createGet("foo", command, jdaTester);
command.onSlashCommand(event);

verify(event, times(1)).reply("Saved message: bar");
}

@Test
void putEmpty() {
SlashCommand command = new DatabaseCommand(database);
JdaTester jdaTester = new JdaTester();

SlashCommandEvent event = createPut("foo", "bar", command, jdaTester);
command.onSlashCommand(event);

verify(event, times(1)).reply("Saved under 'foo'.");
assertValueInDatabase("foo", "bar");
}

@Test
void putOverride() {
SlashCommand command = new DatabaseCommand(database);
JdaTester jdaTester = new JdaTester();

SlashCommandEvent event = createPut("foo", "bar", command, jdaTester);
command.onSlashCommand(event);

event = createPut("foo", "baz", command, jdaTester);
command.onSlashCommand(event);

verify(event, times(1)).reply("Saved under 'foo'.");
assertValueInDatabase("foo", "baz");
}

@Test
void getPutGet() {
SlashCommand command = new DatabaseCommand(database);
JdaTester jdaTester = new JdaTester();

SlashCommandEvent getEvent = createGet("foo", command, jdaTester);
command.onSlashCommand(getEvent);
verify(getEvent, times(1)).reply("Nothing found for the key 'foo'");

SlashCommandEvent putEvent = createPut("foo", "bar", command, jdaTester);
command.onSlashCommand(putEvent);
verify(putEvent, times(1)).reply("Saved under 'foo'.");

command.onSlashCommand(getEvent);
verify(getEvent, times(1)).reply("Saved message: bar");
}

@Test
void getOrPutWithNoTable() throws SQLException {
SlashCommand command = new DatabaseCommand(new Database("jdbc:sqlite:"));
JdaTester jdaTester = new JdaTester();

SlashCommandEvent event = createGet("foo", command, jdaTester);
command.onSlashCommand(event);
verify(event, times(1)).reply("Sorry, something went wrong.");

event = createPut("foo", "bar", command, jdaTester);
command.onSlashCommand(event);
verify(event, times(1)).reply("Sorry, something went wrong.");
}

private void assertValueInDatabase(@NotNull String key, @NotNull String value) {
Result<Record1<String>> results = database.read(context -> {
try (var select = context.select(Storage.STORAGE.VALUE)) {
return select.from(Storage.STORAGE).where(Storage.STORAGE.KEY.eq(key)).fetch();
}
});
assertEquals(1, results.size());
assertEquals(value, results.get(0).get(Storage.STORAGE.VALUE));
}

private void putIntoDatabase(@NotNull String key, @NotNull String value) {
database.write(context -> {
StorageRecord storageRecord =
context.newRecord(Storage.STORAGE).setKey(key).setValue(value);
if (storageRecord.update() == 0) {
storageRecord.insert();
}
});
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.togetherjava.tjbot.commands.basic;

import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
import org.junit.jupiter.api.Test;
import org.togetherjava.tjbot.commands.SlashCommand;
import org.togetherjava.tjbot.jda.JdaTester;

import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

final class PingCommandTest {
@Test
void pingCommand() {
SlashCommand command = new PingCommand();
JdaTester jdaTester = new JdaTester();

SlashCommandEvent event = jdaTester.createSlashCommandEvent(command).build();
command.onSlashCommand(event);

verify(event, times(1)).reply("Pong!");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
final class ReloadCommandTest {
@Test
void ReloadCommand() {
// noinspection
// AnonymousInnerClassWithTooManyMethods,AnonymousInnerClass,AnonymousInnerClassMayBeStatic
@SuppressWarnings({"AnonymousInnerClassWithTooManyMethods", "AnonymousInnerClass",
"AnonymousInnerClassMayBeStatic"})
SlashCommandProvider slashCommandProvider = new SlashCommandProvider() {
@Override
public @NotNull Collection<SlashCommand> getSlashCommands() {
Expand Down
138 changes: 138 additions & 0 deletions application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package org.togetherjava.tjbot.jda;

import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.SelfUser;
import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
import net.dv8tion.jda.api.requests.restaction.MessageAction;
import net.dv8tion.jda.api.utils.ConcurrentSessionController;
import net.dv8tion.jda.api.utils.cache.CacheFlag;
import net.dv8tion.jda.internal.JDAImpl;
import net.dv8tion.jda.internal.entities.*;
import net.dv8tion.jda.internal.requests.Requester;
import net.dv8tion.jda.internal.requests.restaction.MessageActionImpl;
import net.dv8tion.jda.internal.requests.restaction.interactions.ReplyActionImpl;
import net.dv8tion.jda.internal.utils.config.AuthorizationConfig;
import org.jetbrains.annotations.NotNull;
import org.mockito.ArgumentMatchers;
import org.togetherjava.tjbot.commands.SlashCommand;

import java.util.EnumSet;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.function.UnaryOperator;

import static org.mockito.Mockito.*;

/**
* Utility class for testing {@link SlashCommand}s.
* <p>
* Mocks JDA and can create events that can be used to test {@link SlashCommand}s, e.g.
* {@link #createSlashCommandEvent(SlashCommand)}. The created events are Mockito mocks, which can
* be exploited for testing.
* <p>
* An example test using this class might look like:
*
* <pre>
* {
* &#64;code
* SlashCommand command = new PingCommand();
* JdaTester jdaTester = new JdaTester();
*
* SlashCommandEvent event = jdaTester.createSlashCommandEvent(command).build();
* command.onSlashCommand(event);
*
* verify(event, times(1)).reply("Pong!");
* }
* </pre>
*/
public final class JdaTester {
private static final ScheduledExecutorService GATEWAY_POOL = new ScheduledThreadPoolExecutor(4);
private static final ScheduledExecutorService RATE_LIMIT_POOL =
new ScheduledThreadPoolExecutor(4);
private static final String TEST_TOKEN = "TEST_TOKEN";
private static final long USER_ID = 1;
private static final long APPLICATION_ID = 1;
private static final long PRIVATE_CHANNEL_ID = 1;
private static final long GUILD_ID = 1;
private static final long TEXT_CHANNEL_ID = 1;

private final JDAImpl jda;
private final MemberImpl member;

/**
* Creates a new instance. The instance uses a fresh and isolated mocked JDA setup.
* <p>
* Reusing this instance also means to reuse guilds, text channels and such from this JDA setup,
* which can have an impact on tests. For example a previous text that already send messages to
* a channel, the messages will then still be present in the instance.
*/
public JdaTester() {
// TODO Extend this functionality, make it nicer.
// Maybe offer a builder for multiple users and channels and what not
jda = mock(JDAImpl.class);
when(jda.getCacheFlags()).thenReturn(EnumSet.noneOf(CacheFlag.class));

SelfUser selfUser = mock(SelfUserImpl.class);
UserImpl user = spy(new UserImpl(USER_ID, jda));
GuildImpl guild = spy(new GuildImpl(jda, GUILD_ID));
member = spy(new MemberImpl(guild, user));
TextChannelImpl textChannel = spy(new TextChannelImpl(TEXT_CHANNEL_ID, guild));
PrivateChannelImpl privateChannel = spy(new PrivateChannelImpl(PRIVATE_CHANNEL_ID, user));
MessageAction messageAction = mock(MessageActionImpl.class);
EntityBuilder entityBuilder = mock(EntityBuilder.class);

// TODO Depending on the commands we might need a lot more mocking here
when(entityBuilder.createUser(any())).thenReturn(user);
when(entityBuilder.createMember(any(), any())).thenReturn(member);
// TODO Giving out all permissions makes it impossible to test permission requirements on
// commands
doReturn(true).when(member).hasPermission(ArgumentMatchers.<Permission>any());
when(selfUser.getApplicationId()).thenReturn(String.valueOf(APPLICATION_ID));
when(selfUser.getApplicationIdLong()).thenReturn(APPLICATION_ID);
doReturn(selfUser).when(jda).getSelfUser();
when(jda.getGuildChannelById(anyLong())).thenReturn(textChannel);
when(jda.getPrivateChannelById(anyLong())).thenReturn(privateChannel);
when(jda.getGuildById(anyLong())).thenReturn(guild);
when(jda.getEntityBuilder()).thenReturn(entityBuilder);

when(jda.getGatewayPool()).thenReturn(GATEWAY_POOL);
when(jda.getRateLimitPool()).thenReturn(RATE_LIMIT_POOL);
when(jda.getSessionController()).thenReturn(new ConcurrentSessionController());
doReturn(new Requester(jda, new AuthorizationConfig(TEST_TOKEN))).when(jda).getRequester();

doReturn(messageAction).when(privateChannel).sendMessage(anyString());
}

/**
* Creates a Mockito mocked slash command event, which can be used for
* {@link SlashCommand#onSlashCommand(SlashCommandEvent)}.
* <p>
* The method creates a builder that can be used to further adjust the event before creation,
* e.g. provide options.
*
* @param command the command to create an event for
* @return a builder used to create a Mockito mocked slash command event
*/
public @NotNull SlashCommandEventBuilder createSlashCommandEvent(
@NotNull SlashCommand command) {
UnaryOperator<SlashCommandEvent> mockOperator = event -> {
SlashCommandEvent slashCommandEvent = spy(event);
ReplyActionImpl replyAction = mock(ReplyActionImpl.class);

doReturn(replyAction).when(slashCommandEvent).reply(anyString());
when(replyAction.setEphemeral(anyBoolean())).thenReturn(replyAction);
doReturn(member).when(slashCommandEvent).getMember();

return slashCommandEvent;
};

return new SlashCommandEventBuilder(jda, mockOperator).command(command)
.token(TEST_TOKEN)
.channelId(String.valueOf(TEXT_CHANNEL_ID))
.applicationId(String.valueOf(APPLICATION_ID))
.guildId(String.valueOf(GUILD_ID))
.userId(String.valueOf(USER_ID));
}

// TODO Add methods to create button and menu events as well
}
Loading