diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml
index ec8ed52d8..38a504442 100644
--- a/.github/actions/setup-haxeshit/action.yml
+++ b/.github/actions/setup-haxeshit/action.yml
@@ -10,14 +10,6 @@ runs:
         run: |
           haxelib config
         shell: bash
-      - name: Restore/create existing haxelib cache for faster downloads
-        uses: actions/cache@v3
-        id: cache-haxelib-windows
-        with:
-          # wha?
-          key: cache-haxelib-${{ runner.os }}-${{ hashFiles('**/hmm.json')}}
-          path: |
-            .haxelib/
       - name: Installing Haxe lol
         run: |
           haxe -version
diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 794457917..ed509b44d 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -30,12 +30,12 @@ jobs:
       - name: Build game
         run: |
           sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev
-          haxelib run lime build html5 -debug --times
+          haxelib run lime build html5 -release --times
           ls
       - uses: ./.github/actions/upload-itch
         with:
           butler-key: ${{ secrets.BUTLER_API_KEY}}
-          build-dir: export/debug/html5/bin
+          build-dir: export/release/html5/bin
           target: html5
   create-nightly-win:
     needs: check_date
@@ -51,10 +51,25 @@ jobs:
       - uses: ./.github/actions/setup-haxeshit
       - name: Build game
         run: |
-          haxelib run lime build windows -debug -DNO_REDIRECT_ASSETS_FOLDER
+          haxelib run lime build windows -release -DNO_REDIRECT_ASSETS_FOLDER
           dir
       - uses: ./.github/actions/upload-itch
         with:
           butler-key: ${{ secrets.BUTLER_API_KEY}}
-          build-dir: export/debug/windows/bin
+          build-dir: export/release/windows/bin
           target: win
+  test-unit-win:
+    needs: create-nightly-win
+    runs-on: windows-latest
+    permissions:
+       contents: write
+       actions: write
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: 'recursive'
+      - uses: ./.github/actions/setup-haxeshit
+      - name: Run unit tests
+        run: |
+          cd ./tests/unit/
+          ./start-win-native.bat
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 483db9ea9..c018b89e9 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -6,6 +6,7 @@
     "vshaxe.hxcpp-debugger", // CPP debugging
     "openfl.lime-vscode-extension", // Lime integration
     "esbenp.prettier-vscode", // JSON formatting
-    "redhat.vscode-xml" // XML formatting
+    "redhat.vscode-xml", // XML formatting
+    "ryanluker.vscode-coverage-gutters" // Highlight code coverage
   ]
 }
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 86ae2b643..80d2bf76a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -117,5 +117,12 @@
       "args": ["-debug", "-watch"]
     }
   ],
-  "cmake.configureOnOpen": false
+  "cmake.configureOnOpen": false,
+  "coverage-gutters.coverageFileNames": [
+    "lcov.info",
+    "cov.xml",
+    "coverage.xml",
+    "jacoco.xml",
+    "coverage.cobertura.xml"
+  ]
 }
diff --git a/hmm.json b/hmm.json
index a3226281b..e2670420a 100644
--- a/hmm.json
+++ b/hmm.json
@@ -18,13 +18,15 @@
       "name": "flixel-addons",
       "type": "git",
       "dir": null,
-      "ref": "f107166de3e830648e8fbf3da5526d4b94aa7dfc",
+      "ref": "c8c41e26d463aaf2edc0582fb23b6e228235bd16",
       "url": "https://github.com/EliteMasterEric/flixel-addons"
     },
     {
       "name": "flixel-ui",
-      "type": "haxelib",
-      "version": "2.5.0"
+      "type": "git",
+      "dir": null,
+      "ref": "719b4f10d94186ed55f6fef1b6618d32abec8c15",
+      "url": "https://github.com/HaxeFlixel/flixel-ui"
     },
     {
       "name": "flxanimate",
@@ -57,6 +59,13 @@
       "ref": "be0b18553189a55fd42821026618a18615b070e3",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
+    {
+      "name": "hmm",
+      "type": "git",
+      "dir": null,
+      "ref": "d514d7786cabf18b90e60fcee38399fd44c2ddfb",
+      "url": "https://github.com/andywhite37/hmm"
+    },
     {
       "name": "hscript",
       "type": "haxelib",
@@ -93,7 +102,7 @@
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "558798adc5bf0e82d70fef589a59ce88892e0b5b",
+      "ref": "f195121ebec688b417e38ab115185c8d93c349d3",
       "url": "https://github.com/EliteMasterEric/lime"
     },
     {
@@ -128,14 +137,14 @@
       "name": "openfl",
       "type": "git",
       "dir": null,
-      "ref": "1591a6c5f1f72e65d711f7e17e8055df41424d94",
+      "ref": "ef43deb2c68d8a4bcd73abfbd77324fc8220d0c1",
       "url": "https://github.com/EliteMasterEric/openfl"
     },
     {
       "name": "polymod",
       "type": "git",
       "dir": null,
-      "ref": "4bcd614103469af79a320898b823d1df8a55c3de",
+      "ref": "e8a07b81e3bc535238ad8649e38f5d43c46f1b65",
       "url": "https://github.com/larsiusprime/polymod"
     },
     {
@@ -147,13 +156,6 @@
       "name": "tink_json",
       "type": "haxelib",
       "version": "0.11.0"
-    },
-    {
-      "name": "hmm",
-      "type": "git",
-      "dir": null,
-      "ref": "d514d7786cabf18b90e60fcee38399fd44c2ddfb",
-      "url": "https://github.com/andywhite37/hmm"
     }
   ]
 }
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 6c465be9c..82a357ae9 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -1,5 +1,6 @@
 package funkin;
 
+import flixel.FlxState;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond;
 import flixel.addons.transition.TransitionData;
@@ -33,8 +34,10 @@ import Discord.DiscordClient;
  * The initialization state has several functions:
  * - Calls code to set up the game, including loading saves and parsing game data.
  * - Chooses whether to start via debug or via launching normally.
+ *
+ * It should not contain any sprites or rendering.
  */
