changeset 531:53dc4349ace9 onclient

Extract OnClientTester infrastructure into separate class So OnClientTester itself only contains tests.
author Joeri van Ruth <joeri.van.ruth@monetdbsolutions.com>
date Fri, 27 Aug 2021 11:40:24 +0200 (2021-08-27)
parents bf47aab3aeb7
children 41a28ec7d1c1
files tests/OnClientTester.java tests/TestRunner.java
diffstat 2 files changed, 478 insertions(+), 462 deletions(-) [+]
line wrap: on
line diff
--- a/tests/OnClientTester.java
+++ b/tests/OnClientTester.java
@@ -3,24 +3,14 @@ import org.monetdb.jdbc.MonetDownloadHan
 import org.monetdb.jdbc.MonetUploadHandler;
 
 import java.io.*;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
 import java.nio.charset.StandardCharsets;
-import java.sql.*;
+import java.sql.SQLException;
 
-public final class OnClientTester {
-	public static final int VERBOSITY_NONE = 0;
-	public static final int VERBOSITY_ON = 1;
-	public static final int VERBOSITY_SHOW_ALL = 2;
-	private String jdbcUrl;
-	int verbosity = VERBOSITY_NONE;
-	int testCount = 0;
-	int failureCount = 0;
-	private MonetConnection conn;
-	private PrintWriter out;
-	private Statement stmt;
-	private StringWriter outBuffer;
-	private final WatchDog watchDog;
+public final class OnClientTester extends TestRunner {
+
+	public OnClientTester(String jdbcUrl, int verbosity, boolean watchDogEnabled) {
+		super(jdbcUrl, verbosity, watchDogEnabled);
+	}
 
 	public static void main(String[] args) throws SQLException, NoSuchMethodException {
 		String jdbcUrl = null;
@@ -46,200 +36,10 @@ public final class OnClientTester {
 		}
 
 		OnClientTester tester = new OnClientTester(jdbcUrl, verbosity, watchDogEnabled);
-		tester.runTests(requiredPrefix);
-
-		if (tester.verbosity >= VERBOSITY_ON || tester.failureCount > 0) {
-			System.out.println();
-			System.out.println("Ran " + tester.testCount + " tests, " + tester.failureCount + " failed");
-		}
-		if (tester.failureCount > 0) {
-			System.exit(1);
-		}
-	}
-
-	public OnClientTester(String jdbcUrl, int verbosity, boolean watchDogEnabled) {
-		this.jdbcUrl = jdbcUrl;
-		this.verbosity = verbosity;
-		watchDog = new WatchDog();
-		if (watchDogEnabled)
-			watchDog.enable();
-		else
-			watchDog.disable();
-	}
-
-	private void runTests(String testPrefix) throws SQLException, NoSuchMethodException {
-		watchDog.stop();
-		try {
-			String initialPrefix = "test_";
-			String methodPrefix = testPrefix == null ? initialPrefix : initialPrefix + testPrefix;
-
-			for (Method method : this.getClass().getDeclaredMethods()) {
-				String methodName = method.getName();
-				if (methodName.startsWith(methodPrefix) && method.getParameterCount() == 0) {
-					String testName = methodName.substring(initialPrefix.length());
-					runTest(testName, method);
-				}
-			}
-		} finally {
-			watchDog.stop();
-		}
-	}
-
-	private synchronized void runTest(String testName, Method method) throws SQLException {
-		watchDog.setContext("test " + testName);
-		watchDog.setDuration(3_000);
-		outBuffer = new StringWriter();
-		out = new PrintWriter(outBuffer);
-
-		Connection genericConnection = DriverManager.getConnection(jdbcUrl);
-		conn = genericConnection.unwrap(MonetConnection.class);
-		stmt = conn.createStatement();
-
-		boolean failed = false;
-		try {
-			long duration;
-			try {
-				long t0 = System.currentTimeMillis();
-				method.invoke(this);
-				long t1 = System.currentTimeMillis();
-				duration = t1 - t0;
-			} catch (InvocationTargetException e) {
-				Throwable cause = e.getCause();
-				if (cause instanceof  Failure)
-					throw (Failure)cause;
-				else if (cause instanceof Exception) {
-					throw (Exception)cause;
-				} else {
-					throw e;
-				}
-			}
+		int failures = tester.runTests(requiredPrefix);
 
-			if (verbosity > VERBOSITY_ON)
-				System.out.println();
-			if (verbosity >= VERBOSITY_ON)
-				System.out.println("Test " + testName + " succeeded in " + duration + "ms");
-			if (verbosity >= VERBOSITY_SHOW_ALL)
-				dumpOutput(testName);
-		} catch (Failure e) {
-			failed = true;
-			System.out.println();
-			System.out.println("Test " + testName + " failed");
-			dumpOutput(testName);
-		} catch (Exception e) {
-			failed = true;
-			System.out.println();
-			System.out.println("Test " + testName + " failed:");
-			e.printStackTrace(System.out);
-			dumpOutput(testName);
-			// Show the inner bits of the exception again, they may have scrolled off screen
-			Throwable t = e;
-			while (t.getCause() != null) {
-				t = t.getCause();
-			}
-			System.out.println("Innermost cause was " + t);
-			if (t.getStackTrace().length > 0) {
-				System.out.println("                 at " + t.getStackTrace()[0]);
-			}
-		} finally {
-			watchDog.setContext(null);
-			testCount++;
-			if (failed)
-				failureCount++;
-			if (failed && verbosity == VERBOSITY_ON) {
-				// next test case will not print separator
-				System.out.println();
-			}
-			stmt.close();
-			conn.close();
-		}
-	}
-
-	private void dumpOutput(String testName) {
-		String output = outBuffer.getBuffer().toString();
-		if (output.isEmpty()) {
-			System.out.println("(Test did not produce any output)");
-		} else {
-			System.out.println("------ Accumulated output for test " + testName + ":");
-			boolean terminated = output.endsWith(System.lineSeparator());
-			if (terminated) {
-				System.out.print(output);
-			} else {
-				System.out.println(output);
-			}
-			System.out.println("------ End of accumulated output" + (terminated ? "" : " (no trailing newline)"));
-		}
-	}
-
-	private void fail(String message) throws Failure {
-		out.println("FAILURE: " + message);
-		throw new Failure(message);
-	}
-
-	private void checked(String quantity, Object actual) {
-		out.println("  CHECKED: " + "<" + quantity + "> is " + actual + " as expected");
-	}
-
-	private void assertEq(String quantity, Object expected, Object actual) throws Failure {
-		if (expected.equals(actual)) {
-			checked(quantity, actual);
-		} else {
-			fail("Expected <" + quantity + "' to be " + expected + "> got " + actual);
-		}
-	}
-
-	protected boolean execute(String query) throws SQLException {
-		try {
-			watchDog.start();
-			out.println("EXECUTE: " + query);
-			boolean result;
-			result = stmt.execute(query);
-			if (result) {
-				out.println("  OK");
-			} else {
-				out.println("  OK, updated " + stmt.getUpdateCount() + " rows");
-			}
-			return result;
-		} finally {
-			watchDog.stop();
-		}
-	}
-
-	protected void update(String query, int expectedUpdateCount) throws SQLException, Failure {
-		execute(query);
-		int updateCount = stmt.getUpdateCount();
-		assertEq("Update count", expectedUpdateCount, updateCount);
-	}
-
-	protected void expectError(String query, String expectedError) throws SQLException, Failure {
-		try {
-			execute(query);
-		} catch (SQLException e) {
-			if (e.getMessage().contains(expectedError)) {
-				out.println("  GOT EXPECTED EXCEPTION: " + e.getMessage());
-			} else {
-				throw e;
-			}
-		}
-	}
-
-	protected void queryInt(String query, int expected) throws SQLException, Failure {
-		if (execute(query) == false) {
-			fail("Query does not return a result set");
-		}
-		ResultSet rs = stmt.getResultSet();
-		ResultSetMetaData metaData = rs.getMetaData();
-		assertEq("column count", 1, metaData.getColumnCount());
-		if (!rs.next()) {
-			fail("Result set is empty");
-		}
-		int result = rs.getInt(1);
-		if (rs.next()) {
-			String message = "Result set has more than one row";
-			fail(message);
-		}
-		rs.close();
-		checked("row count", 1);
-		assertEq("query result", expected, result);
+		if (failures > 0)
+			System.exit(1);
 	}
 
 	protected void prepare() throws SQLException {
@@ -247,6 +47,157 @@ public final class OnClientTester {
 		execute("CREATE TABLE foo (i INT, t TEXT)");
 	}
 
+	public void test_Upload() throws Exception {
+		prepare();
+		conn.setUploadHandler(new MyUploadHandler(100));
+		update("COPY INTO foo FROM 'banana' ON CLIENT", 100);
+		queryInt("SELECT COUNT(*) FROM foo", 100);
+	}
+
+	public void test_ClientRefusesUpload() throws Exception {
+		prepare();
+		conn.setUploadHandler(new MyUploadHandler("immediate error"));
+		expectError("COPY INTO foo FROM 'banana' ON CLIENT", "immediate error");
+		queryInt("SELECT COUNT(*) FROM foo", 0);
+	}
+
+	public void test_Offset0() throws SQLException, Failure {
+		prepare();
+		conn.setUploadHandler(new MyUploadHandler(100));
+		update("COPY OFFSET 0 INTO foo FROM 'banana' ON CLIENT", 100);
+		queryInt("SELECT MIN(i) FROM foo", 1);
+		queryInt("SELECT MAX(i) FROM foo", 100);
+	}
+
+	public void test_Offset1() throws SQLException, Failure {
+		prepare();
+		conn.setUploadHandler(new MyUploadHandler(100));
+		update("COPY OFFSET 1 INTO foo FROM 'banana' ON CLIENT", 100);
+		queryInt("SELECT MIN(i) FROM foo", 1);
+		queryInt("SELECT MAX(i) FROM foo", 100);
+	}
+
+	public void test_Offset5() throws SQLException, Failure {
+		prepare();
+		conn.setUploadHandler(new MyUploadHandler(100));
+		update("COPY OFFSET 5 INTO foo FROM 'banana' ON CLIENT", 96);
+		queryInt("SELECT MIN(i) FROM foo", 5);
+		queryInt("SELECT MAX(i) FROM foo", 100);
+	}
+
+	public void test_ServerStopsReading() throws SQLException, Failure {
+		prepare();
+		conn.setUploadHandler(new MyUploadHandler(100));
+		update("COPY 10 RECORDS INTO foo FROM 'banana' ON CLIENT", 96);
+		// Server stopped reading after 10 rows. Will we stay in sync?
+		queryInt("SELECT COUNT(i) FROM foo", 10);
+	}
+
+	public void test_Download(int n) throws SQLException, Failure {
+		prepare();
+		MyDownloadHandler handler = new MyDownloadHandler();
+		conn.setDownloadHandler(handler);
+		String q = "INSERT INTO foo SELECT value as i, 'number' || value AS t FROM sys.generate_series(0, " + n + ")";
+		update(q, n);
+		update("COPY (SELECT * FROM foo) INTO 'banana' ON CLIENT", -1);
+		assertEq("download attempts", 1, handler.countAttempts());
+		assertEq("lines downloaded", n, handler.lineCount());
+	}
+
+	public void test_Download() throws SQLException, Failure {
+		test_Download(100);
+	}
+
+	public void test_ClientRefusesDownload() throws SQLException, Failure {
+		prepare();
+		MyDownloadHandler handler = new MyDownloadHandler("download refused");
+		conn.setDownloadHandler(handler);
+		update("INSERT INTO foo SELECT value as i, 'number' || value AS t FROM sys.generate_series(0, 100)", 100);
+		expectError("COPY (SELECT * FROM foo) INTO 'banana' ON CLIENT", "download refused");
+		queryInt("SELECT 42 -- check if the connection still works", 42);
+	}
+
+	public void test_LargeUpload() throws SQLException, Failure {
+		watchDog.setDuration(25_000);
+		prepare();
+		int n = 4_000_000;
+		MyUploadHandler handler = new MyUploadHandler(n);
+		conn.setUploadHandler(handler);
+		handler.setChunkSize(1024 * 1024);
+		update("COPY INTO foo FROM 'banana' ON CLIENT", n);
+		queryInt("SELECT COUNT(DISTINCT i) FROM foo", n);
+	}
+
+	public void test_LargeDownload() throws SQLException, Failure {
+		watchDog.setDuration(25_000);
+		test_Download(4_000_000);
+	}
+
+	public void test_UploadFromStream() throws SQLException, Failure {
+		prepare();
+		MonetUploadHandler handler = new MonetUploadHandler() {
+			final String data = "1|one\n2|two\n3|three\n";
+
+			@Override
+			public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, int offset) throws IOException {
+				ByteArrayInputStream s = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
+				handle.uploadFrom(s);
+			}
+		};
+		conn.setUploadHandler(handler);
+		update("COPY INTO foo FROM 'banana' ON CLIENT", 3);
+		queryInt("SELECT i FROM foo WHERE t = 'three'", 3);
+	}
+
+	public void test_UploadFromReader() throws SQLException, Failure {
+		prepare();
+		MonetUploadHandler handler = new MonetUploadHandler() {
+			final String data = "1|one\n2|two\n3|three\n";
+
+			@Override
+			public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, int offset) throws IOException {
+				StringReader r = new StringReader(data);
+				handle.uploadFrom(r);
+			}
+		};
+		conn.setUploadHandler(handler);
+		update("COPY INTO foo FROM 'banana' ON CLIENT", 3);
+		queryInt("SELECT i FROM foo WHERE t = 'three'", 3);
+	}
+
+	public void test_UploadFromReaderOffset() throws SQLException, Failure {
+		prepare();
+		MonetUploadHandler handler = new MonetUploadHandler() {
+			final String data = "1|one\n2|two\n3|three\n";
+
+			@Override
+			public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, int offset) throws IOException {
+				BufferedReader r = new BufferedReader(new StringReader(data));
+				handle.uploadFrom(r, offset);
+			}
+		};
+		conn.setUploadHandler(handler);
+		update("COPY OFFSET 2 INTO foo FROM 'banana' ON CLIENT", 2);
+		queryInt("SELECT i FROM foo WHERE t = 'three'", 3);
+	}
+
+	public void test_FailUploadLate() throws SQLException, Failure {
+		prepare();
+		conn.setUploadHandler(new MyUploadHandler(100, 50, "i don't like line 50"));
+		expectError("COPY INTO foo FROM 'banana' ON CLIENT", "i don't like");
+		assertEq("connection is closed", true, conn.isClosed());
+	}
+
+	// Disabled because it hangs, triggering the watchdog timer
+	public void testx_FailDownloadLate() throws SQLException, Failure {
+		prepare();
+		MyDownloadHandler handler = new MyDownloadHandler(200, "download refused");
+		conn.setDownloadHandler(handler);
+		update("INSERT INTO foo SELECT value as i, 'number' || value AS t FROM sys.generate_series(0, 100)", 100);
+		expectError("COPY (SELECT * FROM foo) INTO 'banana' ON CLIENT", "download refused");
+		queryInt("SELECT 42 -- check if the connection still works", 42);
+	}
+
 	static class MyUploadHandler implements MonetUploadHandler {
 		private final int rows;
 		private final int errorAt;
@@ -350,6 +301,7 @@ public final class OnClientTester {
 		public int countAttempts() {
 			return attempts;
 		}
+
 		public int countBytes() {
 			return bytesSeen;
 		}
@@ -362,257 +314,4 @@ public final class OnClientTester {
 		}
 	}
 
-
-	static class Failure extends Exception {
-
-		public Failure(String message) {
-			super(message);
-		}
-		public Failure(String message, Throwable cause) {
-			super(message, cause);
-		}
-
-	}
-
-	static class WatchDog {
-		private boolean enabled;
-		private long duration = 1000;
-		private long started = 0;
-		private String context = "no context";
-
-		WatchDog() {
-			Thread watchDog = new Thread(this::work);
-			watchDog.setName("watchdog_timer");
-			watchDog.setDaemon(true);
-			watchDog.start();
-		}
-
-		synchronized void enable() {
-			this.enabled = true;
-			this.notifyAll();
-		}
-
-		synchronized void disable() {
-			this.enabled = false;
-			this.notifyAll();
-		}
-
-		synchronized void setContext(String context) {
-			this.context = context;
-		}
-		synchronized void setDuration(long duration) {
-			if (duration <= 0)
-				throw new IllegalArgumentException("duration should be > 0");
-			this.duration = duration;
-			this.notifyAll();
-		}
-
-		synchronized void start() {
-			started = System.currentTimeMillis();
-			this.notifyAll();
-		}
-
-		synchronized void stop() {
-			started = 0;
-			this.notifyAll();
-		}
-
-		synchronized void kill() {
-			started = -1;
-			this.notifyAll();
-		}
-
-		private synchronized void work() {
-			long now;
-			try {
-				while (true) {
-					now = System.currentTimeMillis();
-					final long sleepTime;
-					if (started < 0) {
-						// client asked us to go away
-						// System.err.println("++ EXIT");
-						return;
-					} else if (!enabled || started == 0) {
-						// wait for client to enable/start us
-						sleepTime = 600_000;
-					} else {
-						long deadline = started + duration;
-						sleepTime = deadline - now;
-					}
-					// System.err.printf("++ now=%d, started=now%+d, duration=%d, sleep=%d%n",
-					// 		now, started - now, duration, sleepTime
-					// 		);
-					if (sleepTime > 0) {
-						this.wait(sleepTime);
-					} else {
-						trigger();
-						return;
-					}
-				}
-			} catch (InterruptedException e) {
-				System.err.println("WATCHDOG TIMER INTERRUPTED, SHOULDN'T HAPPEN");
-				System.exit(4);
-			}
-		}
-
-		private void trigger() {
-			String c = context != null ? context : "no context";
-			System.err.println();
-			System.err.println();
-			System.err.println("WATCHDOG TIMER EXPIRED [" + c + "], KILLING TESTS");
-			System.exit(3);
-		}
-	}
-
-	public void test_Upload() throws Exception {
-		prepare();
-		conn.setUploadHandler(new MyUploadHandler(100));
-		update("COPY INTO foo FROM 'banana' ON CLIENT", 100);
-		queryInt("SELECT COUNT(*) FROM foo", 100);
-	}
-
-	public void test_ClientRefusesUpload() throws Exception {
-		prepare();
-		conn.setUploadHandler(new MyUploadHandler("immediate error"));
-		expectError("COPY INTO foo FROM 'banana' ON CLIENT", "immediate error");
-		queryInt("SELECT COUNT(*) FROM foo", 0);
-	}
-
-	public void test_Offset0() throws SQLException, Failure {
-		prepare();
-		conn.setUploadHandler(new MyUploadHandler(100));
-		update("COPY OFFSET 0 INTO foo FROM 'banana' ON CLIENT", 100);
-		queryInt("SELECT MIN(i) FROM foo", 1);
-		queryInt("SELECT MAX(i) FROM foo", 100);
-	}
-
-	public void test_Offset1() throws SQLException, Failure {
-		prepare();
-		conn.setUploadHandler(new MyUploadHandler(100));
-		update("COPY OFFSET 1 INTO foo FROM 'banana' ON CLIENT", 100);
-		queryInt("SELECT MIN(i) FROM foo", 1);
-		queryInt("SELECT MAX(i) FROM foo", 100);
-	}
-
-	public void test_Offset5() throws SQLException, Failure {
-		prepare();
-		conn.setUploadHandler(new MyUploadHandler(100));
-		update("COPY OFFSET 5 INTO foo FROM 'banana' ON CLIENT", 96);
-		queryInt("SELECT MIN(i) FROM foo", 5);
-		queryInt("SELECT MAX(i) FROM foo", 100);
-	}
-
-	public void test_ServerStopsReading() throws SQLException, Failure {
-		prepare();
-		conn.setUploadHandler(new MyUploadHandler(100));
-		update("COPY 10 RECORDS INTO foo FROM 'banana' ON CLIENT", 96);
-		// Server stopped reading after 10 rows. Will we stay in sync?
-		queryInt("SELECT COUNT(i) FROM foo", 10);
-	}
-
-	public void test_Download(int n) throws SQLException, Failure {
-		prepare();
-		MyDownloadHandler handler = new MyDownloadHandler();
-		conn.setDownloadHandler(handler);
-		String q = "INSERT INTO foo SELECT value as i, 'number' || value AS t FROM sys.generate_series(0, " + n + ")";
-		update(q, n);
-		update("COPY (SELECT * FROM foo) INTO 'banana' ON CLIENT", -1);
-		assertEq("download attempts", 1, handler.countAttempts());
-		assertEq("lines downloaded", n, handler.lineCount());
-	}
-
-	public void test_Download() throws SQLException, Failure {
-		test_Download(100);
-	}
-
-	public void test_ClientRefusesDownload() throws SQLException, Failure {
-		prepare();
-		MyDownloadHandler handler = new MyDownloadHandler("download refused");
-		conn.setDownloadHandler(handler);
-		update("INSERT INTO foo SELECT value as i, 'number' || value AS t FROM sys.generate_series(0, 100)", 100);
-		expectError("COPY (SELECT * FROM foo) INTO 'banana' ON CLIENT", "download refused");
-		queryInt("SELECT 42 -- check if the connection still works", 42);
-	}
-
-	public void test_LargeUpload() throws SQLException, Failure {
-		watchDog.setDuration(25_000);
-		prepare();
-		int n = 4_000_000;
-		MyUploadHandler handler = new MyUploadHandler(n);
-		conn.setUploadHandler(handler);
-		handler.setChunkSize(1024 * 1024);
-		update("COPY INTO foo FROM 'banana' ON CLIENT", n);
-		queryInt("SELECT COUNT(DISTINCT i) FROM foo", n);
-	}
-
-	public void test_LargeDownload() throws SQLException, Failure {
-		watchDog.setDuration(25_000);
-		test_Download(4_000_000);
-	}
-
-	public void test_UploadFromStream() throws SQLException, Failure {
-		prepare();
-		MonetUploadHandler handler = new MonetUploadHandler() {
-			String data = "1|one\n2|two\n3|three\n";
-
-			@Override
-			public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, int offset) throws IOException {
-				ByteArrayInputStream s = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
-				handle.uploadFrom(s);
-			}
-		};
-		conn.setUploadHandler(handler);
-		update("COPY INTO foo FROM 'banana' ON CLIENT", 3);
-		queryInt("SELECT i FROM foo WHERE t = 'three'", 3);
-	}
-
-	public void test_UploadFromReader() throws SQLException, Failure {
-		prepare();
-		MonetUploadHandler handler = new MonetUploadHandler() {
-			String data = "1|one\n2|two\n3|three\n";
-
-			@Override
-			public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, int offset) throws IOException {
-				StringReader r = new StringReader(data);
-				handle.uploadFrom(r);
-			}
-		};
-		conn.setUploadHandler(handler);
-		update("COPY INTO foo FROM 'banana' ON CLIENT", 3);
-		queryInt("SELECT i FROM foo WHERE t = 'three'", 3);
-	}
-
-	public void test_UploadFromReaderOffset() throws SQLException, Failure {
-		prepare();
-		MonetUploadHandler handler = new MonetUploadHandler() {
-			String data = "1|one\n2|two\n3|three\n";
-
-			@Override
-			public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, int offset) throws IOException {
-				BufferedReader r = new BufferedReader(new StringReader(data));
-				handle.uploadFrom(r, offset);
-			}
-		};
-		conn.setUploadHandler(handler);
-		update("COPY OFFSET 2 INTO foo FROM 'banana' ON CLIENT", 2);
-		queryInt("SELECT i FROM foo WHERE t = 'three'", 3);
-	}
-
-	public void test_FailUploadLate() throws SQLException, Failure {
-		prepare();
-		conn.setUploadHandler(new MyUploadHandler(100, 50, "i don't like line 50"));
-		expectError("COPY INTO foo FROM 'banana' ON CLIENT", "i don't like");
-		assertEq("connection is closed", true, conn.isClosed());
-	}
-
-	// Disabled because it hangs, triggering the watchdog timer
-	public void testx_FailDownloadLate() throws SQLException, Failure {
-		prepare();
-		MyDownloadHandler handler = new MyDownloadHandler(200, "download refused");
-		conn.setDownloadHandler(handler);
-		update("INSERT INTO foo SELECT value as i, 'number' || value AS t FROM sys.generate_series(0, 100)", 100);
-		expectError("COPY (SELECT * FROM foo) INTO 'banana' ON CLIENT", "download refused");
-		queryInt("SELECT 42 -- check if the connection still works", 42);
-	}
-
 }
