diff --git a/src/engine/runtime.js b/src/engine/runtime.js
index d3a1723a4..4dd1f1cd9 100644
--- a/src/engine/runtime.js
+++ b/src/engine/runtime.js
@@ -608,7 +608,7 @@ class Runtime extends EventEmitter {
     static get PERIPHERAL_LIST_UPDATE () {
         return 'PERIPHERAL_LIST_UPDATE';
     }
-    
+
     /**
      * Event name for when the user picks a bluetooth device to connect to
      * via Companion Device Manager (CDM)
@@ -2578,6 +2578,15 @@ class Runtime extends EventEmitter {
         this.emit(Runtime.RUNTIME_STARTED);
     }
 
+    /**
+     * Quit the Runtime, clearing any handles which might keep the process alive.
+     * Do not use the runtime after calling this method. This method is meant for test shutdown.
+     */
+    quit () {
+        clearInterval(this._steppingInterval);
+        this._steppingInterval = null;
+    }
+
     /**
      * Turn on profiling.
      * @param {Profiler/FrameCallback} onFrame A callback handle passed a
diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js
index 7cb556c5d..a5e2c4ec8 100644
--- a/src/extension-support/extension-manager.js
+++ b/src/extension-support/extension-manager.js
@@ -81,8 +81,8 @@ class ExtensionManager {
         this.pendingWorkers = [];
 
         /**
-         * Set of loaded extension URLs/IDs (equivalent for built-in extensions).
-         * @type {Set.<string>}
+         * Map of loaded extension URLs/IDs (equivalent for built-in extensions) to service name.
+         * @type {Map.<string,string>}
          * @private
          */
         this._loadedExtensions = new Map();
diff --git a/src/extensions/scratch3_video_sensing/index.js b/src/extensions/scratch3_video_sensing/index.js
index bbdf3c308..008c15a4e 100644
--- a/src/extensions/scratch3_video_sensing/index.js
+++ b/src/extensions/scratch3_video_sensing/index.js
@@ -231,7 +231,8 @@ class Scratch3VideoSensingBlocks {
      * @private
      */
     _loop () {
-        setTimeout(this._loop.bind(this), Math.max(this.runtime.currentStepTime, Scratch3VideoSensingBlocks.INTERVAL));
+        const loopTime = Math.max(this.runtime.currentStepTime, Scratch3VideoSensingBlocks.INTERVAL);
+        this._loopInterval = setTimeout(this._loop.bind(this), loopTime);
 
         // Add frame to detector
         const time = Date.now();
@@ -251,6 +252,13 @@ class Scratch3VideoSensingBlocks {
         }
     }
 
+    /**
+     * Stop the video sampling loop. Only used for testing.
+     */
+    _stopLoop () {
+        clearTimeout(this._loopInterval);
+    }
+
     /**
      * Create data for a menu in scratch-blocks format, consisting of an array
      * of objects with text and value properties. The text is a translated
diff --git a/src/virtual-machine.js b/src/virtual-machine.js
index 01cee0443..85520c1ba 100644
--- a/src/virtual-machine.js
+++ b/src/virtual-machine.js
@@ -175,6 +175,14 @@ class VirtualMachine extends EventEmitter {
         this.runtime.start();
     }
 
+    /**
+     * Quit the VM, clearing any handles which might keep the process alive.
+     * Do not use the runtime after calling this method. This method is meant for test shutdown.
+     */
+    quit () {
+        this.runtime.quit();
+    }
+
     /**
      * "Green flag" handler - start all threads starting with a green flag.
      */
diff --git a/test/integration/addSprite.js b/test/integration/addSprite.js
index 4e61522b6..75024156a 100644
--- a/test/integration/addSprite.js
+++ b/test/integration/addSprite.js
@@ -27,8 +27,8 @@ test('default cat', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length === 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     vm.start();
diff --git a/test/integration/block_to_workspace_comment_import.js b/test/integration/block_to_workspace_comment_import.js
index 3eeb30aee..daea35a59 100644
--- a/test/integration/block_to_workspace_comment_import.js
+++ b/test/integration/block_to_workspace_comment_import.js
@@ -33,8 +33,8 @@ test('importing sb2 project where block comment is converted to workspace commen
         const invalidComments = targetComments.filter(comment => typeof comment.blockId === 'number');
         t.equal(invalidComments.length, 0);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/block_to_workspace_comment_import_no_scripts.js b/test/integration/block_to_workspace_comment_import_no_scripts.js
index 95ac37672..f3dec6707 100644
--- a/test/integration/block_to_workspace_comment_import_no_scripts.js
+++ b/test/integration/block_to_workspace_comment_import_no_scripts.js
@@ -38,8 +38,8 @@ test('importing sb2 project where block comment is converted to workspace commen
         const targetBlocks = Object.values(target.blocks._blocks);
         t.equal(targetBlocks.length, 0);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/broadcast_special_chars_sb2.js b/test/integration/broadcast_special_chars_sb2.js
index eec5d6b5e..63a77df6c 100644
--- a/test/integration/broadcast_special_chars_sb2.js
+++ b/test/integration/broadcast_special_chars_sb2.js
@@ -62,8 +62,8 @@ test('importing sb2 project with special chars in message names', t => {
         t.equal(catMessageBlocks[0].fields.BROADCAST_OPTION.id, ltPerfectMessageId);
         t.equal(catMessageBlocks[1].fields.BROADCAST_OPTION.id, abMessageId);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/broadcast_special_chars_sb3.js b/test/integration/broadcast_special_chars_sb3.js
index 62e0770c7..968e9fc52 100644
--- a/test/integration/broadcast_special_chars_sb3.js
+++ b/test/integration/broadcast_special_chars_sb3.js
@@ -62,8 +62,8 @@ test('importing sb3 project with special chars in message names', t => {
         t.equal(catMessageBlocks[0].fields.BROADCAST_OPTION.id, ltPerfectMessageId);
         t.equal(catMessageBlocks[1].fields.BROADCAST_OPTION.id, abMessageId);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/clone-cleanup.js b/test/integration/clone-cleanup.js
index d8ec4622c..87107a447 100644
--- a/test/integration/clone-cleanup.js
+++ b/test/integration/clone-cleanup.js
@@ -65,8 +65,8 @@ test('clone-cleanup', t => {
             // The second batch of clones has deleted themselves; everything is finished
             verifyCounts(0, 0);
 
+            vm.quit();
             t.end();
-            process.nextTick(process.exit);
             break;
         }
     };
diff --git a/test/integration/cloud_variables_sb2.js b/test/integration/cloud_variables_sb2.js
index 5da62a23a..0b2e0a3a7 100644
--- a/test/integration/cloud_variables_sb2.js
+++ b/test/integration/cloud_variables_sb2.js
@@ -37,6 +37,7 @@ test('importing an sb2 project with cloud variables', t => {
         // when the message is being sent to the server rather than on the client
         t.equal(variable.isCloud, true);
 
+        vm.quit();
         t.end();
     });
 });
@@ -60,6 +61,7 @@ test('importing an sb2 project with cloud variables at the limit for a project',
         // All of the 8 stage variables should be cloud variables
         t.equal(stageVars.filter(v => v.isCloud).length, 10);
 
+        vm.quit();
         t.end();
     });
 });
@@ -85,6 +87,7 @@ test('importing an sb2 project with cloud variables exceeding the limit for a pr
         // Only 8 of the variables should have the isCloud flag set to true
         t.equal(stageVars.filter(v => v.isCloud).length, 10);
 
+        vm.quit();
         t.end();
     });
 });
@@ -115,6 +118,7 @@ test('importing one project after the other resets cloud variable limit', t => {
 
             t.equal(vm.runtime.canAddCloudVariable(), true);
 
+            vm.quit();
             t.end();
         });
     });
