diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml
index 0531adb8..6091e9df 100644
--- a/.github/workflows/dotnetcore.yml
+++ b/.github/workflows/dotnetcore.yml
@@ -15,5 +15,5 @@ jobs:
       uses: actions/setup-dotnet@v1
       with:
         dotnet-version: 3.1.100
-    - name: Build with dotnet
-      run: dotnet build --configuration Release
+    - name: Build and test with dotnet
+      run: dotnet test --configuration Release
diff --git a/PluralKit.Bot/Proxy/ProxyTagParser.cs b/PluralKit.Bot/Proxy/ProxyTagParser.cs
index bdef419c..aa7857ed 100644
--- a/PluralKit.Bot/Proxy/ProxyTagParser.cs
+++ b/PluralKit.Bot/Proxy/ProxyTagParser.cs
@@ -8,10 +8,13 @@ namespace PluralKit.Bot
 {
     public class ProxyTagParser
     {
-        public bool TryMatch(IEnumerable<ProxyMember> members, string input, out ProxyMatch result)
+        public bool TryMatch(IEnumerable<ProxyMember> members, string? input, out ProxyMatch result)
         {
             result = default;
             
+            // Null input is valid and is equivalent to empty string
+            if (input == null) return false;
+            
             // If the message starts with a @mention, and then proceeds to have proxy tags,
             // extract the mention and place it inside the inner message
             // eg. @Ske [text] => [@Ske text]
diff --git a/PluralKit.Core/Database/Functions/ProxyMember.cs b/PluralKit.Core/Database/Functions/ProxyMember.cs
index ff9e02fb..d57480af 100644
--- a/PluralKit.Core/Database/Functions/ProxyMember.cs
+++ b/PluralKit.Core/Database/Functions/ProxyMember.cs
@@ -24,5 +24,13 @@ namespace PluralKit.Core
             : ServerName ?? DisplayName ?? Name;
 
         public string? ProxyAvatar(MessageContext ctx) => ServerAvatar ?? Avatar ?? ctx.SystemAvatar;
+
+        public ProxyMember() { }
+
+        public ProxyMember(string name, params ProxyTag[] tags)
+        {
+            Name = name;
+            ProxyTags = tags;
+        }
     }
 }
\ No newline at end of file
diff --git a/PluralKit.Tests/PluralKit.Tests.csproj b/PluralKit.Tests/PluralKit.Tests.csproj
new file mode 100644
index 00000000..bf30a603
--- /dev/null
+++ b/PluralKit.Tests/PluralKit.Tests.csproj
@@ -0,0 +1,22 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>netcoreapp3.1</TargetFramework>
+
+        <IsPackable>false</IsPackable>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
+        <PackageReference Include="xunit" Version="2.4.0" />
+        <PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
+        <PackageReference Include="coverlet.collector" Version="1.2.0" />
+    </ItemGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\PluralKit.API\PluralKit.API.csproj" />
+      <ProjectReference Include="..\PluralKit.Bot\PluralKit.Bot.csproj" />
+      <ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" />
+    </ItemGroup>
+
+</Project>
diff --git a/PluralKit.Tests/ProxyTagParserTests.cs b/PluralKit.Tests/ProxyTagParserTests.cs
new file mode 100644
index 00000000..568b8b6f
--- /dev/null
+++ b/PluralKit.Tests/ProxyTagParserTests.cs
@@ -0,0 +1,77 @@
+#nullable enable
+using PluralKit.Bot;
+using PluralKit.Core;
+
+using Xunit;
+
+namespace PluralKit.Tests
+{
+    public class ProxyTagParserTests
+    {
+        private ProxyTagParser parser = new ProxyTagParser();
+        private ProxyMember[] members = {
+            new ProxyMember("Tagless"), 
+            new ProxyMember("John", new ProxyTag("[", "]")),
+            new ProxyMember("Curly", new ProxyTag("{", "}")),
+            new ProxyMember("Specific", new ProxyTag("{{", "}}")),
+            new ProxyMember("SuperSpecific", new ProxyTag("{{{", "}}}")),
+            new ProxyMember("Manytags", new ProxyTag("-", "-"), new ProxyTag("<", ">")),
+            new ProxyMember("Lopsided", new ProxyTag("-", "")),
+            new ProxyMember("Othersided", new ProxyTag("", "-"))
+        };
+
+        [Fact]
+        public void EmptyStringMatchesNothing() =>
+            Assert.False(parser.TryMatch(members, "", out _));
+
+        [Fact]
+        public void NullStringMatchesNothing() =>
+            Assert.False(parser.TryMatch(members, null, out _));
+
+        [Fact]
+        public void PlainStringMatchesNothing() =>
+            // Note that we have "Tagless" with no proxy tags
+            Assert.False(parser.TryMatch(members, "string without any of the tags", out _));
+
+        [Fact]
+        public void StringWithBasicTagsMatch() =>
+            Assert.True(parser.TryMatch(members, "[these are john's tags]", out _));
+
+        [Theory]
+        [InlineData("[these are john's tags]", "John")]
+        [InlineData("-lopsided tags on the left", "Lopsided")]
+        [InlineData("lopsided tags on the right-", "Othersided")]
+        public void MatchReturnsCorrectMember(string input, string expectedName)
+        {
+            parser.TryMatch(members, input, out var result);
+            Assert.Equal(expectedName, result.Member.Name);
+        }
+
+        [Fact]
+        public void MatchReturnsCorrectContent()
+        {
+            parser.TryMatch(members, "[these are john's tags]", out var result);
+            Assert.Equal("these are john's tags", result.Content);
+        }
+
+        [Theory]
+        [InlineData("{just curly}", "Curly", "just curly")]
+        [InlineData("{{getting deeper}}", "Specific", "getting deeper")]
+        [InlineData("{{{way too deep}}}", "SuperSpecific", "way too deep")]
+        [InlineData("{{unmatched brackets}}}", "Specific", "unmatched brackets}")]
+        [InlineData("{more unmatched brackets}}}}}", "Curly", "more unmatched brackets}}}}")]
+        public void MostSpecificTagsAreMatched(string input, string expectedName, string expectedContent)
+        {
+            Assert.True(parser.TryMatch(members, input, out var result));
+            Assert.Equal(expectedName, result.Member.Name);
+            Assert.Equal(expectedContent, result.Content);
+        }
+
+        [Theory]
+        [InlineData("")]
+        [InlineData("some text")]
+        [InlineData("{bogus tags, idk}")]
+        public void NoMembersMatchNothing(string input) => 
+            Assert.False(parser.TryMatch(new ProxyMember[]{}, input, out _));
+    }
+}
\ No newline at end of file
diff --git a/PluralKit.sln b/PluralKit.sln
index c33f65c8..84b03bec 100644
--- a/PluralKit.sln
+++ b/PluralKit.sln
@@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Core", "PluralKit
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.API", "PluralKit.API\PluralKit.API.csproj", "{3420F8A9-125C-4F7F-A444-10DD16945754}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Tests", "PluralKit.Tests\PluralKit.Tests.csproj", "{752FE725-5EE1-45E9-B721-0CDD28171AC8}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -24,5 +26,9 @@ Global
 		{3420F8A9-125C-4F7F-A444-10DD16945754}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{3420F8A9-125C-4F7F-A444-10DD16945754}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{3420F8A9-125C-4F7F-A444-10DD16945754}.Release|Any CPU.Build.0 = Release|Any CPU
+		{752FE725-5EE1-45E9-B721-0CDD28171AC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{752FE725-5EE1-45E9-B721-0CDD28171AC8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{752FE725-5EE1-45E9-B721-0CDD28171AC8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{752FE725-5EE1-45E9-B721-0CDD28171AC8}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 EndGlobal