-class InitState extends FlxTransitionableState
+class InitState extends FlxState
 {
   /**
    * Perform a bunch of game setup, then immediately transition to the title screen.
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index 3f5752acc..47afb0a30 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -114,6 +114,7 @@ class PolymodHandler
 
         // Parse hxc files and register the scripted classes in them.
         useScriptedClasses: true,
+        loadScriptsAsync: #if html5 true #else false #end,
       });
 
     if (loadedModList == null)
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index fa47e17b2..276a14a32 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -257,7 +257,7 @@ class ChartEditorDialogHandler
    * @param closable Whether the dialog can be closed by the user.
    * @return The dialog that was opened.
    */
-  @:haxe.warning("-WVarInit")
+  @:haxe.warning("-WVarInit") // Hide the warning about the onDropFile handler.
   public static function openUploadInstDialog(state:ChartEditorState, closable:Bool = true):Dialog
   {
     var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT, true, closable);
@@ -851,6 +851,9 @@ class ChartEditorDialogHandler
       songChartDataVariation = SongValidator.validateSongChartData(songChartDataVariation, 'import');
 
       songChartData.set(variation, songChartDataVariation);
+      state.notePreviewDirty = true;
+      state.notePreviewViewportBoundsDirty = true;
+      state.noteDisplayDirty = true;
 
       // Tell the user the load was successful.
       #if !mac
@@ -878,6 +881,9 @@ class ChartEditorDialogHandler
             songChartDataVariation = SongValidator.validateSongChartData(songChartDataVariation, 'import');
 
             songChartData.set(variation, songChartDataVariation);
+            state.notePreviewDirty = true;
+            state.notePreviewViewportBoundsDirty = true;
+            state.noteDisplayDirty = true;
 
             // Tell the user the load was successful.
             #if !mac
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 058703733..fe2ab6dab 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -85,8 +85,8 @@ using Lambda;
  * @author MasterEric
  */
 // Give other classes access to private instance fields
+// @:nullSafety(Loose) // Enable this while developing, then disable to keep unit tests functional!
 
-@:nullSafety
 @:allow(funkin.ui.debug.charting.ChartEditorCommand)
 @:allow(funkin.ui.debug.charting.ChartEditorDialogHandler)
 @:allow(funkin.ui.debug.charting.ChartEditorThemeHandler)
@@ -590,7 +590,6 @@ class ChartEditorState extends HaxeUIState
 
   /**
    * Whether the note preview graphic needs to be FULLY rebuilt.
-   * The Bitmap can be modified by individual commands without using this.
    */
   var notePreviewDirty:Bool = true;
 
@@ -1338,7 +1337,7 @@ class ChartEditorState extends HaxeUIState
     healthIconBF = new HealthIcon(currentSongCharacterPlayer);
     healthIconBF.autoUpdate = false;
     healthIconBF.size.set(0.5, 0.5);
-    healthIconBF.x = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15;
+    healthIconBF.x = gridTiledSprite.x + gridTiledSprite.width + 15;
     healthIconBF.y = gridTiledSprite.y + 5;
     healthIconBF.flipX = true;
     add(healthIconBF);
@@ -1818,7 +1817,7 @@ class ChartEditorState extends HaxeUIState
     if (healthIconBF != null)
     {
       // Base X position to the right of the grid.
-      var baseHealthIconXPos:Float = gridTiledSprite?.x ?? 0.0 + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15;
+      var baseHealthIconXPos:Float = (gridTiledSprite == null) ? (0) : (gridTiledSprite.x + gridTiledSprite.width + 15);
       // Will be 0 when not bopping. When bopping, will increase to push the icon left.
       var healthIconOffset:Float = healthIconBF.width - (HealthIcon.HEALTH_ICON_SIZE * 0.5);
       healthIconBF.x = baseHealthIconXPos - healthIconOffset;
@@ -2761,12 +2760,12 @@ class ChartEditorState extends HaxeUIState
         trace('Creating new Note... (${renderedNotes.members.length})');
         noteSprite.parentState = this;
 
-        // Setting note data resets position relative to the grid so we fix that.
-        noteSprite.updateNotePosition(renderedNotes);
-
         // The note sprite handles animation playback and positioning.
         noteSprite.noteData = noteData;
 
+        // Setting note data resets position relative to the grid so we fix that.
+        noteSprite.updateNotePosition(renderedNotes);
+
         // Add hold notes that are now visible (and not already displayed).
         if (noteSprite.noteData != null && noteSprite.noteData.length > 0 && displayedHoldNoteData.indexOf(noteSprite.noteData) == -1)
         {
@@ -3912,6 +3911,9 @@ class ChartEditorState extends HaxeUIState
 
     scrollPositionInPixels = 0;
     playheadPositionInPixels = 0;
+    notePreviewDirty = true;
+    notePreviewViewportBoundsDirty = true;
+    noteDisplayDirty = true;
     moveSongToScrollPosition();
   }
 
diff --git a/source/funkin/util/macro/InlineMacro.hx b/source/funkin/util/macro/InlineMacro.hx
new file mode 100644
index 000000000..b0e7ed184
--- /dev/null
+++ b/source/funkin/util/macro/InlineMacro.hx
@@ -0,0 +1,37 @@
+package funkin.util.macro;
+
+#if macro
+using funkin.util.tools.ArrayTools;
+#end
+
+/**
+ * A macro to make fields inline.
+ */
+class InlineMacro
+{
+  /**
+   * For the given class, find the (static?) field with the given name and make it inline.
+   * @param field
+   * @param isStatic
+   */
+  public static macro function makeInline(field:String, isStatic:Bool = false):Array<haxe.macro.Expr.Field>
+  {
+    var pos:haxe.macro.Expr.Position = haxe.macro.Context.currentPos();
+    // The FlxBasic class. We can add new properties to this class.
+    var cls:haxe.macro.Type.ClassType = haxe.macro.Context.getLocalClass().get();
+    // The fields of the FlxClass.
+    var fields:Array<haxe.macro.Expr.Field> = haxe.macro.Context.getBuildFields();
+
+    // Find the field with the given name.
+    var targetField:Null<haxe.macro.Expr.Field> = fields.find(function(f) return f.name == field
+      && (MacroUtil.isFieldStatic(f) == isStatic));
+
+    // If the field was not found, throw an error.
+    if (targetField == null) haxe.macro.Context.error("Field " + field + " not found in class " + cls.name, pos);
+
+    // Add the inline access modifier to the field.
+    targetField.access.push(AInline);
+
+    return fields;
+  }
+}
diff --git a/source/funkin/util/macro/MacroUtil.hx b/source/funkin/util/macro/MacroUtil.hx
index a121200ca..2e2c73279 100644
--- a/source/funkin/util/macro/MacroUtil.hx
+++ b/source/funkin/util/macro/MacroUtil.hx
@@ -99,6 +99,11 @@ class MacroUtil
     return null;
   }
 
+  public static function isFieldStatic(field:haxe.macro.Expr.Field):Bool
+  {
+    return field.access.contains(AStatic);
+  }
+
   /**
    * Converts a value to an equivalent macro expression.
    */
diff --git a/tests/unit/README.md b/tests/unit/README.md
index 00fed78e5..ae8d273bf 100644
--- a/tests/unit/README.md
+++ b/tests/unit/README.md
@@ -48,3 +48,9 @@ There are two parameters:
 ### `testDestroy()`
 
 `testDestroy()` tests whether an `IFlxDestroyable` can safely be `destroy()`ed more than once (null reference errors are fairly common here). For this, `destroyable` has to be set during `before()` of the test class.
+
+### Null Safety
+
+Append each test class with `@:nullSafety` to prevent crash bugs while developing.
+
+Note that `Assert.isNotNull(target)` is considered a vlid
diff --git a/tests/unit/assets/preload/data/levels/blankpathtest.json b/tests/unit/assets/preload/data/levels/blankpathtest.json
new file mode 100644
index 000000000..709012258
--- /dev/null
+++ b/tests/unit/assets/preload/data/levels/blankpathtest.json
@@ -0,0 +1,47 @@
+{
+  "version": "1.0.0",
+  "name": "SHOULD FAIL TO PARSE",
+  "titleAsset": "",
+  "props": [
+    {
+      "assetPath": "storymenu/props/gf",
+      "scale": 1.0,
+      "danceEvery": 2,
+      "offsets": [80, 80],
+      "animations": [
+        {
+          "name": "danceLeft",
+          "prefix": "idle0",
+          "frameIndices": [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
+        },
+        {
+          "name": "danceRight",
+          "prefix": "idle0",
+          "frameIndices": [
+            15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29
+          ]
+        }
+      ]
+    },
+    {
+      "assetPath": "storymenu/props/bf",
+      "scale": 1.0,
+      "danceEvery": 2,
+      "offsets": [150, 80],
+      "animations": [
+        {
+          "name": "idle",
+          "prefix": "idle0",
+          "frameRate": 24
+        },
+        {
+          "name": "confirm",
+          "prefix": "confirm0",
+          "frameRate": 24
+        }
+      ]
+    }
+  ],
+  "background": "#F9CF51",
+  "songs": ["tutorial"]
+}
diff --git a/tests/unit/project.xml b/tests/unit/project.xml
index ccfadce8c..63f164607 100644
--- a/tests/unit/project.xml
+++ b/tests/unit/project.xml
@@ -37,6 +37,9 @@
 
 	<!-- This macro allows addition of new functionality to existing Flixel. -->
 	<haxeflag name="--macro" value="addMetadata('@:build(funkin.util.macro.FlxMacro.buildFlxBasic())', 'flixel.FlxBasic')" />
+	<!-- Macros to satisfy null safety (null safety can't check nested functions, so assertions must be inlined) -->
+	<haxeflag name="--macro" value="addMetadata('@:build(funkin.util.macro.InlineMacro.makeInline(\'fail\', true))', 'massive.munit.Assert')" />
+	<haxeflag name="--macro" value="addMetadata('@:build(funkin.util.macro.InlineMacro.makeInline(\'isNotNull\', true))', 'massive.munit.Assert')" />
 
 	<!-- Assets -->
 	<assets path="assets/preload" rename="assets" exclude="*.ogg" if="web" />
@@ -80,12 +83,22 @@
 	<!-- Test defines -->
 	<set name="no-custom-backend" />
 	<set name="unit-test" />
-	<!--<haxedef name="no-inline" />-->
 	<haxedef name="FLX_UNIT_TEST" />
 	<haxedef name="FLX_RECORD" />
 
-	<!-- Manually set up code coverage -->
+	<!-- Clean up the output -->
+	<haxedef name="no-traces" />
+	<!--
+	-->
+	<haxedef name="ignore-inline" />
+	<haxeflag name="-w" value="-WDeprecated" />
+
+	<!-- Manually set up code coverage (because munit report and lime test are mutually exclusive) -->
+	<haxeflag name="--macro" value="mcover.MCover.coverage(['funkin'],['../../source', 'source/'],[''])" />
 	<haxelib name="mcover" />
 	<haxedef name="MCOVER" />
-	<haxeflag name="--macro" value="mcover.MCover.coverage(['funkin'],['../../source', 'source/'],[''])" />
+	<haxedef name="safeMode"/>
+	<haxedef name="HXCPP_CHECK_POINTER" />
+	<haxedef name="HXCPP_STACK_LINE" />
+	<haxedef name="HXCPP_STACK_TRACE" />
 </project>
diff --git a/tests/unit/report-linux.sh b/tests/unit/report-linux.sh
new file mode 100644
index 000000000..bc9ac2b76
--- /dev/null
+++ b/tests/unit/report-linux.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+cd ./report/
+genhtml -o ./html/ ./lcov.info
diff --git a/tests/unit/source/FunkinAssert.hx b/tests/unit/source/FunkinAssert.hx
index acfba6723..00c3a9e00 100644
--- a/tests/unit/source/FunkinAssert.hx
+++ b/tests/unit/source/FunkinAssert.hx
@@ -10,6 +10,7 @@ using flixel.util.FlxArrayUtil;
 /**
  * @see https://github.com/HaxeFlixel/flixel/tree/dev/tests/unit
  */
+@:nullSafety
 class FunkinAssert
 {
   /**
@@ -21,15 +22,17 @@ class FunkinAssert
    * @param margin The allowed margin of error between the expected and actual values.
    * @param info Info on the position this function was called from. Magic value, passed automatically.
    */
-  public static function areNear(expected:Float, actual:Float, margin:Float = 0.001, ?info:PosInfos):Void
+  public static function areNear(expected:Float, ?actual:Float, margin:Float = 0.001, ?info:PosInfos):Void
   {
+    if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info);
     if (areNearHelper(expected, actual)) Assert.assertionCount++;
     else
       Assert.fail('Value [$actual] is not within [$margin] of [$expected]', info);
   }
 
-  public static function rectsNear(expected:FlxRect, actual:FlxRect, margin:Float = 0.001, ?info:PosInfos):Void
+  public static function rectsNear(expected:FlxRect, ?actual:FlxRect, margin:Float = 0.001, ?info:PosInfos):Void
   {
+    if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info);
     var areNear = areNearHelper(expected.x, actual.x, margin)
       && areNearHelper(expected.y, actual.y, margin)
       && areNearHelper(expected.width, actual.width, margin)
@@ -45,33 +48,83 @@ class FunkinAssert
     return actual >= expected - margin && actual <= expected + margin;
   }
 
-  public static function arraysEqual<T>(expected:Array<T>, actual:Array<T>, ?info:PosInfos):Void
+  public static function arraysEqual<T>(expected:Array<T>, ?actual:Array<T>, ?info:PosInfos):Void
   {
+    if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info);
     if (expected.equals(actual)) Assert.assertionCount++;
     else
       Assert.fail('\nExpected\n   ${expected}\nbut was\n   ${actual}\n', info);
   }
 
-  public static function arraysNotEqual<T>(expected:Array<T>, actual:Array<T>, ?info:PosInfos):Void
+  public static function arraysNotEqual<T>(expected:Array<T>, ?actual:Array<T>, ?info:PosInfos):Void
   {
+    if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info);
     if (!expected.equals(actual)) Assert.assertionCount++;
     else
       Assert.fail('\nValue\n   ${actual}\nwas equal to\n   ${expected}\n', info);
   }
 
