diff --git a/.gitignore b/.gitignore
index 51429020ceb6..369678a13987 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,7 +25,13 @@ libtool
 
 # Generated by tests
 *.payload.got
-tests/build/tests.log
+test-suite.log
+tests/**/*.log
+tests/**/*.trs
+tests/**/*.valgrind-log
+
+tests/unit/test-libnftables
+tests/unit/test-libnftables-static
 
 # Debian package build temporary files
 build-stamp
diff --git a/Makefile.am b/Makefile.am
index f06fab8e3b9f..074d2076c53d 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -31,12 +31,18 @@ lib_LTLIBRARIES =
 noinst_LTLIBRARIES =
 sbin_PROGRAMS =
 check_PROGRAMS =
+noinst_PROGRAMS =
 check_LTLIBRARIES =
 dist_man_MANS =
 CLEANFILES =
+TESTS =
+test_programs =
 check_local =
 check_more =
 
+TESTS_ENVIRONMENT = VERBOSE="$(V)" NFT_TEST_MAKE=n
+LOG_COMPILER = "$(srcdir)/tools/test-runner.sh" --srcdir "$(abs_srcdir)" --builddir "$(abs_builddir)" --
+
 ###############################################################################
 
 pkginclude_HEADERS = \
@@ -205,11 +211,9 @@ endif
 
 ###############################################################################
 
-lib_LTLIBRARIES += src/libnftables.la
+noinst_LTLIBRARIES += src/libnftables-static.la
 
-src_libnftables_la_SOURCES = \
-	src/libnftables.map \
-	\
+src_libnftables_static_la_SOURCES = \
 	src/cache.c \
 	src/cmd.c \
 	src/ct.c \
@@ -257,20 +261,36 @@ src_libnftables_la_SOURCES = \
 	$(NULL)
 
 if BUILD_JSON
-src_libnftables_la_SOURCES += \
+src_libnftables_static_la_SOURCES += \
 	src/json.c \
 	src/parser_json.c \
 	$(NULL)
 endif
 
+src_libnftables_static_la_LIBADD = \
+	src/libparser.la \
+	$(LIBMINIGMP_LIBS) \
+	$(LIBMNL_LIBS) \
+	$(LIBNFTNL_LIBS) \
+	$(XTABLES_LIBS) \
+	$(JANSSON_LIBS) \
+	$(NULL)
+
+###############################################################################
+
+lib_LTLIBRARIES += src/libnftables.la
+
+src_libnftables_la_SOURCES = \
+	src/libnftables.map \
+	$(NULL)
+
 src_libnftables_la_LDFLAGS = \
 	-version-info "${libnftables_LIBVERSION}" \
 	-Wl,--version-script="$(srcdir)/src//libnftables.map" \
 	$(NULL)
 
 src_libnftables_la_LIBADD = \
-	src/libparser.la \
-	$(LIBMINIGMP_LIBS) \
+	src/libnftables-static.la \
 	$(LIBMNL_LIBS) \
 	$(LIBNFTNL_LIBS) \
 	$(XTABLES_LIBS) \
@@ -303,6 +323,22 @@ examples_nft_json_file_LDADD = src/libnftables.la
 
 ###############################################################################
 
+EXTRA_DIST += tests/unit/nft-test.h
+
+###############################################################################
+
+test_programs += tests/unit/test-libnftables-static
+
+tests_unit_test_libnftables_static_LDADD = src/libnftables-static.la
+
+###############################################################################
+
+test_programs += tests/unit/test-libnftables
+
+tests_unit_test_libnftables_LDADD = src/libnftables.la
+
+###############################################################################
+
 if BUILD_MAN
 
 dist_man_MANS += \
@@ -390,6 +426,16 @@ dist_pkgsysconf_DATA = \
 
 ###############################################################################
 
+EXTRA_DIST += \
+	tests/build \
+	tests/json_echo \
+	tests/monitor \
+	tests/py \
+	tests/shell \
+	$(NULL)
+
+###############################################################################
+
 EXTRA_DIST += \
 	py/pyproject.toml \
 	py/setup.cfg \
@@ -403,7 +449,6 @@ EXTRA_DIST += \
 
 EXTRA_DIST += \
 	files \
-	tests \
 	tools \
 	$(NULL)
 
@@ -412,7 +457,13 @@ pkgconfig_DATA = libnftables.pc
 
 ###############################################################################
 
-build-all: all $(check_PROGRAMS) $(check_LTLIBRARIES)
+check_PROGRAMS += $(test_programs)
+
+TESTS += $(test_programs)
+
+###############################################################################
+
+build-all: all $(check_PROGRAMS) $(test_programs) $(check_LTLIBRARIES)
 
 .PHONY: build-all
 
