You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cassandra.apache.org by dc...@apache.org on 2020/09/30 22:03:44 UTC

[cassandra] branch trunk updated: Add UX tests to intree LHF tooling

This is an automated email from the ASF dual-hosted git repository.

dcapwell pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 9802a70  Add UX tests to intree LHF tooling
9802a70 is described below

commit 9802a70f68cdcd239a9128f90f5d8f9a941168de
Author: Berenguer Blasi <be...@gmail.com>
AuthorDate: Wed Sep 30 12:13:15 2020 -0700

    Add UX tests to intree LHF tooling
    
    patch by Berenguer Blasi; reviewed by Brandon Williams, David Capwell for CASSANDRA-15991
---
 .../org/apache/cassandra/tools/SSTableExport.java  |   2 -
 .../cassandra/tools/SSTableMetadataViewer.java     |   1 -
 .../cassandra/tools/StandaloneSSTableUtil.java     |   4 +-
 .../test/FqlReplayDDLExclusionTest.java            |  27 +-
 test/unit/org/apache/cassandra/cql3/CQLTester.java |  30 +-
 .../apache/cassandra/tools/AuditLogViewerTest.java |  93 +++
 .../org/apache/cassandra/tools/BulkLoaderTest.java | 120 ++--
 .../apache/cassandra/tools/ClearSnapshotTest.java  |  41 +-
 .../cassandra/tools/CompactionStressTest.java      |  47 +-
 .../cassandra/tools/GetFullQueryLogTest.java       |  36 +-
 .../org/apache/cassandra/tools/GetVersionTest.java |   6 +-
 .../cassandra/tools/JMXCompatabilityTest.java      |   9 +-
 .../org/apache/cassandra/tools/JMXToolTest.java    |  17 +-
 .../apache/cassandra/tools/OfflineToolUtils.java   |   9 +-
 .../tools/SSTableExpiredBlockersTest.java          |  45 +-
 .../apache/cassandra/tools/SSTableExportTest.java  | 151 ++++-
 .../cassandra/tools/SSTableLevelResetterTest.java  |  55 +-
 .../cassandra/tools/SSTableMetadataViewerTest.java | 139 ++++-
 .../cassandra/tools/SSTableOfflineRelevelTest.java |  45 +-
 .../tools/SSTableRepairedAtSetterTest.java         |  84 ++-
 .../cassandra/tools/StandaloneSSTableUtilTest.java | 136 +++-
 .../cassandra/tools/StandaloneScrubberTest.java    | 152 ++++-
 .../cassandra/tools/StandaloneSplitterTest.java    | 100 ++-
 .../cassandra/tools/StandaloneUpgraderTest.java    |  86 ++-
 .../cassandra/tools/StandaloneVerifierTest.java    | 141 ++++-
 .../org/apache/cassandra/tools/ToolRunner.java     | 693 ++++++++++++---------
 ...tVersionTest.java => ToolsEnvsConfigsTest.java} |  30 +-
 .../tools/cassandrastress/CassandrastressTest.java |  50 ++
 .../{GetVersionTest.java => cqlsh/CqlshTest.java}  |  35 +-
 29 files changed, 1801 insertions(+), 583 deletions(-)

diff --git a/src/java/org/apache/cassandra/tools/SSTableExport.java b/src/java/org/apache/cassandra/tools/SSTableExport.java
index 394f4b6..ca01cc3 100644
--- a/src/java/org/apache/cassandra/tools/SSTableExport.java
+++ b/src/java/org/apache/cassandra/tools/SSTableExport.java
@@ -46,8 +46,6 @@ import org.apache.commons.cli.Option;
 import org.apache.commons.cli.Options;
 import org.apache.commons.cli.ParseException;
 import org.apache.commons.cli.PosixParser;
-import org.apache.cassandra.io.sstable.metadata.MetadataComponent;
-import org.apache.cassandra.io.sstable.metadata.MetadataType;
 import org.apache.cassandra.schema.TableMetadataRef;
 import org.apache.cassandra.utils.FBUtilities;
 
diff --git a/src/java/org/apache/cassandra/tools/SSTableMetadataViewer.java b/src/java/org/apache/cassandra/tools/SSTableMetadataViewer.java
index e99f454..a4da97c 100755
--- a/src/java/org/apache/cassandra/tools/SSTableMetadataViewer.java
+++ b/src/java/org/apache/cassandra/tools/SSTableMetadataViewer.java
@@ -73,7 +73,6 @@ import org.apache.commons.cli.ParseException;
 import org.apache.commons.cli.PosixParser;
 
 import com.google.common.collect.MinMaxPriorityQueue;