@@ -145,8 +149,7 @@ test('local cloud variables get imported as regular variables', t => {
         t.equal(spriteVars.length, 1);
         t.equal(spriteVars[0].isCloud, false);
 
+        vm.quit();
         t.end();
-
-        process.nextTick(process.exit); // This is needed because this is the end of the last test in this file!!!
     });
 });
diff --git a/test/integration/cloud_variables_sb3.js b/test/integration/cloud_variables_sb3.js
index ea177b23a..7caab31ef 100644
--- a/test/integration/cloud_variables_sb3.js
+++ b/test/integration/cloud_variables_sb3.js
@@ -35,6 +35,7 @@ test('importing an sb3 project with cloud variables', t => {
         t.equal(Number(variable.value), 100);
         t.equal(variable.isCloud, true);
 
+        vm.quit();
         t.end();
     });
 });
@@ -58,6 +59,7 @@ test('importing an sb3 project with cloud variables at the limit for a project',
         // All of the 10 stage variables should be cloud variables
         t.equal(stageVars.filter(v => v.isCloud).length, 10);
 
+        vm.quit();
         t.end();
     });
 });
@@ -83,6 +85,7 @@ test('importing an sb3 project with cloud variables exceeding the limit for a pr
         // Only 8 of the variables should have the isCloud flag set to true
         t.equal(stageVars.filter(v => v.isCloud).length, 10);
 
+        vm.quit();
         t.end();
     });
 });
@@ -111,6 +114,7 @@ test('importing one project after the other resets cloud variable limit', t => {
 
             t.equal(vm.runtime.canAddCloudVariable(), true);
 
+            vm.quit();
             t.end();
         });
     });
@@ -141,8 +145,7 @@ test('local cloud variables get imported as regular variables', t => {
         t.equal(spriteVars.length, 1);
         t.equal(spriteVars[0].isCloud, false);
 
+        vm.quit();
         t.end();
-
-        process.nextTick(process.exit); // This is needed because this is the end of the last test in this file!!!
     });
 });