diff --git a/tests/unit/nft-test.h b/tests/unit/nft-test.h
new file mode 100644
index 000000000000..cab97b42c669
--- /dev/null
+++ b/tests/unit/nft-test.h
@@ -0,0 +1,14 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+
+#ifndef __NFT_TEST__H__
+#define __NFT_TEST__H__
+
+#undef NDEBUG
+
+#include <nft.h>
+
+#include <assert.h>
+#include <stdio.h>
+#include <string.h>
+
+#endif /* __NFT_TEST__H__ */
diff --git a/tests/unit/test-libnftables-static.c b/tests/unit/test-libnftables-static.c
new file mode 100644
index 000000000000..e34fcfd77f39
--- /dev/null
+++ b/tests/unit/test-libnftables-static.c
@@ -0,0 +1,16 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+
+#include "nft-test.h"
+
+#include <datatype.h>
+
+static void test_datatype(void)
+{
+	assert(!datatype_lookup(-1));
+}
+
+int main(int argc, char **argv)
+{
+	test_datatype();
+	return 0;
+}
diff --git a/tests/unit/test-libnftables.c b/tests/unit/test-libnftables.c
new file mode 100644
index 000000000000..100558cd5e0f
--- /dev/null
+++ b/tests/unit/test-libnftables.c
@@ -0,0 +1,21 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+
+#include "nft-test.h"
+
+#include <nftables/libnftables.h>
+
+static void test_nft_ctx(void)
+{
+	struct nft_ctx *ctx;
+
+	ctx = nft_ctx_new(0);
+	assert(ctx);
+
+	nft_ctx_free(ctx);
+}
+
+int main(int argc, char **argv)
+{
+	test_nft_ctx();
+	return 0;
+}
diff --git a/tools/test-runner.sh b/tools/test-runner.sh
new file mode 100755
index 000000000000..18658ba4adf1
--- /dev/null
+++ b/tools/test-runner.sh
@@ -0,0 +1,228 @@
+#!/bin/bash
+
+set -e
+
+die() {
+	printf '%s\n' "$*"
+	exit 1
+}
+
+as_bool() {
+	if [ "$#" -eq 0 ] ; then
+		return 1
+	fi
+	case "$1" in
+		y|Y|yes|Yes|YES|1|true|True|TRUE)
+			return 0
+			;;
+		n|N|no|No|NO|0|false|False|FALSE)
+			return 1
+			;;
+		*)
+			# Fallback to the next in the list.
+			shift
+			rc=0
+			as_bool "$@" || rc=$?
+			return "$rc"
+	esac
+}
+
+as_bool_str() {
+	if as_bool "$@" ; then
+		printf y
+	else
+		printf n
+	fi
+}
+
+usage() {
+	echo "  $0 [OPTIONS] TEST"
+	echo
+	echo "Run TEST"
+	echo
+	echo "Options:"
+	echo "  --srcdir dir: Sets SRCDIR=dir."
+	echo "  --builddir dir: Sets BUILDDIR=dir."
+	echo "  -V|--valgrind: Sets VALGRIND=y."
+	echo "  -G|--gdb: Sets NFT_TEST_WRAPPER=gdb."
+	echo "  -m|--make: Sets NFT_TEST_MAKE=y."
+	echo "  --: Separates options from test name."
+	echo "  TEST: the path of the test executable to run."
+	echo
+	echo "Environment variables:"
+	echo "  SRCDIR: set to \$(srcdir) of the project."
+	echo "  BUILDDIR: set to \$(builddir) of the project."
+	echo "  VERBOSE: for verbose output."
+	echo "  VALGRIND: if set to TRUE, run the test under valgrind. NFT_TEST_UNDER_VALGRIND is ignored."
+	echo "  NFT_TEST_UNSHARE_CMD: override the command to unshare the netns."
+	echo "      Set to empty to not unshare."
+	echo "  NFT_TEST_WRAPPER: usually empty. For manual testing, this is prepended to TEST."
+	echo "      Set for example got \"gdb\"."
+	echo "  NFT_TEST_MAKE: if true, call \`make\` on the test first."
+}
+
+usage_and_die() {
+	usage
+	echo
+	die "$@"
+}
+
+TIMESTAMP=$(date '+%Y%m%d-%H%M%S.%3N')
+
+# SRCDIR
+# BUILDDIR
+VERBOSE="$(as_bool_str "$VERBOSE")"
+VALGRIND="$(as_bool_str "$VALGRIND")"
+NFT_TEST_UNSHARE_CMD="${NFT_TEST_UNSHARE_CMD-unshare -m -U --map-root-user -n}"
+NFT_TEST_WRAPPER="${NFT_TEST_WRAPPER}"
+NFT_TEST_MAKE="$(as_bool_str "$NFT_TEST_MAKE")"
+
+unset TEST
+
+while [ $# -gt 0 ] ; do
+	A="$1"
+	shift
+	case "$A" in
+		-h|--help)
+			usage
+			exit 0
+			;;
+		--srcdir)
+			SRCDIR="$1"
+			shift
+			;;
+		--builddir)
+			BUILDDIR="$1"
+			shift
+			;;
+		-V|--valgrind)
+			VALGRIND=y
+			;;
+		-G|--gdb)
+			NFT_TEST_WRAPPER=gdb
+			;;
+		-m|--make)
+			NFT_TEST_MAKE=y
+			;;
+		--)
+			if [ $# -ne 1 ] ; then
+				usage_and_die "Requires a TEST argument after --"
+			fi
+			TEST="$1"
+			shift
+			;;
+		*)
+			if [ -n "${TEST+x}" ] ; then
+				usage_and_die "Unknown argument \"$A\""
+			fi
+			TEST="$A"
+			;;
+	esac
+done
+
+if [ -z "$TEST" ] ; then
+	usage_and_die "Missing test argument. See --help"
+fi
+
+if [ -z "${SRCDIR+x}" ] ; then
+	SRCDIR="$(readlink -f "$(dirname "$0")/..")"
+fi
+if [ ! -d "$SRCDIR" ] ; then
+	die "Invalid \$SRCDIR=\"$SRCDIR\""
+fi
+
+if [ -z "${BUILDDIR+x}" ] ; then
+	re='^(.*/|)(tests/unit/test-[^/]*)$'
+	if [[ "$TEST" =~ $re ]] ; then
+		BUILDDIR="$(readlink -f "${BASH_REMATCH[1]:-.}")" || :
+	else
+		BUILDDIR="$SRCDIR"
+	fi
+fi
+if [ ! -d "$BUILDDIR" ] ; then
+	die "Invalid \$BUILDDIR=\"$BUILDDIR\""
+fi
+
+export TESTDIR="$(dirname "$TEST")"
+export SRCDIR
+export BUILDDIR
+
+if [ "$VALGRIND" = y ] ; then
+	export NFT_TEST_UNDER_VALGRIND=1
+fi
+
+run_unit() {
+	local TEST="$1"
+
+	if [ "$NFT_TEST_MAKE" = y ] ; then
+		local d="$(readlink -f "$BUILDDIR")"
+		local tf="$(readlink -f "$TEST")"
+		local t="${tf#$d/}"
+
+		if [ "$tf" != "$d/$t" ] ; then
+			die "Cannot detect paths for making \"$TEST\" in \"$BUILDDIR\" (\"$d\" and \"$t\")"
+		fi
+		# Don't use "make -C" to avoid the extra "Entering/Leaving directory" messages
+		(cd "$d" && make "$t" ) || die "Making \"$TEST\" failed"
+	fi
+
+	if [ "$VALGRIND" != y ] ; then
+		rc_test=0
+		$NFT_TEST_UNSHARE_CMD \
+			libtool \
+			--mode=execute \
+			$NFT_TEST_WRAPPER \
+			"$TEST" || rc_test=$?
+		if [ "$rc_test" -ne 0 -a "$rc_test" -ne 77 ] ; then
+			die "exec \"$TEST\" failed with exit code $rc_test"
+		fi
+		exit "$rc_test"
+	fi
+
+	LOGFILE="$TEST.valgrind-log"
+
+	rc_test=0
+	$NFT_TEST_UNSHARE_CMD \
+		libtool \
+		--mode=execute \
+		valgrind \
+		--quiet \
+		--error-exitcode=122 \
+		--leak-check=full \
+		--gen-suppressions=all \
+		--num-callers=100 \
+		--log-file="$LOGFILE" \
+		--vgdb-prefix="${TMP:-/tmp}/vgdb-pipe-nft-test-runner-$TIMESTAMP-$$" \
+		$NFT_TEST_VALGRIND_OPTS \
+		"$TEST" \
+		|| rc_test=$?
+
+	if [ -s "$LOGFILE" ] ; then
+		if [ "$rc_test" -eq 0 -o "$rc_test" -eq 122 ] ; then
+			echo "valgrind failed. Logfile at \"$LOGFILE\" :"
+			cat "$LOGFILE"
+			rc_test=122
+		else
+			echo "valgrind also failed. Check logfile at \"$LOGFILE\""
+		fi
+	elif [ ! -f "$LOGFILE" ] ; then
+		echo "valgrind logfile \"$LOGFILE\" missing"
+		if [ "$rc_test" -eq 0 ] ; then
+			rc_test=122
+		fi
+	else
+		rm -rf "$LOGFILE"
+	fi
+
+	exit "$rc_test"
+}
+
+case "$TEST" in
+	tests/unit/test-* | \
+	*/tests/unit/test-* )
+		run_unit "$TEST"
+		;;
+	*)
+		die "Unrecognized test \"$TEST\""
+		;;
+esac