-import org.apache.commons.lang3.time.DurationFormatUtils;
 
 /**
  * Shows the contents of sstable metadata
diff --git a/src/java/org/apache/cassandra/tools/StandaloneSSTableUtil.java b/src/java/org/apache/cassandra/tools/StandaloneSSTableUtil.java
index 9a7847a..cca48fc 100644
--- a/src/java/org/apache/cassandra/tools/StandaloneSSTableUtil.java
+++ b/src/java/org/apache/cassandra/tools/StandaloneSSTableUtil.java
@@ -20,7 +20,6 @@ package org.apache.cassandra.tools;
 
 import org.apache.cassandra.schema.TableMetadata;
 import org.apache.cassandra.schema.Schema;
-import org.apache.cassandra.db.ColumnFamilyStore;
 import org.apache.cassandra.db.Directories;
 import org.apache.cassandra.db.lifecycle.LifecycleTransaction;
 import org.apache.cassandra.utils.OutputHandler;
@@ -28,7 +27,6 @@ import org.apache.commons.cli.*;
 
 import java.io.File;
 import java.io.IOException;
-import java.util.function.BiFunction;
 import java.util.function.BiPredicate;
 
 import static org.apache.cassandra.tools.BulkLoader.CmdLineOptions;
@@ -62,7 +60,7 @@ public class StandaloneSSTableUtil
 
             if (options.cleanup)
             {
-                handler.output("Cleanuping up...");
+                handler.output("Cleaning up...");
                 LifecycleTransaction.removeUnfinishedLeftovers(metadata);
             }
             else
diff --git a/test/distributed/org/apache/cassandra/distributed/test/FqlReplayDDLExclusionTest.java b/test/distributed/org/apache/cassandra/distributed/test/FqlReplayDDLExclusionTest.java
index 1f53b98..f117c51 100644
--- a/test/distributed/org/apache/cassandra/distributed/test/FqlReplayDDLExclusionTest.java
+++ b/test/distributed/org/apache/cassandra/distributed/test/FqlReplayDDLExclusionTest.java
@@ -27,6 +27,7 @@ import org.apache.cassandra.distributed.Cluster;
 import org.apache.cassandra.distributed.api.IInvokableInstance;
 import org.apache.cassandra.distributed.api.QueryResults;
 import org.apache.cassandra.tools.ToolRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
 
 import static org.apache.cassandra.distributed.api.Feature.GOSSIP;
 import static org.apache.cassandra.distributed.api.Feature.NATIVE_PROTOCOL;
@@ -68,14 +69,12 @@ public class FqlReplayDDLExclusionTest extends TestBaseImpl
 
                 node.executeInternal("DROP TABLE fql_ks.fql_table;");
 
-                final ToolRunner.Runners runners = new ToolRunner.Runners();
-
                 // without --replay-ddl-statements, the replay will fail on insert because underlying table is not there
-                final ToolRunner negativeRunner = runners.invokeClassAsTool("org.apache.cassandra.fqltool.FullQueryLogTool",
-                                                                            "replay",
-                                                                            "--keyspace", "fql_ks",
-                                                                            "--target", "127.0.0.1",
-                                                                            "--", temporaryFolder.getRoot().getAbsolutePath());
+                final ToolResult negativeRunner = ToolRunner.invokeClass("org.apache.cassandra.fqltool.FullQueryLogTool",
+                                                                         "replay",
+                                                                         "--keyspace", "fql_ks",
+                                                                         "--target", "127.0.0.1",
+                                                                         "--", temporaryFolder.getRoot().getAbsolutePath());
 
                 assertEquals(0, negativeRunner.getExitCode());
 
@@ -90,13 +89,13 @@ public class FqlReplayDDLExclusionTest extends TestBaseImpl
                 }
 
                 // here we replay with --replay-ddl-statements so table will be created and insert will succeed
-                final ToolRunner positiveRunner = runners.invokeClassAsTool("org.apache.cassandra.fqltool.FullQueryLogTool",
-                                                                            "replay",
-                                                                            "--keyspace", "fql_ks",
-                                                                            "--target", "127.0.0.1",
-                                                                            // important
-                                                                            "--replay-ddl-statements",
-                                                                            "--", temporaryFolder.getRoot().getAbsolutePath());
+                final ToolResult positiveRunner = ToolRunner.invokeClass("org.apache.cassandra.fqltool.FullQueryLogTool",
+                                                                         "replay",
+                                                                         "--keyspace", "fql_ks",
+                                                                         "--target", "127.0.0.1",
+                                                                         // important
+                                                                         "--replay-ddl-statements",
+                                                                         "--", temporaryFolder.getRoot().getAbsolutePath());
 
                 assertEquals(0, positiveRunner.getExitCode());
 
diff --git a/test/unit/org/apache/cassandra/cql3/CQLTester.java b/test/unit/org/apache/cassandra/cql3/CQLTester.java
index 4392236..3e2f220 100644
--- a/test/unit/org/apache/cassandra/cql3/CQLTester.java
+++ b/test/unit/org/apache/cassandra/cql3/CQLTester.java
@@ -464,7 +464,7 @@ public abstract class CQLTester
             }
         });
     }
-    
+
     public static List<String> buildNodetoolArgs(List<String> args)
     {
         List<String> allArgs = new ArrayList<>();
@@ -472,11 +472,35 @@ public abstract class CQLTester
         allArgs.add("-p");
         allArgs.add(Integer.toString(jmxPort));
         allArgs.add("-h");
-        allArgs.add(jmxHost);
+        allArgs.add(jmxHost == null ? "127.0.0.1" : jmxHost);
         allArgs.addAll(args);
         return allArgs;
     }
-    
+
+    public static List<String> buildCqlshArgs(List<String> args)
+    {
+        List<String> allArgs = new ArrayList<>();
+        allArgs.add("bin/cqlsh");
+        allArgs.add(nativeAddr.getHostAddress());
+        allArgs.add(Integer.toString(nativePort));
+        allArgs.add("-e");
+        allArgs.addAll(args);
+        return allArgs;
+    }
+
+    public static List<String> buildCassandraStressArgs(List<String> args)
+    {
+        List<String> allArgs = new ArrayList<>();
+        allArgs.add("tools/bin/cassandra-stress");
+        allArgs.addAll(args);
+        if (args.indexOf("-port") == -1)
+        {
+            allArgs.add("-port");
+            allArgs.add("native=" + Integer.toString(nativePort));
+        }
+        return allArgs;
+    }
+
     // lazy initialization for all tests that require Java Driver
     protected static void requireNetwork() throws ConfigurationException
     {
diff --git a/test/unit/org/apache/cassandra/tools/AuditLogViewerTest.java b/test/unit/org/apache/cassandra/tools/AuditLogViewerTest.java
index 649712a..ed23088 100644
--- a/test/unit/org/apache/cassandra/tools/AuditLogViewerTest.java
+++ b/test/unit/org/apache/cassandra/tools/AuditLogViewerTest.java
@@ -22,10 +22,14 @@ import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+
 import org.apache.commons.io.FileUtils;
+
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Before;
@@ -38,12 +42,19 @@ import net.openhft.chronicle.queue.ExcerptAppender;
 import net.openhft.chronicle.queue.RollCycles;
 import net.openhft.chronicle.wire.WireOut;
 import org.apache.cassandra.audit.BinAuditLogger;
+import org.apache.cassandra.tools.ToolRunner.ObservableTool;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 import static org.junit.Assert.assertTrue;
 
 public class AuditLogViewerTest
 {
     private Path path;
+    private final String toolPath = "tools/bin/auditlogviewer";
 
     @Before
     public void setUp() throws IOException
@@ -62,6 +73,88 @@ public class AuditLogViewerTest
     }
 
     @Test
+    public void testNoArgs()
+    {
+        ToolResult tool = ToolRunner.invoke(toolPath);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Audit log files directory path is a required argument."));
+        assertEquals(1, tool.getExitCode());
+    }
+
+    @Test
+    public void testMaybeChangeDocs()
+    {
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invoke(toolPath, "-h");
+        String help = "usage: auditlogviewer <path1> [<path2>...<pathN>] [options]\n" + 
+                       "--\n" + 
+                       "View the audit log contents in human readable format\n" + 
+                       "--\n" + 
+                       "Options are:\n" + 
+                       " -f,--follow             Upon reacahing the end of the log continue\n" + 
+                       "                         indefinitely waiting for more records\n" + 
+                       " -h,--help               display this help message\n" + 
+                       " -i,--ignore             Silently ignore unsupported records\n" + 
+                       " -r,--roll_cycle <arg>   How often to roll the log file was rolled. May be\n" + 
+                       "                         necessary for Chronicle to correctly parse file names. (MINUTELY, HOURLY,\n" + 
+                       "                         DAILY). Default HOURLY.\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testHelpArg()
+    {
+        Arrays.asList("-h", "--help").forEach(arg -> {
+            ToolResult tool = ToolRunner.invoke(toolPath, arg);
+            assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+            assertTrue(tool.getCleanedStderr(),tool.getCleanedStderr().isEmpty());
+            tool.assertOnExitCode();
+        });
+    }
+
+    @Test
+    public void testIgnoreArg()
+    {
+        Arrays.asList("-i", "--ignore").forEach(arg -> {
+            ToolResult tool = ToolRunner.invoke(toolPath, path.toAbsolutePath().toString(), arg);
+            assertTrue(tool.getStdout(), tool.getStdout().isEmpty());
+            // @IgnoreAssert see CASSANDRA-16021
+//                assertTrue(tool.getCleanedStderr(),
+//                           tool.getCleanedStderr().isEmpty() // j8 is fine
+//                           || tool.getCleanedStderr().startsWith("WARNING: An illegal reflective access operation has occurred")); //j11 throws an error
+            tool.assertOnExitCode();
+        });
+    }
+
+    @Test
+    public void testFollowNRollArgs()
+    {
+            Lists.cartesianProduct(Arrays.asList("-f", "--follow"), Arrays.asList("-r", "--roll_cycle")).forEach(arg -> {
+                try (ObservableTool tool = ToolRunner.invokeAsync(toolPath,
+                                                             path.toAbsolutePath().toString(),
+                                                             arg.get(0),
+                                                             arg.get(1),
+                                                             "TEST_SECONDLY");)
+                {
+                    // Tool is running in the background 'following' so wait and then we have to kill it
+                    try
+                    {
+                        Thread.sleep(3000);
+                    }
+                    catch(InterruptedException e)
+                    {
+                        Thread.currentThread().interrupt();
+                    }
+                    assertTrue(tool.getPartialStdout(), tool.getPartialStdout().isEmpty());
+                    // @IgnoreAssert see CASSANDRA-16021
+    //                assertTrue(tool.getCleanedStderr(),
+    //                           tool.getCleanedStderr().isEmpty() // j8 is fine
+    //                           || tool.getCleanedStderr().startsWith("WARNING: An illegal reflective access operation has occurred")); //j11 throws an error
+                }
+        });
+    }
+
+    @Test
     public void testDisplayRecord()
     {
         List<String> records = new ArrayList<>();
diff --git a/test/unit/org/apache/cassandra/tools/BulkLoaderTest.java b/test/unit/org/apache/cassandra/tools/BulkLoaderTest.java
index 354511a..382f352 100644
--- a/test/unit/org/apache/cassandra/tools/BulkLoaderTest.java
+++ b/test/unit/org/apache/cassandra/tools/BulkLoaderTest.java
@@ -23,22 +23,22 @@ import org.junit.runner.RunWith;
 
 import com.datastax.driver.core.exceptions.NoHostAvailableException;
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.assertThat;
 import static org.junit.Assert.fail;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class BulkLoaderTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
     public void testBulkLoader_NoArgs() throws Exception
     {
-        ToolRunner tool = runner.invokeClassAsTool("org.apache.cassandra.tools.BulkLoader");
+        ToolResult tool = ToolRunner.invokeClass(BulkLoader.class);
         assertEquals(1, tool.getExitCode());
-        assertTrue(!tool.getStderr().isEmpty());
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsString("Missing sstable directory argument"));
         
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
@@ -51,19 +51,17 @@ public class BulkLoaderTest extends OfflineToolUtils
     @Test
     public void testBulkLoader_WithArgs() throws Exception
     {
-        try
-        {
-            runner.invokeClassAsTool("org.apache.cassandra.tools.BulkLoader", "-d", "127.9.9.1", OfflineToolUtils.sstableDirName("legacy_sstables", "legacy_ma_simple"))
-                   .waitAndAssertOnCleanExit();
-            fail();
-        }
-        catch (RuntimeException e)
-        {
-            if (!(e.getCause() instanceof BulkLoadException))
-                throw e;
-            if (!(e.getCause().getCause() instanceof NoHostAvailableException))
-                throw e;
-        }
+        ToolResult tool = ToolRunner.invokeClass(BulkLoader.class,
+                                                 "-d",
+                                                 "127.9.9.1",
+                                                 OfflineToolUtils.sstableDirName("legacy_sstables", "legacy_ma_simple"));
+
+        assertEquals(-1, tool.getExitCode());
+        if (!(tool.getException().getCause() instanceof BulkLoadException))
+            throw tool.getException();
+        if (!(tool.getException().getCause().getCause() instanceof NoHostAvailableException))
+            throw tool.getException();
+
         assertNoUnexpectedThreadsStarted(null, new String[]{"globalEventExecutor-1-1", "globalEventExecutor-1-2"});
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -75,20 +73,20 @@ public class BulkLoaderTest extends OfflineToolUtils
     @Test
     public void testBulkLoader_WithArgs1() throws Exception
     {
-        try
-        {
-            runner.invokeClassAsTool("org.apache.cassandra.tools.BulkLoader", "-d", "127.9.9.1", "--port", "9042", OfflineToolUtils.sstableDirName("legacy_sstables", "legacy_ma_simple"))
-                  .waitAndAssertOnCleanExit();
-            fail();
-        }
-        catch (RuntimeException e)
-        {
-            if (!(e.getCause() instanceof BulkLoadException))
-                throw e;
-            if (!(e.getCause().getCause() instanceof NoHostAvailableException))
-                throw e;
-        }
-        assertNoUnexpectedThreadsStarted(null, new String[]{"globalEventExecutor-1-1", "globalEventExecutor-1-2"});
+        ToolResult tool = ToolRunner.invokeClass(BulkLoader.class,
+                                                 "-d",
+                                                 "127.9.9.1",
+                                                 "--port",
+                                                 "9042",
+                                                 OfflineToolUtils.sstableDirName("legacy_sstables", "legacy_ma_simple"));
+
+        assertEquals(-1, tool.getExitCode());
+        if (!(tool.getException().getCause() instanceof BulkLoadException))
+            throw tool.getException();
+        if (!(tool.getException().getCause().getCause() instanceof NoHostAvailableException))
+            throw tool.getException();
+
+        assertNoUnexpectedThreadsStarted(null, new String[] { "globalEventExecutor-1-1", "globalEventExecutor-1-2" });
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
         assertSystemKSNotLoaded();
@@ -99,20 +97,20 @@ public class BulkLoaderTest extends OfflineToolUtils
     @Test
     public void testBulkLoader_WithArgs2() throws Exception
     {
-        try
-        {
-            runner.invokeClassAsTool("org.apache.cassandra.tools.BulkLoader", "-d", "127.9.9.1:9042", "--port", "9041", OfflineToolUtils.sstableDirName("legacy_sstables", "legacy_ma_simple"))
-                  .waitAndAssertOnCleanExit();
-            fail();
-        }
-        catch (RuntimeException e)
-        {
-            if (!(e.getCause() instanceof BulkLoadException))
-                throw e;
-            if (!(e.getCause().getCause() instanceof NoHostAvailableException))
-                throw e;
-        }
-        assertNoUnexpectedThreadsStarted(null, new String[]{"globalEventExecutor-1-1", "globalEventExecutor-1-2"});
+        ToolResult tool = ToolRunner.invokeClass(BulkLoader.class,
+                                                 "-d",
+                                                 "127.9.9.1:9042",
+                                                 "--port",
+                                                 "9041",
+                                                 OfflineToolUtils.sstableDirName("legacy_sstables", "legacy_ma_simple"));
+
+        assertEquals(-1, tool.getExitCode());
+        if (!(tool.getException().getCause() instanceof BulkLoadException))
+            throw tool.getException();
+        if (!(tool.getException().getCause().getCause() instanceof NoHostAvailableException))
+            throw tool.getException();
+
+        assertNoUnexpectedThreadsStarted(null, new String[] { "globalEventExecutor-1-1", "globalEventExecutor-1-2" });
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
         assertSystemKSNotLoaded();
@@ -123,26 +121,24 @@ public class BulkLoaderTest extends OfflineToolUtils
     @Test(expected = NoHostAvailableException.class)
     public void testBulkLoader_WithArgs3() throws Throwable
     {
-        try
-        {
-            runner.invokeClassAsTool("org.apache.cassandra.tools.BulkLoader", "-d", "127.9.9.1", "--port", "9041", OfflineToolUtils.sstableDirName("legacy_sstables", "legacy_ma_simple"));
-        }
-        catch (RuntimeException e)
-        {
-            throw e.getCause().getCause();
-        }
+        ToolResult tool = ToolRunner.invokeClass(BulkLoader.class,
+                                                 "-d",
+                                                 "127.9.9.1",
+                                                 "--port",
+                                                 "9041",
+                                                 OfflineToolUtils.sstableDirName("legacy_sstables", "legacy_ma_simple"));
+        assertEquals(-1, tool.getExitCode());
+        throw tool.getException().getCause().getCause();
     }
 
     @Test(expected = NoHostAvailableException.class)
     public void testBulkLoader_WithArgs4() throws Throwable
     {
-        try
-        {
-            runner.invokeClassAsTool("org.apache.cassandra.tools.BulkLoader", "-d", "127.9.9.1:9041", OfflineToolUtils.sstableDirName("legacy_sstables", "legacy_ma_simple"));
-        }
-        catch (RuntimeException e)
-        {
-            throw e.getCause().getCause();
-        }
+        ToolResult tool = ToolRunner.invokeClass(BulkLoader.class,
+                                                 "-d",
+                                                 "127.9.9.1:9041",
+                                                 OfflineToolUtils.sstableDirName("legacy_sstables", "legacy_ma_simple"));
+        assertEquals(-1, tool.getExitCode());
+        throw tool.getException().getCause().getCause();
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/ClearSnapshotTest.java b/test/unit/org/apache/cassandra/tools/ClearSnapshotTest.java
index 7e70467..b631822 100644
--- a/test/unit/org/apache/cassandra/tools/ClearSnapshotTest.java
+++ b/test/unit/org/apache/cassandra/tools/ClearSnapshotTest.java
@@ -29,14 +29,16 @@ import org.junit.BeforeClass;
 import org.junit.Test;
 
 import org.apache.cassandra.cql3.CQLTester;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 import static org.junit.Assert.assertTrue;
 
 public class ClearSnapshotTest extends CQLTester
 {
     private static NodeProbe probe;
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
 
     @BeforeClass
     public static void setup() throws Exception
@@ -52,33 +54,36 @@ public class ClearSnapshotTest extends CQLTester
     }
 
     @Test
-    public void testClearSnapshot_NoArgs() throws IOException
+    public void testClearSnapshot_NoArgs()
     {
-        ToolRunner tool = runner.invokeNodetool("clearsnapshot");
+        ToolResult tool = ToolRunner.invokeNodetool("clearsnapshot");
         assertEquals(2, tool.getExitCode());
-        assertTrue("Tool stderr: " +  tool.getStderr(), tool.getStderr().contains("Specify snapshot name or --all"));
+        assertTrue("Tool stderr: " +  tool.getCleanedStderr(), tool.getCleanedStderr().contains("Specify snapshot name or --all"));
         
-        runner.invokeNodetool("clearsnapshot", "--all").waitAndAssertOnCleanExit();
+        tool = ToolRunner.invokeNodetool("clearsnapshot", "--all");
+        tool.assertOnCleanExit();
     }
 
     @Test
-    public void testClearSnapshot_AllAndName() throws IOException
+    public void testClearSnapshot_AllAndName()
     {
-        ToolRunner tool = runner.invokeNodetool("clearsnapshot", "-t", "some-name", "--all");
+        ToolResult tool = ToolRunner.invokeNodetool("clearsnapshot", "-t", "some-name", "--all");
         assertEquals(2, tool.getExitCode());
-        assertTrue("Tool stderr: " +  tool.getStderr(), tool.getStderr().contains("Specify only one of snapshot name or --all"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Specify only one of snapshot name or --all"));
     }
 
     @Test
-    public void testClearSnapshot_RemoveByName() throws IOException
+    public void testClearSnapshot_RemoveByName()
     {
-        ToolRunner tool = runner.invokeNodetool("snapshot","-t","some-name").waitAndAssertOnCleanExit();
+        ToolResult tool = ToolRunner.invokeNodetool("snapshot","-t","some-name");
+        tool.assertOnCleanExit();
         assertTrue(!tool.getStdout().isEmpty());
         
         Map<String, TabularData> snapshots_before = probe.getSnapshotDetails();
         Assert.assertTrue(snapshots_before.containsKey("some-name"));
         
-        tool = runner.invokeNodetool("clearsnapshot","-t","some-name").waitAndAssertOnCleanExit();
+        tool = ToolRunner.invokeNodetool("clearsnapshot","-t","some-name");
+        tool.assertOnCleanExit();
         assertTrue(!tool.getStdout().isEmpty());
         
         Map<String, TabularData> snapshots_after = probe.getSnapshotDetails();
@@ -86,17 +91,21 @@ public class ClearSnapshotTest extends CQLTester
     }
 
     @Test
-    public void testClearSnapshot_RemoveMultiple() throws IOException
+    public void testClearSnapshot_RemoveMultiple()
     {
-        ToolRunner tool = runner.invokeNodetool("snapshot","-t","some-name").waitAndAssertOnCleanExit();
-        assertTrue(!tool.getStdout().isEmpty());
-        tool = runner.invokeNodetool("snapshot","-t","some-other-name").waitAndAssertOnCleanExit();
+        ToolResult tool = ToolRunner.invokeNodetool("snapshot","-t","some-name");
+        tool.assertOnCleanExit();
         assertTrue(!tool.getStdout().isEmpty());
+
+        tool = ToolRunner.invokeNodetool("snapshot","-t","some-other-name");
+        tool.assertOnCleanExit();
+            assertTrue(!tool.getStdout().isEmpty());
         
         Map<String, TabularData> snapshots_before = probe.getSnapshotDetails();
         Assert.assertTrue(snapshots_before.size() == 2);
 
-        tool = runner.invokeNodetool("clearsnapshot","--all").waitAndAssertOnCleanExit();
+        tool = ToolRunner.invokeNodetool("clearsnapshot","--all");
+        tool.assertOnCleanExit();
         assertTrue(!tool.getStdout().isEmpty());
         
         Map<String, TabularData> snapshots_after = probe.getSnapshotDetails();
diff --git a/test/unit/org/apache/cassandra/tools/CompactionStressTest.java b/test/unit/org/apache/cassandra/tools/CompactionStressTest.java
index 651e24d..09b82fe 100644
--- a/test/unit/org/apache/cassandra/tools/CompactionStressTest.java
+++ b/test/unit/org/apache/cassandra/tools/CompactionStressTest.java
@@ -24,16 +24,16 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class CompactionStressTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
     public void testNoArgs()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.stress.CompactionStress").waitAndAssertOnCleanExit();
+        ToolResult tool = ToolRunner.invokeClass("org.apache.cassandra.stress.CompactionStress");
+        tool.assertOnCleanExit();
     }
 
     @Test
@@ -43,27 +43,26 @@ public class CompactionStressTest extends OfflineToolUtils
         File file = new File(classLoader.getResource("blogpost.yaml").getFile());
         String profileFile = file.getAbsolutePath();
 
-        runner.invokeClassAsTool("org.apache.cassandra.stress.CompactionStress",
-                                 "write",
-                                 "-d",
-                                 "build/test/cassandra",
-                                 "-g",
-                                 "0",
-                                 "-p",
-                                 profileFile,
-                                 "-t",
-                                 "4")
-              .waitAndAssertOnCleanExit();
+        ToolResult tool = ToolRunner.invokeClass("org.apache.cassandra.stress.CompactionStress",
+                                                 "write",
+                                                 "-d",
+                                                 "build/test/cassandra",
+                                                 "-g",
+                                                 "0",
+                                                 "-p",
+                                                 profileFile,
+                                                 "-t",
+                                                 "4");
+        tool.assertOnCleanExit();
 
-        runner.invokeClassAsTool("org.apache.cassandra.stress.CompactionStress",
-                                 "compact",
-                                 "-d",
-                                 "build/test/cassandra",
-                                 "-p",
-                                 profileFile,
-                                 "-t",
-                                 "4")
-              .waitAndAssertOnCleanExit();
+        tool = ToolRunner.invokeClass("org.apache.cassandra.stress.CompactionStress",
+                                      "compact",
+                                      "-d",
+                                      "build/test/cassandra",
+                                      "-p",
+                                      profileFile,
+                                      "-t",
+                                      "4");
+              tool.assertOnCleanExit();
     }
-
 }
diff --git a/test/unit/org/apache/cassandra/tools/GetFullQueryLogTest.java b/test/unit/org/apache/cassandra/tools/GetFullQueryLogTest.java
index 82d5482..44007a5 100644
--- a/test/unit/org/apache/cassandra/tools/GetFullQueryLogTest.java
+++ b/test/unit/org/apache/cassandra/tools/GetFullQueryLogTest.java
@@ -29,6 +29,7 @@ import org.junit.rules.TemporaryFolder;
 
 import org.apache.cassandra.cql3.CQLTester;
 import org.apache.cassandra.fql.FullQueryLoggerOptions;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
 
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
@@ -36,7 +37,6 @@ import static org.junit.Assert.assertTrue;
 public class GetFullQueryLogTest extends CQLTester
 {
     private static NodeProbe probe;
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
 
     @ClassRule
     public static TemporaryFolder temporaryFolder = new TemporaryFolder();
@@ -55,7 +55,7 @@ public class GetFullQueryLogTest extends CQLTester
     }
 
     @After
-    public void afterTest()
+    public void afterTest() throws InterruptedException
     {
         disableFullQueryLog();
     }
@@ -87,29 +87,39 @@ public class GetFullQueryLogTest extends CQLTester
 
     private String getFullQueryLog()
     {
-        return runner.invokeNodetool("getfullquerylog").waitAndAssertOnCleanExit().getStdout();
+        ToolResult tool = ToolRunner.invokeNodetool("getfullquerylog");
+        tool.assertOnCleanExit();
+        return tool.getStdout();
     }
 
     private void resetFullQueryLog()
     {
-        runner.invokeNodetool("resetfullquerylog").waitAndAssertOnCleanExit();
+        ToolRunner.invokeNodetool("resetfullquerylog").assertOnCleanExit();
     }
 
     private void disableFullQueryLog()
     {
-        runner.invokeNodetool("disablefullquerylog").waitAndAssertOnCleanExit();
+        ToolRunner.invokeNodetool("disablefullquerylog").assertOnCleanExit();
     }
 
     private void enableFullQueryLog()
     {
-        runner.invokeNodetool("enablefullquerylog",
-                              "--path", temporaryFolder.getRoot().toString(),
-                              "--blocking", "false",
-                              "--max-archive-retries", "5",
-                              "--archive-command", "/path/to/script.sh %path",
-                              "--max-log-size", "100000",
-                              "--max-queue-weight", "10000",
-                              "--roll-cycle", "DAILY").waitAndAssertOnCleanExit();
+        ToolRunner.invokeNodetool("enablefullquerylog",
+                                  "--path",
+                                  temporaryFolder.getRoot().toString(),
+                                  "--blocking",
+                                  "false",
+                                  "--max-archive-retries",
+                                  "5",
+                                  "--archive-command",
+                                  "/path/to/script.sh %path",
+                                  "--max-log-size",
+                                  "100000",
+                                  "--max-queue-weight",
+                                  "10000",
+                                  "--roll-cycle",
+                                  "DAILY")
+                  .assertOnCleanExit();
     }
 
     private void testChangedOutput(final String getFullQueryLogOutput)
diff --git a/test/unit/org/apache/cassandra/tools/GetVersionTest.java b/test/unit/org/apache/cassandra/tools/GetVersionTest.java
index c5f5282..1feeb45 100644
--- a/test/unit/org/apache/cassandra/tools/GetVersionTest.java
+++ b/test/unit/org/apache/cassandra/tools/GetVersionTest.java
@@ -22,16 +22,16 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class GetVersionTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
     public void testGetVersion()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.GetVersion").waitAndAssertOnCleanExit();
+        ToolResult tool = ToolRunner.invokeClass(GetVersion.class);
+        tool.assertOnCleanExit();
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
diff --git a/test/unit/org/apache/cassandra/tools/JMXCompatabilityTest.java b/test/unit/org/apache/cassandra/tools/JMXCompatabilityTest.java
index 816257e..2494dd2 100644
--- a/test/unit/org/apache/cassandra/tools/JMXCompatabilityTest.java
+++ b/test/unit/org/apache/cassandra/tools/JMXCompatabilityTest.java
@@ -13,11 +13,10 @@ import com.datastax.driver.core.SimpleStatement;
 import org.apache.cassandra.cql3.CQLTester;
 import org.apache.cassandra.service.CassandraDaemon;
 import org.apache.cassandra.service.GCInspector;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
 import org.apache.cassandra.transport.ProtocolVersion;
 import org.assertj.core.api.Assertions;
 
-import static org.apache.cassandra.tools.ToolRunner.Runners.invokeTool;
-
 /**
  * This class is to monitor the JMX compatability cross different versions, and relies on a gold set of metrics which
  * were generated following the instructions below.  These tests only check for breaking changes, so will ignore any
@@ -66,7 +65,7 @@ public class JMXCompatabilityTest extends CQLTester
         executeNet(ProtocolVersion.CURRENT, new SimpleStatement("SELECT * FROM " + name + " WHERE pk=?", 42));
 
         String script = "tools/bin/jmxtool dump -f yaml --url service:jmx:rmi:///jndi/rmi://" + jmxHost + ":" + jmxPort + "/jmxrmi > " + TMP.getRoot().getAbsolutePath() + "/out.yaml";
-        invokeTool("bash", "-c", script).assertOnExitCode().assertCleanStdErr();
+        ToolRunner.invoke("bash", "-c", script).assertOnCleanExit();
         CREATED_TABLE = true;
     }
 
@@ -164,8 +163,8 @@ public class JMXCompatabilityTest extends CQLTester
             args.add("--exclude-operation");
             args.add(a);
         });
-        ToolRunner result = invokeTool(args);
-        result.assertOnExitCode().assertCleanStdErr();
+        ToolResult result = ToolRunner.invoke(args);
+        result.assertOnCleanExit();
         Assertions.assertThat(result.getStdout()).isEmpty();
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/JMXToolTest.java b/test/unit/org/apache/cassandra/tools/JMXToolTest.java
index 229354b..4fba8cd 100644
--- a/test/unit/org/apache/cassandra/tools/JMXToolTest.java
+++ b/test/unit/org/apache/cassandra/tools/JMXToolTest.java
@@ -10,6 +10,7 @@ import org.junit.Test;
 
 import org.apache.cassandra.io.util.DataInputBuffer;
 import org.apache.cassandra.io.util.DataOutputBuffer;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
 import org.apache.cassandra.utils.Generators;
 import org.assertj.core.api.Assertions;
 import org.quicktheories.core.Gen;
@@ -35,8 +36,8 @@ public class JMXToolTest
     @Test
     public void cliHelp()
     {
-        ToolRunner result = jmxtool();
-        result.assertCleanStdErr().assertOnExitCode();
+        ToolResult result = jmxtool();
+        result.assertOnCleanExit();
 
         Assertions.assertThat(result.getStdout())
                   .isEqualTo("usage: jmxtool <command> [<args>]\n" +
@@ -53,8 +54,8 @@ public class JMXToolTest
     @Test
     public void cliHelpDiff()
     {
-        ToolRunner result = jmxtool("help", "diff");
-        result.assertCleanStdErr().assertOnExitCode();
+        ToolResult result = jmxtool("help", "diff");
+        result.assertOnCleanExit();
 
         Assertions.assertThat(result.getStdout())
                   .isEqualTo("NAME\n" +
@@ -107,8 +108,8 @@ public class JMXToolTest
     @Test
     public void cliHelpDump()
     {
-        ToolRunner result = jmxtool("help", "dump");
-        result.assertCleanStdErr().assertOnExitCode();
+        ToolResult result = jmxtool("help", "dump");
+        result.assertOnCleanExit();
 
         Assertions.assertThat(result.getStdout())
                   .isEqualTo("NAME\n" +
@@ -132,12 +133,12 @@ public class JMXToolTest
                              "\n");
     }
 
-    private static ToolRunner jmxtool(String... args)
+    private static ToolResult jmxtool(String... args)
     {
         List<String> cmd = new ArrayList<>(1 + args.length);
         cmd.add("tools/bin/jmxtool");
         cmd.addAll(Arrays.asList(args));
-        return ToolRunner.Runners.invokeTool(cmd);
+        return ToolRunner.invoke(cmd);
     }
 
     private void serde(JMXTool.Dump.Format serializer, JMXTool.Diff.Format deserializer)
diff --git a/test/unit/org/apache/cassandra/tools/OfflineToolUtils.java b/test/unit/org/apache/cassandra/tools/OfflineToolUtils.java
index 9383f7d..ae82fd8 100644
--- a/test/unit/org/apache/cassandra/tools/OfflineToolUtils.java
+++ b/test/unit/org/apache/cassandra/tools/OfflineToolUtils.java
@@ -110,7 +110,7 @@ public abstract class OfflineToolUtils
                                     .filter(threadName -> optional.stream().anyMatch(pattern -> pattern.matcher(threadName).matches()))
                                     .collect(Collectors.toSet());
 
-        if (!current.isEmpty())
+        if (!remain.isEmpty())
             System.err.println("Unexpected thread names: " + remain);
         if (!notPresent.isEmpty())
             System.err.println("Mandatory thread missing: " + notPresent);
@@ -240,4 +240,11 @@ public abstract class OfflineToolUtils
         FileUtils.copyDirectory(new File(srcDir, "legacy_tables"), new File(dataDir, "legacy_sstables"));
         return dataDir;
     }
+    
+    protected void assertCorrectEnvPostTest()
+    {
+        assertNoUnexpectedThreadsStarted(EXPECTED_THREADS_WITH_SCHEMA, OPTIONAL_THREADS_WITH_SCHEMA);
+        assertSchemaLoaded();
+        assertServerNotLoaded();
+    }
 }
diff --git a/test/unit/org/apache/cassandra/tools/SSTableExpiredBlockersTest.java b/test/unit/org/apache/cassandra/tools/SSTableExpiredBlockersTest.java
index ad2dc3e..0476453 100644
--- a/test/unit/org/apache/cassandra/tools/SSTableExpiredBlockersTest.java
+++ b/test/unit/org/apache/cassandra/tools/SSTableExpiredBlockersTest.java
@@ -22,18 +22,24 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class SSTableExpiredBlockersTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
-    public void testSSTableExpiredBlockers_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableExpiredBlockers").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(SSTableExpiredBlockers.class);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
+
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -43,12 +49,31 @@ public class SSTableExpiredBlockersTest extends OfflineToolUtils
     }
 
     @Test
-    public void testSSTableExpiredBlockers_WithArgs()
+    public void testMaybeChangeDocs()
     {
-        // returns exit code 1, since no sstables are there
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableExpiredBlockers", "system_schema", "tables").getExitCode());
-        assertNoUnexpectedThreadsStarted(EXPECTED_THREADS_WITH_SCHEMA, OPTIONAL_THREADS_WITH_SCHEMA);
-        assertSchemaLoaded();
-        assertServerNotLoaded();
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(SSTableExpiredBlockers.class);
+        String help = "Usage: sstableexpiredblockers <keyspace> <table>\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testWrongArgsIgnored()
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableExpiredBlockers.class, "--debugwrong", "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("No sstables for"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
+        assertCorrectEnvPostTest();
+    }
+
+    @Test
+    public void testDefaultCall()
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableExpiredBlockers.class, "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("No sstables for"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
+        assertCorrectEnvPostTest();
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/SSTableExportTest.java b/test/unit/org/apache/cassandra/tools/SSTableExportTest.java
index 54af60c..15949af 100644
--- a/test/unit/org/apache/cassandra/tools/SSTableExportTest.java
+++ b/test/unit/org/apache/cassandra/tools/SSTableExportTest.java
@@ -18,22 +18,39 @@
 
 package org.apache.cassandra.tools;
 
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.exc.MismatchedInputException;
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class SSTableExportTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
+    private ObjectMapper mapper = new ObjectMapper();
+    private TypeReference<List<Map<String, Object>>> jacksonListOfMapsType = new TypeReference<List<Map<String, Object>>>() {};
+
     @Test
-    public void testSSTableExport_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableExport").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(SSTableExport.class);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("You must supply exactly one sstable"));
+        assertEquals(1, tool.getExitCode());
         assertNoUnexpectedThreadsStarted(null, OPTIONAL_THREADS_WITH_SCHEMA);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -43,12 +60,130 @@ public class SSTableExportTest extends OfflineToolUtils
     }
 
     @Test
-    public void testSSTableExport_WithArgs() throws Exception
+    public void testMaybeChangeDocs()
+    {
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(SSTableExport.class);
+        String help = "usage: sstabledump <sstable file path> <options>\n" + 
+                       "                   \n" + 
+                       "Dump contents of given SSTable to standard output in JSON format.\n" + 
+                       " -d         CQL row per line internal representation\n" + 
+                       " -e         enumerate partition keys only\n" + 
+                       " -k <arg>   Partition key\n" + 
+                       " -l         Output json lines, by partition\n" + 
+                       " -t         Print raw timestamps instead of iso8601 date strings\n" + 
+                       " -x <arg>   Excluded partition key\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testWrongArgFailsAndPrintsHelp() throws IOException
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableExport.class, "--debugwrong", findOneSSTable("legacy_sstables", "legacy_ma_simple"));
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Unrecognized option"));
+        assertEquals(1, tool.getExitCode());
+    }
+
+    @Test
+    public void testDefaultCall() throws IOException
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableExport.class,findOneSSTable("legacy_sstables", "legacy_ma_simple"));
+        List<Map<String, Object>> parsed = mapper.readValue(tool.getStdout(), jacksonListOfMapsType);
+        assertTrue(tool.getStdout(), parsed.get(0).get("partition") != null);
+        assertTrue(tool.getStdout(), parsed.get(0).get("rows") != null);
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        tool.assertOnExitCode();
+        assertPostTestEnv();
+    }
+
+    @Test
+    public void testCQLRowArg() throws IOException
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableExport.class, findOneSSTable("legacy_sstables", "legacy_ma_simple"), "-d");
+        assertThat(tool.getStdout(), CoreMatchers.startsWith("[0]"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        tool.assertOnExitCode();
+        assertPostTestEnv();
+    }
+
+    @Test
+    public void testPKOnlyArg() throws IOException
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableExport.class, findOneSSTable("legacy_sstables", "legacy_ma_simple"), "-e");
+        assertEquals(tool.getStdout(), "[ [ \"0\" ], [ \"1\" ], [ \"2\" ], [ \"3\" ], [ \"4\" ]\n]", tool.getStdout());
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        tool.assertOnExitCode();
+        assertPostTestEnv();
+    }
+
+    @Test
+    public void testPKArg() throws IOException
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableExport.class, findOneSSTable("legacy_sstables", "legacy_ma_simple"), "-k", "0");
+        List<Map<String, Object>> parsed = mapper.readValue(tool.getStdout(), jacksonListOfMapsType);
+        assertEquals(tool.getStdout(), 1, parsed.size());
+        assertEquals(tool.getStdout(), "0", ((List) ((Map) parsed.get(0).get("partition")).get("key")).get(0));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        tool.assertOnExitCode();
+        assertPostTestEnv();
+    }
+
+    @Test
+    public void testExcludePKArg() throws IOException
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableExport.class, findOneSSTable("legacy_sstables", "legacy_ma_simple"), "-x", "0");
+        List<Map<String, Object>> parsed = mapper.readValue(tool.getStdout(), jacksonListOfMapsType);
+        assertEquals(tool.getStdout(), 4, parsed.size());
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        tool.assertOnExitCode();
+        assertPostTestEnv();
+    }
+
+    @Test
+    public void testTSFormatArg() throws IOException
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableExport.class, findOneSSTable("legacy_sstables", "legacy_ma_simple"), "-t");
+        List<Map<String, Object>> parsed = mapper.readValue(tool.getStdout(), jacksonListOfMapsType);
+        assertEquals(tool.getStdout(),
+                     "1445008632854000",
+                     ((Map) ((List<Map>) parsed.get(0).get("rows")).get(0).get("liveness_info")).get("tstamp"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        tool.assertOnExitCode();
+        assertPostTestEnv();
+    }
+
+    @Test
+    public void testJSONLineArg() throws IOException
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableExport.class, findOneSSTable("legacy_sstables", "legacy_ma_simple"), "-l");
+        try
+        {
+            mapper.readValue(tool.getStdout(), jacksonListOfMapsType);
+            fail("Shouldn't be able to deserialize that output, now it's not a collection anymore.");
+        }
+        catch(MismatchedInputException e)
+        {
+        }
+
+        int parsedCount = 0;
+        for (String jsonLine : tool.getStdout().split("\\R"))
+        {
+            Map line = mapper.readValue(jsonLine, Map.class);
+            assertTrue(jsonLine, line.containsKey("partition"));
+            parsedCount++;
+        }
+
+        assertEquals(tool.getStdout(), 5, parsedCount);
+        assertThat(tool.getStdout(), CoreMatchers.startsWith("{\""));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        tool.assertOnExitCode();
+        assertPostTestEnv();
+    }
+
+    private void assertPostTestEnv()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableExport",findOneSSTable("legacy_sstables", "legacy_ma_simple"))
-              .waitAndAssertOnCleanExit();
         assertNoUnexpectedThreadsStarted(null, OPTIONAL_THREADS_WITH_SCHEMA);
-        assertSchemaNotLoaded();
         assertCLSMNotLoaded();
         assertSystemKSNotLoaded();
         assertServerNotLoaded();
diff --git a/test/unit/org/apache/cassandra/tools/SSTableLevelResetterTest.java b/test/unit/org/apache/cassandra/tools/SSTableLevelResetterTest.java
index 947a988..e413b14 100644
--- a/test/unit/org/apache/cassandra/tools/SSTableLevelResetterTest.java
+++ b/test/unit/org/apache/cassandra/tools/SSTableLevelResetterTest.java
@@ -22,18 +22,23 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class SSTableLevelResetterTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
-    public void testSSTableLevelResetter_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableLevelResetter").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(SSTableLevelResetter.class);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -43,12 +48,42 @@ public class SSTableLevelResetterTest extends OfflineToolUtils
     }
 
     @Test
-    public void testSSTableLevelResetter_WithArgs()
+    public void testMaybeChangeDocs()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableLevelResetter", "--really-reset", "system_schema", "tables")
-              .waitAndAssertOnCleanExit();
-        assertNoUnexpectedThreadsStarted(EXPECTED_THREADS_WITH_SCHEMA, OPTIONAL_THREADS_WITH_SCHEMA);
-        assertSchemaLoaded();
-        assertServerNotLoaded();
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(SSTableLevelResetter.class, "-h");
+        String help = "This command should be run with Cassandra stopped, otherwise you will get very strange behavior\n" + 
+                      "Verify that Cassandra is not running and then execute the command like this:\n" + 
+                      "Usage: sstablelevelreset --really-reset <keyspace> <table>\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testWrongArgFailsAndPrintsHelp()
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableLevelResetter.class, "--debugwrong", "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
+    }
+
+    @Test
+    public void testDefaultCall()
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableLevelResetter.class, "--really-reset", "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("Found no sstables,"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(0,tool.getExitCode());
+        assertCorrectEnvPostTest();
+    }
+
+    @Test
+    public void testMissingSecurityFlagCall()
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableLevelResetter.class, "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
+        assertCorrectEnvPostTest();
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/SSTableMetadataViewerTest.java b/test/unit/org/apache/cassandra/tools/SSTableMetadataViewerTest.java
index 9370315..db0c958 100644
--- a/test/unit/org/apache/cassandra/tools/SSTableMetadataViewerTest.java
+++ b/test/unit/org/apache/cassandra/tools/SSTableMetadataViewerTest.java
@@ -18,22 +18,36 @@
 
 package org.apache.cassandra.tools;
 
+import java.util.Arrays;
+
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.lang3.tuple.Pair;
+
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class SSTableMetadataViewerTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
-    public void testSSTableOfflineRelevel_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableMetadataViewer").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(SSTableMetadataViewer.class);
+        {
+            assertTrue(tool.getStdout(), tool.getStdout().isEmpty());
+            assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Options:"));
+            assertEquals(1, tool.getExitCode());
+        }
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -43,9 +57,122 @@ public class SSTableMetadataViewerTest extends OfflineToolUtils
     }
 
     @Test
-    public void testSSTableOfflineRelevel_WithArgs()
+    public void testMaybeChangeDocs()
+    {
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(SSTableMetadataViewer.class, "-h");
+        assertEquals("You must supply at least one sstable\n" + 
+                     "usage: sstablemetadata <options> <sstable...> [-c] [-g <arg>] [-s] [-t <arg>] [-u]\n" + 
+                     "\n" + 
+                     "Dump information about SSTable[s] for Apache Cassandra 3.x\n" + 
+                     "Options:\n" + 
+                     "  -c,--colors                 Use ANSI color sequences\n" + 
+                     "  -g,--gc_grace_seconds <arg> Time to use when calculating droppable tombstones\n" + 
+                     "  -s,--scan                   Full sstable scan for additional details. Only available in 3.0+ sstables. Defaults: false\n" + 
+                     "  -t,--timestamp_unit <arg>   Time unit that cell timestamps are written with\n" + 
+                     "  -u,--unicode                Use unicode to draw histograms and progress bars\n\n" 
+                     , tool.getCleanedStderr());
+    }
+
+    @Test
+    public void testWrongArgFailsAndPrintsHelp()
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableMetadataViewer.class, "--debugwrong", "ks", "tab");
+        assertTrue(tool.getStdout(), tool.getStdout().isEmpty());
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Options:"));
+        assertEquals(1, tool.getExitCode());
+    }
+
+    @Test
+    public void testDefaultCall()
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableMetadataViewer.class, "ks", "tab");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("No such file"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(0,tool.getExitCode());
+        assertGoodEnvPostTest();
+    }
+
+    @Test
+    public void testFlagArgs()
+    {
+        Arrays.asList("-c",
+                      "--colors",
+                      "-s",
+                      "--scan",
+                      "-u",
+                      "--unicode")
+              .forEach(arg -> {
+                  ToolResult tool = ToolRunner.invokeClass(SSTableMetadataViewer.class, arg, "ks", "tab");
+                  assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("No such file"));
+                  Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+                  assertEquals(0,tool.getExitCode());
+                  assertGoodEnvPostTest();
+              });
+    }
+
+    @Test
+    public void testGCArg()
+    {
+        Arrays.asList(Pair.of("-g", ""),
+                      Pair.of("-g", "w"),
+                      Pair.of("--gc_grace_seconds", ""),
+                      Pair.of("--gc_grace_seconds", "w"))
+              .forEach(arg -> {
+                  ToolResult tool = ToolRunner.invokeClass(SSTableMetadataViewer.class,
+                                                           arg.getLeft(),
+                                                           arg.getRight(),
+                                                           "ks",
+                                                           "tab");
+                  assertEquals(-1, tool.getExitCode());
+                  Assertions.assertThat(tool.getStderr()).contains(NumberFormatException.class.getSimpleName());
+              });
+
+        Arrays.asList(Pair.of("-g", "5"), Pair.of("--gc_grace_seconds", "5")).forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(SSTableMetadataViewer.class,
+                                                            arg.getLeft(),
+                                                            arg.getRight(),
+                                                            "ks",
+                                                            "tab");
+            assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("No such file"));
+            Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+            tool.assertOnExitCode();
+            assertGoodEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testTSUnitArg()
+    {
+        Arrays.asList(Pair.of("-t", ""),
+                      Pair.of("-t", "w"),
+                      Pair.of("--timestamp_unit", ""),
+                      Pair.of("--timestamp_unit", "w"))
+              .forEach(arg -> {
+                  ToolResult tool = ToolRunner.invokeClass(SSTableMetadataViewer.class,
+                                                           arg.getLeft(),
+                                                           arg.getRight(),
+                                                           "ks",
+                                                           "tab");
+                  assertEquals(-1, tool.getExitCode());
+                  Assertions.assertThat(tool.getStderr()).contains(IllegalArgumentException.class.getSimpleName());
+              });
+
+        Arrays.asList(Pair.of("-t", "SECONDS"), Pair.of("--timestamp_unit", "SECONDS")).forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(SSTableMetadataViewer.class,
+                                                       arg.getLeft(),
+                                                       arg.getRight(),
+                                                       "ks",
+                                                       "tab");
+            assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("No such file"));
+            Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+            tool.assertOnExitCode();
+            assertGoodEnvPostTest();
+        });
+    }
+
+    private void assertGoodEnvPostTest()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableMetadataViewer", "ks", "tab").waitAndAssertOnCleanExit();
         assertNoUnexpectedThreadsStarted(null, OPTIONAL_THREADS_WITH_SCHEMA);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
diff --git a/test/unit/org/apache/cassandra/tools/SSTableOfflineRelevelTest.java b/test/unit/org/apache/cassandra/tools/SSTableOfflineRelevelTest.java
index 1d155bc..a49dd49 100644
--- a/test/unit/org/apache/cassandra/tools/SSTableOfflineRelevelTest.java
+++ b/test/unit/org/apache/cassandra/tools/SSTableOfflineRelevelTest.java
@@ -22,18 +22,23 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class SSTableOfflineRelevelTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
-    public void testSSTableOfflineRelevel_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableOfflineRelevel").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(SSTableOfflineRelevel.class);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -43,12 +48,32 @@ public class SSTableOfflineRelevelTest extends OfflineToolUtils
     }
 
     @Test
-    public void testSSTableOfflineRelevel_WithArgs()
+    public void testMaybeChangeDocs()
     {
-        // Note: SSTableOfflineRelevel exits with code 1 if no sstables to relevel have been found
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableOfflineRelevel", "system_schema", "tables").getExitCode());
-        assertNoUnexpectedThreadsStarted(EXPECTED_THREADS_WITH_SCHEMA, OPTIONAL_THREADS_WITH_SCHEMA);
-        assertSchemaLoaded();
-        assertServerNotLoaded();
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(SSTableOfflineRelevel.class, "-h");
+        String help = "This command should be run with Cassandra stopped!\n" + 
+                      "Usage: sstableofflinerelevel [--dry-run] <keyspace> <columnfamily>\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testDefaultCall()
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableOfflineRelevel.class, "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("No sstables to relevel for system_schema.tables"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
+        assertCorrectEnvPostTest();
+    }
+
+    @Test
+    public void testDryrunArg()
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableOfflineRelevel.class, "--dry-run", "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("No sstables to relevel for system_schema.tables"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
+        assertCorrectEnvPostTest();
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/SSTableRepairedAtSetterTest.java b/test/unit/org/apache/cassandra/tools/SSTableRepairedAtSetterTest.java
index 737b0eb..e1f5f95 100644
--- a/test/unit/org/apache/cassandra/tools/SSTableRepairedAtSetterTest.java
+++ b/test/unit/org/apache/cassandra/tools/SSTableRepairedAtSetterTest.java
@@ -18,22 +18,33 @@
 
 package org.apache.cassandra.tools;
 
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.io.util.FileUtils;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class SSTableRepairedAtSetterTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
-    public void testSSTableRepairedAtSetter_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableRepairedAtSetter").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(SSTableRepairedAtSetter.class);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -43,10 +54,69 @@ public class SSTableRepairedAtSetterTest extends OfflineToolUtils
     }
 
     @Test
-    public void testSSTableRepairedAtSetter_WithArgs() throws Exception
+    public void testMaybeChangeDocs()
+    {
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(SSTableRepairedAtSetter.class, "-h");
+        String help = "This command should be run with Cassandra stopped, otherwise you will get very strange behavior\n" + 
+                      "Verify that Cassandra is not running and then execute the command like this:\n" + 
+                      "Usage: sstablerepairedset --really-set [--is-repaired | --is-unrepaired] [-f <sstable-list> | <sstables>]\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testWrongArgFailsAndPrintsHelp() throws IOException
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableRepairedAtSetter.class,
+                                                       "--debugwrong",
+                                                       findOneSSTable("legacy_sstables", "legacy_ma_simple"));
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(1, tool.getExitCode());
+    }
+
+    @Test
+    public void testIsrepairedArg() throws Exception
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableRepairedAtSetter.class,
+                                                       "--really-set",
+                                                       "--is-repaired",
+                                                       findOneSSTable("legacy_sstables", "legacy_ma_simple"));
+        tool.assertOnCleanExit();
+        assertNoUnexpectedThreadsStarted(null, OPTIONAL_THREADS_WITH_SCHEMA);
+        assertSchemaNotLoaded();
+        assertCLSMNotLoaded();
+        assertSystemKSNotLoaded();
+        assertKeyspaceNotLoaded();
+        assertServerNotLoaded();
+    }
+
+    @Test
+    public void testIsunrepairedArg() throws Exception
+    {
+        ToolResult tool = ToolRunner.invokeClass(SSTableRepairedAtSetter.class,
+                                                 "--really-set",
+                                                 "--is-unrepaired",
+                                                 findOneSSTable("legacy_sstables", "legacy_ma_simple"));
+        tool.assertOnCleanExit();
+        assertNoUnexpectedThreadsStarted(null, OPTIONAL_THREADS_WITH_SCHEMA);
+        assertSchemaNotLoaded();
+        assertCLSMNotLoaded();
+        assertSystemKSNotLoaded();
+        assertKeyspaceNotLoaded();
+        assertServerNotLoaded();
+    }
+
+    @Test
+    public void testFilesArg() throws Exception
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.SSTableRepairedAtSetter", "--really-set", "--is-repaired", findOneSSTable("legacy_sstables", "legacy_ma_simple"))
-              .waitAndAssertOnCleanExit();
+        File tmpFile = FileUtils.createTempFile("sstablelist.txt", "tmp");
+        tmpFile.deleteOnExit();
+        Files.write(tmpFile.toPath(), findOneSSTable("legacy_sstables", "legacy_ma_simple").getBytes());
+        
+        String file = tmpFile.getAbsolutePath();
+        ToolResult tool = ToolRunner.invokeClass(SSTableRepairedAtSetter.class, "--really-set", "--is-repaired", "-f", file);
+        tool.assertOnCleanExit();
         assertNoUnexpectedThreadsStarted(null, OPTIONAL_THREADS_WITH_SCHEMA);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
diff --git a/test/unit/org/apache/cassandra/tools/StandaloneSSTableUtilTest.java b/test/unit/org/apache/cassandra/tools/StandaloneSSTableUtilTest.java
index 834f537..460fb10 100644
--- a/test/unit/org/apache/cassandra/tools/StandaloneSSTableUtilTest.java
+++ b/test/unit/org/apache/cassandra/tools/StandaloneSSTableUtilTest.java
@@ -18,22 +18,31 @@
 
 package org.apache.cassandra.tools;
 
+import java.util.Arrays;
+
+import org.apache.commons.lang3.tuple.Pair;
+
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class StandaloneSSTableUtilTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
-    public void testStandaloneSSTableUtil_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.StandaloneSSTableUtil").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(StandaloneSSTableUtil.class);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Missing arguments"));
+        assertEquals(1, tool.getExitCode());
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -43,12 +52,119 @@ public class StandaloneSSTableUtilTest extends OfflineToolUtils
     }
 
     @Test
-    public void testStandaloneSSTableUtil_WithArgs()
+    public void testWrongArgFailsAndPrintsHelp()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.StandaloneSSTableUtil", "--debug", "-c", "system_schema", "tables")
-              .waitAndAssertOnCleanExit();
-        assertNoUnexpectedThreadsStarted(EXPECTED_THREADS_WITH_SCHEMA, OPTIONAL_THREADS_WITH_SCHEMA);
-        assertSchemaLoaded();
-        assertServerNotLoaded();
+        ToolResult tool = ToolRunner.invokeClass(StandaloneSSTableUtil.class, "--debugwrong", "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Unrecognized option"));
+        assertEquals(1, tool.getExitCode());
+    }
+
+    @Test
+    public void testMaybeChangeDocs()
+    {
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(StandaloneSSTableUtil.class, "-h");
+        String help = "usage: sstableutil [options] <keyspace> <column_family>\n" + 
+                       "--\n" + 
+                       "List sstable files for the provided table.\n" + 
+                       "--\n" + 
+                       "Options are:\n" + 
+                       " -c,--cleanup      clean-up any outstanding transactions\n" + 
+                       " -d,--debug        display stack traces\n" + 
+                       " -h,--help         display this help message\n" + 
+                       " -o,--oplog        include operation logs\n" + 
+                       " -t,--type <arg>   all (list all files, final or temporary), tmp (list\n" + 
+                       "                   temporary files only), final (list final files only),\n" + 
+                       " -v,--verbose      verbose output\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testDefaultCall()
+    {
+        ToolResult tool = ToolRunner.invokeClass(StandaloneSSTableUtil.class, "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("Listing files..."));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(0,tool.getExitCode());
+        assertCorrectEnvPostTest();
+    }
+
+    @Test
+    public void testListFilesArgs()
+    {
+        Arrays.asList("-d", "--debug", "-o", "-oplog", "-v", "--verbose").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneSSTableUtil.class,
+                                                            arg,
+                                                            "system_schema",
+                                                            "tables");
+            Assertions.assertThat(tool.getStdout()).as("Arg: [%s]", arg).isEqualTo("Listing files...\n");
+            Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+            tool.assertOnExitCode();
+            assertCorrectEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testCleanupArg()
+    {
+        Arrays.asList("-c", "--cleanup").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneSSTableUtil.class,
+                                                            arg,
+                                                            "system_schema",
+                                                            "tables");
+            assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("Cleaning up..."));
+            Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+            tool.assertOnExitCode();
+            assertCorrectEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testHelpArg()
+    {
+        Arrays.asList("-h", "--help").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneSSTableUtil.class, arg);
+            assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+            Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+            tool.assertOnExitCode();
+            assertCorrectEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testTypeArg()
+    {
+        Arrays.asList("-t", "--type").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneSSTableUtil.class,
+                                                            arg,
+                                                            "system_schema",
+                                                            "tables");
+            assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+            assertThat("Arg: [" + arg + "]", tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Missing arguments"));
+            assertEquals("Arg: [" + arg + "]", 1, tool.getExitCode());
+            assertCorrectEnvPostTest();
+        });
+        
+        //'-t wrong' renders 'all' file types
+        Arrays.asList(Pair.of("-t", "all"),
+                      Pair.of("-t", "tmp"),
+                      Pair.of("-t", "final"),
+                      Pair.of("-t", "wrong"),
+                      Pair.of("--type", "all"),
+                      Pair.of("--type", "tmp"),
+                      Pair.of("--type", "final"),
+                      Pair.of("--type", "wrong"))
+              .forEach(arg -> {
+                  ToolResult tool = ToolRunner.invokeClass(StandaloneSSTableUtil.class,
+                                                             arg.getLeft(),
+                                                             arg.getRight(),
+                                                             "system_schema",
+                                                             "tables");
+                  Assertions.assertThat(tool.getStdout()).as("Arg: [%s]", arg).isEqualTo("Listing files...\n");
+                  Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+                  tool.assertOnExitCode();
+                  assertCorrectEnvPostTest();
+              });
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/StandaloneScrubberTest.java b/test/unit/org/apache/cassandra/tools/StandaloneScrubberTest.java
index a99f657..3593025 100644
--- a/test/unit/org/apache/cassandra/tools/StandaloneScrubberTest.java
+++ b/test/unit/org/apache/cassandra/tools/StandaloneScrubberTest.java
@@ -18,22 +18,32 @@
 
 package org.apache.cassandra.tools;
 
+import java.util.Arrays;
+
+import org.apache.commons.lang3.tuple.Pair;
+
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class StandaloneScrubberTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
-    public void testStandaloneScrubber_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.StandaloneScrubber").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(StandaloneScrubber.class);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Missing arguments"));
+        assertEquals(1, tool.getExitCode());
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -43,12 +53,134 @@ public class StandaloneScrubberTest extends OfflineToolUtils
     }
 
     @Test
-    public void testStandaloneScrubber_WithArgs()
+    public void testMaybeChangeDocs()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.StandaloneScrubber", "--debug", "system_schema", "tables")
-              .waitAndAssertOnCleanExit();
-        assertNoUnexpectedThreadsStarted(EXPECTED_THREADS_WITH_SCHEMA, OPTIONAL_THREADS_WITH_SCHEMA);
-        assertSchemaLoaded();
-        assertServerNotLoaded();
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(StandaloneScrubber.class, "-h");
+        String help = "usage: sstablescrub [options] <keyspace> <column_family>\n" + 
+                       "--\n" + 
+                       "Scrub the sstable for the provided table.\n" + 
+                       "--\n" + 
+                       "Options are:\n" + 
+                       "    --debug                     display stack traces\n" + 
+                       " -e,--header-fix <arg>          Option whether and how to perform a check of the sstable serialization-headers and fix\n" + 
+                       "                                known, fixable issues.\n" + 
+                       "                                Possible argument values:\n" + 
+                       "                                - validate-only: validate the serialization-headers, but do not fix those. Do not continue with scrub - i.e. only\n" + 
+                       "                                validate the header (dry-run of fix-only).\n" + 
+                       "                                - validate: (default) validate the serialization-headers, but do not fix those and only continue with scrub if no error\n" + 
+                       "                                were detected.\n" + 
+                       "                                - fix-only: validate and fix the serialization-headers, don't continue with scrub.\n" + 
+                       "                                - fix: validate and fix the serialization-headers, do not fix and do not continue with scrub if the serialization-header\n" + 
+                       "                                check encountered errors.\n" + 
+                       "                                - off: don't perform the serialization-header checks.\n" + 
+                       " -h,--help                      display this help message\n" + 
+                       " -m,--manifest-check            only check and repair the leveled manifest, without actually scrubbing the sstables\n" + 
+                       " -n,--no-validate               do not validate columns using column validator\n" + 
+                       " -r,--reinsert-overflowed-ttl   Rewrites rows with overflowed expiration date affected by CASSANDRA-14092 with the\n" + 
+                       "                                maximum supported expiration date of 2038-01-19T03:14:06+00:00. The rows are rewritten with the original timestamp\n" + 
+                       "                                incremented by one millisecond to override/supersede any potential tombstone that may have been generated during\n" + 
+                       "                                compaction of the affected rows.\n" + 
+                       " -s,--skip-corrupted            skip corrupt rows in counter tables\n" + 
+                       " -v,--verbose                   verbose output\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testWrongArgFailsAndPrintsHelp()
+    {
+        ToolResult tool = ToolRunner.invokeClass(StandaloneScrubber.class, "--debugwrong", "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Unrecognized option"));
+        assertEquals(1, tool.getExitCode());
+    }
+
+    @Test
+    public void testDefaultCall()
+    {
+        ToolResult tool = ToolRunner.invokeClass(StandaloneScrubber.class, "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("Pre-scrub sstables snapshotted into snapshot"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(0,tool.getExitCode());
+        assertCorrectEnvPostTest();
+    }
+
+    @Test
+    public void testFlagArgs()
+    {
+        Arrays.asList("--debug",
+                      "-m",
+                      "--manifest-check",
+                      "-n",
+                      "--no-validate",
+                      "-r",
+                      "--reinsert-overflowed-ttl",
+                      "-s",
+                      "--skip-corrupted",
+                      "-v",
+                      "--verbose")
+              .forEach(arg -> {
+                  ToolResult tool = ToolRunner.invokeClass(StandaloneScrubber.class,
+                                                                  arg,
+                                                                  "system_schema",
+                                                                  "tables");
+                  assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("Pre-scrub sstables snapshotted into snapshot"));
+                  Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+                  tool.assertOnExitCode();
+                  assertCorrectEnvPostTest();
+              });
+    }
+
+    @Test
+    public void testHelpArg()
+    {
+        Arrays.asList("-h", "--help").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneScrubber.class, arg);
+            assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+            Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+            tool.assertOnExitCode();
+            assertCorrectEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testHeaderFixArg()
+    {
+        Arrays.asList(Pair.of("-e", ""),
+                      Pair.of("-e", "wrong"),
+                      Pair.of("--header-fix", ""),
+                      Pair.of("--header-fix", "wrong"))
+              .forEach(arg -> {
+                  ToolResult tool = ToolRunner.invokeClass(StandaloneScrubber.class,
+                                                                  arg.getLeft(),
+                                                                  arg.getRight(),
+                                                                  "system_schema",
+                                                                  "tables");
+                  assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+                  assertTrue("Arg: [" + arg + "]\n" + tool.getCleanedStderr(), tool.getCleanedStderr().contains("Invalid argument value"));
+                  assertEquals(1, tool.getExitCode());
+              });
+
+        Arrays.asList(Pair.of("-e", "validate-only"),
+                      Pair.of("-e", "validate"),
+                      Pair.of("-e", "fix-only"),
+                      Pair.of("-e", "fix"),
+                      Pair.of("-e", "off"),
+                      Pair.of("--header-fix", "validate-only"),
+                      Pair.of("--header-fix", "validate"),
+                      Pair.of("--header-fix", "fix-only"),
+                      Pair.of("--header-fix", "fix"),
+                      Pair.of("--header-fix", "off"))
+              .forEach(arg -> {
+                  ToolResult tool = ToolRunner.invokeClass(StandaloneScrubber.class,
+                                                                  arg.getLeft(),
+                                                                  arg.getRight(),
+                                                                  "system_schema",
+                                                                  "tables");
+                  assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("Pre-scrub sstables snapshotted into snapshot"));
+                  Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+                  tool.assertOnExitCode();
+                  assertCorrectEnvPostTest();
+              });
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/StandaloneSplitterTest.java b/test/unit/org/apache/cassandra/tools/StandaloneSplitterTest.java
index 753a258..287275b 100644
--- a/test/unit/org/apache/cassandra/tools/StandaloneSplitterTest.java
+++ b/test/unit/org/apache/cassandra/tools/StandaloneSplitterTest.java
@@ -18,19 +18,27 @@
 
 package org.apache.cassandra.tools;
 
+import java.util.Arrays;
+
+import org.apache.commons.lang3.tuple.Pair;
+
 import org.junit.BeforeClass;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class StandaloneSplitterTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
+    // Note: StandaloneSplitter modifies sstables
+
     @BeforeClass
     public static void before()
     {
@@ -41,9 +49,12 @@ public class StandaloneSplitterTest extends OfflineToolUtils
     }
 
     @Test
-    public void testStandaloneSplitter_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.StandaloneSplitter").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(StandaloneSplitter.class);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("No sstables to split"));
+        assertEquals(1, tool.getExitCode());
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -52,5 +63,84 @@ public class StandaloneSplitterTest extends OfflineToolUtils
         assertServerNotLoaded();
     }
 
-    // Note: StandaloneSplitter modifies sstables
+    @Test
+    public void testMaybeChangeDocs()
+    {
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(StandaloneSplitter.class, "-h");
+        String help = "usage: sstablessplit [options] <filename> [<filename>]*\n" + 
+                      "--\n" + 
+                      "Split the provided sstables files in sstables of maximum provided file\n" + 
+                      "size (see option --size).\n" + 
+                      "--\n" + 
+                      "Options are:\n" + 
+                      "    --debug         display stack traces\n" + 
+                      " -h,--help          display this help message\n" + 
+                      "    --no-snapshot   don't snapshot the sstables before splitting\n" + 
+                      " -s,--size <size>   maximum size in MB for the output sstables (default:\n" + 
+                      "                    50)\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testWrongArgFailsAndPrintsHelp()
+    {
+        ToolResult tool = ToolRunner.invokeClass(StandaloneSplitter.class, "--debugwrong", "mockFile");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Unrecognized option"));
+        assertEquals(1, tool.getExitCode());
+    }
+
+    @Test
+    public void testWrongFilename()
+    {
+        ToolResult tool = ToolRunner.invokeClass(StandaloneSplitter.class, "mockFile");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("Skipping inexisting file mockFile"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("No valid sstables to split"));
+        assertEquals(1, tool.getExitCode());
+        assertCorrectEnvPostTest();
+    }
+
+    @Test
+    public void testFlagArgs()
+    {
+        Arrays.asList("--debug", "--no-snapshot").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneSplitter.class, arg, "mockFile");
+            assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("Skipping inexisting file mockFile"));
+            assertThat("Arg: [" + arg + "]", tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("No valid sstables to split"));
+            assertEquals(1, tool.getExitCode());
+            assertCorrectEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testSizeArg()
+    {
+        Arrays.asList(Pair.of("-s", ""), Pair.of("-s", "w"), Pair.of("--size", ""), Pair.of("--size", "w"))
+              .forEach(arg -> {
+                  ToolResult tool = ToolRunner.invokeClass(StandaloneSplitter.class,
+                                                           arg.getLeft(),
+                                                           arg.getRight(),
+                                                           "mockFile");
+                  assertEquals(-1, tool.getExitCode());
+                  Assertions.assertThat(tool.getStderr()).contains(NumberFormatException.class.getSimpleName());
+              });
+
+        Arrays.asList(Pair.of("-s", "0"),
+                      Pair.of("-s", "1000"),
+                      Pair.of("-s", "-1"),
+                      Pair.of("--size", "0"),
+                      Pair.of("--size", "1000"),
+                      Pair.of("--size", "-1"))
+              .forEach(arg -> {
+                  ToolResult tool = ToolRunner.invokeClass(StandaloneSplitter.class,
+                                                                  arg.getLeft(),
+                                                                  arg.getRight(),
+                                                                  "mockFile");
+                  assertThat("Arg: [" + arg + "]", tool.getStdout(), CoreMatchers.containsStringIgnoringCase("Skipping inexisting file mockFile"));
+                  assertThat("Arg: [" + arg + "]", tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("No valid sstables to split"));
+                  assertEquals(1, tool.getExitCode());
+                  assertCorrectEnvPostTest();
+              });
+    }
 }
diff --git a/test/unit/org/apache/cassandra/tools/StandaloneUpgraderTest.java b/test/unit/org/apache/cassandra/tools/StandaloneUpgraderTest.java
index 1bfbbd2..3a4177b 100644
--- a/test/unit/org/apache/cassandra/tools/StandaloneUpgraderTest.java
+++ b/test/unit/org/apache/cassandra/tools/StandaloneUpgraderTest.java
@@ -18,22 +18,29 @@
 
 package org.apache.cassandra.tools;
 
+import java.util.Arrays;
+
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class StandaloneUpgraderTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
     @Test
-    public void testStandaloneUpgrader_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.StandaloneUpgrader").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(StandaloneUpgrader.class);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Missing arguments"));
+        assertEquals(1, tool.getExitCode());
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -43,12 +50,71 @@ public class StandaloneUpgraderTest extends OfflineToolUtils
     }
 
     @Test
-    public void testStandaloneUpgrader_WithArgs()
+    public void testMaybeChangeDocs()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.StandaloneUpgrader", "--debug", "system_schema", "tables")
-              .waitAndAssertOnCleanExit();
-        assertNoUnexpectedThreadsStarted(EXPECTED_THREADS_WITH_SCHEMA, OPTIONAL_THREADS_WITH_SCHEMA);
-        assertSchemaLoaded();
-        assertServerNotLoaded();
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(StandaloneUpgrader.class, "-h");
+        String help = "usage: sstableupgrade [options] <keyspace> <cf> [snapshot]\n" + 
+                       "--\n" + 
+                       "Upgrade the sstables in the given cf (or snapshot) to the current version\n" + 
+                       "of Cassandra.This operation will rewrite the sstables in the specified cf\n" + 
+                       "to match the currently installed version of Cassandra.\n" + 
+                       "The snapshot option will only upgrade the specified snapshot. Upgrading\n" + 
+                       "snapshots is required before attempting to restore a snapshot taken in a\n" + 
+                       "major version older than the major version Cassandra is currently running.\n" + 
+                       "This will replace the files in the given snapshot as well as break any\n" + 
+                       "hard links to live sstables.\n" + 
+                       "--\n" + 
+                       "Options are:\n" + 
+                       "    --debug         display stack traces\n" + 
+                       " -h,--help          display this help message\n" + 
+                       " -k,--keep-source   do not delete the source sstables\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testWrongArgFailsAndPrintsHelp()
+    {
+        ToolResult tool = ToolRunner.invokeClass(StandaloneUpgrader.class, "--debugwrong", "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Unrecognized option"));
+        assertEquals(1, tool.getExitCode());
+    }
+
+    @Test
+    public void testDefaultCall()
+    {
+        ToolResult tool = ToolRunner.invokeClass(StandaloneUpgrader.class, "system_schema", "tables");
+        Assertions.assertThat(tool.getStdout()).isEqualTo("Found 0 sstables that need upgrading.\n");
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(0,tool.getExitCode());
+        assertCorrectEnvPostTest();
+    }
+
+    @Test
+    public void testFlagArgs()
+    {
+        Arrays.asList("--debug", "-k", "--keep-source").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneUpgrader.class,
+                                                       arg,
+                                                       "system_schema",
+                                                       "tables");
+            Assertions.assertThat(tool.getStdout()).as("Arg: [%s]", arg).isEqualTo("Found 0 sstables that need upgrading.\n");
+            Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+            assertEquals(0,tool.getExitCode());
+            assertCorrectEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testHelpArg()
+    {
+        Arrays.asList("-h", "--help").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneUpgrader.class, arg);
+            Assertions.assertThat(tool.getStdout()).as("Arg: [%s]", arg).isNotEmpty();
+            Assertions.assertThat(tool.getCleanedStderr()).as("Arg: [%s]", arg).isEmpty();
+            tool.assertOnExitCode();
+            assertCorrectEnvPostTest();
+        });
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/StandaloneVerifierTest.java b/test/unit/org/apache/cassandra/tools/StandaloneVerifierTest.java
index e33a154..f736edd 100644
--- a/test/unit/org/apache/cassandra/tools/StandaloneVerifierTest.java
+++ b/test/unit/org/apache/cassandra/tools/StandaloneVerifierTest.java
@@ -18,22 +18,30 @@
 
 package org.apache.cassandra.tools;
 
+import java.util.Arrays;
+
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.assertj.core.api.Assertions;
+import org.hamcrest.CoreMatchers;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 
 @RunWith(OrderedJUnit4ClassRunner.class)
 public class StandaloneVerifierTest extends OfflineToolUtils
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
+
     @Test
-    public void testStandaloneVerifier_NoArgs()
+    public void testNoArgsPrintsHelp()
     {
-        assertEquals(1, runner.invokeClassAsTool("org.apache.cassandra.tools.StandaloneVerifier").getExitCode());
+        ToolResult tool = ToolRunner.invokeClass(StandaloneVerifier.class);
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Missing arguments"));
+        assertEquals(1, tool.getExitCode());
         assertNoUnexpectedThreadsStarted(null, null);
         assertSchemaNotLoaded();
         assertCLSMNotLoaded();
@@ -43,12 +51,125 @@ public class StandaloneVerifierTest extends OfflineToolUtils
     }
 
     @Test
-    public void testStandaloneVerifier_WithArgs()
+    public void testMaybeChangeDocs()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.StandaloneVerifier", "--debug", "system_schema", "tables")
-              .waitAndAssertOnCleanExit();
-        assertNoUnexpectedThreadsStarted(EXPECTED_THREADS_WITH_SCHEMA, OPTIONAL_THREADS_WITH_SCHEMA);
-        assertSchemaLoaded();
-        assertServerNotLoaded();
+        // If you added, modified options or help, please update docs if necessary
+        ToolResult tool = ToolRunner.invokeClass(StandaloneVerifier.class, "-h");
+        String help = "usage: sstableverify [options] <keyspace> <column_family>\n" + 
+                       "--\n" + 
+                       "Verify the sstable for the provided table.\n" + 
+                       "--\n" + 
+                       "Options are:\n" + 
+                       " -c,--check_version          make sure sstables are the latest version\n" + 
+                       "    --debug                  display stack traces\n" + 
+                       " -e,--extended               extended verification\n" + 
+                       " -h,--help                   display this help message\n" + 
+                       " -q,--quick                  do a quick check, don't read all data\n" + 
+                       " -r,--mutate_repair_status   don't mutate repair status\n" + 
+                       " -t,--token_range <range>    long token range of the format left,right.\n" + 
+                       "                             This may be provided multiple times to define multiple different ranges\n" + 
+                       " -v,--verbose                verbose output\n";
+        Assertions.assertThat(tool.getStdout()).isEqualTo(help);
+    }
+
+    @Test
+    public void testWrongArgFailsAndPrintsHelp()
+    {
+        ToolResult tool = ToolRunner.invokeClass(StandaloneVerifier.class, "--debugwrong", "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("Unrecognized option"));
+        assertEquals(1, tool.getExitCode());
+    }
+
+    @Test
+    public void testDefaultCall()
+    {
+        ToolResult tool = ToolRunner.invokeClass(StandaloneVerifier.class, "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("using the following options"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        assertEquals(0,tool.getExitCode());
+        assertCorrectEnvPostTest();
+    }
+
+    @Test
+    public void testDebugArg()
+    {
+        ToolResult tool = ToolRunner.invokeClass(StandaloneVerifier.class, "--debug", "system_schema", "tables");
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("debug=true"));
+        Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+        tool.assertOnExitCode();
+        assertCorrectEnvPostTest();
+    }
+
+    @Test
+    public void testExtendedArg()
+    {
+        Arrays.asList("-e", "--extended").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneVerifier.class,
+                                                       arg,
+                                                       "system_schema",
+                                                       "tables");
+            assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("extended=true"));
+            Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+            tool.assertOnExitCode();
+            assertCorrectEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testQuickArg()
+    {
+        Arrays.asList("-q", "--quick").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneVerifier.class,
+                                                       arg,
+                                                       "system_schema",
+                                                       "tables");
+            assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("quick=true"));
+            Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+            tool.assertOnExitCode();
+            assertCorrectEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testRepairStatusArg()
+    {
+        Arrays.asList("-r", "--mutate_repair_status").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneVerifier.class,
+                                                       arg,
+                                                       "system_schema",
+                                                       "tables");
+            assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("mutateRepairStatus=true"));
+            Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+            tool.assertOnExitCode();
+            assertCorrectEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testHelpArg()
+    {
+        Arrays.asList("-h", "--help").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneVerifier.class, arg);
+            assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+            Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+            tool.assertOnExitCode();
+            assertCorrectEnvPostTest();
+        });
+    }
+
+    @Test
+    public void testVerboseArg()
+    {
+        Arrays.asList("-v", "--verbose").forEach(arg -> {
+            ToolResult tool = ToolRunner.invokeClass(StandaloneVerifier.class,
+                                                       arg,
+                                                       "system_schema",
+                                                       "tables");
+            assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("verbose=true"));
+            Assertions.assertThat(tool.getCleanedStderr()).isEmpty();
+            tool.assertOnExitCode();
+            assertCorrectEnvPostTest();
+        });
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/ToolRunner.java b/test/unit/org/apache/cassandra/tools/ToolRunner.java
index 7334347..98fdaed 100644
--- a/test/unit/org/apache/cassandra/tools/ToolRunner.java
+++ b/test/unit/org/apache/cassandra/tools/ToolRunner.java
@@ -18,7 +18,6 @@
 
 package org.apache.cassandra.tools;
 
-import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -28,13 +27,17 @@ import java.lang.reflect.InvocationTargetException;
 import java.security.Permission;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
 import java.util.stream.Collectors;
 
-import com.google.common.base.Preconditions;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
-import org.apache.commons.io.IOUtils;
+
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -44,178 +47,11 @@ import org.apache.cassandra.tools.OfflineToolUtils.SystemExitException;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
-public class ToolRunner implements AutoCloseable
+public class ToolRunner
 {
     protected static final Logger logger = LoggerFactory.getLogger(ToolRunner.class);
 
     private static final ImmutableList<String> DEFAULT_CLEANERS = ImmutableList.of("(?im)^picked up.*\\R");
-    private static final String[] EMPTY_STRING_ARRAY = new String[0];
-
-    private final List<String> allArgs = new ArrayList<>();
-    private Process process;
-    @SuppressWarnings("resource")
-    private final ByteArrayOutputStream errBuffer = new ByteArrayOutputStream();
-    @SuppressWarnings("resource")
-    private final ByteArrayOutputStream outBuffer = new ByteArrayOutputStream();
-    private InputStream stdin;
-    private Thread[] ioWatchers;
-    private Map<String, String> envs;
-    private boolean runOutOfProcess = true;
-
-    public ToolRunner(List<String> args)
-    {
-        this.allArgs.addAll(args);
-    }
-    
-    public ToolRunner(List<String> args, boolean runOutOfProcess)
-    {
-        this.allArgs.addAll(args);
-        this.runOutOfProcess = runOutOfProcess;
-    }
-
-    public ToolRunner withStdin(InputStream stdin)
-    {
-        this.stdin = stdin;
-        return this;
-    }
-
-    public ToolRunner withEnvs(Map<String, String> envs)
-    {
-        Preconditions.checkArgument(runOutOfProcess, "Not supported");
-        this.envs = envs;
-        return this;
-    }
-
-    public ToolRunner start()
-    {
-        if (process != null)
-            throw new IllegalStateException("Process already started. Create a new ToolRunner instance for each invocation.");
-
-        logger.debug("Starting {} with args {}", runOutOfProcess ? "process" : "class" , argsToLogString());
-
-        try
-        {
-            if (runOutOfProcess)
-            {
-                ProcessBuilder pb = new ProcessBuilder(allArgs);
-                if (envs != null)
-                    pb.environment().putAll(envs);
-                process = pb.start();
-            }
-            else
-            {
-                PrintStream originalSysOut = System.out;
-                PrintStream originalSysErr = System.err;
-                InputStream originalSysIn = System.in;
-                originalSysOut.flush();
-                originalSysErr.flush();
-                ByteArrayOutputStream toolOut = new ByteArrayOutputStream();
-                ByteArrayOutputStream toolErr = new ByteArrayOutputStream();
-
-                System.setIn(stdin == null ? originalSysIn : stdin);
-
-                int exit;
-                try (PrintStream newOut = new PrintStream(toolOut); PrintStream newErr = new PrintStream(toolErr))
-                {
-                    System.setOut(newOut);
-                    System.setErr(newErr);
-                    String clazz = allArgs.get(0);
-                    String[] clazzArgs = allArgs.subList(1, allArgs.size()).toArray(EMPTY_STRING_ARRAY);
-                    exit = runClassAsTool(clazz, clazzArgs);
-                }
-                
-                final int exitCode = exit;
-                System.setOut(originalSysOut);
-                System.setErr(originalSysErr);
-                System.setIn(originalSysIn);
-                
-                process = new Process() {
-
-                    @Override
-                    public void destroy()
-                    {
-                    }
-
-                    @Override
-                    public int exitValue()
-                    {
-                        return exitCode;
-                    }
-
-                    @Override
-                    public InputStream getErrorStream()
-                    {
-                        return new ByteArrayInputStream(toolErr.toByteArray());
-                    }
-
-                    @Override
-                    public InputStream getInputStream()
-                    {
-                        return new ByteArrayInputStream(toolOut.toByteArray());
-                    }
-
-                    @Override
-                    public OutputStream getOutputStream()
-                    {
-                        if (stdin == null)
-                            return null;
-
-                        ByteArrayOutputStream out;
-                        try
-                        {
-                            out = new ByteArrayOutputStream(stdin.available());
-                            IOUtils.copy(stdin, out);
-                        }
-                        catch(IOException e)
-                        {
-                            throw new RuntimeException("Failed to get stdin", e);
-                        }
-                        return out;
-                    }
-
-                    @Override
-                    public int waitFor()
-                    {
-                        return exitValue();
-                    }
-                    
-                };
-            }
-
-            // each stream tends to use a bounded buffer, so need to process each stream in its own thread else we
-            // might block on an idle stream, not consuming the other stream which is blocked in the other process
-            // as nothing is consuming
-            int numWatchers = 2;
-            // only need a stdin watcher when forking
-            boolean includeStdinWatcher = runOutOfProcess && stdin != null;
-            if (includeStdinWatcher)
-                numWatchers = 3;
-            ioWatchers = new Thread[numWatchers];
-            ioWatchers[0] = new Thread(new StreamGobbler<>(process.getErrorStream(), errBuffer));
-            ioWatchers[0].setDaemon(true);
-            ioWatchers[0].setName("IO Watcher stderr for " + allArgs);
-            ioWatchers[0].start();
-
-            ioWatchers[1] = new Thread(new StreamGobbler<>(process.getInputStream(), outBuffer));
-            ioWatchers[1].setDaemon(true);
-            ioWatchers[1].setName("IO Watcher stdout for " + allArgs);
-            ioWatchers[1].start();
-
-            if (includeStdinWatcher)
-            {
-                ioWatchers[2] = new Thread(new StreamGobbler<>(stdin, process.getOutputStream()));
-                ioWatchers[2].setDaemon(true);
-                ioWatchers[2].setName("IO Watcher stdin for " + allArgs);
-                ioWatchers[2].start();
-            }
-        }
-        catch (IOException e)
-        {
-            throw new RuntimeException("Failed to start " + allArgs, e);
-        }
-
-        return this;
-    }
 
     public static int runClassAsTool(String clazz, String... args)
     {
@@ -273,228 +109,479 @@ public class ToolRunner implements AutoCloseable
         }
     }
 
-    public boolean isRunning()
+    private static final class StreamGobbler<T extends OutputStream> implements Runnable
     {
-        return process != null && process.isAlive();
-    }
+        private static final int BUFFER_SIZE = 8_192;
 
-    public int waitFor()
-    {
-        try
+        private final InputStream input;
+        private final T out;
+
+        private StreamGobbler(InputStream input, T out)
         {
-            int rc = process.waitFor();
-            // must call first in order to make sure the stdin ioWatcher will exit
-            onComplete();
-            for (Thread t : ioWatchers)
-                t.join();
-            return rc;
+            this.input = input;
+            this.out = out;
         }
-        catch (InterruptedException e)
+
+        public void run()
         {
-            throw new RuntimeException(e);
+            byte[] buffer = new byte[BUFFER_SIZE];
+            while (true)
+            {
+                try
+                {
+                    int read = input.read(buffer);
+                    if (read == -1)
+                    {
+                        return;
+                    }
+                    out.write(buffer, 0, read);
+                }
+                catch (IOException e)
+                {
+                    logger.error("Unexpected IO Error while reading stream", e);
+                    return;
+                }
+            }
         }
     }
 
-    public ToolRunner waitAndAssertOnExitCode()
-    {
-        assertExitCode(waitFor());
-        return this;
-    }
-    
-    public ToolRunner waitAndAssertOnCleanExit()
+    /**
+     * Invokes Cqlsh. The first arg is the cql to execute
+     */
+    public static ToolResult invokeCqlsh(String... args)
     {
-        return waitAndAssertOnExitCode().assertCleanStdErr();
+        return invokeCqlsh(Arrays.asList(args));
     }
