Repository: pajlads/DinkPlugin
Pull request #74 (2022-11-22 09:35:24)

feat: add mocked notifier tests
Utilize mockito to test all notifiers using stub methods & injected mock objects; following up from #73 

In short, we provide realistic inputs (runelite events) to each notifier and check whether the corresponding output (NotificationBody) is appropriate

pajlada: Very nice, thank you!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a81390..95cb0f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Dev: Add mockito test suite for notifiers. (#74)
 - Dev: Utilize more dependency injection. (#73)
 
 ## 1.1.0
diff --git a/README.md b/README.md
index 49373ed..d6fc623 100644
--- a/README.md
+++ b/README.md
@@ -242,7 +242,7 @@ JSON:
 
 `%TIER%` will be replaced with the combat achievement tier (e.g., Easy, Hard, Grandmaster)
 
-`%TASK` will be replaced with the name of the combat task (e.g., Peach Conjurer)
+`%TASK%` will be replaced with the name of the combat task (e.g., Peach Conjurer)
 
 ```json5
 {
diff --git a/build.gradle.kts b/build.gradle.kts
index dbf380e..d997953 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -30,6 +30,12 @@ dependencies {
     testImplementation(group = "org.junit.jupiter", name = "junit-jupiter-api", version = junitVersion)
     testImplementation(group = "org.junit.jupiter", name = "junit-jupiter-params", version = junitVersion)
     testImplementation(group = "org.junit.jupiter", name = "junit-jupiter-engine", version = junitVersion)
+
+    // mocking and test injection used by runelite client
+    testImplementation(group = "org.mockito", name = "mockito-core", version = "4.9.0") // runelite uses 3.1.0
+    testImplementation(group = "com.google.inject.extensions", name = "guice-testlib", version = "4.1.0") {
+        exclude(group = "com.google.inject", module = "guice") // already provided by runelite client
+    }
 }
 
 group = "dinkplugin"
diff --git a/src/main/java/dinkplugin/Utils.java b/src/main/java/dinkplugin/Utils.java
index abf8c6e..5444c84 100644
--- a/src/main/java/dinkplugin/Utils.java
+++ b/src/main/java/dinkplugin/Utils.java
@@ -25,7 +25,7 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -134,7 +134,7 @@ public class Utils {
     }
 
     public static <K, V> Map<K, V> reduce(@NotNull Iterable<V> items, Function<V, K> deriveKey, BinaryOperator<V> aggregate) {
-        final Map<K, V> map = new HashMap<>();
+        final Map<K, V> map = new LinkedHashMap<>();
         items.forEach(v -> map.merge(deriveKey.apply(v), v, aggregate));
         return map;
     }
diff --git a/src/main/java/dinkplugin/notifiers/LevelNotifier.java b/src/main/java/dinkplugin/notifiers/LevelNotifier.java
index f7627a0..d820099 100644
--- a/src/main/java/dinkplugin/notifiers/LevelNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/LevelNotifier.java
@@ -55,7 +55,7 @@ public class LevelNotifier extends BaseNotifier {
         }
     }
 
-    public void handleLevelUp(String skill, int level, int xp) {
+    private void handleLevelUp(String skill, int level, int xp) {
         if (!isEnabled()) return;
 
         int virtualLevel = level < 99 ? level : Experience.getLevelForXp(xp); // avoid log(n) query when not needed
@@ -75,10 +75,14 @@ public class LevelNotifier extends BaseNotifier {
         Map<String, Integer> lSkills = new HashMap<>();
 
         for (String skill : levelledSkills) {
-            if (index == levelledSkills.size()) {
-                skillMessage.append(" and ");
-            } else if (index > 0) {
-                skillMessage.append(", ");
+            if (index > 0) {
+                if (levelledSkills.size() > 2) {
+                    skillMessage.append(',');
+                }
+                skillMessage.append(' ');
+                if (index + 1 == levelledSkills.size()) {
+                    skillMessage.append("and ");
+                }
             }
             skillMessage.append(String.format("%s to %s", skill, currentLevels.get(skill)));
             lSkills.put(skill, currentLevels.get(skill));
diff --git a/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java b/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
index 4ac1009..f4aa4dd 100644
--- a/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
@@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.events.WidgetLoaded;
 import net.runelite.api.widgets.Widget;
 import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.VisibleForTesting;
 
 import java.time.Duration;
 import java.util.regex.Matcher;
@@ -17,10 +18,10 @@ import java.util.regex.Pattern;
 @Slf4j
 public class SpeedrunNotifier extends BaseNotifier {
     private static final Pattern TIME_PATTERN = Pattern.compile("(?<minutes>\\d+):(?<seconds>\\d{2})\\.(?<fractional>\\d{2})");
-    private static final int SPEEDRUN_COMPLETED_GROUP_ID = 781;
-    private static final int SPEEDRUN_COMPLETED_QUEST_NAME_CHILD_ID = 4;
-    private static final int SPEEDRUN_COMPLETED_DURATION_CHILD_ID = 10;
-    private static final int SPEEDRUN_COMPLETED_PB_CHILD_ID = 12;
+    static final @VisibleForTesting int SPEEDRUN_COMPLETED_GROUP_ID = 781;
+    static final @VisibleForTesting int SPEEDRUN_COMPLETED_QUEST_NAME_CHILD_ID = 4;
+    static final @VisibleForTesting int SPEEDRUN_COMPLETED_DURATION_CHILD_ID = 10;
+    static final @VisibleForTesting int SPEEDRUN_COMPLETED_PB_CHILD_ID = 12;
 
     @Override
     public boolean isEnabled() {
diff --git a/src/main/java/dinkplugin/notifiers/data/SpeedrunNotificationData.java b/src/main/java/dinkplugin/notifiers/data/SpeedrunNotificationData.java
index 9a33dbe..277ddfe 100644
--- a/src/main/java/dinkplugin/notifiers/data/SpeedrunNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/SpeedrunNotificationData.java
@@ -1,10 +1,12 @@
 package dinkplugin.notifiers.data;
 
 import lombok.EqualsAndHashCode;
+import lombok.ToString;
 import lombok.Value;
 
 @Value
 @EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
 public class SpeedrunNotificationData extends QuestNotificationData {
     String personalBest;
     String currentTime;


Test patch:
diff --git a/src/test/java/dinkplugin/MockedTestBase.java b/src/test/java/dinkplugin/MockedTestBase.java
new file mode 100644
index 0000000..52ad3ca
--- /dev/null
+++ b/src/test/java/dinkplugin/MockedTestBase.java
@@ -0,0 +1,24 @@
+package dinkplugin;
+
+import com.google.inject.Guice;
+import com.google.inject.testing.fieldbinder.BoundFieldModule;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.mockito.MockitoAnnotations;
+
+public abstract class MockedTestBase {
+
+    private AutoCloseable mocks;
+
+    @BeforeEach
+    protected void setUp() {
+        this.mocks = MockitoAnnotations.openMocks(this);
+        Guice.createInjector(BoundFieldModule.of(this)).injectMembers(this);
+    }
+
+    @AfterEach
+    protected void cleanUp() throws Exception {
+        mocks.close();
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/ClueNotifierTest.java b/src/test/java/dinkplugin/notifiers/ClueNotifierTest.java
new file mode 100644
index 0000000..912abaf
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/ClueNotifierTest.java
@@ -0,0 +1,122 @@
+package dinkplugin.notifiers;
+
+import com.google.inject.testing.fieldbinder.Bind;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.ClueNotificationData;
+import dinkplugin.notifiers.data.SerializedItemStack;
+import net.runelite.api.ItemComposition;
+import net.runelite.api.ItemID;
+import net.runelite.api.events.WidgetLoaded;
+import net.runelite.api.widgets.Widget;
+import net.runelite.api.widgets.WidgetID;
+import net.runelite.api.widgets.WidgetInfo;
+import net.runelite.client.game.ItemManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.util.Collections;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ClueNotifierTest extends MockedNotifierTest {
+
+    private static final int RUBY_PRICE = 900;
+    private static final int TUNA_PRICE = 100;
+
+    @Bind
+    @Mock
+    ItemManager itemManager;
+
+    @InjectMocks
+    ClueNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyClue()).thenReturn(true);
+        when(config.clueSendImage()).thenReturn(false);
+        when(config.clueShowItems()).thenReturn(false);
+        when(config.clueMinValue()).thenReturn(500);
+        when(config.clueNotifyMessage()).thenReturn("%USERNAME% has completed a %CLUE% clue, for a total of %COUNT%. They obtained: %LOOT%");
+
+        // init item mocks
+        when(itemManager.getItemPrice(ItemID.RUBY)).thenReturn(RUBY_PRICE);
+        ItemComposition ruby = mock(ItemComposition.class);
+        when(ruby.getName()).thenReturn("Ruby");
+        when(itemManager.getItemComposition(ItemID.RUBY)).thenReturn(ruby);
+
+        when(itemManager.getItemPrice(ItemID.TUNA)).thenReturn(TUNA_PRICE);
+        ItemComposition tuna = mock(ItemComposition.class);
+        when(tuna.getName()).thenReturn("Tuna");
+        when(itemManager.getItemComposition(ItemID.TUNA)).thenReturn(tuna);
+    }
+
+    @Test
+    void testNotify() {
+        // fire chat event
+        notifier.onChatMessage("You have completed 1312 medium Treasure Trails.");
+
+        // mock widgets
+        Widget widget = mock(Widget.class);
+        when(client.getWidget(WidgetInfo.CLUE_SCROLL_REWARD_ITEM_CONTAINER)).thenReturn(widget);
+
+        Widget child = mock(Widget.class);
+        when(child.getItemQuantity()).thenReturn(1);
+        when(child.getItemId()).thenReturn(ItemID.RUBY);
+
+        Widget[] children = { child };
+        when(widget.getChildren()).thenReturn(children);
+
+        // fire widget event
+        WidgetLoaded event = new WidgetLoaded();
+        event.setGroupId(WidgetID.CLUE_SCROLL_REWARD_GROUP_ID);
+        notifier.onWidgetLoaded(event);
+
+        // verify notification message
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has completed a %s clue, for a total of %d. They obtained: %s", PLAYER_NAME, "medium", 1312, "1 x Ruby (" + RUBY_PRICE + ")"))
+                .extra(new ClueNotificationData("medium", 1312, Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"))))
+                .type(NotificationType.CLUE)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoreLoot() {
+        // fire chat event
+        notifier.onChatMessage("You have completed 1337 medium Treasure Trails.");
+
+        // mock widgets
+        Widget widget = mock(Widget.class);
+        when(client.getWidget(WidgetInfo.CLUE_SCROLL_REWARD_ITEM_CONTAINER)).thenReturn(widget);
+
+        Widget child = mock(Widget.class);
+        when(child.getItemQuantity()).thenReturn(1);
+        when(child.getItemId()).thenReturn(ItemID.TUNA);
+
+        Widget[] children = { child };
+        when(widget.getChildren()).thenReturn(children);
+
+        // fire widget event
+        WidgetLoaded event = new WidgetLoaded();
+        event.setGroupId(WidgetID.CLUE_SCROLL_REWARD_GROUP_ID);
+        notifier.onWidgetLoaded(event);
+
+        // ensure no notification was fired
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java b/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
new file mode 100644
index 0000000..af7ba94
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
@@ -0,0 +1,59 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.CollectionNotificationData;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CollectionNotifierTest extends MockedNotifierTest {
+
+    @InjectMocks
+    CollectionNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyCollectionLog()).thenReturn(true);
+        when(config.collectionSendImage()).thenReturn(false);
+        when(config.collectionNotifyMessage()).thenReturn("%USERNAME% has added %ITEM% to their collection");
+    }
+
+    @Test
+    void testNotify() {
+        String item = "Seercull";
+
+        // send fake message
+        notifier.onChatMessage("New item added to your collection log: " + item);
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has added %s to their collection", PLAYER_NAME, item))
+                .extra(new CollectionNotificationData(item))
+                .type(NotificationType.COLLECTION)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnore() {
+        // send unrelated message
+        notifier.onChatMessage("New item added to your backpack: weed");
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/CombatTaskMatcherTest.java b/src/test/java/dinkplugin/notifiers/CombatTaskMatcherTest.java
new file mode 100644
index 0000000..ff1bbee
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/CombatTaskMatcherTest.java
@@ -0,0 +1,77 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.domain.CombatAchievementTier;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.stream.Stream;
+
+import static dinkplugin.domain.CombatAchievementTier.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+class CombatTaskMatcherTest {
+
+    @ParameterizedTest(name = "Combat Achievement regex should match: {0}")
+    @ArgumentsSource(AchievementProvider.class)
+    void shouldParse(String message, Pair<CombatAchievementTier, String> expected) {
+        assertEquals(expected, CombatTaskNotifier.parse(message).orElse(null));
+    }
+
+    @ParameterizedTest(name = "Combat Achievement regex should not match: {0}")
+    @ValueSource(
+        strings = {
+            "Forsen: forsen",
+            "Your heriboar harvest count is: 69.",
+            "Your King Black Dragon kill count is: 581.",
+            "You have completed your task! You killed 31 TzKal-Zuk. You gained 75 xp.",
+            "Congratulations, you just advanced a Strength level.",
+            "Congratulations, you've completed a gachi combat task: Swordfight with the homies."
+        }
+    )
+    void shouldNotParse(String message) {
+        assertFalse(CombatTaskNotifier.parse(message).isPresent());
+    }
+
+    private static class AchievementProvider implements ArgumentsProvider {
+        @Override
+        public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
+            return Stream.of(
+                Arguments.of(
+                    "Congratulations, you've completed an easy combat task: Defence? What Defence?.",
+                    Pair.of(EASY, "Defence? What Defence?")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed an easy combat task: A Slow Death.",
+                    Pair.of(EASY, "A Slow Death")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed a medium combat task: Fire in the Hole!.",
+                    Pair.of(MEDIUM, "Fire in the Hole!")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed a medium combat task: I'd rather Be Illiterate.",
+                    Pair.of(MEDIUM, "I'd rather Be Illiterate")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed a hard combat task: Whack-a-Mole.",
+                    Pair.of(HARD, "Whack-a-Mole")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed a master combat task: Nibblers, Begone!.",
+                    Pair.of(MASTER, "Nibblers, Begone!")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed a grandmaster combat task: Inferno Grandmaster.",
+                    Pair.of(GRANDMASTER, "Inferno Grandmaster")
+                )
+            );
+        }
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java b/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java
index 4bdc790..504953e 100644
--- a/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java
@@ -1,77 +1,70 @@
 package dinkplugin.notifiers;
 
+import dinkplugin.domain.AchievementDiaries;
 import dinkplugin.domain.CombatAchievementTier;
-import org.apache.commons.lang3.tuple.Pair;
-import org.junit.jupiter.api.extension.ExtensionContext;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.ArgumentsProvider;
-import org.junit.jupiter.params.provider.ArgumentsSource;
-import org.junit.jupiter.params.provider.ValueSource;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.CombatAchievementData;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
 
-import java.util.stream.Stream;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
-import static dinkplugin.domain.CombatAchievementTier.*;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
+class CombatTaskNotifierTest extends MockedNotifierTest {
 
-class CombatTaskNotifierTest {
+    @InjectMocks
+    CombatTaskNotifier notifier;
 
-    @ParameterizedTest(name = "Combat Achievement regex should match: {0}")
-    @ArgumentsSource(AchievementProvider.class)
-    void shouldParse(String message, Pair<CombatAchievementTier, String> expected) {
-        assertEquals(expected, CombatTaskNotifier.parse(message).orElse(null));
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyCombatTask()).thenReturn(true);
+        when(config.combatTaskSendImage()).thenReturn(false);
+        when(config.combatTaskMessage()).thenReturn("%USERNAME% has completed %TIER% combat task: %TASK%");
+        when(config.minCombatAchievementTier()).thenReturn(CombatAchievementTier.HARD);
+    }
+
+    @Test
+    void testNotify() {
+        // send fake message
+        notifier.onGameMessage("Congratulations, you've completed a hard combat task: Whack-a-Mole.");
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has completed %s combat task: %s", PLAYER_NAME, AchievementDiaries.Difficulty.HARD, "Whack-a-Mole"))
+                .extra(new CombatAchievementData(CombatAchievementTier.HARD, "Whack-a-Mole"))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.COMBAT_ACHIEVEMENT)
+                .build()
+        );
     }
 
-    @ParameterizedTest(name = "Combat Achievement regex should not match: {0}")
-    @ValueSource(
-        strings = {
-            "Forsen: forsen",
-            "Your heriboar harvest count is: 69.",
-            "Your King Black Dragon kill count is: 581.",
-            "You have completed your task! You killed 31 TzKal-Zuk. You gained 75 xp.",
-            "Congratulations, you just advanced a Strength level.",
-            "Congratulations, you've completed a gachi combat task: Swordfight with the homies."
-        }
-    )
-    void shouldNotParse(String message) {
-        assertFalse(CombatTaskNotifier.parse(message).isPresent());
+    @Test
+    void testSkipped() {
+        // send too easy achievement
+        notifier.onGameMessage("Congratulations, you've completed an easy combat task: A Slow Death.");
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
     }
 
-    private static class AchievementProvider implements ArgumentsProvider {
-        @Override
-        public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
-            return Stream.of(
-                Arguments.of(
-                    "Congratulations, you've completed an easy combat task: Defence? What Defence?.",
-                    Pair.of(EASY, "Defence? What Defence?")
-                ),
-                Arguments.of(
-                    "Congratulations, you've completed an easy combat task: A Slow Death.",
-                    Pair.of(EASY, "A Slow Death")
-                ),
-                Arguments.of(
-                    "Congratulations, you've completed a medium combat task: Fire in the Hole!.",
-                    Pair.of(MEDIUM, "Fire in the Hole!")
-                ),
-                Arguments.of(
-                    "Congratulations, you've completed a medium combat task: I'd rather Be Illiterate.",
-                    Pair.of(MEDIUM, "I'd rather Be Illiterate")
-                ),
-                Arguments.of(
-                    "Congratulations, you've completed a hard combat task: Whack-a-Mole.",
-                    Pair.of(HARD, "Whack-a-Mole")
-                ),
-                Arguments.of(
-                    "Congratulations, you've completed a master combat task: Nibblers, Begone!.",
-                    Pair.of(MASTER, "Nibblers, Begone!")
-                ),
-                Arguments.of(
-                    "Congratulations, you've completed a grandmaster combat task: Inferno Grandmaster.",
-                    Pair.of(GRANDMASTER, "Inferno Grandmaster")
-                )
-            );
-        }
+    @Test
+    void testIgnore() {
+        // send unrelated message
+        notifier.onGameMessage("Congratulations, you've completed a gachi combat task: Swordfight with the homies.");
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
     }
 
 }
diff --git a/src/test/java/dinkplugin/notifiers/DeathNotifierItemTest.java b/src/test/java/dinkplugin/notifiers/DeathNotifierItemTest.java
new file mode 100644
index 0000000..8cf0b2e
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/DeathNotifierItemTest.java
@@ -0,0 +1,84 @@
+package dinkplugin.notifiers;
+
+import net.runelite.api.Item;
+import net.runelite.api.ItemID;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.function.Function;
+
+import static dinkplugin.notifiers.DeathNotifier.splitItemsByKept;
+import static java.util.Arrays.asList;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DeathNotifierItemTest {
+    private static final Pair<Item, Long> BOND, EGG, GRAIN, POT, SALMON, TUNA, BAG, CLUE, JESTER, TROPHY, AVA;
+
+    @Test
+    void testSplit() {
+        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(EGG, GRAIN, POT, SALMON, TUNA), 3);
+        assertEquals(asList(EGG, GRAIN, POT), split.getLeft());
+        assertEquals(asList(SALMON, TUNA), split.getRight());
+    }
+
+    @Test
+    void testSplitProtected() {
+        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(EGG, GRAIN, POT, SALMON, TUNA), 4);
+        assertEquals(asList(EGG, GRAIN, POT, SALMON), split.getLeft());
+        assertEquals(singletonList(TUNA), split.getRight());
+    }
+
+    @Test
+    void testSplitBond() {
+        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(BOND, BOND, BOND, BOND, BOND, EGG), 3);
+        assertEquals(asList(BOND, BOND, BOND, BOND, BOND, EGG), split.getLeft());
+        assertTrue(split.getRight().isEmpty());
+    }
+
+    @Test
+    void testSplitSkulled() {
+        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(BOND, GRAIN), 0);
+        assertEquals(singletonList(BOND), split.getLeft());
+        assertEquals(singletonList(GRAIN), split.getRight());
+    }
+
+    @Test
+    void testSplitSkulledProtect() {
+        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(BOND, GRAIN, EGG), 1);
+        assertEquals(asList(BOND, GRAIN), split.getLeft());
+        assertEquals(singletonList(EGG), split.getRight());
+    }
+
+    @Test
+    void testSplitSkulledProtectOrdered() {
+        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(BOND, EGG, GRAIN), 1);
+        assertEquals(asList(BOND, EGG), split.getLeft());
+        assertEquals(singletonList(GRAIN), split.getRight());
+    }
+
+    @Test
+    void testSplitNeverKept() {
+        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(BAG, EGG, CLUE, BOND, JESTER, TROPHY, GRAIN, AVA, POT, TUNA), 3);
+        assertEquals(asList(EGG, BOND, GRAIN, POT), split.getLeft());
+        assertEquals(asList(BAG, CLUE, JESTER, TROPHY, AVA, TUNA), split.getRight());
+    }
+
+    static {
+        Function<Integer, Pair<Item, Long>> item = id -> Pair.of(new Item(id, 1), 0L);
+
+        BOND = item.apply(ItemID.OLD_SCHOOL_BOND);
+        EGG = item.apply(ItemID.EGG);
+        GRAIN = item.apply(ItemID.GRAIN);
+        POT = item.apply(ItemID.POT);
+        SALMON = item.apply(ItemID.SALMON);
+        TUNA = item.apply(ItemID.TUNA);
+        BAG = item.apply(ItemID.LOOTING_BAG);
+        CLUE = item.apply(ItemID.CLUE_BOX);
+        JESTER = item.apply(ItemID.SILLY_JESTER_HAT);
+        TROPHY = item.apply(ItemID.TRAILBLAZER_DRAGON_TROPHY);
+        AVA = item.apply(ItemID.AVAS_ACCUMULATOR);
+    }
+}
diff --git a/src/test/java/dinkplugin/notifiers/DeathNotifierTest.java b/src/test/java/dinkplugin/notifiers/DeathNotifierTest.java
index 1454b2f..fa5b5b4 100644
--- a/src/test/java/dinkplugin/notifiers/DeathNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/DeathNotifierTest.java
@@ -1,84 +1,75 @@
 package dinkplugin.notifiers;
 
-import net.runelite.api.Item;
-import net.runelite.api.ItemID;
-import org.apache.commons.lang3.tuple.Pair;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.DeathNotificationData;
+import net.runelite.api.Player;
+import net.runelite.api.Varbits;
+import net.runelite.api.coords.WorldPoint;
+import net.runelite.api.events.ActorDeath;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
 
-import java.util.List;
-import java.util.function.Function;
+import java.util.Collections;
 
-import static dinkplugin.notifiers.DeathNotifier.splitItemsByKept;
-import static java.util.Arrays.asList;
-import static java.util.Collections.singletonList;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
-class DeathNotifierTest {
-    private static final Pair<Item, Long> BOND, EGG, GRAIN, POT, SALMON, TUNA, BAG, CLUE, JESTER, TROPHY, AVA;
+class DeathNotifierTest extends MockedNotifierTest {
 
-    @Test
-    void testSplit() {
-        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(EGG, GRAIN, POT, SALMON, TUNA), 3);
-        assertEquals(asList(EGG, GRAIN, POT), split.getLeft());
-        assertEquals(asList(SALMON, TUNA), split.getRight());
-    }
+    @InjectMocks
+    DeathNotifier notifier;
 
-    @Test
-    void testSplitProtected() {
-        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(EGG, GRAIN, POT, SALMON, TUNA), 4);
-        assertEquals(asList(EGG, GRAIN, POT, SALMON), split.getLeft());
-        assertEquals(singletonList(TUNA), split.getRight());
-    }
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
 
-    @Test
-    void testSplitBond() {
-        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(BOND, BOND, BOND, BOND, BOND, EGG), 3);
-        assertEquals(asList(BOND, BOND, BOND, BOND, BOND, EGG), split.getLeft());
-        assertTrue(split.getRight().isEmpty());
-    }
+        // init config mocks
+        when(config.notifyDeath()).thenReturn(true);
+        when(config.deathNotifPvpEnabled()).thenReturn(true);
+        when(config.deathSendImage()).thenReturn(false);
+        when(config.deathNotifyMessage()).thenReturn("%USERNAME% has died, losing %VALUELOST% gp");
+        when(config.deathNotifPvpMessage()).thenReturn("%USERNAME% has just been PKed by %PKER% for %VALUELOST% gp...");
 
-    @Test
-    void testSplitSkulled() {
-        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(BOND, GRAIN), 0);
-        assertEquals(singletonList(BOND), split.getLeft());
-        assertEquals(singletonList(GRAIN), split.getRight());
+        // init client mocks
+        when(client.getVarbitValue(Varbits.IN_WILDERNESS)).thenReturn(1);
+        when(client.getPlayers()).thenReturn(Collections.emptyList());
+        WorldPoint location = new WorldPoint(0, 0, 0);
+        when(localPlayer.getWorldLocation()).thenReturn(location);
     }
 
     @Test
-    void testSplitSkulledProtect() {
-        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(BOND, GRAIN, EGG), 1);
-        assertEquals(asList(BOND, GRAIN), split.getLeft());
-        assertEquals(singletonList(EGG), split.getRight());
-    }
+    void testNotifyEmpty() {
+        // fire event
+        notifier.onActorDeath(new ActorDeath(localPlayer));
 
-    @Test
-    void testSplitSkulledProtectOrdered() {
-        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(BOND, EGG, GRAIN), 1);
-        assertEquals(asList(BOND, EGG), split.getLeft());
-        assertEquals(singletonList(GRAIN), split.getRight());
+        // verify notification
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has died, losing %d gp", PLAYER_NAME, 0))
+                .extra(new DeathNotificationData(0L, false, null, Collections.emptyList(), Collections.emptyList()))
+                .type(NotificationType.DEATH)
+                .build()
+        );
     }
 
     @Test
-    void testSplitNeverKept() {
-        Pair<List<Pair<Item, Long>>, List<Pair<Item, Long>>> split = splitItemsByKept(asList(BAG, EGG, CLUE, BOND, JESTER, TROPHY, GRAIN, AVA, POT, TUNA), 3);
-        assertEquals(asList(EGG, BOND, GRAIN, POT), split.getLeft());
-        assertEquals(asList(BAG, CLUE, JESTER, TROPHY, AVA, TUNA), split.getRight());
-    }
+    void testIgnore() {
+        // prepare mock
+        Player other = mock(Player.class);
 
-    static {
-        Function<Integer, Pair<Item, Long>> item = id -> Pair.of(new Item(id, 1), 0L);
+        // fire event
+        notifier.onActorDeath(new ActorDeath(other));
 
-        BOND = item.apply(ItemID.OLD_SCHOOL_BOND);
-        EGG = item.apply(ItemID.EGG);
-        GRAIN = item.apply(ItemID.GRAIN);
-        POT = item.apply(ItemID.POT);
-        SALMON = item.apply(ItemID.SALMON);
-        TUNA = item.apply(ItemID.TUNA);
-        BAG = item.apply(ItemID.LOOTING_BAG);
-        CLUE = item.apply(ItemID.CLUE_BOX);
-        JESTER = item.apply(ItemID.SILLY_JESTER_HAT);
-        TROPHY = item.apply(ItemID.TRAILBLAZER_DRAGON_TROPHY);
-        AVA = item.apply(ItemID.AVAS_ACCUMULATOR);
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
     }
+
 }
diff --git a/src/test/java/dinkplugin/notifiers/DiaryNotifierTest.java b/src/test/java/dinkplugin/notifiers/DiaryNotifierTest.java
new file mode 100644
index 0000000..339f460
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/DiaryNotifierTest.java
@@ -0,0 +1,158 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.domain.AchievementDiaries;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.DiaryNotificationData;
+import net.runelite.api.GameState;
+import net.runelite.api.Varbits;
+import net.runelite.api.events.VarbitChanged;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import java.util.stream.IntStream;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@SuppressWarnings("MagicConstant")
+class DiaryNotifierTest extends MockedNotifierTest {
+
+    @InjectMocks
+    DiaryNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyAchievementDiary()).thenReturn(true);
+        when(config.diarySendImage()).thenReturn(false);
+        when(config.minDiaryDifficulty()).thenReturn(AchievementDiaries.Difficulty.MEDIUM);
+        when(config.diaryNotifyMessage()).thenReturn("%USERNAME% has completed the %DIFFICULTY% %AREA% Diary, for a total of %TOTAL%");
+
+        // init client mocks
+        when(client.getGameState()).thenReturn(GameState.LOGGED_IN);
+    }
+
+    @Test
+    void testNotifyFirst() {
+        // initially 0 diary completions
+        when(client.getVarbitValue(anyInt())).thenReturn(0);
+
+        // perform enough ticks to trigger diary initialization
+        IntStream.range(0, 16).forEach(i -> notifier.onTick());
+
+        // trigger diary completion
+        int id = Varbits.DIARY_DESERT_ELITE;
+        notifier.onVarbitChanged(event(id, 1));
+
+        // verify notification message
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has completed the %s %s Diary, for a total of %d", PLAYER_NAME, AchievementDiaries.Difficulty.ELITE, "Desert", 1))
+                .extra(new DiaryNotificationData("Desert", AchievementDiaries.Difficulty.ELITE, 1))
+                .type(NotificationType.ACHIEVEMENT_DIARY)
+                .playerName(PLAYER_NAME)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyKaramja() {
+        // initially many diary completions
+        when(client.getVarbitValue(anyInt())).thenReturn(1);
+        int total = AchievementDiaries.INSTANCE.getDiaries().size() - 3;
+
+        // perform enough ticks to trigger diary initialization
+        IntStream.range(0, 16).forEach(i -> notifier.onTick());
+
+        // trigger diary started
+        int id = Varbits.DIARY_KARAMJA_HARD;
+        notifier.onVarbitChanged(event(id, 2));
+
+        // verify notification message
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has completed the %s %s Diary, for a total of %d", PLAYER_NAME, AchievementDiaries.Difficulty.HARD, "Karamja", total + 1))
+                .extra(new DiaryNotificationData("Karamja", AchievementDiaries.Difficulty.HARD, total + 1))
+                .type(NotificationType.ACHIEVEMENT_DIARY)
+                .playerName(PLAYER_NAME)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoreKaramja() {
+        // initially 0 diary completions
+        when(client.getVarbitValue(anyInt())).thenReturn(0);
+
+        // perform enough ticks to trigger diary initialization
+        IntStream.range(0, 16).forEach(i -> notifier.onTick());
+
+        // trigger diary started
+        int id = Varbits.DIARY_KARAMJA_HARD;
+        notifier.onVarbitChanged(event(id, 1));
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreCompleted() {
+        // initially many diary completions
+        when(client.getVarbitValue(anyInt())).thenReturn(1);
+
+        // perform enough ticks to trigger diary initialization
+        IntStream.range(0, 16).forEach(i -> notifier.onTick());
+
+        // trigger varbit event
+        int id = Varbits.DIARY_DESERT_ELITE;
+        notifier.onVarbitChanged(event(id, 1));
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreUninitialized() {
+        // trigger varbit event
+        int id = Varbits.DIARY_DESERT_ELITE;
+        notifier.onVarbitChanged(event(id, 1));
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreDifficulty() {
+        // initially 0 diary completions
+        when(client.getVarbitValue(anyInt())).thenReturn(0);
+
+        // perform enough ticks to trigger diary initialization
+        IntStream.range(0, 16).forEach(i -> notifier.onTick());
+
+        // trigger varbit event
+        int id = Varbits.DIARY_FALADOR_EASY;
+        notifier.onVarbitChanged(event(id, 1));
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+    private static VarbitChanged event(int id, int value) {
+        VarbitChanged event = new VarbitChanged();
+        event.setVarbitId(id);
+        event.setValue(value);
+        return event;
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java b/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java
new file mode 100644
index 0000000..3c82a4d
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java
@@ -0,0 +1,90 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.BossNotificationData;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class KillCountNotifierTest extends MockedNotifierTest {
+
+    @InjectMocks
+    KillCountNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyKillCount()).thenReturn(true);
+        when(config.killCountSendImage()).thenReturn(true);
+        when(config.killCountMessage()).thenReturn("%USERNAME% has defeated %BOSS% with a completion count of %COUNT%");
+    }
+
+    @Test
+    void testNotifyInterval() {
+        // more config
+        when(config.killCountNotifyInitial()).thenReturn(false);
+        when(config.killCountInterval()).thenReturn(70);
+
+        // fire event
+        String gameMessage = "Your King Black Dragon kill count is: 420.";
+        notifier.onGameMessage(gameMessage);
+
+        // check notification
+        verify(messageHandler).createMessage(
+            true,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has defeated King Black Dragon with a completion count of 420")
+                .extra(new BossNotificationData("King Black Dragon", 420, gameMessage))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.KILL_COUNT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyInitial() {
+        // more config
+        when(config.killCountNotifyInitial()).thenReturn(true);
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire event
+        String gameMessage = "Your King Black Dragon kill count is: 1.";
+        notifier.onGameMessage(gameMessage);
+
+        // check notification
+        verify(messageHandler).createMessage(
+            true,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has defeated King Black Dragon with a completion count of 1")
+                .extra(new BossNotificationData("King Black Dragon", 1, gameMessage))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.KILL_COUNT)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnore() {
+        // more config
+        when(config.killCountNotifyInitial()).thenReturn(false);
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire event
+        String gameMessage = "Your King Black Dragon kill count is: 1.";
+        notifier.onGameMessage(gameMessage);
+
+        // ensure no message
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java b/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
new file mode 100644
index 0000000..6e3e186
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
@@ -0,0 +1,134 @@
+package dinkplugin.notifiers;
+
+import com.google.common.collect.ImmutableMap;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.LevelNotificationData;
+import net.runelite.api.Skill;
+import net.runelite.api.events.StatChanged;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import java.util.stream.IntStream;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LevelNotifierTest extends MockedNotifierTest {
+
+    @InjectMocks
+    LevelNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyLevel()).thenReturn(true);
+        when(config.levelSendImage()).thenReturn(false);
+        when(config.levelInterval()).thenReturn(5);
+        when(config.levelNotifyMessage()).thenReturn("%USERNAME% has levelled %SKILL%");
+
+        // init base level
+        notifier.onStatChanged(new StatChanged(Skill.AGILITY, 0, 1, 1));
+        notifier.onStatChanged(new StatChanged(Skill.ATTACK, 14_000_000, 99, 99));
+        notifier.onStatChanged(new StatChanged(Skill.HUNTER, 300, 4, 4));
+    }
+
+    @Test
+    void testNotify() {
+        // fire skill event
+        notifier.onStatChanged(new StatChanged(Skill.AGILITY, 400, 5, 5));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has levelled Agility to 5")
+                .extra(new LevelNotificationData(ImmutableMap.of("Agility", 5), ImmutableMap.of("Agility", 5, "Attack", 99, "Hunter", 4)))
+                .type(NotificationType.LEVEL)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyVirtual() {
+        // fire skill event
+        notifier.onStatChanged(new StatChanged(Skill.ATTACK, 15_000_000, 99, 100));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has levelled Attack to 100")
+                .extra(new LevelNotificationData(ImmutableMap.of("Attack", 100), ImmutableMap.of("Agility", 1, "Attack", 100, "Hunter", 4)))
+                .type(NotificationType.LEVEL)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyTwo() {
+        // fire skill events
+        notifier.onStatChanged(new StatChanged(Skill.AGILITY, 400, 5, 5));
+        notifier.onStatChanged(new StatChanged(Skill.ATTACK, 15_000_000, 99, 100));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has levelled Agility to 5 and Attack to 100")
+                .extra(new LevelNotificationData(ImmutableMap.of("Agility", 5, "Attack", 100), ImmutableMap.of("Agility", 5, "Attack", 100, "Hunter", 4)))
+                .type(NotificationType.LEVEL)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyMany() {
+        // fire skill events
+        notifier.onStatChanged(new StatChanged(Skill.AGILITY, 400, 5, 5));
+        notifier.onStatChanged(new StatChanged(Skill.ATTACK, 15_000_000, 99, 100));
+        notifier.onStatChanged(new StatChanged(Skill.HUNTER, 400, 5, 5));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has levelled Agility to 5, Attack to 100, and Hunter to 5")
+                .extra(new LevelNotificationData(ImmutableMap.of("Agility", 5, "Attack", 100, "Hunter", 5), ImmutableMap.of("Agility", 5, "Attack", 100, "Hunter", 5)))
+                .type(NotificationType.LEVEL)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoreInterval() {
+        // fire skill event
+        notifier.onStatChanged(new StatChanged(Skill.AGILITY, 100, 2, 2));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/LootNotifierTest.java b/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
new file mode 100644
index 0000000..8e9c899
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
@@ -0,0 +1,243 @@
+package dinkplugin.notifiers;
+
+import com.google.inject.testing.fieldbinder.Bind;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.LootNotificationData;
+import dinkplugin.notifiers.data.SerializedItemStack;
+import net.runelite.api.ItemComposition;
+import net.runelite.api.ItemID;
+import net.runelite.api.NPC;
+import net.runelite.api.Player;
+import net.runelite.client.events.NpcLootReceived;
+import net.runelite.client.events.PlayerLootReceived;
+import net.runelite.client.game.ItemManager;
+import net.runelite.client.game.ItemStack;
+import net.runelite.client.plugins.loottracker.LootReceived;
+import net.runelite.client.util.QuantityFormatter;
+import net.runelite.http.api.loottracker.LootRecordType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LootNotifierTest extends MockedNotifierTest {
+
+    private static final int RUBY_PRICE = 900;
+    private static final int OPAL_PRICE = 600;
+    private static final int TUNA_PRICE = 100;
+    private static final String LOOTED_NAME = "Rasmus";
+
+    @Bind
+    @Mock
+    ItemManager itemManager;
+
+    @InjectMocks
+    LootNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyLoot()).thenReturn(true);
+        when(config.lootSendImage()).thenReturn(false);
+        when(config.lootIcons()).thenReturn(false);
+        when(config.minLootValue()).thenReturn(500);
+        when(config.lootNotifyMessage()).thenReturn("%USERNAME% has looted: %LOOT% from %SOURCE% for %TOTAL_VALUE% gp");
+
+        // init item mocks
+        when(itemManager.getItemPrice(ItemID.RUBY)).thenReturn(RUBY_PRICE);
+        ItemComposition ruby = mock(ItemComposition.class);
+        when(ruby.getName()).thenReturn("Ruby");
+        when(itemManager.getItemComposition(ItemID.RUBY)).thenReturn(ruby);
+
+        when(itemManager.getItemPrice(ItemID.OPAL)).thenReturn(OPAL_PRICE);
+        ItemComposition opal = mock(ItemComposition.class);
+        when(opal.getName()).thenReturn("Opal");
+        when(itemManager.getItemComposition(ItemID.OPAL)).thenReturn(opal);
+
+        when(itemManager.getItemPrice(ItemID.TUNA)).thenReturn(TUNA_PRICE);
+        ItemComposition tuna = mock(ItemComposition.class);
+        when(tuna.getName()).thenReturn("Tuna");
+        when(itemManager.getItemComposition(ItemID.TUNA)).thenReturn(tuna);
+    }
+
+    @Test
+    void testNotifyNpc() {
+        // prepare mocks
+        NPC npc = mock(NPC.class);
+        String name = "Rasmus";
+        when(npc.getName()).thenReturn(name);
+
+        // fire event
+        NpcLootReceived event = new NpcLootReceived(npc, Collections.singletonList(new ItemStack(ItemID.RUBY, 1, null)));
+        notifier.onNpcLootReceived(event);
+
+        // verify notification message
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has looted: %s from %s for %d gp", PLAYER_NAME, "1 x Ruby (" + RUBY_PRICE + ")", name, RUBY_PRICE))
+                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), name))
+                .type(NotificationType.LOOT)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoreNpc() {
+        // prepare mocks
+        NPC npc = mock(NPC.class);
+        when(npc.getName()).thenReturn(LOOTED_NAME);
+
+        // fire event
+        NpcLootReceived event = new NpcLootReceived(npc, Collections.singletonList(new ItemStack(ItemID.TUNA, 1, null)));
+        notifier.onNpcLootReceived(event);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+    @Test
+    void testNotifyPickpocket() {
+        // fire event
+        LootReceived event = new LootReceived(LOOTED_NAME, 99, LootRecordType.PICKPOCKET, Collections.singletonList(new ItemStack(ItemID.RUBY, 1, null)), RUBY_PRICE);
+        notifier.onLootReceived(event);
+
+        // verify notification message
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has looted: %s from %s for %d gp", PLAYER_NAME, "1 x Ruby (" + RUBY_PRICE + ")", LOOTED_NAME, RUBY_PRICE))
+                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), LOOTED_NAME))
+                .type(NotificationType.LOOT)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnorePickpocket() {
+        // fire event
+        LootReceived event = new LootReceived(LOOTED_NAME, 99, LootRecordType.PICKPOCKET, Collections.singletonList(new ItemStack(ItemID.TUNA, 1, null)), TUNA_PRICE);
+        notifier.onLootReceived(event);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+    @Test
+    void testNotifyPlayer() {
+        // prepare mocks
+        Player player = mock(Player.class);
+        when(player.getName()).thenReturn(LOOTED_NAME);
+
+        // fire event
+        PlayerLootReceived event = new PlayerLootReceived(player, Arrays.asList(new ItemStack(ItemID.RUBY, 1, null), new ItemStack(ItemID.TUNA, 1, null)));
+        notifier.onPlayerLootReceived(event);
+
+        // verify notification message
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has looted: %s from %s for %s gp", PLAYER_NAME, "1 x Ruby (" + RUBY_PRICE + ")", LOOTED_NAME, QuantityFormatter.quantityToStackSize(RUBY_PRICE + TUNA_PRICE)))
+                .extra(new LootNotificationData(Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME))
+                .type(NotificationType.LOOT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyMultiple() {
+        // fire event
+        int total = RUBY_PRICE + OPAL_PRICE + TUNA_PRICE;
+        LootReceived event = new LootReceived(
+            LOOTED_NAME,
+            99,
+            LootRecordType.EVENT,
+            Arrays.asList(
+                new ItemStack(ItemID.RUBY, 1, null),
+                new ItemStack(ItemID.OPAL, 1, null),
+                new ItemStack(ItemID.TUNA, 1, null)
+            ),
+            total
+        );
+        notifier.onLootReceived(event);
+
+        // verify notification message
+        String loot = String.format("1 x Ruby (%d)\n1 x Opal (%d)", RUBY_PRICE, OPAL_PRICE);
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has looted: %s from %s for %s gp", PLAYER_NAME, loot, LOOTED_NAME, QuantityFormatter.quantityToStackSize(total)))
+                .extra(new LootNotificationData(Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.OPAL, 1, OPAL_PRICE, "Opal"), new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME))
+                .type(NotificationType.LOOT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyRepeated() {
+        // fire event
+        int total = TUNA_PRICE * 5;
+        LootReceived event = new LootReceived(
+            LOOTED_NAME,
+            99,
+            LootRecordType.EVENT,
+            Arrays.asList(
+                new ItemStack(ItemID.TUNA, 1, null),
+                new ItemStack(ItemID.TUNA, 1, null),
+                new ItemStack(ItemID.TUNA, 1, null),
+                new ItemStack(ItemID.TUNA, 1, null),
+                new ItemStack(ItemID.TUNA, 1, null)
+            ),
+            total
+        );
+        notifier.onLootReceived(event);
+
+        // verify notification message
+        String loot = String.format("5 x Tuna (%d)", 5 * TUNA_PRICE);
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has looted: %s from %s for %s gp", PLAYER_NAME, loot, LOOTED_NAME, QuantityFormatter.quantityToStackSize(total)))
+                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 5, TUNA_PRICE, "Tuna")), LOOTED_NAME))
+                .type(NotificationType.LOOT)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoreRepeated() {
+        // fire event
+        int total = TUNA_PRICE * 4;
+        LootReceived event = new LootReceived(
+            LOOTED_NAME,
+            99,
+            LootRecordType.EVENT,
+            Arrays.asList(
+                new ItemStack(ItemID.TUNA, 1, null),
+                new ItemStack(ItemID.TUNA, 1, null),
+                new ItemStack(ItemID.TUNA, 1, null),
+                new ItemStack(ItemID.TUNA, 1, null)
+            ),
+            total
+        );
+        notifier.onLootReceived(event);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
new file mode 100644
index 0000000..8b2d293
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
@@ -0,0 +1,45 @@
+package dinkplugin.notifiers;
+
+import com.google.inject.testing.fieldbinder.Bind;
+import dinkplugin.DinkPluginConfig;
+import dinkplugin.MockedTestBase;
+import dinkplugin.message.DiscordMessageHandler;
+import net.runelite.api.Client;
+import net.runelite.api.Player;
+import net.runelite.api.WorldType;
+import org.mockito.Mock;
+
+import java.util.EnumSet;
+
+import static org.mockito.Mockito.when;
+
+class MockedNotifierTest extends MockedTestBase {
+
+    protected static final String PLAYER_NAME = "dank";
+
+    @Bind
+    @Mock
+    protected DinkPluginConfig config;
+
+    @Bind
+    @Mock
+    protected Client client;
+
+    @Mock
+    protected Player localPlayer;
+
+    @Bind
+    @Mock
+    protected DiscordMessageHandler messageHandler;
+
+    @Override
+    protected void setUp() {
+        super.setUp();
+
+        // init client mocks
+        when(client.getWorldType()).thenReturn(EnumSet.noneOf(WorldType.class));
+        when(client.getLocalPlayer()).thenReturn(localPlayer);
+        when(localPlayer.getName()).thenReturn(PLAYER_NAME);
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/PetNotifierTest.java b/src/test/java/dinkplugin/notifiers/PetNotifierTest.java
new file mode 100644
index 0000000..173daa8
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/PetNotifierTest.java
@@ -0,0 +1,55 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class PetNotifierTest extends MockedNotifierTest {
+
+    @InjectMocks
+    PetNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyPet()).thenReturn(true);
+        when(config.petSendImage()).thenReturn(false);
+        when(config.petNotifyMessage()).thenReturn("%USERNAME% got a pet");
+    }
+
+    @Test
+    void testNotify() {
+        // send fake message
+        notifier.onChatMessage("You feel something weird sneaking into your backpack.");
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " got a pet")
+                .type(NotificationType.PET)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnore() {
+        // send non-pet message
+        notifier.onChatMessage("You feel Forsen's warmth behind you.");
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/QuestNotifierTest.java b/src/test/java/dinkplugin/notifiers/QuestNotifierTest.java
new file mode 100644
index 0000000..24a458c
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/QuestNotifierTest.java
@@ -0,0 +1,73 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.QuestNotificationData;
+import net.runelite.api.events.WidgetLoaded;
+import net.runelite.api.widgets.Widget;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import static net.runelite.api.widgets.WidgetID.QUEST_COMPLETED_GROUP_ID;
+import static net.runelite.api.widgets.WidgetInfo.QUEST_COMPLETED_NAME_TEXT;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class QuestNotifierTest extends MockedNotifierTest {
+
+    @InjectMocks
+    QuestNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyQuest()).thenReturn(true);
+        when(config.questSendImage()).thenReturn(false);
+        when(config.questNotifyMessage()).thenReturn("%USERNAME% has completed: %QUEST%");
+    }
+
+    @Test
+    void testNotify() {
+        // mock widget
+        Widget questWidget = mock(Widget.class);
+        when(client.getWidget(QUEST_COMPLETED_NAME_TEXT)).thenReturn(questWidget);
+        when(questWidget.getText()).thenReturn("You have completed the Dragon Slayer quest!");
+
+        // send event
+        notifier.onWidgetLoaded(event(QUEST_COMPLETED_GROUP_ID));
+
+        // verify notification
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has completed: Dragon Slayer")
+                .extra(new QuestNotificationData("Dragon Slayer"))
+                .type(NotificationType.QUEST)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnore() {
+        // send unrelated event
+        notifier.onWidgetLoaded(event(-1));
+
+        // verify no message
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+    private static WidgetLoaded event(int id) {
+        WidgetLoaded event = new WidgetLoaded();
+        event.setGroupId(id);
+        return event;
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/SlayerNotifierTest.java b/src/test/java/dinkplugin/notifiers/SlayerNotifierTest.java
new file mode 100644
index 0000000..18cd368
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/SlayerNotifierTest.java
@@ -0,0 +1,60 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.SlayerNotificationData;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class SlayerNotifierTest extends MockedNotifierTest {
+
+    @InjectMocks
+    SlayerNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifySlayer()).thenReturn(true);
+        when(config.slayerSendImage()).thenReturn(false);
+        when(config.slayerPointThreshold()).thenReturn(1);
+        when(config.slayerNotifyMessage()).thenReturn("%USERNAME% has completed: %TASK%, getting %POINTS% points for a total %TASKCOUNT% tasks completed");
+    }
+
+    @Test
+    void testNotify() {
+        // fire chat messages
+        notifier.onChatMessage("You have completed your task! You killed 1 TzTok-Jad. You gained 69,420 xp.");
+        notifier.onChatMessage("You've completed 100 tasks and received 10 points, giving you a total of 200; return to a Slayer master.");
+
+        // check notification message
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has completed: %s, getting %d points for a total %d tasks completed", PLAYER_NAME, "1 TzTok-Jad", 10, 100))
+                .extra(new SlayerNotificationData("1 TzTok-Jad", "100", "10"))
+                .type(NotificationType.SLAYER)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnorePoints() {
+        // fire chat messages
+        notifier.onChatMessage("You have completed your task! You killed 1 TzTok-Jad. You gained 69,420 xp.");
+        notifier.onChatMessage("You've completed 101 tasks and received 0 points, giving you a total of 200; return to a Slayer master.");
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/SpeedrunNotifierTest.java b/src/test/java/dinkplugin/notifiers/SpeedrunNotifierTest.java
new file mode 100644
index 0000000..243407e
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/SpeedrunNotifierTest.java
@@ -0,0 +1,98 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.SpeedrunNotificationData;
+import net.runelite.api.events.WidgetLoaded;
+import net.runelite.api.widgets.Widget;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import java.time.Duration;
+
+import static dinkplugin.notifiers.SpeedrunNotifier.SPEEDRUN_COMPLETED_DURATION_CHILD_ID;
+import static dinkplugin.notifiers.SpeedrunNotifier.SPEEDRUN_COMPLETED_GROUP_ID;
+import static dinkplugin.notifiers.SpeedrunNotifier.SPEEDRUN_COMPLETED_PB_CHILD_ID;
+import static dinkplugin.notifiers.SpeedrunNotifier.SPEEDRUN_COMPLETED_QUEST_NAME_CHILD_ID;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class SpeedrunNotifierTest extends MockedNotifierTest {
+
+    private static final String QUEST_NAME = "Cook's Assistant";
+
+    @InjectMocks
+    SpeedrunNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifySpeedrun()).thenReturn(true);
+        when(config.speedrunPBOnly()).thenReturn(true);
+        when(config.speedrunSendImage()).thenReturn(false);
+        when(config.speedrunPBMessage()).thenReturn("%USERNAME% has beat their PB of %QUEST% with a time of %TIME%");
+
+        // init common widget mocks
+        Widget quest = mock(Widget.class);
+        when(client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_QUEST_NAME_CHILD_ID)).thenReturn(quest);
+        when(quest.getText()).thenReturn("You have completed " + QUEST_NAME + "!");
+
+        Widget pb = mock(Widget.class);
+        when(client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_PB_CHILD_ID)).thenReturn(pb);
+        when(pb.getText()).thenReturn("1:30.25");
+    }
+
+    @Test
+    void testNotify() {
+        String latest = "1:15.30";
+
+        // mock widget
+        Widget time = mock(Widget.class);
+        when(client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_DURATION_CHILD_ID)).thenReturn(time);
+        when(time.getText()).thenReturn(latest);
+
+        // fire fake event
+        notifier.onWidgetLoaded(event());
+
+        // check notification message
+        verify(messageHandler).createMessage(
+            false,
+            NotificationBody.builder()
+                .content(String.format("%s has beat their PB of %s with a time of %s", PLAYER_NAME, QUEST_NAME, latest))
+                .extra(new SpeedrunNotificationData(QUEST_NAME, Duration.ofMinutes(1).plusSeconds(30).plusMillis(250).toString(), Duration.ofMinutes(1).plusSeconds(15).plusMillis(300).toString()))
+                .type(NotificationType.SPEEDRUN)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnore() {
+        String latest = "1:45.30";
+
+        // mock widget
+        Widget time = mock(Widget.class);
+        when(client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_DURATION_CHILD_ID)).thenReturn(time);
+        when(time.getText()).thenReturn(latest);
+
+        // fire fake event
+        notifier.onWidgetLoaded(event());
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(anyBoolean(), any());
+    }
+
+    private WidgetLoaded event() {
+        WidgetLoaded event = new WidgetLoaded();
+        event.setGroupId(SPEEDRUN_COMPLETED_GROUP_ID);
+        return event;
+    }
+
+}