diff --git a/src/commands/test.cr b/src/commands/test.cr
index d238fa320..08ee705a2 100644
--- a/src/commands/test.cr
+++ b/src/commands/test.cr
@@ -48,14 +48,30 @@ module Mint
         description: "Will use supplied runtime path instead of the default distribution",
         required: false
 
-      define_argument test : String
+      define_flag watch : Bool,
+        description: "Watch files for changes and rerun tests",
+        required: false
+
+      define_argument test : String,
+        description: "The path to the test file to run"
 
       def run
-        succeeded = nil
-        execute "Running Tests" do
-          succeeded = TestRunner.new(flags, arguments).run
+        MintJson.parse_current.check_dependencies!
+
+        runner =
+          TestRunner.new(flags, arguments)
+
+        if flags.watch
+          runner.watch
+        else
+          succeeded = nil
+
+          execute "Running Tests" do
+            succeeded = runner.run
+          end
+
+          exit(1) unless succeeded
         end
-        exit(1) unless succeeded
       end
     end
   end
diff --git a/src/render/terminal.cr b/src/render/terminal.cr
index 1dce912a0..769df69c6 100644
--- a/src/render/terminal.cr
+++ b/src/render/terminal.cr
@@ -247,6 +247,10 @@ module Mint
         print contents.to_s
       end
 
+      def reset
+        print "\ec"
+      end
+
       def puts(contents = nil)
         print contents if contents
         print "\n"
diff --git a/src/test_runner.cr b/src/test_runner.cr
index d8b377942..f3029e133 100644
--- a/src/test_runner.cr
+++ b/src/test_runner.cr
@@ -5,6 +5,7 @@ module Mint
     class Message
       include JSON::Serializable
 
+      property id : String
       property type : String
       property name : String?
       property suite : String?
@@ -30,9 +31,10 @@ module Mint
     @artifacts : TypeChecker::Artifacts?
     @reporter : Reporter
     @browser_path : String?
-    @script : String?
+    @browser : {Process, String}?
 
     @failed = [] of Message
+    @test_id = Random::Secure.hex
     @succeeded = 0
 
     def initialize(@flags : Cli::Test::Flags, @arguments : Cli::Test::Arguments)
@@ -46,15 +48,38 @@ module Mint
       @succeeded = 0
     end
 
-    def run : Bool
-      MintJson.parse_current.check_dependencies!
+    def watch
+      workspace = Workspace.current
+      workspace.on "change" { run_tests }
+      workspace.watch
 
-      ast = terminal.measure "#{COG} Compiling tests..." do
-        compile_ast.tap do |a|
-          @script = compile_script(a)
-        end
+      setup_kemal
+      run_tests
+
+      Server.run "Test", @flags.host, @flags.port, false
+    end
+
+    def run_tests
+      @test_id = Random::Secure.hex
+      cleanup_browser
+      terminal.reset
+
+      begin
+        type_checker = TypeChecker.new(ast)
+        type_checker.check
+
+        @reporter.reset
+        open_page
+      rescue error : Error
+        terminal.reset
+        terminal.puts error.to_terminal
+      rescue error
+        terminal.reset
+        terminal.puts error.to_s
       end
+    end
 
+    def run : Bool
       if ast.try(&.suites.empty?)
         terminal.puts
         terminal.puts "There are no tests to run!"
@@ -74,7 +99,7 @@ module Mint
       @failed.empty?
     end
 
-    def compile_ast
+    def ast
       file_argument =
         @arguments.test
 
@@ -96,7 +121,7 @@ module Mint
       end
     end
 
-    def compile_script(ast)
+    def script
       type_checker = TypeChecker.new(ast)
       type_checker.check
 
@@ -186,15 +211,21 @@ module Mint
 
       begin
         process = open_process(browser_path, profile_directory)
-        at_exit do
-          process.signal(:kill) rescue nil
-          FileUtils.rm_rf(profile_directory)
-        end
+        @browser = {process, profile_directory}
+        at_exit { cleanup_browser }
         @channel.receive
       ensure
-        process.try &.signal(:kill) rescue nil
+        cleanup_browser
+      end
+    end
+
+    def cleanup_browser
+      @browser.try do |(process, profile_directory)|
+        process.signal(:kill) rescue nil
         FileUtils.rm_rf(profile_directory)
       end
+
+      @browser = nil
     end
 
     def open_page
@@ -206,9 +237,6 @@ module Mint
       ws_url =
         "ws://#{@flags.browser_host}:#{@flags.browser_port}/"
 
-      page_source =
-        ECR.render("#{__DIR__}/test_runner.ecr")
-
       runtime =
         if runtime_path = @flags.runtime
           Cli.runtime_file_not_found(runtime_path) unless File.exists?(runtime_path)
@@ -219,7 +247,7 @@ module Mint
 
       get "/" do
         reset
-        page_source
+        ECR.render("#{__DIR__}/test_runner.ecr")
       end
 
       get "/external-javascripts.js" do |env|
@@ -258,7 +286,7 @@ module Mint
       end
 
       get "/tests" do
-        @script
+        script
       end
 
       ws "/" do |socket|
@@ -277,6 +305,8 @@ module Mint
     end
 
     def handle_message(data : Message) : Nil
+      return unless data.id == @test_id
+
       case data.type
       when "LOG"
         terminal.puts data.result
@@ -296,7 +326,7 @@ module Mint
         @failed << data
 
         @reporter.done
-        stop_server
+        stop_server unless @flags.watch
       end
     end
 
@@ -335,7 +365,7 @@ module Mint
           end
         end
 
-      stop_server
+      stop_server unless @flags.watch
     end
 
     def stop_server
diff --git a/src/test_runner.ecr b/src/test_runner.ecr
index 10f270f95..45f7d4ef7 100644
--- a/src/test_runner.ecr
+++ b/src/test_runner.ecr
@@ -8,6 +8,8 @@