-    
+
     /**
-     * Checks if the stdErr is empty after removing any potential JVM env info output and other noise
-     * 
-     * Some JVM configs may output env info on stdErr. We need to remove those to see what was the tool's actual stdErr
-     * @return The ToolRunner instance
+     * Invokes Cqlsh. The first arg is the cql to execute
      */
-    public ToolRunner assertCleanStdErr()
+    public static ToolResult invokeCqlsh(List<String> args)
     {
-        assertTrue("Failed because cleaned stdErr wasn't empty: " + getCleanedStderr(), getCleanedStderr().isEmpty());
-        return this;
+        return invoke(CQLTester.buildCqlshArgs(args));
     }
 
-    public ToolRunner assertOnExitCode()
+    public static ToolResult invokeCassandraStress(String... args)
     {
-        assertExitCode(getExitCode());
-        return this;
+        return invokeCassandraStress(Arrays.asList(args));
     }
 
-    private void assertExitCode(int code)
+    public static ToolResult invokeCassandraStress(List<String> args)
     {
-        if (code != 0)
-            fail(String.format("%s%nexited with code %d%nstderr:%n%s%nstdout:%n%s",
-                               argsToLogString(),
-                               code,
-                               getStderr(),
-                               getStdout()));
+        return invoke(CQLTester.buildCassandraStressArgs(args));
     }
 