diff --git a/test/integration/comments.js b/test/integration/comments.js
index 5962b5eb6..14304ab83 100644
--- a/test/integration/comments.js
+++ b/test/integration/comments.js
@@ -70,8 +70,8 @@ test('importing sb2 project with comments', t => {
         t.equal(stopAllBlock.comment, blockComments[4].id);
         t.equal(stopAllBlock.opcode, 'control_stop');
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/comments_sb3.js b/test/integration/comments_sb3.js
index fd3766622..ceebfb2c1 100644
--- a/test/integration/comments_sb3.js
+++ b/test/integration/comments_sb3.js
@@ -70,8 +70,8 @@ test('load an sb3 project with comments', t => {
         t.equal(stopAllBlock.comment, blockComments[4].id);
         t.equal(stopAllBlock.opcode, 'control_stop');
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/complex.js b/test/integration/complex.js
index bce19ca67..de22faef0 100644
--- a/test/integration/complex.js
+++ b/test/integration/complex.js
@@ -19,8 +19,8 @@ test('complex', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length === 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Manipulate each target
diff --git a/test/integration/control.js b/test/integration/control.js
index eb3108365..3a3ad7d01 100644
--- a/test/integration/control.js
+++ b/test/integration/control.js
@@ -15,8 +15,8 @@ test('control', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length > 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/data.js b/test/integration/data.js
index 297ea5de0..65e50c4a7 100644
--- a/test/integration/data.js
+++ b/test/integration/data.js
@@ -14,8 +14,8 @@ test('data', t => {
     // Evaluate playground data and exit
     vm.on('playgroundData', () => {
         // @todo Additional tests
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/delete-and-restore-sprite.js b/test/integration/delete-and-restore-sprite.js
index 9ed1c781c..80352cd0c 100644
--- a/test/integration/delete-and-restore-sprite.js
+++ b/test/integration/delete-and-restore-sprite.js
@@ -27,8 +27,8 @@ test('default cat', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length === 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     vm.start();
diff --git a/test/integration/event.js b/test/integration/event.js
index d64e30003..886bca2e0 100644
--- a/test/integration/event.js
+++ b/test/integration/event.js
@@ -15,8 +15,8 @@ test('event', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length > 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/execute.js b/test/integration/execute.js
index 7a63e7785..71344acf4 100644
--- a/test/integration/execute.js
+++ b/test/integration/execute.js
@@ -66,6 +66,8 @@ fs.readdirSync(executeDir)
             log.suggest.deny('vm', 'error');
             t.tearDown(() => log.suggest.clear());
 
+            const vm = new VirtualMachine();
+
             // Map string messages to tap reporting methods. This will be used
             // with events from scratch's runtime emitted on block instructions.
             let didPlan;
@@ -86,6 +88,7 @@ fs.readdirSync(executeDir)
                 },
                 end () {
                     didEnd = true;
+                    vm.quit();
                     t.end();
                 }
             };
@@ -100,7 +103,6 @@ fs.readdirSync(executeDir)
                 return reporters.comment(text);
             };
 
-            const vm = new VirtualMachine();
             vm.attachStorage(makeTestStorage());
 
             // Start the VM and initialize some vm properties.
@@ -138,6 +140,7 @@ fs.readdirSync(executeDir)
                     // it can be resolved.
                     if (!didEnd) {
                         t.fail('did not say "end"');
+                        vm.quit();
                         t.end();
                     }
                 });
diff --git a/test/integration/hat-execution-order.js b/test/integration/hat-execution-order.js
index 78f2bef27..b6772cdf9 100644
--- a/test/integration/hat-execution-order.js
+++ b/test/integration/hat-execution-order.js
@@ -20,8 +20,8 @@ test('complex', t => {
         const results = vm.runtime.targets[0].variables[resultKey].value;
         t.deepEqual(results, ['3', '2', '1', 'stage']);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/import-sb.js b/test/integration/import-sb.js
index 3e96051ce..b58afa069 100644
--- a/test/integration/import-sb.js
+++ b/test/integration/import-sb.js
@@ -15,8 +15,8 @@ test('default', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length === 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/import-sb2-from-object.js b/test/integration/import-sb2-from-object.js
index 43397d092..63b7ae9bc 100644
--- a/test/integration/import-sb2-from-object.js
+++ b/test/integration/import-sb2-from-object.js
@@ -15,8 +15,8 @@ test('default', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length === 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/list-monitor-rename.js b/test/integration/list-monitor-rename.js
index bb57daf16..286f861ae 100644
--- a/test/integration/list-monitor-rename.js
+++ b/test/integration/list-monitor-rename.js
@@ -31,8 +31,8 @@ test('importing sb3 project with incorrect list monitor name', t => {
             t.equal(monitorBlock.fields.LIST.value, renamedListName);
         }
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/load-extensions.js b/test/integration/load-extensions.js
index bdeafb001..dfddb8deb 100644
--- a/test/integration/load-extensions.js
+++ b/test/integration/load-extensions.js
@@ -3,9 +3,19 @@ const tap = require('tap');
 const {test} = tap;
 const fs = require('fs');
 const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
+const dispatch = require('../../src/dispatch/central-dispatch');
 const VirtualMachine = require('../../src/index');
 
-tap.tearDown(() => process.nextTick(process.exit));
+/**
+ * Call _stopLoop() on the Video Sensing extension.
+ * @param {VirtualMachine} vm - a VM instance which has loaded the 'videoSensing' extension.
+ */
+const stopVideoLoop = vm => {
+    // TODO: provide a general way to tell extensions to shut down
+    // Ideally we'd just dispose of the extension's Worker...
+    const serviceName = vm.extensionManager._loadedExtensions.get('videoSensing');
+    dispatch.call(serviceName, '_stopLoop');
+};
 
 test('Load external extensions', async t => {
     const vm = new VirtualMachine();
@@ -25,6 +35,9 @@ test('Load external extensions', async t => {
                 });
         });
     }
+
+    stopVideoLoop(vm);
+    vm.quit();
     t.end();
 });
 
@@ -64,5 +77,7 @@ test('Load video sensing extension and video properties', async t => {
         t.equal(vm.runtime.ioDevices.video._ghost, project.videoTransparency);
     }
 
+    stopVideoLoop(vm);
+    vm.quit();
     t.end();
 });
diff --git a/test/integration/load-sb2-originally-sb1-without-backdrop-image.js b/test/integration/load-sb2-originally-sb1-without-backdrop-image.js
index 8a79cd223..d1211e247 100644
--- a/test/integration/load-sb2-originally-sb1-without-backdrop-image.js
+++ b/test/integration/load-sb2-originally-sb1-without-backdrop-image.js
@@ -17,8 +17,8 @@ test('sb2 project (originally from Scratch 1.4) with missing backdrop image shou
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length === 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     vm.start();
diff --git a/test/integration/looks.js b/test/integration/looks.js
index 63999d379..869c7fb00 100644
--- a/test/integration/looks.js
+++ b/test/integration/looks.js
@@ -15,8 +15,8 @@ test('looks', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length === 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/monitors_sb2.js b/test/integration/monitors_sb2.js
index 12ab92a83..e98ac40f8 100644
--- a/test/integration/monitors_sb2.js
+++ b/test/integration/monitors_sb2.js
@@ -122,8 +122,8 @@ test('importing sb2 project with monitors', t => {
         t.equal(monitorRecord.spriteName, null);
         t.equal(monitorRecord.targetId, null);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/monitors_sb2_to_sb3.js b/test/integration/monitors_sb2_to_sb3.js
index ec881c2d3..77b67bce8 100644
--- a/test/integration/monitors_sb2_to_sb3.js
+++ b/test/integration/monitors_sb2_to_sb3.js
@@ -140,7 +140,6 @@ test('saving and loading sb2 project with monitors preserves sliderMin and slide
         t.equal(monitorRecord.targetId, null);
 
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/monitors_sb3.js b/test/integration/monitors_sb3.js
index f55edb864..f688d79c6 100644
--- a/test/integration/monitors_sb3.js
+++ b/test/integration/monitors_sb3.js
@@ -247,8 +247,8 @@ test('importing sb3 project with monitors', t => {
         t.equal(monitorRecord.targetId, null);
         t.equal(vm.extensionManager.isExtensionLoaded('ev3'), true);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/motion.js b/test/integration/motion.js
index 67839385d..6d69bb9b8 100644
--- a/test/integration/motion.js
+++ b/test/integration/motion.js
@@ -15,8 +15,8 @@ test('motion', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length > 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/offline-custom-assets.js b/test/integration/offline-custom-assets.js
index 8e8ed59e2..50bd9c001 100644
--- a/test/integration/offline-custom-assets.js
+++ b/test/integration/offline-custom-assets.js
@@ -33,8 +33,8 @@ test('offline-custom-assets', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length === 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/pen.js b/test/integration/pen.js
index 0045854a7..e2821e24d 100644
--- a/test/integration/pen.js
+++ b/test/integration/pen.js
@@ -37,8 +37,8 @@ test('pen', t => {
         t.equal(originalPenState.penAttributes.diameter, 51);
         t.equal(clonePenState.penAttributes.diameter, 42);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/procedure.js b/test/integration/procedure.js
index c738c3245..a3264fae8 100644
--- a/test/integration/procedure.js
+++ b/test/integration/procedure.js
@@ -15,8 +15,8 @@ test('procedure', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length === 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/running_project_changed_state.js b/test/integration/running_project_changed_state.js
index 1af086d56..ea18f8787 100644
--- a/test/integration/running_project_changed_state.js
+++ b/test/integration/running_project_changed_state.js
@@ -19,8 +19,8 @@ test('Running project should not emit project changed event', t => {
     // Evaluate playground data and exit
     vm.on('playgroundData', () => {
         t.equal(projectChanged, false);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/saythink-and-wait.js b/test/integration/saythink-and-wait.js
index 9b42e8ee6..e902d3a98 100644
--- a/test/integration/saythink-and-wait.js
+++ b/test/integration/saythink-and-wait.js
@@ -29,8 +29,8 @@ test('say/think and wait', t => {
             // The test will fail if the project throws.
             setTimeout(() => {
                 vm.stopAll();
+                vm.quit();
                 t.end();
-                process.nextTick(process.exit);
             }, 2000);
         });
     });
diff --git a/test/integration/sb2-import-extension-monitors.js b/test/integration/sb2-import-extension-monitors.js
index 5420429bf..75dad3444 100644
--- a/test/integration/sb2-import-extension-monitors.js
+++ b/test/integration/sb2-import-extension-monitors.js
@@ -25,8 +25,6 @@ const visibleTempoMonitorProjectUri = path.resolve(
     __dirname, '../fixtures/visible-tempo-monitor-no-other-music-blocks.sb2');
 const visibleTempoMonitorProject = readFileToBuffer(visibleTempoMonitorProjectUri);
 
-tap.tearDown(() => process.nextTick(process.exit));
-
 test('loading sb2 project with invisible video monitor should not load monitor or extension', t => {
     const vm = new VirtualMachine();
     vm.attachStorage(makeTestStorage());
@@ -39,6 +37,7 @@ test('loading sb2 project with invisible video monitor should not load monitor o
     vm.loadProject(invisibleVideoMonitorProject).then(() => {
         t.equal(vm.extensionManager.isExtensionLoaded('videoSensing'), false);
         t.equal(vm.runtime._monitorState.size, 0);
+        vm.quit();
         t.end();
     });
 });
@@ -55,6 +54,7 @@ test('loading sb2 project with visible video monitor should not load extension',
     vm.loadProject(visibleVideoMonitorProject).then(() => {
         t.equal(vm.extensionManager.isExtensionLoaded('videoSensing'), false);
         t.equal(vm.runtime._monitorState.size, 0);
+        vm.quit();
         t.end();
     });
 });
@@ -86,6 +86,7 @@ test('sb2 project with invisible music monitor should not load monitor or extens
     vm.loadProject(invisibleTempoMonitorProject).then(() => {
         t.equal(vm.extensionManager.isExtensionLoaded('music'), false);
         t.equal(vm.runtime._monitorState.size, 0);
+        vm.quit();
         t.end();
     });
 });
@@ -104,6 +105,7 @@ test('sb2 project with visible music monitor should load monitor and extension',
         t.equal(vm.runtime._monitorState.size, 1);
         t.equal(vm.runtime._monitorState.has('music_getTempo'), true);
         t.equal(vm.runtime._monitorState.get('music_getTempo').visible, true);
+        vm.quit();
         t.end();
     });
 });
diff --git a/test/integration/sb2_corrupted_png.js b/test/integration/sb2_corrupted_png.js
index 78584ec2c..0812caffd 100644
--- a/test/integration/sb2_corrupted_png.js
+++ b/test/integration/sb2_corrupted_png.js
@@ -71,14 +71,14 @@ const test = tap.test;
 
 test('load sb2 project with corrupted bitmap costume file', t => {
     t.equal(vm.runtime.targets.length, 2);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = vm.runtime.targets[1];
     t.equal(greenGuySprite.getName(), 'GreenGuy');
     t.equal(greenGuySprite.getCostumes().length, 1);
-    
+
     const corruptedCostume = greenGuySprite.getCostumes()[0];
     t.equal(corruptedCostume.name, 'GreenGuy');
     t.equal(corruptedCostume.assetId, defaultBitmapAssetId);
@@ -96,14 +96,14 @@ test('load and then save project with corrupted bitmap costume file', t => {
     const resavedProject = JSON.parse(vm.toJSON());
 
     t.equal(resavedProject.targets.length, 2);
-    
+
     const stage = resavedProject.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = resavedProject.targets[1];
     t.equal(greenGuySprite.name, 'GreenGuy');
     t.equal(greenGuySprite.costumes.length, 1);
-    
+
     const corruptedCostume = greenGuySprite.costumes[0];
     t.equal(corruptedCostume.name, 'GreenGuy');
     // Resaved project costume should have the metadata that corresponds to the original broken costume
@@ -122,5 +122,4 @@ test('serializeCostume saves orignal broken costume', t => {
     t.equal(costume.fileName, `${brokenCostumeMd5}.png`);
     t.equal(md5(costume.fileContent), brokenCostumeMd5);
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sb2_corrupted_svg.js b/test/integration/sb2_corrupted_svg.js
index 26c26a1d8..5ad71fa17 100644
--- a/test/integration/sb2_corrupted_svg.js
+++ b/test/integration/sb2_corrupted_svg.js
@@ -30,7 +30,7 @@ global.Image = function () {
         width: 1,
         height: 1
     };
-    
+
     setTimeout(() => image.onload(), 1000);
     return image;
 };
@@ -73,14 +73,14 @@ const test = tap.test;
 
 test('load sb2 project with corrupted vector costume file', t => {
     t.equal(vm.runtime.targets.length, 2);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = vm.runtime.targets[1];
     t.equal(blueGuySprite.getName(), 'Blue Guy');
     t.equal(blueGuySprite.getCostumes().length, 1);
-    
+
     const corruptedCostume = blueGuySprite.getCostumes()[0];
     t.equal(corruptedCostume.name, 'Blue Guy 2');
     t.equal(corruptedCostume.assetId, defaultVectorAssetId);
@@ -98,14 +98,14 @@ test('load and then save project with corrupted vector costume file', t => {
     const resavedProject = JSON.parse(vm.toJSON());
 
     t.equal(resavedProject.targets.length, 2);
-    
+
     const stage = resavedProject.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = resavedProject.targets[1];
     t.equal(blueGuySprite.name, 'Blue Guy');
     t.equal(blueGuySprite.costumes.length, 1);
-    
+
     const corruptedCostume = blueGuySprite.costumes[0];
     t.equal(corruptedCostume.name, 'Blue Guy 2');
     // Resaved project costume should have the metadata that corresponds to the original broken costume
@@ -124,5 +124,4 @@ test('serializeCostume saves orignal broken costume', t => {
     t.equal(costume.fileName, `${brokenCostumeMd5}.svg`);
     t.equal(md5(costume.fileContent), brokenCostumeMd5);
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sb2_missing_png.js b/test/integration/sb2_missing_png.js
index 0d633f225..d10734f1d 100644
--- a/test/integration/sb2_missing_png.js
+++ b/test/integration/sb2_missing_png.js
@@ -56,14 +56,14 @@ const test = tap.test;
 
 test('loading sb2 project with missing bitmap costume file', t => {
     t.equal(vm.runtime.targets.length, 2);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = vm.runtime.targets[1];
     t.equal(greenGuySprite.getName(), 'GreenGuy');
     t.equal(greenGuySprite.getCostumes().length, 1);
-    
+
     const missingCostume = greenGuySprite.getCostumes()[0];
     t.equal(missingCostume.name, 'GreenGuy');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -81,14 +81,14 @@ test('load and then save sb2 project with missing costume file', t => {
     const resavedProject = JSON.parse(vm.toJSON());
 
     t.equal(resavedProject.targets.length, 2);
-    
+
     const stage = resavedProject.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = resavedProject.targets[1];
     t.equal(greenGuySprite.name, 'GreenGuy');
     t.equal(greenGuySprite.costumes.length, 1);
-    
+
     const missingCostume = greenGuySprite.costumes[0];
     t.equal(missingCostume.name, 'GreenGuy');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -102,10 +102,9 @@ test('load and then save sb2 project with missing costume file', t => {
 
 test('serializeCostume does not save data for missing costume', t => {
     const costumeDescs = serializeCostumes(vm.runtime);
-    
+
     t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop
     t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.png`);
 
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sb2_missing_svg.js b/test/integration/sb2_missing_svg.js
index f8a9da30d..1c5bb5fb4 100644
--- a/test/integration/sb2_missing_svg.js
+++ b/test/integration/sb2_missing_svg.js
@@ -55,14 +55,14 @@ const test = tap.test;
 
 test('loading sb2 project with missing vector costume file', t => {
     t.equal(vm.runtime.targets.length, 2);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = vm.runtime.targets[1];
     t.equal(blueGuySprite.getName(), 'Blue Guy');
     t.equal(blueGuySprite.getCostumes().length, 1);
-    
+
     const missingCostume = blueGuySprite.getCostumes()[0];
     t.equal(missingCostume.name, 'Blue Guy 2');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -80,14 +80,14 @@ test('load and then save sb2 project with missing costume file', t => {
     const resavedProject = JSON.parse(vm.toJSON());
 
     t.equal(resavedProject.targets.length, 2);
-    
+
     const stage = resavedProject.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = resavedProject.targets[1];
     t.equal(blueGuySprite.name, 'Blue Guy');
     t.equal(blueGuySprite.costumes.length, 1);
-    
+
     const missingCostume = blueGuySprite.costumes[0];
     t.equal(missingCostume.name, 'Blue Guy 2');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -101,10 +101,9 @@ test('load and then save sb2 project with missing costume file', t => {
 
 test('serializeCostume does not save data for missing costume', t => {
     const costumeDescs = serializeCostumes(vm.runtime);
-    
+
     t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop
     t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.svg`);
 
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sb3_corrupted_png.js b/test/integration/sb3_corrupted_png.js
index dd02ca4fd..fd6af12c2 100644
--- a/test/integration/sb3_corrupted_png.js
+++ b/test/integration/sb3_corrupted_png.js
@@ -71,14 +71,14 @@ const test = tap.test;
 
 test('load sb3 project with corrupted bitmap costume file', t => {
     t.equal(vm.runtime.targets.length, 2);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = vm.runtime.targets[1];
     t.equal(greenGuySprite.getName(), 'Green Guy');
     t.equal(greenGuySprite.getCostumes().length, 1);
-    
+
     const corruptedCostume = greenGuySprite.getCostumes()[0];
     t.equal(corruptedCostume.name, 'Green Guy');
     t.equal(corruptedCostume.assetId, defaultBitmapAssetId);
@@ -96,14 +96,14 @@ test('load and then save project with corrupted bitmap costume file', t => {
     const resavedProject = JSON.parse(vm.toJSON());
 
     t.equal(resavedProject.targets.length, 2);
-    
+
     const stage = resavedProject.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = resavedProject.targets[1];
     t.equal(greenGuySprite.name, 'Green Guy');
     t.equal(greenGuySprite.costumes.length, 1);
-    
+
     const corruptedCostume = greenGuySprite.costumes[0];
     t.equal(corruptedCostume.name, 'Green Guy');
     // Resaved project costume should have the metadata that corresponds to the original broken costume
@@ -122,5 +122,4 @@ test('serializeCostume saves orignal broken costume', t => {
     t.equal(costume.fileName, `${brokenCostumeMd5}.png`);
     t.equal(md5(costume.fileContent), brokenCostumeMd5);
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sb3_corrupted_sound.js b/test/integration/sb3_corrupted_sound.js
index 04f69453b..6afeda71e 100644
--- a/test/integration/sb3_corrupted_sound.js
+++ b/test/integration/sb3_corrupted_sound.js
@@ -32,7 +32,7 @@ const FakeAudioEngine = function () {
             if (soundDataString.includes('here is some')) {
                 return Promise.reject(new Error('mock audio engine broke'));
             }
-            
+
             // Otherwise return fake data
             return Promise.resolve({
                 id: fakeId++,
@@ -65,14 +65,14 @@ const test = tap.test;
 
 test('load sb3 project with corrupted sound file', t => {
     t.equal(vm.runtime.targets.length, 2);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const catSprite = vm.runtime.targets[1];
     t.equal(catSprite.getName(), 'Sprite1');
     t.equal(catSprite.getSounds().length, 1);
-    
+
     const corruptedSound = catSprite.getSounds()[0];
     t.equal(corruptedSound.name, 'Boop Sound Recording');
     t.equal(corruptedSound.assetId, defaultSoundAssetId);
@@ -90,14 +90,14 @@ test('load and then save project with corrupted sound file', t => {
     const resavedProject = JSON.parse(vm.toJSON());
 
     t.equal(resavedProject.targets.length, 2);
-    
+
     const stage = resavedProject.targets[0];
     t.ok(stage.isStage);
 
     const catSprite = resavedProject.targets[1];
     t.equal(catSprite.name, 'Sprite1');
     t.equal(catSprite.sounds.length, 1);
-    
+
     const corruptedSound = catSprite.sounds[0];
     t.equal(corruptedSound.name, 'Boop Sound Recording');
     // Resaved project costume should have the metadata that corresponds to the original broken costume
@@ -116,5 +116,4 @@ test('serializeSounds saves orignal broken sound', t => {
     t.equal(sound.fileName, `${brokenSoundMd5}.wav`);
     t.equal(md5(sound.fileContent), brokenSoundMd5);
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sb3_corrupted_svg.js b/test/integration/sb3_corrupted_svg.js
index 281c1c604..d4efe3653 100644
--- a/test/integration/sb3_corrupted_svg.js
+++ b/test/integration/sb3_corrupted_svg.js
@@ -52,14 +52,14 @@ const test = tap.test;
 
 test('load sb3 project with corrupted vector costume file', t => {
     t.equal(vm.runtime.targets.length, 2);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = vm.runtime.targets[1];
     t.equal(blueGuySprite.getName(), 'Blue Square Guy');
     t.equal(blueGuySprite.getCostumes().length, 1);
-    
+
     const corruptedCostume = blueGuySprite.getCostumes()[0];
     t.equal(corruptedCostume.name, 'costume1');
     t.equal(corruptedCostume.assetId, defaultVectorAssetId);
@@ -77,14 +77,14 @@ test('load and then save project with corrupted vector costume file', t => {
     const resavedProject = JSON.parse(vm.toJSON());
 
     t.equal(resavedProject.targets.length, 2);
-    
+
     const stage = resavedProject.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = resavedProject.targets[1];
     t.equal(blueGuySprite.name, 'Blue Square Guy');
     t.equal(blueGuySprite.costumes.length, 1);
-    
+
     const corruptedCostume = blueGuySprite.costumes[0];
     t.equal(corruptedCostume.name, 'costume1');
     // Resaved project costume should have the metadata that corresponds to the original broken costume
@@ -103,5 +103,4 @@ test('serializeCostume saves orignal broken costume', t => {
     t.equal(costume.fileName, `${brokenCostumeMd5}.svg`);
     t.equal(md5(costume.fileContent), brokenCostumeMd5);
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sb3_missing_png.js b/test/integration/sb3_missing_png.js
index a16e17a66..c080e819e 100644
--- a/test/integration/sb3_missing_png.js
+++ b/test/integration/sb3_missing_png.js
@@ -56,14 +56,14 @@ const test = tap.test;
 
 test('loading sb3 project with missing bitmap costume file', t => {
     t.equal(vm.runtime.targets.length, 2);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = vm.runtime.targets[1];
     t.equal(greenGuySprite.getName(), 'Green Guy');
     t.equal(greenGuySprite.getCostumes().length, 1);
-    
+
     const missingCostume = greenGuySprite.getCostumes()[0];
     t.equal(missingCostume.name, 'Green Guy');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -81,14 +81,14 @@ test('load and then save sb3 project with missing costume file', t => {
     const resavedProject = JSON.parse(vm.toJSON());
 
     t.equal(resavedProject.targets.length, 2);
-    
+
     const stage = resavedProject.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = resavedProject.targets[1];
     t.equal(greenGuySprite.name, 'Green Guy');
     t.equal(greenGuySprite.costumes.length, 1);
-    
+
     const missingCostume = greenGuySprite.costumes[0];
     t.equal(missingCostume.name, 'Green Guy');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -102,10 +102,9 @@ test('load and then save sb3 project with missing costume file', t => {
 
 test('serializeCostume does not save data for missing costume', t => {
     const costumeDescs = serializeCostumes(vm.runtime);
-    
+
     t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop
     t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.png`);
 
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sb3_missing_sound.js b/test/integration/sb3_missing_sound.js
index 16927bd10..b3c6980c2 100644
--- a/test/integration/sb3_missing_sound.js
+++ b/test/integration/sb3_missing_sound.js
@@ -32,13 +32,13 @@ const test = tap.test;
 
 test('loading sb3 project with missing sound file', t => {
     t.equal(vm.runtime.targets.length, 2);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const catSprite = vm.runtime.targets[1];
     t.equal(catSprite.getSounds().length, 1);
-    
+
     const missingSound = catSprite.getSounds()[0];
     t.equal(missingSound.name, 'Boop Sound Recording');
     // Sound should have original data but no asset
@@ -57,14 +57,14 @@ test('load and then save sb3 project with missing sound file', t => {
     const resavedProject = JSON.parse(vm.toJSON());
 
     t.equal(resavedProject.targets.length, 2);
-    
+
     const stage = resavedProject.targets[0];
     t.ok(stage.isStage);
 
     const catSprite = resavedProject.targets[1];
     t.equal(catSprite.name, 'Sprite1');
     t.equal(catSprite.sounds.length, 1);
-    
+
     const missingSound = catSprite.sounds[0];
     t.equal(missingSound.name, 'Boop Sound Recording');
     // Costume should have both default sound data (e.g. "Gray Question Sound" ^_^) and original data
@@ -78,10 +78,9 @@ test('load and then save sb3 project with missing sound file', t => {
 
 test('serializeCostume does not save data for missing costume', t => {
     const soundDescs = serializeSounds(vm.runtime);
-    
+
     t.equal(soundDescs.length, 1); // Should only have one sound, the pop sound for the stage
     t.not(soundDescs[0].fileName, `${missingSoundAssetId}.wav`);
 
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sb3_missing_svg.js b/test/integration/sb3_missing_svg.js
index 523f3459e..f0adf82dd 100644
--- a/test/integration/sb3_missing_svg.js
+++ b/test/integration/sb3_missing_svg.js
@@ -35,14 +35,14 @@ const test = tap.test;
 
 test('loading sb3 project with missing vector costume file', t => {
     t.equal(vm.runtime.targets.length, 2);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = vm.runtime.targets[1];
     t.equal(blueGuySprite.getName(), 'Blue Square Guy');
     t.equal(blueGuySprite.getCostumes().length, 1);
-    
+
     const missingCostume = blueGuySprite.getCostumes()[0];
     t.equal(missingCostume.name, 'costume1');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -60,14 +60,14 @@ test('load and then save sb3 project with missing costume file', t => {
     const resavedProject = JSON.parse(vm.toJSON());
 
     t.equal(resavedProject.targets.length, 2);
-    
+
     const stage = resavedProject.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = resavedProject.targets[1];
     t.equal(blueGuySprite.name, 'Blue Square Guy');
     t.equal(blueGuySprite.costumes.length, 1);
-    
+
     const missingCostume = blueGuySprite.costumes[0];
     t.equal(missingCostume.name, 'costume1');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -81,10 +81,9 @@ test('load and then save sb3 project with missing costume file', t => {
 
 test('serializeCostume does not save data for missing costume', t => {
     const costumeDescs = serializeCostumes(vm.runtime);
-    
+
     t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop
     t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.svg`);
 
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sensing.js b/test/integration/sensing.js
index 9deece625..dbe0df94f 100644
--- a/test/integration/sensing.js
+++ b/test/integration/sensing.js
@@ -15,8 +15,8 @@ test('sensing', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length > 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/sound.js b/test/integration/sound.js
index 8ba9e666c..6d27cb511 100644
--- a/test/integration/sound.js
+++ b/test/integration/sound.js
@@ -20,8 +20,8 @@ test('sound', t => {
     vm.on('playgroundData', e => {
         const threads = JSON.parse(e.threads);
         t.ok(threads.length > 0);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/sprite2_corrupted_png.js b/test/integration/sprite2_corrupted_png.js
index d579e5310..d82bf84ce 100644
--- a/test/integration/sprite2_corrupted_png.js
+++ b/test/integration/sprite2_corrupted_png.js
@@ -76,14 +76,14 @@ const test = tap.test;
 
 test('load sprite2 with corrupted bitmap costume file', t => {
     t.equal(vm.runtime.targets.length, 3);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = vm.runtime.targets[2];
     t.equal(greenGuySprite.getName(), 'GreenGuy');
     t.equal(greenGuySprite.getCostumes().length, 1);
-    
+
     const corruptedCostume = greenGuySprite.getCostumes()[0];
     t.equal(corruptedCostume.name, 'GreenGuy');
     t.equal(corruptedCostume.assetId, defaultBitmapAssetId);
@@ -102,7 +102,7 @@ test('load and then save sprite with corrupted costume file', t => {
 
     t.equal(resavedSprite.name, 'GreenGuy');
     t.equal(resavedSprite.costumes.length, 1);
-    
+
     const corruptedCostume = resavedSprite.costumes[0];
     t.equal(corruptedCostume.name, 'GreenGuy');
     // Resaved project costume should have the metadata that corresponds to the original broken costume
@@ -121,5 +121,4 @@ test('serializeCostume saves orignal broken costume', t => {
     t.equal(costume.fileName, `${brokenCostumeMd5}.png`);
     t.equal(md5(costume.fileContent), brokenCostumeMd5);
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sprite2_corrupted_svg.js b/test/integration/sprite2_corrupted_svg.js
index 45b8e6a6b..866448d79 100644
--- a/test/integration/sprite2_corrupted_svg.js
+++ b/test/integration/sprite2_corrupted_svg.js
@@ -77,14 +77,14 @@ const test = tap.test;
 
 test('load sprite2 with corrupted vector costume file', t => {
     t.equal(vm.runtime.targets.length, 3);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = vm.runtime.targets[2];
     t.equal(blueGuySprite.getName(), 'Blue Guy');
     t.equal(blueGuySprite.getCostumes().length, 1);
-    
+
     const corruptedCostume = blueGuySprite.getCostumes()[0];
     t.equal(corruptedCostume.name, 'Blue Guy 2');
     t.equal(corruptedCostume.assetId, defaultVectorAssetId);
@@ -103,7 +103,7 @@ test('load and then save sprite with corrupted costume file', t => {
 
     t.equal(resavedSprite.name, 'Blue Guy');
     t.equal(resavedSprite.costumes.length, 1);
-    
+
     const corruptedCostume = resavedSprite.costumes[0];
     t.equal(corruptedCostume.name, 'Blue Guy 2');
     // Resaved project costume should have the metadata that corresponds to the original broken costume
@@ -122,5 +122,4 @@ test('serializeCostume saves orignal broken costume', t => {
     t.equal(costume.fileName, `${brokenCostumeMd5}.svg`);
     t.equal(md5(costume.fileContent), brokenCostumeMd5);
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sprite2_missing_png.js b/test/integration/sprite2_missing_png.js
index dad0d5314..7eeccea6b 100644
--- a/test/integration/sprite2_missing_png.js
+++ b/test/integration/sprite2_missing_png.js
@@ -59,14 +59,14 @@ const test = tap.test;
 
 test('loading sprite2 with missing bitmap costume file', t => {
     t.equal(vm.runtime.targets.length, 3);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = vm.runtime.targets[2];
     t.equal(greenGuySprite.getName(), 'GreenGuy');
     t.equal(greenGuySprite.getCostumes().length, 1);
-    
+
     const missingCostume = greenGuySprite.getCostumes()[0];
     t.equal(missingCostume.name, 'GreenGuy');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -85,7 +85,7 @@ test('load and then save sprite2 with missing bitmap costume file', t => {
 
     t.equal(resavedSprite.name, 'GreenGuy');
     t.equal(resavedSprite.costumes.length, 1);
-    
+
     const missingCostume = resavedSprite.costumes[0];
     t.equal(missingCostume.name, 'GreenGuy');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -99,9 +99,8 @@ test('load and then save sprite2 with missing bitmap costume file', t => {
 
 test('serializeCostume does not save data for missing costume', t => {
     const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
-   
+
     t.equal(costumeDescs.length, 0);
-    
+
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sprite2_missing_svg.js b/test/integration/sprite2_missing_svg.js
index e53885f38..bcd9b12f8 100644
--- a/test/integration/sprite2_missing_svg.js
+++ b/test/integration/sprite2_missing_svg.js
@@ -59,14 +59,14 @@ const test = tap.test;
 
 test('loading sprite2 with missing vector costume file', t => {
     t.equal(vm.runtime.targets.length, 3);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = vm.runtime.targets[2];
     t.equal(blueGuySprite.getName(), 'Blue Guy');
     t.equal(blueGuySprite.getCostumes().length, 1);
-    
+
     const missingCostume = blueGuySprite.getCostumes()[0];
     t.equal(missingCostume.name, 'Blue Guy 2');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -85,7 +85,7 @@ test('load and then save sprite2 with missing vector costume file', t => {
 
     t.equal(resavedSprite.name, 'Blue Guy');
     t.equal(resavedSprite.costumes.length, 1);
-    
+
     const missingCostume = resavedSprite.costumes[0];
     t.equal(missingCostume.name, 'Blue Guy 2');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -99,9 +99,8 @@ test('load and then save sprite2 with missing vector costume file', t => {
 
 test('serializeCostume does not save data for missing costume', t => {
     const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
-   
+
     t.equal(costumeDescs.length, 0);
-    
+
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sprite3_corrupted_png.js b/test/integration/sprite3_corrupted_png.js
index 2898fa82a..fd8aafd66 100644
--- a/test/integration/sprite3_corrupted_png.js
+++ b/test/integration/sprite3_corrupted_png.js
@@ -76,14 +76,14 @@ const test = tap.test;
 
 test('load sprite3 with corrupted bitmap costume file', t => {
     t.equal(vm.runtime.targets.length, 3);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = vm.runtime.targets[2];
     t.equal(greenGuySprite.getName(), 'Green Guy');
     t.equal(greenGuySprite.getCostumes().length, 1);
-    
+
     const corruptedCostume = greenGuySprite.getCostumes()[0];
     t.equal(corruptedCostume.name, 'Green Guy');
     t.equal(corruptedCostume.assetId, defaultBitmapAssetId);
@@ -102,7 +102,7 @@ test('load and then save sprite with corrupted costume file', t => {
 
     t.equal(resavedSprite.name, 'Green Guy');
     t.equal(resavedSprite.costumes.length, 1);
-    
+
     const corruptedCostume = resavedSprite.costumes[0];
     t.equal(corruptedCostume.name, 'Green Guy');
     // Resaved project costume should have the metadata that corresponds to the original broken costume
@@ -121,5 +121,4 @@ test('serializeCostume saves orignal broken costume', t => {
     t.equal(costume.fileName, `${brokenCostumeMd5}.png`);
     t.equal(md5(costume.fileContent), brokenCostumeMd5);
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sprite3_corrupted_svg.js b/test/integration/sprite3_corrupted_svg.js
index 9bbe96bc9..f78f468a0 100644
--- a/test/integration/sprite3_corrupted_svg.js
+++ b/test/integration/sprite3_corrupted_svg.js
@@ -57,14 +57,14 @@ const test = tap.test;
 
 test('load sprite3 with corrupted vector costume file', t => {
     t.equal(vm.runtime.targets.length, 3);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = vm.runtime.targets[2];
     t.equal(blueGuySprite.getName(), 'Blue Square Guy');
     t.equal(blueGuySprite.getCostumes().length, 1);
-    
+
     const corruptedCostume = blueGuySprite.getCostumes()[0];
     t.equal(corruptedCostume.name, 'costume1');
     t.equal(corruptedCostume.assetId, defaultVectorAssetId);
@@ -83,7 +83,7 @@ test('load and then save sprite with corrupted costume file', t => {
 
     t.equal(resavedSprite.name, 'Blue Square Guy');
     t.equal(resavedSprite.costumes.length, 1);
-    
+
     const corruptedCostume = resavedSprite.costumes[0];
     t.equal(corruptedCostume.name, 'costume1');
     // Resaved project costume should have the metadata that corresponds to the original broken costume
@@ -102,5 +102,4 @@ test('serializeCostume saves orignal broken costume', t => {
     t.equal(costume.fileName, `${brokenCostumeMd5}.svg`);
     t.equal(md5(costume.fileContent), brokenCostumeMd5);
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sprite3_missing_png.js b/test/integration/sprite3_missing_png.js
index bbc7a816c..443db2ca1 100644
--- a/test/integration/sprite3_missing_png.js
+++ b/test/integration/sprite3_missing_png.js
@@ -59,14 +59,14 @@ const test = tap.test;
 
 test('loading sprite3 with missing bitmap costume file', t => {
     t.equal(vm.runtime.targets.length, 3);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const greenGuySprite = vm.runtime.targets[2];
     t.equal(greenGuySprite.getName(), 'Green Guy');
     t.equal(greenGuySprite.getCostumes().length, 1);
-    
+
     const missingCostume = greenGuySprite.getCostumes()[0];
     t.equal(missingCostume.name, 'Green Guy');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -85,7 +85,7 @@ test('load and then save sprite3 with missing bitmap costume file', t => {
 
     t.equal(resavedSprite.name, 'Green Guy');
     t.equal(resavedSprite.costumes.length, 1);
-    
+
     const missingCostume = resavedSprite.costumes[0];
     t.equal(missingCostume.name, 'Green Guy');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -99,9 +99,8 @@ test('load and then save sprite3 with missing bitmap costume file', t => {
 
 test('serializeCostume does not save data for missing costume', t => {
     const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
-   
+
     t.equal(costumeDescs.length, 0);
-    
+
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/sprite3_missing_svg.js b/test/integration/sprite3_missing_svg.js
index 6bd0985f8..1fdbb4586 100644
--- a/test/integration/sprite3_missing_svg.js
+++ b/test/integration/sprite3_missing_svg.js
@@ -39,14 +39,14 @@ const test = tap.test;
 
 test('loading sprite3 with missing vector costume file', t => {
     t.equal(vm.runtime.targets.length, 3);
-    
+
     const stage = vm.runtime.targets[0];
     t.ok(stage.isStage);
 
     const blueGuySprite = vm.runtime.targets[2];
     t.equal(blueGuySprite.getName(), 'Blue Square Guy');
     t.equal(blueGuySprite.getCostumes().length, 1);
-    
+
     const missingCostume = blueGuySprite.getCostumes()[0];
     t.equal(missingCostume.name, 'costume1');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -65,7 +65,7 @@ test('load and then save sprite3 with missing vector costume file', t => {
 
     t.equal(resavedSprite.name, 'Blue Square Guy');
     t.equal(resavedSprite.costumes.length, 1);
-    
+
     const missingCostume = resavedSprite.costumes[0];
     t.equal(missingCostume.name, 'costume1');
     // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
@@ -79,9 +79,8 @@ test('load and then save sprite3 with missing vector costume file', t => {
 
 test('serializeCostume does not save data for missing costume', t => {
     const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
-   
+
     t.equal(costumeDescs.length, 0);
-    
+
     t.end();
-    process.nextTick(process.exit);
 });
diff --git a/test/integration/stack-click.js b/test/integration/stack-click.js
index b2e2ec171..a6911064f 100644
--- a/test/integration/stack-click.js
+++ b/test/integration/stack-click.js
@@ -22,8 +22,8 @@ test('stack click activates the stack', t => {
     vm.on('playgroundData', () => {
         // The sprite should have moved 100 to the right
         t.equal(vm.editingTarget.x, 100);
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/unknown-opcode-as-reporter-block.js b/test/integration/unknown-opcode-as-reporter-block.js
index b07d3b5c5..7ea150fc1 100644
--- a/test/integration/unknown-opcode-as-reporter-block.js
+++ b/test/integration/unknown-opcode-as-reporter-block.js
@@ -48,7 +48,7 @@ test('unknown opcode', t => {
         t.true(blocks.getBlock(fourthBlockInputId).shadow);
         t.equal(blocks.getBlock(fourthBlockInputId).opcode, 'sound_sounds_menu');
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 });
diff --git a/test/integration/unknown-opcode-in-c-block.js b/test/integration/unknown-opcode-in-c-block.js
index e2f4c6597..4fa2bd8cd 100644
--- a/test/integration/unknown-opcode-in-c-block.js
+++ b/test/integration/unknown-opcode-in-c-block.js
@@ -33,7 +33,7 @@ test('unknown opcode', t => {
         t.equal(blocks.getBlock(secondBlockId).opcode, 'control_forever');
         t.equal(innerBlockId, null);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 });
diff --git a/test/integration/unknown-opcode.js b/test/integration/unknown-opcode.js
index 53b9b64f9..ac015fb11 100644
--- a/test/integration/unknown-opcode.js
+++ b/test/integration/unknown-opcode.js
@@ -49,7 +49,7 @@ test('unknown opcode', t => {
         t.equal(undefinedComment.x, 0);
         t.equal(undefinedComment.y, 0);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 });
diff --git a/test/integration/variable_monitor_reset.js b/test/integration/variable_monitor_reset.js
index 0ef055b47..c722df015 100644
--- a/test/integration/variable_monitor_reset.js
+++ b/test/integration/variable_monitor_reset.js
@@ -35,8 +35,8 @@ test('importing one project after the other resets monitored variables', t => {
             const jamalVarBlock = vm.runtime.monitorBlocks.getBlock(jamalVarId);
             t.notOk(jamalVarBlock);
 
+            vm.quit();
             t.end();
-            process.nextTick(process.exit);
         });
     });
 });
diff --git a/test/integration/variable_special_chars_sb2.js b/test/integration/variable_special_chars_sb2.js
index 19086e345..b0c47bdd9 100644
--- a/test/integration/variable_special_chars_sb2.js
+++ b/test/integration/variable_special_chars_sb2.js
@@ -114,8 +114,8 @@ test('importing sb2 project with special chars in variable names', t => {
         t.equal(bananasVarBlocks.length, 1);
         t.equal(bananasVarBlocks[0].fields.VARIABLE.id, ltPerfectVarId);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/integration/variable_special_chars_sb3.js b/test/integration/variable_special_chars_sb3.js
index b599415af..5fb7c7542 100644
--- a/test/integration/variable_special_chars_sb3.js
+++ b/test/integration/variable_special_chars_sb3.js
@@ -114,8 +114,8 @@ test('importing sb3 project with special chars in variable names', t => {
         t.equal(bananasVarBlocks.length, 1);
         t.equal(bananasVarBlocks[0].fields.VARIABLE.id, ltPerfectVarId);
 
+        vm.quit();
         t.end();
-        process.nextTick(process.exit);
     });
 
     // Start VM, load project, and run
diff --git a/test/unit/engine_runtime.js b/test/unit/engine_runtime.js
index de5025dba..a1c13d152 100644
--- a/test/unit/engine_runtime.js
+++ b/test/unit/engine_runtime.js
@@ -6,8 +6,6 @@ const Runtime = require('../../src/engine/runtime');
 const MonitorRecord = require('../../src/engine/monitor-record');
 const {Map} = require('immutable');
 
-tap.tearDown(() => process.nextTick(process.exit));
-
 const test = tap.test;
 
 test('spec', t => {
@@ -193,6 +191,7 @@ test('Starting the runtime emits an event', t => {
     });
     rt.start();
     t.equal(started, true);
+    rt.quit();
     t.end();
 });
 
@@ -209,6 +208,7 @@ test('Runtime cannot be started while already running', t => {
     // Starting again should not emit another event
     rt.start();
     t.equal(started, false);
+    rt.quit();
     t.end();
 });
 
@@ -224,6 +224,7 @@ test('setCompatibilityMode restarts if it was already running', t => {
 
     rt.setCompatibilityMode(true);
     t.equal(started, true);
+    rt.quit();
     t.end();
 });
 
diff --git a/test/unit/project_changed_state.js b/test/unit/project_changed_state.js
index f910a603f..85e362cba 100644
--- a/test/unit/project_changed_state.js
+++ b/test/unit/project_changed_state.js
@@ -27,8 +27,6 @@ tap.beforeEach(() => {
     });
 });
 
-tap.tearDown(() => process.nextTick(process.exit));
-
 const test = tap.test;
 
 test('Adding a sprite (from sprite2) should emit a project changed event', t => {
diff --git a/test/unit/project_changed_state_blocks.js b/test/unit/project_changed_state_blocks.js
index f077cb931..9685cdd29 100644
--- a/test/unit/project_changed_state_blocks.js
+++ b/test/unit/project_changed_state_blocks.js
@@ -53,8 +53,6 @@ tap.beforeEach(() => {
     });
 });
 
-tap.tearDown(() => process.nextTick(process.exit));
-
 const test = tap.test;
 
 test('Creating a block should emit a project changed event', t => {
diff --git a/test/unit/project_load_changed_state.js b/test/unit/project_load_changed_state.js
index 0f510ef41..853541488 100644
--- a/test/unit/project_load_changed_state.js
+++ b/test/unit/project_load_changed_state.js
@@ -4,8 +4,6 @@ const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer
 const makeTestStorage = require('../fixtures/make-test-storage');
 const VirtualMachine = require('../../src/virtual-machine');
 
-tap.tearDown(() => process.nextTick(process.exit));
-
 const test = tap.test;
 
 // Test that loading a project does not emit a project change
diff --git a/test/unit/virtual-machine.js b/test/unit/virtual-machine.js
index 20ac78f7f..87b213ff0 100644
--- a/test/unit/virtual-machine.js
+++ b/test/unit/virtual-machine.js
@@ -8,8 +8,6 @@ const Renderer = require('../fixtures/fake-renderer');
 const Runtime = require('../../src/engine/runtime');
 const RenderedTarget = require('../../src/sprites/rendered-target');
 
-tap.tearDown(() => process.nextTick(process.exit));
-
 const test = tap.test;
 
 test('deleteSound returns function after deleting or null if nothing was deleted', t => {
@@ -1016,6 +1014,7 @@ test('Starting the VM emits an event', t => {
     });
     vm.start();
     t.equal(started, true);
+    vm.quit();
     t.end();
 });