-  public static function pointsEqual(expected:FlxPoint, actual:FlxPoint, ?msg:String, ?info:PosInfos)
+  public static function pointsEqual(expected:FlxPoint, ?actual:FlxPoint, ?msg:String, ?info:PosInfos)
   {
+    if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info);
     if (expected.equals(actual)) Assert.assertionCount++;
     else if (msg != null) Assert.fail(msg, info);
     else
       Assert.fail("Value [" + actual + "] was not equal to expected value [" + expected + "]", info);
   }
 
-  public static function pointsNotEqual(expected:FlxPoint, actual:FlxPoint, ?msg:String, ?info:PosInfos)
+  public static function pointsNotEqual(expected:FlxPoint, ?actual:FlxPoint, ?msg:String, ?info:PosInfos)
   {
+    if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info);
     if (!expected.equals(actual)) Assert.assertionCount++;
     else if (msg != null) Assert.fail(msg, info);
     else
       Assert.fail("Value [" + actual + "] was equal to value [" + expected + "]", info);
   }
+
+  /**
+   * Execute `targetFunc`, expecting it to throw an exception.
+   * If it doesn't, or if the exception doesn't validate against the provided `predicate`, fail.
+   */
+  public static function validateThrows(targetFunc:Void->Void, predicate:Dynamic->Bool, ?info:PosInfos)
+  {
+    try
+    {
+      targetFunc();
+      Assert.fail("Expected exception to be thrown, got no failure.", info);
+    }
+    catch (e:Dynamic)
+    {
+      if (predicate(e))
+      {
+        Assert.assertionCount++;
+      }
+      else
+      {
+        Assert.fail('Expected exception to match predicate, but failed (got ${e})', info);
+      }
+    }
+  }
+
+  /**
+   * Execute `targetFunc`, expecting it to throw a `json2object.Error.CustomFunctionException` with a message matching `expected`.
+   * I made this its own function since it's the most common specific use case of `validateThrows`.
+   */
+  public static function validateThrowsJ2OCustom(targetFunc:Void->Void, expected:String, ?info:PosInfos)
+  {
+    var predicate:Dynamic->Bool = function(err:Dynamic):Bool {
+      if (!Std.isOfType(err, json2object.Error)) Assert.fail('Expected error of type json2object.Error, got ${Type.typeof(err)}');
+
+      switch (err)
+      {
+        case json2object.Error.CustomFunctionException(msg, pos):
+          if (msg != expected) Assert.fail('Expected message [${expected}], got [${msg}].');
+        default:
+          Assert.fail('Expected error of type CustomFunctionException, got [${err}].');
+      }
+
+      return true;
+    };
+    validateThrows(targetFunc, predicate, info);
+  }
 }