-    public String argsToLogString()
+    public static ToolResult invokeNodetool(String... args)
     {
-        return allArgs.stream().collect(Collectors.joining(",\n    ", "[", "]"));
+        return invokeNodetool(Arrays.asList(args));
     }
 
-    public int getExitCode()
+    public static ToolResult invokeNodetool(List<String> args)
     {
-        return process.exitValue();
+        return invoke(CQLTester.buildNodetoolArgs(args));
     }
 
-    public String getStdout()
+    public static ToolResult invoke(List<String> args)
     {
-        return outBuffer.toString();
+        return invoke(args.toArray(new String[args.size()]));
     }
 
-    public String getStderr()
+    public static ToolResult invoke(String... args) 
     {
-        return errBuffer.toString();
+        try (ObservableTool  t = invokeAsync(args))
+        {
+            return t.waitComplete();
+        }
     }
 
-    /**
-     * Checks if the stdErr is empty after removing any potential JVM env info output and other noise
-     * 
-     * Some JVM configs may output env info on stdErr. We need to remove those to see what was the tool's actual stdErr
-     * 
-     * @param regExpCleaners List of regExps to remove from stdErr
-     * @return The stdErr with all excludes removed
-     */
-    public String getCleanedStderr(List<String> regExpCleaners)
+    public static ObservableTool invokeAsync(String... args)
     {
-        String sanitizedStderr = getStderr();
-        for (String regExp: regExpCleaners)
-            sanitizedStderr = sanitizedStderr.replaceAll(regExp, "");
-        return sanitizedStderr;
+        return invokeAsync(Collections.emptyMap(), null, Arrays.asList(args));
     }
 
