diff --git a/.gitignore b/.gitignore
index 49809446793d..c824e8f8091f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,8 @@ stamp-h1
 
 /mkfs/mkfs.f2fs
 /fsck/fsck.f2fs
+/fsck/dump.f2fs
+/fsck/inject.f2fs
 /tools/fibmap.f2fs
 /tools/parse.f2fs
 /tools/f2fscrypt
@@ -54,3 +56,14 @@ stamp-h1
 # cscope files
 cscope.*
 ncscope.*
+
+# testcase files
+tests/*/log
+tests/*/out
+tests/*/PASS
+tests/*/FAIL
+tests/*/expect
+tests/meta.img
+tests/data.img
+tests/runtests
+tests/test_config
diff --git a/Makefile.am b/Makefile.am
index d2921d626e48..0c9ec66d02b6 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -2,4 +2,4 @@
 
 ACLOCAL_AMFLAGS = -I m4
 
-SUBDIRS = man lib mkfs fsck tools
+SUBDIRS = man lib mkfs fsck tools tests
diff --git a/configure.ac b/configure.ac
index 2053a65bbb36..364dd162e7c0 100644
--- a/configure.ac
+++ b/configure.ac
@@ -263,6 +263,7 @@ AC_CONFIG_FILES([
 	fsck/Makefile
 	tools/Makefile
 	tools/f2fs_io/Makefile
+	tests/Makefile
 ])
 
 AC_CHECK_MEMBER([struct blk_zone.capacity],
diff --git a/tests/Makefile.am b/tests/Makefile.am
new file mode 100644
index 000000000000..670cd373c628
--- /dev/null
+++ b/tests/Makefile.am
@@ -0,0 +1,28 @@
+## Makefile.am
+
+noinst_SCRIPTS = test_config runtests
+CLEANFILES = $(bin_SCRIPTS)
+EXTRA_DIST = test_config.in runtests.in
+
+test_config: $(srcdir)/test_config.in
+	@echo "Creating test_config script..."
+	@[ -f test_config ] && chmod u+w test_config || true
+	@echo "#!/bin/bash" > test_config
+	@echo "" >> test_config
+	@echo "TOPDIR=@top_srcdir@" >> test_config
+	@cat $(srcdir)/test_config.in >> test_config
+	@chmod +x-w test_config
+
+runtests: $(srcdir)/runtests.in
+	@echo "Creating runtests script..."
+	@[ -f runtests ] && chmod u+w runtests || true
+	@echo "#!/bin/bash" > runtests
+	@echo "" >> runtests
+	@echo "SRCDIR=@srcdir@" >> runtests
+	@cat $(srcdir)/runtests.in >> runtests
+	@chmod +x-w runtests
+
+clean-local:
+	@[ -f runtests ] && runtests clean || true
+	@rm -f runtests test_config
+
diff --git a/tests/filter.sed b/tests/filter.sed
new file mode 100644
index 000000000000..ea69c8eb65a4
--- /dev/null
+++ b/tests/filter.sed
@@ -0,0 +1,60 @@
+## f2fs info that could be ignored
+
+/^Info: No Kernel Check/d
+/^Info: not exist \/proc\/version!/d
+/^Info: version timestamp/d
+/be careful to overwrite a mounted loopback file/d
+/^Info: MKFS version/,+1d
+/^Info: FSCK version/,+2d
+/^Info: superblock encrypt level/d
+/^Info: total FS sectors/d
+/^Done: [0-9\.]\+ secs/d
+/^Info: total FS sectors/d
+/^\[FSCK\] Max image size/d
+/^Info: flush_journal_entries/d
+/^Info: Segments per section =/d
+/^Info: Sections per zone =/d
+/^Info: Device\[[0-9]\]/d
+/^Info: CKPT version =/d
+/^Info: superblock features =/d
+/^Info: flush_journal_entries/d
+/^Info: Corrupted valid nat_bits in checkpoint/d
+/^Info: Checked valid nat_bits in checkpoint/d
+
+## fsck messages should be kept, but some numeric is arbitrary and should
+## be removed
+
+# e.g [ASSERT] (is_valid_ssa_data_blk: 340) => [ASSERT] (is_valid_ssa_data_blk: x)
+s/\(\[ASSERT\] ([a-zA-Z_][0-9a-zA-Z_]\+:\s*\)[0-9]\+)/\1x)/g
+# e.g [FIX] (nullify_nat_entry:3274) => [FIX] (nullify_nat_entry:x)
+s/\(\[FIX\] ([a-zA-Z_][0-9a-zA-Z_]\+:\s*\)[0-9]\+)/\1x)/g
+# e.g [fsck_chk_quota_files:2242] => [fsck_chk_quota_files:x]
+s/^\(\[[a-zA-Z_][0-9a-zA-Z_]\+:\s*\)[0-9]\+\]/\1x\]/g
+# e.g Info: Duplicate valid checkpoint to mirror position 270848 -> 270336 =>
+#     Info: Duplicate valid checkpoint to mirror position x -> x
+s/\(Info: Duplicate valid checkpoint to mirror position \)[0-9]\+\( -> \)[0-9]\+/\1x\2x/g
+# e.g Info: checkpoint state = 1c5 :  trimmed nat_bits crc compacted_summary unmount =>
+#     Info: checkpoint state = x :  nat_bits crc compacted_summary unmount
+s/\(Info: checkpoint state = \)[0-9a-f]\+ :/\1x :/g
+s/\(Info: checkpoint state = x :.*\) trimmed\(.*$\)/\1\2/g
+# e.g Info: write_checkpoint() cur_cp:1 => Info: write_checkpoint() cur_cp:x
+s/\(Info: write_checkpoint() cur_cp:\)[0-1]/\1x/g
+# e.g Info: fix_checkpoint() cur_cp:1 => Info: fix_checkpoint() cur_cp:x
+s/\(Info: fix_checkpoint() cur_cp:\)[0-1]/\1x/g
+# e.g [FSCK] Unreachable nat entries                        [Fail] [0x1] =>
+#     [FSCK] Unreachable nat entries                        [Fail] [x]
+# e.g [FSCK] Unreachable nat entries                        [Ok...] [0x0] =>
+#     [FSCK] Unreachable nat entries                        [Ok...] [x] =>
+s/\(\[FSCK\] Unreachable nat entries\s*\[\(Ok\.\.\|Fail\)\] \[\)0x[0-9a-f]\+\]/\1x\]/g
+s/\(\[FSCK\] Hard link checking for regular file\s*\[\(Ok\.\.\|Fail\)\] \[\)0x[0-9a-f]\+\]/\1x\]/g
+# e.g [FSCK] valid_block_count matching with CP             [Ok..] [0x10] =>
+#     [FSCK] valid_block_count matching with CP             [Ok..] [x]
+# e.g [FSCK] valid_block_count matching with CP             [Fail] [0x10, 0x9] =>
+#     [FSCK] valid_block_count matching with CP             [Fail] [x, x]
+s/\(\[FSCK\] valid_block_count matching with CP\s*\[\(Ok\.\.\|Fail\)\] \[\)[0-9a-fx, ]\+\]/\1x\]/g
+s/\(\[FSCK\] valid_node_count matching with CP (de lookup)\s*\[\(Ok\.\.\|Fail\)\] \[\)[0-9a-fx, ]\+\]/\1x\]/g
+s/\(\[FSCK\] valid_node_count matching with CP (nat lookup)\s*\[\(Ok\.\.\|Fail\)\] \[\)[0-9a-fx, ]\+\]/\1x\]/g
+s/\(\[FSCK\] valid_inode_count matched with CP\s*\[\(Ok\.\.\|Fail\)\] \[\)[0-9a-fx, ]\+\]/\1x\]/g
+s/\(\[FSCK\] free segment_count matched with CP\s*\[\(Ok\.\.\|Fail\)\] \[\)[0-9a-fx, ]\+\]/\1x\]/g
+
+## inject info is verifed by fsck result, so it also could be removed
diff --git a/tests/helpers b/tests/helpers
new file mode 100644
index 000000000000..312ecb9c8e99
--- /dev/null
+++ b/tests/helpers
@@ -0,0 +1,157 @@
+#!/bin/sh
+
+# If multiple devices are used, META is the first device and DATA is the
+# second device. If there is only one device, META is used only.
+META=`realpath $TOPDIR/tests/meta.img`
+DATA=`realpath $TOPDIR/tests/data.img`
+OUT=$TESTDIR/out
+EXP=$TESTDIR/expect
+LOG=$TESTDIR/log
+
+# $1: pre: cleanup previous test result
+#     post: remove image files
+cleanup() {
+	if [ x"$1" = x"pre" ]; then
+		rm $TESTDIR/PASS $TESTDIR/FAIL $TESTDIR/expect $OUT $LOG
+	fi
+
+	if [ x"$1" = x"pre" ] || [ x"$1" = x"post" ]; then
+		echo "rm $META $DATA"
+	fi
+}
+
+# check test result
+check_result() {
+	local NAME=`basename $TESTDIR`
+
+	if [ ! -e $EXP ]; then
+		cp "$EXP".in $EXP
+	fi
+	sed -f $FILTER -i $OUT
+	cmp -s $OUT $EXP
+	if [ x"$?" = x"0" ]; then
+		echo "$NAME: $DESC: pass"
+		touch $TESTDIR/PASS
+	else
+		echo "$NAME: $DESC: failed"
+		echo "diff $EXP $OUT"
+		diff $EXP $OUT > $TESTDIR/FAIL
+		exit
+	fi
+}
+
+# $1: path of image file
+# $2: size in MB
+create_img_file() {
+	truncate -s $2"M" $1
+}
+
+make_f2fs() {
+	local metasize=10
+	local datasize=118
+
+	if [ $SEGS_PER_SEC -gt 1 ]; then
+		metasize=$((3 * ($SEGS_PER_SEC * 2)))
+		datasize=$((11* ($SEGS_PER_SEC * 2)))
+		MKFS_OPTS="$MKFS_OPTS -s $SEGS_PER_SEC"
+	fi
+	if [ $MULTIDEV -eq 1 ]; then
+		create_img_file $META $metasize
+		create_img_file $DATA $datasize
+		MKFS_OPTS="$MKFS_OPTS -c $DATA"
+	else
+		create_img_file $META $(($metasize + $datasize))
+	fi
+
+	$MKFS $MKFS_OPTS $META
+	$DUMP -d 1 $META
+
+	return $?
+}
+
+safe_mount() {
+	local dev=""
+
+	# If multiple devices are used, DATA should be associated to a
+	# loop device in advance.
+	# dump/fsck will check the path of the the DATA device, to make
+	# them happy, replace devs[1].path with the loop device before
+	# mount, and restore it after umount.
+	if [ $MULTIDEV -eq 1 ]; then
+		dev=`losetup -f`
+		losetup $dev $DATA
+		if [ $? -ne 0 ]; then
+			echo "cannot setup loop dev: losetup $dev $DATA"
+			return -1
+		fi
+		$INJECT --sb 1 --mb devs.path --idx 1 --str $dev $META
+	fi
+
+	mount -t f2fs $*
+	return $?
+}
+
+#1: mntpoint
+safe_umount() {
+	local max_retry=10
+	local dev=""
+	local i=0
+
+	umount $1
+	while [ $? -ne 0 ]; do
+		i=$(($i + 1))
+		if [ $i -gt $max_retry ]; then
+			return 1
+		fi
+		echo "cannot umount f2fs image, retry: $i"
+		sleep 1
+		umount $1
+	done
+
+	dev=`losetup -j $DATA | cut -d ":" -f 1`
+	if [ x"$dev" != x"" ]; then
+		losetup -d $dev
+		# restore devs[1].path from $dev to $DATA
+		$INJECT --sb 1 --mb devs.path --idx 1 --str $DATA $META
+	fi
+	return 0
+}
+
+# $1: member of sb
+get_sb() {
+	local sb_cp="`$DUMP $DUMP_OPT -d 1 $META`"
+	local cut_here=`echo "$sb_cp" | awk '/^\| Checkpoint/ {print NR}'`
+	local sb="`echo "$sb_cp" | head -n $(($cut_here - 2))`"
+	local val=`echo "$sb" | grep "^$1\s" | sed "s/^$1\s*\[0x\s*[0-9a-f]\+ : \([0-9]*\)\]/\1/g"`
+	echo $val
+}
+
+# $1: member of cp
+get_cp() {
+	local sb_cp="`$DUMP $DUMP_OPT -d 1 $META`"
+	local cut_here=`echo "$sb_cp" | awk '/^\| Checkpoint/ {print NR}'`
+	local cp="`echo "$sb_cp" | tail -n +$(($cut_here - 2))`"
+	local val=`echo "$cp" | grep "^$1\s" | sed "s/^$1\s*\[0x\s*[0-9a-f]\+ : \([0-9]*\)\]/\1/g"`
+	echo $val
+}
+
+# $1: blkaddr
+get_segno() {
+	local main_blkaddr=`get_sb main_blkaddr`
+	local log_blks_per_seg=`get_sb log_blocks_per_seg`
+	echo $((($1 - $main_blkaddr) >> $log_blks_per_seg))
+}
+
+# $1: segno
+start_block() {
+	local main_blkaddr=`get_sb main_blkaddr`
+	local log_blks_per_seg=`get_sb log_blocks_per_seg`
+	echo $(($main_blkaddr + ($1 << $log_blks_per_seg)))
+}
+
+# $1: blkaddr
+offset_in_seg() {
+	local main_blkaddr=`get_sb main_blkaddr`
+	local log_blks_per_seg=`get_sb log_blocks_per_seg`
+	echo $((($1 - $main_blkaddr) % (1 << $log_blks_per_seg)))
+}
diff --git a/tests/runtests.in b/tests/runtests.in
new file mode 100644
index 000000000000..d49634752d42
--- /dev/null
+++ b/tests/runtests.in
@@ -0,0 +1,46 @@
+#!/bin/sh
+#
+# run testcases
+#
+# run all testcases:
+#   runtests
+#
+# run one testcase:
+#   runtests <testcase directory path>
+#
+# cleanup:
+#   runtests clean
+#
+
+# clean temporary files
+if [ x"$1" = x"clean" ]; then
+	find $SRCDIR \( -name FAIL -o -name PASS -o -name expect -o -name log -o -name out -o -name meta.img -o -name data.img \) -exec rm -f {} \;
+	exit
+fi
+
+TEST_CONFIG=$SRCDIR/test_config
+. $TEST_CONFIG
+
+# $1: path of testcase path
+run_one_test() {
+	local subdir=`realpath $1`
+	local name=`basename $subdir`
+	echo "Run testcase: $name ..."
+	TESTDIR=$subdir
+	. $subdir/script
+	echo ""
+}
+
+# run one testcase
+# $1: testcase path
+if [ x"$1" != x"" ]; then
+	run_one_test $1
+	exit
+fi
+
+# run all testcases
+TESTS=`find ./ -type f -name script`
+for testcase in ${TESTS[@]}; do
+	subdir=`dirname $testcase`
+	run_one_test $subdir
+done
diff --git a/tests/test_config.in b/tests/test_config.in
new file mode 100644
index 000000000000..b4249dc26179
--- /dev/null
+++ b/tests/test_config.in
@@ -0,0 +1,47 @@
+#!/bin/sh
+#
+# Test configuration
+#
+
+check_executable() {
+	file $1 | grep ELF > /dev/null
+	[ $? -ne 0 ] && { echo "ERROR: $1 not ELF"; exit; }
+}
+
+check_diff() {
+	diff $1 $2 > /dev/null
+	[ $? -ne 0 ] && { echo "WARNING: $1 and $2 differ"; }
+}
+
+# path of tools
+MKFS="$TOPDIR/mkfs/mkfs.f2fs"
+FSCK="$TOPDIR/fsck/fsck.f2fs"
+DUMP="$TOPDIR/fsck/dump.f2fs"
+INJECT="$TOPDIR/fsck/inject.f2fs"
+F2FS_IO="$TOPDIR/tools/f2fs_io/f2fs_io"
+FILTER="$TOPDIR/tests/filter.sed"
+[ ! -e $MKFS ] && { echo "$MKFS not exist"; exit; }
+[ ! -e $FSCK ] && { echo "$FSCK not exist"; exit; }
+check_executable $FSCK
+[ ! -e $DUMP ] && { cp $FSCK $DUMP; }
+check_executable $DUMP
+check_diff $FSCK $DUMP
+[ ! -e $F2FS_IO ] && { echo "$F2FS_IO not exist"; exit; }
+[ ! -e $INJECT ] && { cp $FSCK $INJECT; }
+check_executable $INJECT
+check_diff $FSCK $INJECT
+
+# config of mkfs
+F2FS_FEATURES="encrypt,extra_attr,compression"
+MKFS_OPTS="-O $F2FS_FEATURES -f"
+MULTIDEV=0
+SEGS_PER_SEC=1
+
+# config of fsck
+FSCK_OPTS="--no-kernel-check -N"
+
+# config of dump
+DUMP_OPTS="-N"
+
+# config of mount
+MNT_OPTS=""