diff --git a/tests/unit/source/FunkinTest.hx b/tests/unit/source/FunkinTest.hx
index 7e0e0b26c..8f47a7d36 100644
--- a/tests/unit/source/FunkinTest.hx
+++ b/tests/unit/source/FunkinTest.hx
@@ -11,6 +11,7 @@ import massive.munit.Assert;
 /**
  * @see https://github.com/HaxeFlixel/flixel/tree/dev/tests/unit
  */
+@:nullSafety
 class FunkinTest
 {
   public static final MS_PER_STEP:Float = 1.0 / 60.0 * 1000;
@@ -19,7 +20,7 @@ class FunkinTest
   static inline var TICKS_PER_FRAME:UInt = 25;
   static var totalSteps:UInt = 0;
 
-  var destroyable:IFlxDestroyable;
+  var destroyable:Null<IFlxDestroyable> = null;
 
   public function new() {}
 
diff --git a/tests/unit/source/MockTest.hx b/tests/unit/source/MockTest.hx
index c1ae0a90d..308dbb45a 100644
--- a/tests/unit/source/MockTest.hx
+++ b/tests/unit/source/MockTest.hx
@@ -5,6 +5,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.DateUtil;
 
+@:nullSafety
 class MockTest extends FunkinTest
 {
   public function new()
@@ -45,16 +46,12 @@ class MockTest extends FunkinTest
     // If not, a VerificationException will be thrown and the test will fail.
     mockatoo.Mockatoo.verify(mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false), times(1));
 
-    try
-    {
+    FunkinAssert.validateThrows(function() {
       // Attempt to verify the method was called.
       // This should FAIL, since we didn't call the method.
-      mockatoo.Mockatoo.verify(mockAnim.addByIndices("testAnim", "blablabla", [], "", 24, false, false, false), times(1));
-      Assert.fail("Mocking function should have thrown but didn't.");
-    }
-    catch (_:mockatoo.exception.VerificationException)
-    {
-      // Expected.
-    }
+      mockatoo.Mockatoo.verify(mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false), times(1));
+    }, function(err) {
+      return Std.isOfType(err, mockatoo.exception.VerificationException);
+    });
   }
 }
diff --git a/tests/unit/source/TestMain.hx b/tests/unit/source/TestMain.hx
index a3fbdb428..11eb87b33 100644
--- a/tests/unit/source/TestMain.hx
+++ b/tests/unit/source/TestMain.hx
@@ -6,11 +6,14 @@ import flixel.FlxState;
 import massive.munit.TestRunner;
 import massive.munit.client.HTTPClient;
 import massive.munit.client.SummaryReportClient;
+import funkin.util.logging.CrashHandler;
+import funkin.util.FileUtil;
 
 /**
  * Auto generated Test Application.
  * Refer to munit command line tool for more information (haxelib run munit)
  */