-    /**
-     * Checks if the stdErr is empty after removing any potential JVM env info output. Uses default list of excludes
-     * 
-     * {@link #getCleanedStderr(List)}
-     */
-    public String getCleanedStderr()
+    public static ToolResult invoke(Map<String, String> env, InputStream stdin, List<String> args)
     {
-        return getCleanedStderr(DEFAULT_CLEANERS);
+        try (ObservableTool  t = invokeAsync(env, stdin, args))
+        {
+            return t.waitComplete();
+        }
     }
 
-    public void forceKill()
+    public static ObservableTool invokeAsync(Map<String, String> env, InputStream stdin, List<String> args)
     {
+        ProcessBuilder pb = new ProcessBuilder(args);
+        if (env != null && !env.isEmpty())
+            pb.environment().putAll(env);
         try
         {
-            process.exitValue();
-            // process no longer alive - just ignore that fact
+            return new ForkedObservableTool(pb.start(), stdin, args);
         }
-        catch (IllegalThreadStateException e)
+        catch (IOException e)
         {
-            process.destroyForcibly();
+            return new FailedObservableTool(e, args);
         }
     }
 
-    @Override
-    public void close()
+    public static ToolResult invokeClass(String klass,  String... args)
     {
-        forceKill();
-        onComplete();
+        return invokeClass(klass, null, args);
     }
 
