diff --git a/acinclude.m4 b/acinclude.m4
index a797adc82..8b1888075 100644
--- a/acinclude.m4
+++ b/acinclude.m4
@@ -366,3 +366,15 @@ AC_DEFUN([OVN_CHECK_OVS], [
   AC_SUBST(OVSVERSION)
   AC_MSG_RESULT([OVS version is $OVSVERSION])
 ])
+
+AC_DEFUN([OVN_ENABLE_UNIT_TESTS],
+  [AC_ARG_ENABLE(
+     [unit_tests],
+     [AC_HELP_STRING([--enable-unit-tests], [Enable unit test framework])],
+     [], [enable_unit_tests=no])
+   AC_CONFIG_COMMANDS_PRE(
+     [if test "X$enable_unit_tests" = Xyes; then
+        OVS_CFLAGS="$OVS_CFLAGS -DENABLE_UNIT_TESTS"
+      fi])
+
+    AC_SUBST([enable_unit_tests])])
diff --git a/configure.ac b/configure.ac
index 0b17f05b9..613e2ba3d 100644
--- a/configure.ac
+++ b/configure.ac
@@ -166,6 +166,7 @@ OVS_CONDITIONAL_CC_OPTION([-Wno-unused], [HAVE_WNO_UNUSED])
 OVS_CONDITIONAL_CC_OPTION([-Wno-unused-parameter], [HAVE_WNO_UNUSED_PARAMETER])
 OVS_ENABLE_WERROR
 OVS_ENABLE_SPARSE
+OVN_ENABLE_UNIT_TESTS
 
 OVS_CHECK_PRAGMA_MESSAGE
 OVN_CHECK_OVS
diff --git a/lib/automake.mk b/lib/automake.mk
index f3e9c8818..3e9c2a697 100644
--- a/lib/automake.mk
+++ b/lib/automake.mk
@@ -23,7 +23,9 @@ lib_libovn_la_SOURCES = \
 	lib/ovn-util.h \
 	lib/logical-fields.c \
 	lib/inc-proc-eng.c \
-	lib/inc-proc-eng.h
+	lib/inc-proc-eng.h \
+	lib/unit-test.c \
+	lib/unit-test.h
 nodist_lib_libovn_la_SOURCES = \
 	lib/ovn-dirs.c \
 	lib/ovn-nb-idl.c \
diff --git a/lib/unit-test.c b/lib/unit-test.c
new file mode 100644
index 000000000..3a12727c6
--- /dev/null
+++ b/lib/unit-test.c
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2020 Red Hat, Inc.
+ *
+ * Licensed 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.
+ */
+
+#include <config.h>
+
+#include "openvswitch/shash.h"
+#include "openvswitch/dynamic-string.h"
+#include "util.h"
+#include "unixctl.h"
+#include "command-line.h"
+
+#include "lib/unit-test.h"
+
+#ifdef ENABLE_UNIT_TESTS
+static struct shash unit_tests = SHASH_INITIALIZER(&unit_tests);
+
+struct unit_test_data {
+    size_t min_args;
+    size_t max_args;
+    ovs_cmdl_handler cb;
+};
+
+void
+register_unit_test(const char *name, size_t min_args, size_t max_args,
+                   ovs_cmdl_handler cb)
+{
+    struct unit_test_data *test_data = xmalloc(sizeof *test_data);
+    test_data->min_args = min_args;
+    test_data->max_args = max_args;
+    test_data->cb = cb;
+    shash_add_once(&unit_tests, name, test_data);
+}
+
+void
+run_unit_test(struct ovs_cmdl_context *ctx)
+{
+    struct unit_test_data *test_data = shash_find_data(&unit_tests,
+                                                       ctx->argv[0]);
+    if (!test_data) {
+        struct ds *output = ctx->pvt;
+        ds_put_format(output, "Unable to find unit test %s\n", ctx->argv[0]);
+        return;
+    }
+
+    int test_args = ctx->argc - 1; /* Ignore test name */
+    if (test_args < test_data->min_args ||
+        test_args > test_data->max_args) {
+        struct ds *output = ctx->pvt;
+        ds_put_format(output, "Invalid number of arguments. "
+                              "Minimum: %"PRIuSIZE". Maxium: %"PRIuSIZE". "
+                              "Got: %d\n",
+                              test_data->min_args, test_data->max_args,
+                              test_args);
+    }
+    test_data->cb(ctx);
+}
+
+static void
+unixctl_run_unit_test(struct unixctl_conn *conn, int argc,
+                      const char *argv[], void *ignore OVS_UNUSED)
+{
+    if (argc < 2) {
+        unixctl_command_reply_error(conn, "No unit test specified");
+        return;
+    }
+
+    struct ds reply = DS_EMPTY_INITIALIZER;
+    struct ovs_cmdl_context ctx = {
+        .argc = argc - 1,
+        .argv = (char **) argv + 1,
+        .pvt = &reply,
+    };
+    run_unit_test(&ctx);
+    unixctl_command_reply(conn, ds_cstr(&reply));
+
+    ds_destroy(&reply);
+}
+
+void
+register_unixctl_unit_test(void)
+{
+    unixctl_command_register("unit-test", "", 1, UINT_MAX,
+                             unixctl_run_unit_test, NULL);
+}
+
+#else /* ENABLE_UNIT_TESTS */
+
+void
+register_unit_test(const char *name OVS_UNUSED, size_t min_args OVS_UNUSED,
+                   size_t max_args OVS_UNUSED, ovs_cmdl_handler cb OVS_UNUSED)
+{
+    return;
+}
+
+void
+run_unit_test(struct ovs_cmdl_context *ctx OVS_UNUSED)
+{
+    return;
+}
+
+void
+register_unixctl_unit_test(void)
+{
+    return;
+}
+
+#endif /* ENABLE_UNIT_TESTS */
diff --git a/lib/unit-test.h b/lib/unit-test.h
new file mode 100644
index 000000000..8dc4e2cd7
--- /dev/null
+++ b/lib/unit-test.h
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2020 Red Hat, Inc.
+ *
+ * Licensed 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.
+ */
+
+#ifndef OVN_UNIT_TEST_H
+#define OVN_UNIT_TEST_H 1
+
+#include "openvswitch/compiler.h"
+#include "command-line.h"
+
+void register_unit_test(const char *name, size_t min_args, size_t max_args,
+                        ovs_cmdl_handler cb);
+
+void run_unit_test(struct ovs_cmdl_context *ctx);
+
+void register_unixctl_unit_test(void);
+
+#define UNIT_TEST_DEFINE(NAME, MIN_ARGS, MAX_ARGS, CB)     \
+    OVS_CONSTRUCTOR(unit_test_##NAME) {                    \
+        register_unit_test(#NAME, MIN_ARGS, MAX_ARGS, CB); \
+    }
+#endif
diff --git a/tests/atlocal.in b/tests/atlocal.in
index 26681f02d..ca48ee93d 100644
--- a/tests/atlocal.in
+++ b/tests/atlocal.in
@@ -3,6 +3,7 @@ HAVE_OPENSSL='@HAVE_OPENSSL@'
 OPENSSL_SUPPORTS_SNI='@OPENSSL_SUPPORTS_SNI@'
 HAVE_UNBOUND='@HAVE_UNBOUND@'
 EGREP='@EGREP@'
+ENABLE_UNIT_TESTS='@enable_unit_tests@'
 
 if test x"$PYTHON3" = x; then
     PYTHON3='@PYTHON3@'