+@:nullSafety
 class TestMain
 {
   /**
@@ -18,6 +21,8 @@ class TestMain
    */
   static final INCLUDE_IGNORED_REPORT:Bool = false;
 
+  static final COVERAGE_FOLDER:String = "../../../report";
+
   static function main()
   {
     new TestMain();
@@ -25,34 +30,46 @@ class TestMain
 
   public function new()
   {
-    // Flixel was not designed for unit testing so we can only have one instance for now.
-    Lib.current.stage.addChild(new FlxGame(640, 480, FlxState, 60, 60, true));
+    try
+    {
+      CrashHandler.initialize();
 
-    var suites = new Array<Class<massive.munit.TestSuite>>();
-    suites.push(TestSuite);
+      // Flixel was not designed for unit testing so we can only have one instance for now.
+      Lib.current.stage.addChild(new FlxGame(640, 480, FlxState, 60, 60, true));
 
-    #if MCOVER
-    // Print individual test results alongside coverage results for each test class,
-    // as well as a final coverage report for the entire test suite.
-    var innerClient = new massive.munit.client.RichPrintClient(INCLUDE_IGNORED_REPORT);
-    var client = new mcover.coverage.munit.client.MCoverPrintClient(innerClient);
-    // Print final test results alongside detailed coverage results for the test suite.
-    var httpClient = new HTTPClient(new mcover.coverage.munit.client.MCoverSummaryReportClient());
-    // NOTE: You can also create a custom ICoverageTestResultClient implementation
+      var suites = new Array<Class<massive.munit.TestSuite>>();
+      suites.push(TestSuite);
 
-    mcover.coverage.MCoverage.getLogger().addClient(new mcover.coverage.client.CodecovJsonPrintClient());
-    #else
-    // Print individual test results.
-    var client = new massive.munit.client.RichPrintClient(INCLUDE_IGNORED_REPORT);
-    // Print final test suite results.
-    var httpClient = new HTTPClient(new SummaryReportClient());
-    #end
+      #if MCOVER
+      // Print individual test results alongside coverage results for each test class,
+      // as well as a final coverage report for the entire test suite.
+      var innerClient = new massive.munit.client.RichPrintClient(INCLUDE_IGNORED_REPORT);
+      var client = new mcover.coverage.munit.client.MCoverPrintClient(innerClient);
+      // Print final test results alongside detailed coverage results for the test suite.
+      var httpClient = new HTTPClient(new mcover.coverage.munit.client.MCoverSummaryReportClient());
+      // NOTE: You can also create a custom ICoverageTestResultClient implementation
 
-    var runner = new TestRunner(client);
-    runner.addResultClient(httpClient);
+      // Output coverage in LCOV format.
+      FileUtil.createDirIfNotExists(COVERAGE_FOLDER);
+      mcover.coverage.MCoverage.getLogger().addClient(new mcover.coverage.client.LcovPrintClient("Funkin' Coverage Report", '${COVERAGE_FOLDER}/lcov.info'));
+      #else
+      // Print individual test results.
+      var client = new massive.munit.client.RichPrintClient(INCLUDE_IGNORED_REPORT);
+      // Print final test suite results.
+      var httpClient = new HTTPClient(new SummaryReportClient());
+      #end
 
-    runner.completionHandler = completionHandler;
-    runner.run(suites);
+      var runner = new TestRunner(client);
+      runner.addResultClient(httpClient);
+
+      runner.completionHandler = completionHandler;
+      runner.run(suites);
+    }
+    catch (e)
+    {
+      trace('UNCAUGHT EXCEPTION');
+      trace(e);
+    }
   }
 
   /**
diff --git a/tests/unit/source/funkin/ConductorTest.hx b/tests/unit/source/funkin/ConductorTest.hx
index 856c52186..0cfbe3960 100644
--- a/tests/unit/source/funkin/ConductorTest.hx
+++ b/tests/unit/source/funkin/ConductorTest.hx
@@ -7,10 +7,11 @@ import funkin.play.song.SongData.SongTimeChange;
 import funkin.util.Constants;
 import massive.munit.Assert;
 
+@:nullSafety
 @:access(funkin.Conductor)
 class ConductorTest extends FunkinTest
 {
-  var conductorState:ConductorState;
+  var conductorState:Null<ConductorState> = null;
 
   @Before
   function before()
@@ -54,6 +55,9 @@ class ConductorTest extends FunkinTest
   @Test
   function testUpdate():Void
   {
+    var currentConductorState:Null<ConductorState> = conductorState;
+    Assert.isNotNull(currentConductorState);
+
     Assert.areEqual(0, Conductor.songPosition);
 
     step(); // 1
@@ -72,15 +76,15 @@ class ConductorTest extends FunkinTest
     Assert.areEqual(0, Conductor.currentStep);
     FunkinAssert.areNear(8 / 9, Conductor.currentStepTime);
 
-    Assert.areEqual(0, conductorState.beatsHit);
-    Assert.areEqual(0, conductorState.stepsHit);
+    Assert.areEqual(0, currentConductorState.beatsHit);
+    Assert.areEqual(0, currentConductorState.stepsHit);
 
     step(); // 9
 
-    Assert.areEqual(0, conductorState.beatsHit);
-    Assert.areEqual(1, conductorState.stepsHit);
-    conductorState.beatsHit = 0;
-    conductorState.stepsHit = 0;
+    Assert.areEqual(0, currentConductorState.beatsHit);
+    Assert.areEqual(1, currentConductorState.stepsHit);
+    currentConductorState.beatsHit = 0;
+    currentConductorState.stepsHit = 0;
 
     FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 9, Conductor.songPosition);
     Assert.areEqual(0, Conductor.currentBeat);
@@ -89,10 +93,10 @@ class ConductorTest extends FunkinTest
 
     step(35 - 9); // 35
 
-    Assert.areEqual(0, conductorState.beatsHit);
-    Assert.areEqual(2, conductorState.stepsHit);
-    conductorState.beatsHit = 0;
-    conductorState.stepsHit = 0;
+    Assert.areEqual(0, currentConductorState.beatsHit);
+    Assert.areEqual(2, currentConductorState.stepsHit);
+    currentConductorState.beatsHit = 0;
+    currentConductorState.stepsHit = 0;
 
     FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 35, Conductor.songPosition);
     Assert.areEqual(0, Conductor.currentBeat);
@@ -101,10 +105,10 @@ class ConductorTest extends FunkinTest
 
     step(); // 36
 
-    Assert.areEqual(1, conductorState.beatsHit);
-    Assert.areEqual(1, conductorState.stepsHit);
-    conductorState.beatsHit = 0;
-    conductorState.stepsHit = 0;
+    Assert.areEqual(1, currentConductorState.beatsHit);
+    Assert.areEqual(1, currentConductorState.stepsHit);
+    currentConductorState.beatsHit = 0;
+    currentConductorState.stepsHit = 0;
 
     FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 36, Conductor.songPosition);
     Assert.areEqual(1, Conductor.currentBeat);
diff --git a/tests/unit/source/funkin/data/BaseRegistryTest.hx b/tests/unit/source/funkin/data/BaseRegistryTest.hx
index 3532c4403..31e44b6ed 100644
--- a/tests/unit/source/funkin/data/BaseRegistryTest.hx
+++ b/tests/unit/source/funkin/data/BaseRegistryTest.hx
@@ -7,6 +7,7 @@ import funkin.data.BaseRegistry;
 import funkin.util.SortUtil;
 import funkin.util.VersionUtil;
 
+@:nullSafety
 @:access(funkin.data.BaseRegistry)
 class BaseRegistryTest extends FunkinTest
 {
@@ -49,6 +50,7 @@ class BaseRegistryTest extends FunkinTest
 
     // Ensure blablabla got parsed correctly.
     var blablabla = MyTypeRegistry.instance.fetchEntry("blablabla");
+    Assert.isNotNull(blablabla);
     Assert.areEqual(blablabla.id, "blablabla");
     Assert.areEqual(blablabla._data.version, "1.0.0");
     Assert.areEqual(blablabla._data.name, "blablabla API");
diff --git a/tests/unit/source/funkin/data/level/LevelRegistryTest.hx b/tests/unit/source/funkin/data/level/LevelRegistryTest.hx
new file mode 100644
index 000000000..3d9cf5d29
--- /dev/null
+++ b/tests/unit/source/funkin/data/level/LevelRegistryTest.hx
@@ -0,0 +1,146 @@
+package funkin.data.level;
+
+import funkin.data.level.LevelRegistry;
+import funkin.ui.story.Level;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import massive.munit.util.Timer;
+
+@:nullSafety
+@:access(funkin.ui.story.Level)
+@:access(funkin.data.level.LevelRegistry)
+class LevelRegistryTest extends FunkinTest
+{
+  public function new()
+  {
+    super();
+  }
+
+  @BeforeClass
+  public function beforeClass():Void
+  {
+    LevelRegistry.instance.loadEntries();
+  }
+
+  @AfterClass
+  public function afterClass():Void {}
+
+  @Before
+  public function setup():Void {}
+
+  @After
+  public function tearDown():Void {}
+
+  @Test
+  public function testValid():Void
+  {
+    Assert.isNotNull(LevelRegistry.instance);
+  }
+
+  @Test
+  public function testParseEntryData():Void
+  {
+    var result:Null<LevelData> = LevelRegistry.instance.parseEntryData("test");
+
+    Assert.isNotNull(result);
+
+    Assert.areEqual("1.0.0", result.version);
+    Assert.areEqual("TEACHING TIME", result.name);
+    Assert.areEqual("storymenu/titles/tutorial", result.titleAsset);
+
+    Assert.areEqual(2, result.props.length);
+
+    Assert.areEqual("storymenu/props/gf", result.props[0].assetPath);
+    Assert.areEqual(1.0, result.props[0].scale);
+    Assert.areEqual(2, result.props[0].danceEvery);
+    Assert.areEqual([80, 80], result.props[0].offsets);
+    var anims = result.props[0].animations;
+    Assert.isNotNull(anims);
+    Assert.areEqual(2, anims.length);
+
+    var anim0 = anims[0];
+    Assert.isNotNull(anim0);
+    Assert.areEqual("danceLeft", anim0.name);
+    Assert.areEqual("idle0", anim0.prefix);
+    Assert.areEqual([30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], anim0.frameIndices);
+
+    var anim1 = anims[1];
+    Assert.isNotNull(anim1);
+    Assert.areEqual("danceRight", anim1.name);
+    Assert.areEqual("idle0", anim1.prefix);
+    Assert.areEqual([15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], anim1.frameIndices);
+
+    Assert.areEqual("storymenu/props/bf", result.props[1].assetPath);
+    Assert.areEqual(1.0, result.props[1].scale);
+    Assert.areEqual(2, result.props[1].danceEvery);
+    Assert.areEqual([150, 80], result.props[1].offsets);
+    anims = result.props[1].animations;
+    Assert.isNotNull(anims);
+    Assert.areEqual(2, anims.length);
+
+    anim0 = anims[0];
+    Assert.isNotNull(anim0);
+    Assert.areEqual("idle", anim0.name);
+    Assert.areEqual("idle0", anim0.prefix);
+    Assert.areEqual(24, anim0.frameRate);
+
+    anim1 = anims[1];
+    Assert.isNotNull(anim1);
+    Assert.areEqual("confirm", anim1.name);
+    Assert.areEqual("confirm0", anim1.prefix);
+    Assert.areEqual(24, anim1.frameRate);
+
+    Assert.areEqual("#F9CF51", result.background);
+    Assert.areEqual(["tutorial"], result.songs);
+  }
+
+  @Test
+  public function testCreateEntry():Void
+  {
+    var result:Null<Level> = LevelRegistry.instance.createEntry("test");
+
+    Assert.isNotNull(result);
+
+    Assert.areEqual("Level(test)", result.toString());
+    Assert.areEqual("TEACHING TIME", result.getTitle());
+
+    Assert.areEqual(true, result.isUnlocked());
+    Assert.areEqual(true, result.isVisible());
+  }
+
+  @Test
+  public function testFetchEntry():Void
+  {
+    var result:Null<Level> = LevelRegistry.instance.fetchEntry("test");
+
+    Assert.isNotNull(result);
+
+    Assert.areEqual("Level(test)", result.toString());
+    Assert.areEqual("TEACHING TIME", result.getTitle());
+
+    Assert.areEqual(true, result.isUnlocked());
+    Assert.areEqual(true, result.isVisible());
+  }
+
+  @Test
+  @Ignore("Requires redoing validation.")
+  public function testCreateEntryBlankPath():Void
+  {
+    FunkinAssert.validateThrows(function() {
+      var result:Null<Level> = LevelRegistry.instance.createEntry("blankpathtest");
+    }, function(err) {
+      return err == "Could not parse level data for id: blankpathtest";
+    });
+  }
+
+  @Test
+  @Ignore("Requires redoing validation.")
+  public function testFetchBadEntry():Void
+  {
+    var result:Null<Level> = LevelRegistry.instance.fetchEntry("blablabla");
+    Assert.isNull(result);
+
+    var result2:Null<Level> = LevelRegistry.instance.fetchEntry("blankpathtest");
+    Assert.isNull(result2);
+  }
+}
diff --git a/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
index ec33b37d7..8ae9cb31f 100644
--- a/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
+++ b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
@@ -6,6 +6,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import massive.munit.util.Timer;
 
+@:nullSafety
 @:access(funkin.play.notes.notestyle.NoteStyle)
 @:access(funkin.data.notestyle.NoteStyleRegistry)
 class NoteStyleRegistryTest extends FunkinTest
@@ -16,48 +17,57 @@ class NoteStyleRegistryTest extends FunkinTest
   }
 
   @BeforeClass
-  public function beforeClass()
+  public function beforeClass():Void
   {
     NoteStyleRegistry.instance.loadEntries();
   }
 
   @AfterClass
-  public function afterClass() {}
+  public function afterClass():Void {}
 
   @Before
-  public function setup() {}
+  public function setup():Void {}
 
   @After
-  public function tearDown() {}
+  public function tearDown():Void {}
 
   @Test
-  public function testValid()
+  public function testValid():Void
   {
     Assert.isNotNull(NoteStyleRegistry.instance);
   }
 
   @Test
-  public function testParseEntryData()
+  public function testParseEntryData():Void
   {
-    var result:NoteStyleData = NoteStyleRegistry.instance.parseEntryData("test2");
+    var result:Null<NoteStyleData> = NoteStyleRegistry.instance.parseEntryData("test2");
+
+    Assert.isNotNull(result);
 
     Assert.areEqual(result.version, "1.0.0");
     Assert.areEqual(result.name, "Test2");
     Assert.areEqual(result.author, "Eric");
     Assert.areEqual(result.fallback, "funkin");
 
-    Assert.areEqual(result.assets.note.assetPath, "shared:coolstuff");
-    Assert.areEqual(result.assets.note.scale, 1.8);
-    Assert.areEqual(result.assets.note.data.left.prefix, "noteLeft1");
-    Assert.areEqual(result.assets.note.data.down.prefix, "noteDown3");
-    Assert.areEqual(result.assets.note.data.up.prefix, "noteUp2");
-    Assert.areEqual(result.assets.note.data.right.prefix, "noteRight4");
+    Assert.isNotNull(result.assets);
+
+    var note:Null<NoteStyleData.NoteStyleAssetData<NoteStyleData.NoteStyleData_Note>> = result.assets.note;
+    Assert.isNotNull(note);
+
+    Assert.areEqual(note.assetPath, "shared:coolstuff");
+    Assert.areEqual(note.scale, 1.8);
+    Assert.areEqual(note.data.left.prefix, "noteLeft1");
+    Assert.areEqual(note.data.down.prefix, "noteDown3");
+    Assert.areEqual(note.data.up.prefix, "noteUp2");
+    Assert.areEqual(note.data.right.prefix, "noteRight4");
   }
 
   @Test
-  public function testFetchEntry()
+  public function testFetchEntry():Void
   {
-    var result:NoteStyle = NoteStyleRegistry.instance.fetchEntry("test2");
+    var result:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry("test2");
+
+    Assert.isNotNull(result);
 
     Assert.areEqual(result.toString(), "NoteStyle(test2)");
     Assert.areEqual(result.getName(), "Test2");
@@ -66,15 +76,15 @@ class NoteStyleRegistryTest extends FunkinTest
   }
 
   @Test
-  public function testFetchBadEntry()
+  public function testFetchBadEntry():Void
   {
-    var result:NoteStyle = NoteStyleRegistry.instance.fetchEntry("blablabla");
+    var result:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry("blablabla");
 
-    Assert.areEqual(result, null);
+    Assert.isNull(result);
   }
 
   @Test
-  public function testFetchDefault()
+  public function testFetchDefault():Void
   {
     var nsrMock:NoteStyleRegistry = mock(NoteStyleRegistry);
 
diff --git a/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx b/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx
index 31e8e38ae..6b4b46c10 100644
--- a/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx
+++ b/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx
@@ -34,7 +34,9 @@ class NoteStyleTest extends FunkinTest
   @Ignore("This test doesn't work, crashes when the project has 2 mocks of the same class???")
   public function testBuildNoteSprite()
   {
-    var target:NoteStyle = NoteStyleRegistry.instance.fetchEntry("funkin");
+    var target:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry("funkin");
+
+    Assert.isNotNull(target);
 
     var mockNoteSprite:NoteSprite = mock(NoteSprite);
     // var mockAnim = mock(FlxAnimationController);
@@ -48,8 +50,11 @@ class NoteStyleTest extends FunkinTest
   @Test
   public function testFallbackBehavior()
   {
-    var target1:NoteStyle = NoteStyleRegistry.instance.fetchEntry("funkin");
-    var target2:NoteStyle = NoteStyleRegistry.instance.fetchEntry("test2");
+    var target1:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry("funkin");
+    var target2:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry("test2");
+
+    Assert.isNotNull(target1);
+    Assert.isNotNull(target2);
 
     Assert.areEqual("funkin", target1.id);
     Assert.areEqual("test2", target2.id);
@@ -63,7 +68,6 @@ class NoteStyleTest extends FunkinTest
     // Overridden fields are different.
     Assert.areEqual("arrows", target1.getNoteAssetPath(false));
     Assert.areEqual("coolstuff", target2.getNoteAssetPath(false));
-    
     Assert.areEqual("shared:arrows", target1.getNoteAssetPath(true));
     Assert.areEqual("shared:coolstuff", target2.getNoteAssetPath(true));
 
diff --git a/tests/unit/source/funkin/util/BezierUtilTest.hx b/tests/unit/source/funkin/util/BezierUtilTest.hx
index 90186c111..5ff2ade65 100644
--- a/tests/unit/source/funkin/util/BezierUtilTest.hx
+++ b/tests/unit/source/funkin/util/BezierUtilTest.hx
@@ -6,6 +6,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.BezierUtil;
 
+@:nullSafety
 @:access(funkin.util.BezierUtil)
 class BezierUtilTest extends FunkinTest
 {
diff --git a/tests/unit/source/funkin/util/ClipboardUtilTest.hx b/tests/unit/source/funkin/util/ClipboardUtilTest.hx
index 9efd96d34..311160d6b 100644
--- a/tests/unit/source/funkin/util/ClipboardUtilTest.hx
+++ b/tests/unit/source/funkin/util/ClipboardUtilTest.hx
@@ -5,6 +5,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.ClipboardUtil;
 
+@:nullSafety
 @:access(funkin.util.ClipboardUtil)
 class ClipboardUtilTest extends FunkinTest
 {
diff --git a/tests/unit/source/funkin/util/DateUtilTest.hx b/tests/unit/source/funkin/util/DateUtilTest.hx
index c8adb3824..35c7bab9c 100644
--- a/tests/unit/source/funkin/util/DateUtilTest.hx
+++ b/tests/unit/source/funkin/util/DateUtilTest.hx
@@ -5,6 +5,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.DateUtil;
 
+@:nullSafety
 @:access(funkin.util.DateUtil)
 class DateUtilTest extends FunkinTest
 {
diff --git a/tests/unit/source/funkin/util/SerializerUtilTest.hx b/tests/unit/source/funkin/util/SerializerUtilTest.hx
index d2adc3350..6a0152376 100644
--- a/tests/unit/source/funkin/util/SerializerUtilTest.hx
+++ b/tests/unit/source/funkin/util/SerializerUtilTest.hx
@@ -13,6 +13,7 @@ typedef FooBar =
   c:Int
 };
 
+@:nullSafety
 @:access(funkin.util.SerializerUtil)
 class SerializerUtilTest extends FunkinTest
 {
diff --git a/tests/unit/source/funkin/util/SortUtilTest.hx b/tests/unit/source/funkin/util/SortUtilTest.hx
index 4720c3da6..1a39bf655 100644
--- a/tests/unit/source/funkin/util/SortUtilTest.hx
+++ b/tests/unit/source/funkin/util/SortUtilTest.hx
@@ -9,6 +9,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.SortUtil;
 
+@:nullSafety
 @:access(funkin.util.SortUtil)
 class SortUtilTest extends FunkinTest
 {
diff --git a/tests/unit/source/funkin/util/VersionUtilTest.hx b/tests/unit/source/funkin/util/VersionUtilTest.hx
index 55848955a..517b37e5d 100644
--- a/tests/unit/source/funkin/util/VersionUtilTest.hx
+++ b/tests/unit/source/funkin/util/VersionUtilTest.hx
@@ -57,8 +57,26 @@ class VersionUtilTest extends FunkinTest
   {
     var jsonStr:String = "{ \"version\": \"3.1.0\" }";
 
-    var version:thx.semver.Version = VersionUtil.getVersionFromJSON(jsonStr);
+    var version:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(jsonStr);
+
+    Assert.isNotNull(version);
 
     Assert.areEqual("3.1.0", version.toString());
   }
+
+  @Test
+  public function testGetVersionFromJSONBad()
+  {
+    var jsonStr:String = "{ \"version\": \"bleh\" }";
+
+    Assert.throws(String, function() {
+      var version:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(jsonStr);
+    });
+
+    var jsonStr2:String = "{ \"blah\": \"3.1.0\" }";
+
+    var version2:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(jsonStr2);
+
+    Assert.isNull(version2);
+  }
 }
diff --git a/tests/unit/source/funkin/util/assets/DataAssetsTest.hx b/tests/unit/source/funkin/util/assets/DataAssetsTest.hx
index 7612d4706..b3df036e5 100644
--- a/tests/unit/source/funkin/util/assets/DataAssetsTest.hx
+++ b/tests/unit/source/funkin/util/assets/DataAssetsTest.hx
@@ -6,6 +6,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.assets.DataAssets;
 
+@:nullSafety
 @:access(funkin.util.assets.DataAssets)
 class DataAssetsTest extends FunkinTest
 {
diff --git a/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx b/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx
index 990d998f2..02b03055d 100644
--- a/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx
+++ b/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx
@@ -9,6 +9,7 @@ import massive.munit.async.AsyncFactory;
 import funkin.util.DateUtil;
 import flixel.FlxSprite;
 
+@:nullSafety
 @:access(funkin.util.assets.FlxAnimationUtil)
 class FlxAnimationUtilTest extends FunkinTest
 {
diff --git a/tests/unit/source/funkin/util/tools/ArraySortToolsTest.hx b/tests/unit/source/funkin/util/tools/ArraySortToolsTest.hx
index 0bcef00b5..b9518151b 100644
--- a/tests/unit/source/funkin/util/tools/ArraySortToolsTest.hx
+++ b/tests/unit/source/funkin/util/tools/ArraySortToolsTest.hx
@@ -5,6 +5,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.tools.ArrayTools;
 
+@:nullSafety
 @:access(funkin.util.tools.ArrayTools)
 class ArraySortToolsTest extends FunkinTest
 {
@@ -54,21 +55,30 @@ class ArraySortToolsTest extends FunkinTest
 
     // Just make sure these don't crash.
     ArraySortTools.mergeSort([], compare);
+  }
+
+  @Test
+  @:nullSafety(Off)
+  public function testMergeSortNull()
+  {
+    var testArray:Array<Int> = [5, 4, 3, 2, 1];
+
+    function compare(a:Int, b:Int)
+    {
+      return a - b;
+    }
+
+    // Just make sure these don't crash.
     ArraySortTools.mergeSort(null, compare);
     ArraySortTools.mergeSort([], null);
     ArraySortTools.mergeSort(null, null);
 
     // Make sure these throw an exception.
-    try
-    {
+    FunkinAssert.validateThrows(function() {
       ArraySortTools.mergeSort(testArray, null);
-
-      Assert.fail("Function should have thrown an exception.");
-    }
-    catch (e)
-    {
-      Assert.areEqual("No comparison function provided.", e);
-    }
+    }, function(err) {
+      return err == 'No comparison function provided.';
+    });
   }
 
   @Test
@@ -97,6 +107,18 @@ class ArraySortToolsTest extends FunkinTest
     Assert.areEqual(testArray2[1], 6);
     Assert.areEqual(testArray2[2], 9);
     Assert.areEqual(testArray2[3], 12);
+  }
+
+  @Test
+  @:nullSafety(Off)
+  public function testQuickSortNull()
+  {
+    var testArray:Array<Int> = [5, 4, 3, 2, 1];
+
+    function compare(a:Int, b:Int)
+    {
+      return a - b;
+    }
 
     // Just make sure these don't crash.
     ArraySortTools.quickSort([], compare);
@@ -105,16 +127,11 @@ class ArraySortToolsTest extends FunkinTest
     ArraySortTools.quickSort(null, null);
 
     // Make sure these throw an exception.
-    try
-    {
+    FunkinAssert.validateThrows(function() {
       ArraySortTools.quickSort(testArray, null);
-
-      Assert.fail("Function should have thrown an exception.");
-    }
-    catch (e)
-    {
-      Assert.areEqual("No comparison function provided.", e);
-    }
+    }, function(err) {
+      return err == 'No comparison function provided.';
+    });
   }
 
   @Test
@@ -143,6 +160,18 @@ class ArraySortToolsTest extends FunkinTest
     Assert.areEqual(testArray2[1], 6);
     Assert.areEqual(testArray2[2], 9);
     Assert.areEqual(testArray2[3], 12);
+  }
+
+  @Test
+  @:nullSafety(Off)
+  public function testInsertionSortNull()
+  {
+    var testArray:Array<Int> = [5, 4, 3, 2, 1];
+
+    function compare(a:Int, b:Int)
+    {
+      return a - b;
+    }
 
     // Just make sure these don't crash.
     ArraySortTools.insertionSort([], compare);
@@ -151,15 +180,10 @@ class ArraySortToolsTest extends FunkinTest
     ArraySortTools.insertionSort(null, null);
 
     // Make sure these throw an exception.
-    try
-    {
+    FunkinAssert.validateThrows(function() {
       ArraySortTools.insertionSort(testArray, null);
-
-      Assert.fail("Function should have thrown an exception.");
-    }
-    catch (e)
-    {
-      Assert.areEqual("No comparison function provided.", e);
-    }
+    }, function(err) {
+      return err == 'No comparison function provided.';
+    });
   }
 }
diff --git a/tests/unit/source/funkin/util/tools/ArrayToolsTest.hx b/tests/unit/source/funkin/util/tools/ArrayToolsTest.hx
index 68adb833b..df8d364f2 100644
--- a/tests/unit/source/funkin/util/tools/ArrayToolsTest.hx
+++ b/tests/unit/source/funkin/util/tools/ArrayToolsTest.hx
@@ -5,6 +5,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.tools.ArrayTools;
 
+@:nullSafety
 @:access(funkin.util.tools.ArrayTools)
 class ArrayToolsTest extends FunkinTest
 {
diff --git a/tests/unit/source/funkin/util/tools/IteratorToolsTest.hx b/tests/unit/source/funkin/util/tools/IteratorToolsTest.hx
index deaea352b..2c5a406c6 100644
--- a/tests/unit/source/funkin/util/tools/IteratorToolsTest.hx
+++ b/tests/unit/source/funkin/util/tools/IteratorToolsTest.hx
@@ -5,6 +5,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.tools.IteratorTools;
 
+@:nullSafety
 @:access(funkin.util.tools.IteratorTools)
 class IteratorToolsTest extends FunkinTest
 {
diff --git a/tests/unit/source/funkin/util/tools/MapToolsTest.hx b/tests/unit/source/funkin/util/tools/MapToolsTest.hx
index c3fe34461..1ada408b6 100644
--- a/tests/unit/source/funkin/util/tools/MapToolsTest.hx
+++ b/tests/unit/source/funkin/util/tools/MapToolsTest.hx
@@ -5,6 +5,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.tools.MapTools;
 
+@:nullSafety
 @:access(funkin.util.tools.MapTools)
 class MapToolsTest extends FunkinTest
 {
diff --git a/tests/unit/source/funkin/util/tools/StringToolsTest.hx b/tests/unit/source/funkin/util/tools/StringToolsTest.hx
index 215b5e402..ff2ee2dbd 100644
--- a/tests/unit/source/funkin/util/tools/StringToolsTest.hx
+++ b/tests/unit/source/funkin/util/tools/StringToolsTest.hx
@@ -5,6 +5,7 @@ import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.util.tools.StringTools;
 
+@:nullSafety
 @:access(funkin.util.tools.StringTools)
 class StringToolsTest extends FunkinTest
 {
diff --git a/tests/unit/start-linux-native.sh b/tests/unit/start-linux-native.sh
new file mode 100644
index 000000000..5110b410f
--- /dev/null
+++ b/tests/unit/start-linux-native.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+haxe test-cpp.hxml
diff --git a/tests/unit/start-linux-web.sh b/tests/unit/start-linux-web.sh
new file mode 100644
index 000000000..68e785b90
--- /dev/null
+++ b/tests/unit/start-linux-web.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+haxe test-web.hxml
diff --git a/tests/unit/start-win-web.bat b/tests/unit/start-win-web.bat
new file mode 100644
index 000000000..27664f96b
--- /dev/null
+++ b/tests/unit/start-win-web.bat
@@ -0,0 +1,3 @@
+REM Launches the unit tests for the native target on Windows.
+
+haxe test-web.hxml
diff --git a/tests/unit/test-cpp.hxml b/tests/unit/test-cpp.hxml
index 9379f841d..e25d92539 100644
--- a/tests/unit/test-cpp.hxml
+++ b/tests/unit/test-cpp.hxml
@@ -1,11 +1,6 @@
 # Updates TestSuite.hx to include all tests
-#-cmd haxelib run munit gen
-# Actually performs the tests
-#-cmd haxelib run munit test -debug -coverage
-# -debug may or may not be needed
-# -coverage adds code coverage reporting
-
-# Legacy style. Doesn't give detailed coverage reports,
-# but it works without crashing.
 -cmd haxelib run munit gen
--cmd haxelib run lime test cpp
+# Actually performs the tests
+# Lime is used for compatibility reasons, and build flags in `project.xml` ensure coverage is enabled
+-cmd haxelib run lime test cpp -debug
+
diff --git a/tests/unit/test.hxml-old b/tests/unit/test.hxml-old
index d86661fc8..e8a7d9bde 100644
--- a/tests/unit/test.hxml-old
+++ b/tests/unit/test.hxml-old
@@ -1,3 +1,39 @@
+## CPP
+--next
+--verbose
+--debug
+-main TestMain
+-cpp build/cpp_test
+# Funkin' deps
+-lib lime
+-lib openfl
+-lib flixel
+-lib flixel-addons
+-lib flixel-ui
+-lib hscript
+-lib polymod
+-lib haxeui-core
+-lib haxeui-flixel
+-lib flxanimate
+-lib hxCodec
+-lib thx.semver
+-lib json2object
+-lib tink_json
+# Test deps
+-lib munit
+-lib hamcrest
+-lib mcover
+-lib mockatoo
+# Class paths
+-cp source
+-cp ../../source
+# Flixel macros
+--remap flash:openfl
+--macro flixel.system.macros.FlxDefines.run()
+# Funkin' macros
+--macro addMetadata('@:build(funkin.util.macro.FlxMacro.buildFlxBasic())', 'flixel.FlxBasic')
+
+
 ## JavaScript HTML5
 --next
 -js build/js_test.js