-    private void onComplete()
+    public static ToolResult invokeClass(Class<?> klass,  String... args)
     {
-        if (stdin != null)
+        return invokeClass(klass.getName(), null, args);
+    }
+
+    public static ToolResult invokeClass(String klass, InputStream stdin, String... args)
+    {
+        List<String> allArgs = new ArrayList<>();
+        allArgs.add(klass);
+        allArgs.addAll(Arrays.asList(args));
+        
+        PrintStream originalSysOut = System.out;
+        PrintStream originalSysErr = System.err;
+        InputStream originalSysIn = System.in;
+        originalSysOut.flush();
+        originalSysErr.flush();
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        ByteArrayOutputStream err = new ByteArrayOutputStream();
+
+        System.setIn(stdin == null ? originalSysIn : stdin);
+
+        try (PrintStream newOut = new PrintStream(out);
+             PrintStream newErr = new PrintStream(err))
         {
-            try
-            {
-                stdin.close();
-            }
-            catch (IOException e)
-            {
-                logger.warn("Error closing stdin for {}", allArgs, e);
-            }
+            System.setOut(newOut);
+            System.setErr(newErr);
+            int rc = runClassAsTool(klass, args);
+            out.flush();
+            err.flush();
+            return new ToolResult(allArgs, rc, out.toString(), err.toString(), null);
+        }
+        catch (Exception e)
+        {
+            return new ToolResult(allArgs,
+                                  -1,
+                                  out.toString(),
+                                  err.toString() + "\n" + Throwables.getStackTraceAsString(e),
+                                  e);
+        }
+        finally
+        {
+            System.setOut(originalSysOut);
+            System.setErr(originalSysErr);
+            System.setIn(originalSysIn);
         }
     }
 