new file mode 100644
--- /dev/null
+++ b/tests/TestRunner.java
@@ -0,0 +1,317 @@
+import org.monetdb.jdbc.MonetConnection;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.sql.*;
+
+public class TestRunner {
+	public static final int VERBOSITY_NONE = 0;
+	public static final int VERBOSITY_ON = 1;
+	public static final int VERBOSITY_SHOW_ALL = 2;
+	protected final String jdbcUrl;
+	private final int verbosity;
+	protected final WatchDog watchDog;
+	protected MonetConnection conn;
+	private Statement stmt;
+	private StringWriter outBuffer;
+	private PrintWriter out;
+	int testCount = 0;
+	int failureCount = 0;
+
+	public TestRunner(String jdbcUrl, int verbosity, boolean watchDogEnabled) {
+		this.jdbcUrl = jdbcUrl;
+		this.verbosity = verbosity;
+		watchDog = new WatchDog();
+		if (watchDogEnabled)
+			watchDog.enable();
+		else
+			watchDog.disable();
+	}
+
+	protected int runTests(String testPrefix) throws SQLException, NoSuchMethodException {
+		watchDog.stop();
+		try {
+			String initialPrefix = "test_";
+			String methodPrefix = testPrefix == null ? initialPrefix : initialPrefix + testPrefix;
+
+			for (Method method : this.getClass().getDeclaredMethods()) {
+				String methodName = method.getName();
+				if (methodName.startsWith(methodPrefix) && method.getParameterCount() == 0) {
+					String testName = methodName.substring(initialPrefix.length());
+					runTest(testName, method);
+				}
+			}
+		} finally {
+			watchDog.stop();
+		}
+
+		if (verbosity >= VERBOSITY_ON || failureCount > 0) {
+			System.out.println();
+			System.out.println("Ran " + testCount + " tests, " + failureCount + " failed");
+		}
+
+		return failureCount;
+	}
+
+	private synchronized void runTest(String testName, Method method) throws SQLException {
+		watchDog.setContext("test " + testName);
+		watchDog.setDuration(3_000);
+		outBuffer = new StringWriter();
+		out = new PrintWriter(outBuffer);
+
+		Connection genericConnection = DriverManager.getConnection(jdbcUrl);
+		conn = genericConnection.unwrap(MonetConnection.class);
+		stmt = conn.createStatement();
+
+		boolean failed = false;
+		try {
+			long duration;
+			try {
+				long t0 = System.currentTimeMillis();
+				method.invoke(this);
+				long t1 = System.currentTimeMillis();
+				duration = t1 - t0;
+			} catch (InvocationTargetException e) {
+				Throwable cause = e.getCause();
+				if (cause instanceof Failure)
+					throw (Failure) cause;
+				else if (cause instanceof Exception) {
+					throw (Exception) cause;
+				} else {
+					throw e;
+				}
+			}
+
+			if (verbosity > VERBOSITY_ON)
+				System.out.println();
+			if (verbosity >= VERBOSITY_ON)
+				System.out.println("Test " + testName + " succeeded in " + duration + "ms");
+			if (verbosity >= VERBOSITY_SHOW_ALL)
+				dumpOutput(testName);
+		} catch (Failure e) {
+			failed = true;
+			System.out.println();
+			System.out.println("Test " + testName + " failed");
+			dumpOutput(testName);
+		} catch (Exception e) {
+			failed = true;
+			System.out.println();
+			System.out.println("Test " + testName + " failed:");
+			e.printStackTrace(System.out);
+			dumpOutput(testName);
+			// Show the inner bits of the exception again, they may have scrolled off screen
+			Throwable t = e;
+			while (t.getCause() != null) {
+				t = t.getCause();
+			}
+			System.out.println("Innermost cause was " + t);
+			if (t.getStackTrace().length > 0) {
+				System.out.println("                 at " + t.getStackTrace()[0]);
+			}
+		} finally {
+			watchDog.setContext(null);
+			testCount++;
+			if (failed)
+				failureCount++;
+			if (failed && verbosity == VERBOSITY_ON) {
+				// next test case will not print separator
+				System.out.println();
+			}
+			stmt.close();
+			conn.close();
+		}
+	}
+
+	private void dumpOutput(String testName) {
+		String output = outBuffer.getBuffer().toString();
+		if (output.isEmpty()) {
+			System.out.println("(Test did not produce any output)");
+		} else {
+			System.out.println("------ Accumulated output for test " + testName + ":");
+			boolean terminated = output.endsWith(System.lineSeparator());
+			if (terminated) {
+				System.out.print(output);
+			} else {
+				System.out.println(output);
+			}
+			System.out.println("------ End of accumulated output" + (terminated ? "" : " (no trailing newline)"));
+		}
+	}
+
+	private void fail(String message) throws Failure {
+		out.println("FAILURE: " + message);
+		throw new Failure(message);
+	}
+
+	private void checked(String quantity, Object actual) {
+		out.println("  CHECKED: " + "<" + quantity + "> is " + actual + " as expected");
+	}
+
+	protected void assertEq(String quantity, Object expected, Object actual) throws Failure {
+		if (expected.equals(actual)) {
+			checked(quantity, actual);
+		} else {
+			fail("Expected <" + quantity + "' to be " + expected + "> got " + actual);
+		}
+	}
+
+	protected boolean execute(String query) throws SQLException {
+		try {
+			watchDog.start();
+			out.println("EXECUTE: " + query);
+			boolean result;
+			result = stmt.execute(query);
+			if (result) {
+				out.println("  OK");
+			} else {
+				out.println("  OK, updated " + stmt.getUpdateCount() + " rows");
+			}
+			return result;
+		} finally {
+			watchDog.stop();
+		}
+	}
+
+	protected void update(String query, int expectedUpdateCount) throws SQLException, Failure {
+		execute(query);
+		int updateCount = stmt.getUpdateCount();
+		assertEq("Update count", expectedUpdateCount, updateCount);
+	}
+
+	protected void expectError(String query, String expectedError) throws SQLException, Failure {
+		try {
+			execute(query);
+		} catch (SQLException e) {
+			if (e.getMessage().contains(expectedError)) {
+				out.println("  GOT EXPECTED EXCEPTION: " + e.getMessage());
+			} else {
+				throw e;
+			}
+		}
+	}
+
+	protected void queryInt(String query, int expected) throws SQLException, Failure {
+		if (execute(query) == false) {
+			fail("Query does not return a result set");
+		}
+		ResultSet rs = stmt.getResultSet();
+		ResultSetMetaData metaData = rs.getMetaData();
+		assertEq("column count", 1, metaData.getColumnCount());
+		if (!rs.next()) {
+			fail("Result set is empty");
+		}
+		int result = rs.getInt(1);
+		if (rs.next()) {
+			String message = "Result set has more than one row";
+			fail(message);
+		}
+		rs.close();
+		checked("row count", 1);
+		assertEq("query result", expected, result);
+	}
+
+	static class Failure extends Exception {
+
+		public Failure(String message) {
+			super(message);
+		}
+
+		public Failure(String message, Throwable cause) {
+			super(message, cause);
+		}
+
+	}
+
+	static class WatchDog {
+		private boolean enabled;
+		private long duration = 1000;
+		private long started = 0;
+		private String context = "no context";
+
+		WatchDog() {
+			Thread watchDog = new Thread(this::work);
+			watchDog.setName("watchdog_timer");
+			watchDog.setDaemon(true);
+			watchDog.start();
+		}
+
+		synchronized void enable() {
+			this.enabled = true;
+			this.notifyAll();
+		}
+
+		synchronized void disable() {
+			this.enabled = false;
+			this.notifyAll();
+		}
+
+		synchronized void setContext(String context) {
+			this.context = context;
+		}
+
+		synchronized void setDuration(long duration) {
+			if (duration <= 0)
+				throw new IllegalArgumentException("duration should be > 0");
+			this.duration = duration;
+			this.notifyAll();
+		}
+
+		synchronized void start() {
+			started = System.currentTimeMillis();
+			this.notifyAll();
+		}
+
+		synchronized void stop() {
+			started = 0;
+			this.notifyAll();
+		}
+
+		synchronized void kill() {
+			started = -1;
+			this.notifyAll();
+		}
+
+		private synchronized void work() {
+			long now;
+			try {
+				while (true) {
+					now = System.currentTimeMillis();
+					final long sleepTime;
+					if (started < 0) {
+						// client asked us to go away
+						// System.err.println("++ EXIT");
+						return;
+					} else if (!enabled || started == 0) {
+						// wait for client to enable/start us
+						sleepTime = 600_000;
+					} else {
+						long deadline = started + duration;
+						sleepTime = deadline - now;
+					}
+					// System.err.printf("++ now=%d, started=now%+d, duration=%d, sleep=%d%n",
+					// 		now, started - now, duration, sleepTime
+					// 		);
+					if (sleepTime > 0) {
+						this.wait(sleepTime);
+					} else {
+						trigger();
+						return;
+					}
+				}
+			} catch (InterruptedException e) {
+				System.err.println("WATCHDOG TIMER INTERRUPTED, SHOULDN'T HAPPEN");
+				System.exit(4);
+			}
+		}
+
+		private void trigger() {
+			String c = context != null ? context : "no context";
+			System.err.println();
+			System.err.println();
+			System.err.println("WATCHDOG TIMER EXPIRED [" + c + "], KILLING TESTS");
+			System.exit(3);
+		}
+	}
+}