-    private static final class StreamGobbler<T extends OutputStream> implements Runnable
+    public static Builder builder(List<String> args)
     {
-        private static final int BUFFER_SIZE = 8_192;
+        return new Builder(args);
+    }
 
-        private final InputStream input;
-        private final T out;
+    public static final class ToolResult
+    {
+        private final List<String> allArgs;
+        private final int exitCode;
+        private final String stdout;
+        private final String stderr;
+        private final Exception e;
 
-        private StreamGobbler(InputStream input, T out)
+        private ToolResult(List<String> allArgs, int exitCode, String stdout, String stderr, Exception e)
         {
-            this.input = input;
-            this.out = out;
+            this.allArgs = allArgs;
+            this.exitCode = exitCode;
+            this.stdout = stdout;
+            this.stderr = stderr;
+            this.e = e;
         }
 
-        public void run()
+        public int getExitCode()
         {
-            byte[] buffer = new byte[BUFFER_SIZE];
-            while (true)
-            {
-                try
-                {
-                    int read = input.read(buffer);
-                    if (read == -1)
-                    {
-                        return;
-                    }
-                    out.write(buffer, 0, read);
-                }
-                catch (IOException e)
-                {
-                    logger.error("Unexpected IO Error while reading stream", e);
-                    return;
-                }
-            }
+            return exitCode;
+        }
+
+        public String getStdout()
+        {
+            return stdout;
+        }
+
+        public String getStderr()
+        {
+            return stderr;
+        }
+        
+        public Exception getException()
+        {
+            return e;
+        }
+        
+        /**
+         * Checks if the stdErr is empty after removing any potential JVM env info output and other noise
+         * 
+         * Some JVM configs may output env info on stdErr. We need to remove those to see what was the tool's actual
+         * stdErr
+         * 
+         * @return The ToolRunner instance
+         */
+        public void assertCleanStdErr()
+        {
+            assertTrue("Failed because cleaned stdErr wasn't empty: " + getCleanedStderr(),
+                       getCleanedStderr().isEmpty());
+        }
+
+        public void assertOnExitCode()
+        {
+            assertExitCode(getExitCode());
+        }
+
+        private void assertExitCode(int code)
+        {
+            if (code != 0)
+                fail(String.format("%s%nexited with code %d%nstderr:%n%s%nstdout:%n%s",
+                                   argsToLogString(),
+                                   code,
+                                   getStderr(),
+                                   getStdout()));
         }
+
+        public String argsToLogString()
+        {
+            return allArgs.stream().collect(Collectors.joining(",\n    ", "[", "]"));
+        }
+
+        /**
+         * Returns stdErr after removing any potential JVM env info output through the provided cleaners
+         * 
+         * Some JVM configs may output env info on stdErr. We need to remove those to see what was the tool's actual
+         * stdErr
+         * 
+         * @param regExpCleaners
+         *            List of regExps to remove from stdErr
+         * @return The stdErr with all excludes removed
+         */
+        public String getCleanedStderr(List<String> regExpCleaners)
+        {
+            String sanitizedStderr = getStderr();
+            for (String regExp : regExpCleaners)
+                sanitizedStderr = sanitizedStderr.replaceAll(regExp, "");
+            return sanitizedStderr;
+        }
+
+        /**
+         * Returns stdErr after removing any potential JVM env info output. Uses default list of excludes
+         * 
+         * {@link #getCleanedStderr(List)}
+         */
+        public String getCleanedStderr()
+        {
+            return getCleanedStderr(DEFAULT_CLEANERS);
+        }
+        
+        public void assertOnCleanExit()
+        {
+            assertOnExitCode();
+            assertCleanStdErr();
+        }
+    }
+
+    public interface ObservableTool extends AutoCloseable
+    {
+        String getPartialStdout();
+
+        String getPartialStderr();
+
+        boolean isDone();
+
+        ToolResult waitComplete();
+
+        @Override
+        void close();
     }
 
-    public static class Runners
+    private static final class FailedObservableTool implements ObservableTool
     {
-        public static ToolRunner invokeNodetool(String... args)
+        private final List<String> args;
+        private final IOException error;
+
+        private FailedObservableTool(IOException error, List<String> args)
+        {
+            this.args = args;
+            this.error = error;
+        }
+
+        @Override
+        public String getPartialStdout()
+        {
+            return "";
+        }
+
+        @Override
+        public String getPartialStderr()
         {
-            return invokeNodetool(Arrays.asList(args));
+            return error.getMessage();
         }
 
-        public static ToolRunner invokeNodetool(List<String> args)
+        @Override
+        public boolean isDone()
         {
-            return invokeTool(buildNodetoolArgs(args), true);
+            return true;
         }
 
-        private static List<String> buildNodetoolArgs(List<String> args)
+        @Override
+        public ToolResult waitComplete()
         {
-            return CQLTester.buildNodetoolArgs(args);
+            return new ToolResult(args, -1, getPartialStdout(), getPartialStderr(), error);
         }
 
-        public static ToolRunner invokeClassAsTool(String... args)
+        @Override
+        public void close()
         {
-            return invokeClassAsTool(Arrays.asList(args));
+
         }
+    }
 
-        public static ToolRunner invokeClassAsTool(List<String> args)
+    private static final class ForkedObservableTool implements ObservableTool
+    {
+        @SuppressWarnings("resource")
+        private final ByteArrayOutputStream err = new ByteArrayOutputStream();
+        @SuppressWarnings("resource")
+        private final ByteArrayOutputStream out = new ByteArrayOutputStream();
+        @SuppressWarnings("resource")
+        private final InputStream stdin;
+        private final Process process;
+        private final Thread[] ioWatchers;
+        private final List<String> args;
+
+        private ForkedObservableTool(Process process, InputStream stdin, List<String> args)
         {
-            return invokeTool(args, false);
+            this.process = process;
+            this.args = args;
+            this.stdin = stdin;
+
+            // Each stream tends to use a bounded buffer, so need to process each stream in its own thread else we
+            // might block on an idle stream, not consuming the other stream which is blocked in the other process
+            // as nothing is consuming
+            int numWatchers = 2;
+            // only need a stdin watcher when forking
+            boolean includeStdinWatcher = stdin != null;
+            if (includeStdinWatcher)
+                numWatchers = 3;
+            ioWatchers = new Thread[numWatchers];
+            ioWatchers[0] = new Thread(new StreamGobbler<>(process.getErrorStream(), err));
+            ioWatchers[0].setDaemon(true);
+            ioWatchers[0].setName("IO Watcher stderr");
+            ioWatchers[0].start();
+
+            ioWatchers[1] = new Thread(new StreamGobbler<>(process.getInputStream(), out));
+            ioWatchers[1].setDaemon(true);
+            ioWatchers[1].setName("IO Watcher stdout");
+            ioWatchers[1].start();
+
+            if (includeStdinWatcher)
+            {
+                ioWatchers[2] = new Thread(new StreamGobbler<>(stdin, process.getOutputStream()));
+                ioWatchers[2].setDaemon(true);
+                ioWatchers[2].setName("IO Watcher stdin");
+                ioWatchers[2].start();
+            }
+        }
+
+        @Override
+        public String getPartialStdout()
+        {
+            return out.toString();
+        }
+
+        @Override
+        public String getPartialStderr()
+        {
+            return err.toString();
         }
 
-        public static ToolRunner invokeTool(String... args)
+        @Override
+        public boolean isDone()
         {
-            return invokeTool(Arrays.asList(args));
+            return !process.isAlive();
         }
 
-        public static ToolRunner invokeTool(List<String> args)
+        @Override
+        public ToolResult waitComplete()
         {
-            return invokeTool(args, true);
+            try
+            {
+                int rc = process.waitFor();
+                onComplete();
+                return new ToolResult(args, rc, out.toString(), err.toString(), null);
+            }
+            catch (InterruptedException e)
+            {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException(e);
+            }
         }
 
-        public static ToolRunner invokeTool(List<String> args, boolean runOutOfProcess)
+        private void onComplete() throws InterruptedException
         {
-            try (ToolRunner runner = new ToolRunner(args, runOutOfProcess).start())
+            try
             {
-                runner.waitFor();
-                return runner;
+                if (stdin != null)
+                    stdin.close();
             }
+            catch (IOException e)
+            {
+                logger.warn("Error closing stdin", e);
+            }
+            for (Thread t : ioWatchers)
+                t.join();
+        }
+
+        @Override
+        public void close()
+        {
+            if (!process.isAlive())
+                return;
+            process.destroyForcibly();
+        }
+    }
+
+    public static final class Builder
+    {
+        private final Map<String, String> env = new HashMap<>();
+        private final List<String> args;
+        private InputStream stdin;
+
+        public Builder(List<String> args)
+        {
+            this.args = Objects.requireNonNull(args);
+        }
+
+        public Builder withEnv(String key, String value)
+        {
+            env.put(key, value);
+            return this;
+        }
+
+        public Builder withEnvs(Map<String, String> map)
+        {
+            env.putAll(map);
+            return this;
+        }
+
+        public Builder withStdin(InputStream input)
+        {
+            this.stdin = input;
+            return this;
+        }
+
+        public ObservableTool invokeAsync()
+        {
+            return ToolRunner.invokeAsync(env, stdin, args);
+        }
+
+        public ToolResult invoke()
+        {
+            return ToolRunner.invoke(env, stdin, args);
         }
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/GetVersionTest.java b/test/unit/org/apache/cassandra/tools/ToolsEnvsConfigsTest.java
similarity index 53%
copy from test/unit/org/apache/cassandra/tools/GetVersionTest.java
copy to test/unit/org/apache/cassandra/tools/ToolsEnvsConfigsTest.java
index c5f5282..fd39901 100644
--- a/test/unit/org/apache/cassandra/tools/GetVersionTest.java
+++ b/test/unit/org/apache/cassandra/tools/ToolsEnvsConfigsTest.java
@@ -18,25 +18,27 @@
 
 package org.apache.cassandra.tools;
 
+import java.util.Collections;
+
+import com.google.common.collect.ImmutableMap;
+
 import org.junit.Test;
-import org.junit.runner.RunWith;
 
-import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.cql3.CQLTester;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+
+import static org.junit.Assert.assertTrue;
 
-@RunWith(OrderedJUnit4ClassRunner.class)
-public class GetVersionTest extends OfflineToolUtils
+public class ToolsEnvsConfigsTest
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
+    //Some JDK can output env info on stdout/err. Check we can clean them
+    @SuppressWarnings("resource")
     @Test
-    public void testGetVersion()
+    public void testJDKEnvInfoDefaultCleaners()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.GetVersion").waitAndAssertOnCleanExit();
-        assertNoUnexpectedThreadsStarted(null, null);
-        assertSchemaNotLoaded();
-        assertCLSMNotLoaded();
-        assertSystemKSNotLoaded();
-        assertKeyspaceNotLoaded();
-        assertServerNotLoaded();
+        ToolResult tool = ToolRunner.invoke(ImmutableMap.of("_JAVA_OPTIONS", "-Djava.net.preferIPv4Stack=true"),
+                                            null,
+                                            CQLTester.buildNodetoolArgs(Collections.emptyList()));
+        assertTrue("Cleaned Stderr was not empty: " + tool.getCleanedStderr(), tool.getCleanedStderr().isEmpty());
     }
 }
diff --git a/test/unit/org/apache/cassandra/tools/cassandrastress/CassandrastressTest.java b/test/unit/org/apache/cassandra/tools/cassandrastress/CassandrastressTest.java
new file mode 100644
index 0000000..8ba091a
--- /dev/null
+++ b/test/unit/org/apache/cassandra/tools/cassandrastress/CassandrastressTest.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools.cassandrastress;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import org.apache.cassandra.cql3.CQLTester;
+import org.apache.cassandra.tools.ToolRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.hamcrest.CoreMatchers;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+public class CassandrastressTest extends CQLTester
+{
+    @BeforeClass
+    public static void setUp()
+    {
+        requireNetwork();
+    }
+
+    @Test
+    public void testNoArgsPrintsHelp()
+    {
+        ToolResult tool = ToolRunner.invokeCassandraStress();
+        assertThat(tool.getStdout(), CoreMatchers.containsStringIgnoringCase("usage:"));
+        assertTrue("Tool stderr: " +  tool.getCleanedStderr(), tool.getCleanedStderr().isEmpty());
+        assertEquals(1, tool.getExitCode());
+    }
+    
+}
diff --git a/test/unit/org/apache/cassandra/tools/GetVersionTest.java b/test/unit/org/apache/cassandra/tools/cqlsh/CqlshTest.java
similarity index 53%
copy from test/unit/org/apache/cassandra/tools/GetVersionTest.java
copy to test/unit/org/apache/cassandra/tools/cqlsh/CqlshTest.java
index c5f5282..4e6dd20 100644
--- a/test/unit/org/apache/cassandra/tools/GetVersionTest.java
+++ b/test/unit/org/apache/cassandra/tools/cqlsh/CqlshTest.java
@@ -16,27 +16,32 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.tools;
+package org.apache.cassandra.tools.cqlsh;
 
+import org.junit.BeforeClass;
 import org.junit.Test;
-import org.junit.runner.RunWith;
 
-import org.apache.cassandra.OrderedJUnit4ClassRunner;
+import org.apache.cassandra.cql3.CQLTester;
+import org.apache.cassandra.tools.ToolRunner;
+import org.apache.cassandra.tools.ToolRunner.ToolResult;
+import org.hamcrest.CoreMatchers;
 
-@RunWith(OrderedJUnit4ClassRunner.class)
-public class GetVersionTest extends OfflineToolUtils
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
+
+public class CqlshTest extends CQLTester
 {
-    private ToolRunner.Runners runner = new ToolRunner.Runners();
-    
+    @BeforeClass
+    public static void setUp()
+    {
+        requireNetwork();
+    }
+
     @Test
-    public void testGetVersion()
+    public void testKeyspaceRequired()
     {
-        runner.invokeClassAsTool("org.apache.cassandra.tools.GetVersion").waitAndAssertOnCleanExit();
-        assertNoUnexpectedThreadsStarted(null, null);
-        assertSchemaNotLoaded();
-        assertCLSMNotLoaded();
-        assertSystemKSNotLoaded();
-        assertKeyspaceNotLoaded();
-        assertServerNotLoaded();
+        ToolResult tool = ToolRunner.invokeCqlsh("SELECT * FROM test");
+        assertThat(tool.getCleanedStderr(), CoreMatchers.containsStringIgnoringCase("No keyspace has been specified"));
+        assertEquals(2, tool.getExitCode());
     }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@cassandra.apache.org
For additional commands, e-mail: commits-help@cassandra.apache.org