diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 0c5a05fae37e84a2b2d1e5a0b155637f6e380863..8484b182bbabcb3afc69b250360d0aefe26a60d1 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -14,7 +14,7 @@ variables:
   image:
     name: alpine
   script:
-    - apk add -u meson $ALPINE_DEPS $ALPINE_STATIC_DEPS zstd-dev zstd-static
+    - apk add -u meson $ALPINE_DEPS $ALPINE_JOB_DEPS $ALPINE_STATIC_DEPS zstd-dev zstd-static
     - meson setup --auto-features=enabled build
     - ninja -C build
     - meson setup build-static -Dc_link_args=-static -Ddefault_library=static -Dprefer_static=true
@@ -26,6 +26,8 @@ variables:
 
 test:alpine:x86_64:
   extends: .test:alpine
+  variables:
+    ALPINE_JOB_DEPS: "shellcheck"
   tags:
     - docker-alpine
     - x86_64
diff --git a/test/alpine/test1.sh b/test/alpine/test1.sh
index 12de8a85032da1d69bd703b5c7f5b9afead794b0..ee91cf184b57367881f47ab7f2ffe54559f0439a 100755
--- a/test/alpine/test1.sh
+++ b/test/alpine/test1.sh
@@ -2,14 +2,14 @@
 
 # desc: test if basic add/del/upgrade works
 
-$APK add --root $ROOT --initdb --repository $PWD/repo1 test-a
+$APK add --root "$ROOT" --initdb --repository "$PWD/repo1" test-a
 
-test "$($ROOT/usr/bin/test-a)" = "hello from test-a-1.0"
+test "$("$ROOT"/usr/bin/test-a)" = "hello from test-a-1.0"
 
-$APK upgrade --root $ROOT --repository $PWD/repo2
+$APK upgrade --root "$ROOT" --repository "$PWD/repo2"
 
-test "$($ROOT/usr/bin/test-a)" = "hello from test-a-1.1"
+test "$("$ROOT"/usr/bin/test-a)" = "hello from test-a-1.1"
 
-$APK del --root $ROOT test-a
+$APK del --root "$ROOT" test-a
 
 [ -x "$ROOT/usr/bin/test-a" ] || true
diff --git a/test/alpine/test2.sh b/test/alpine/test2.sh
index 851119bac0dbccd5c8d983d0e62ed5604d22ce08..943acd7566ba619d4f7766cf9967da6017eb22a9 100755
--- a/test/alpine/test2.sh
+++ b/test/alpine/test2.sh
@@ -3,19 +3,19 @@
 # desc: test if dependencies works
 
 # test-b depends on test-a
-$APK add --root $ROOT --initdb --repository $PWD/repo1 test-b
+$APK add --root "$ROOT" --initdb --repository "$PWD/repo1" test-b
 
 # check if test-a was installed
-test "$($ROOT/usr/bin/test-a)" = "hello from test-a-1.0"
+test "$("$ROOT"/usr/bin/test-a)" = "hello from test-a-1.0"
 
 # run an upgrade
-$APK upgrade --root $ROOT --repository $PWD/repo2
+$APK upgrade --root "$ROOT" --repository "$PWD/repo2"
 
 # test if test-a was upgraded
-test "$($ROOT/usr/bin/test-a)" = "hello from test-a-1.1"
+test "$("$ROOT"/usr/bin/test-a)" = "hello from test-a-1.1"
 
 # remove test-b
-$APK del --root $ROOT test-b
+$APK del --root "$ROOT" test-b
 
 # test if the dependency was removed too
 if [ -x "$ROOT/usr/bin/test-a" ]; then
diff --git a/test/alpine/test3.sh b/test/alpine/test3.sh
index 421d576dca52c000752c054f9d3380ad99dbbf7d..6b7fb2b4be6e0896537da10d03616bf31830695f 100755
--- a/test/alpine/test3.sh
+++ b/test/alpine/test3.sh
@@ -2,11 +2,11 @@
 
 # desc: test successful pre-install
 
-$APK add --root $ROOT --initdb --repository $PWD/repo1 --repository $SYSREPO \
+$APK add --root "$ROOT" --initdb --repository "$PWD/repo1" --repository "$SYSREPO" \
 	-U test-c
 
 # check that package was installed
-$APK info --root $ROOT -e test-c
+$APK info --root "$ROOT" -e test-c
 
 # check if pre-install was executed
-test -f $ROOT/pre-install
+test -f "$ROOT"/pre-install
diff --git a/test/alpine/test4.sh b/test/alpine/test4.sh
index 5a1c8ff81e6b847f8e9f23195d1f9ed3f1d4c0d4..491578f4ac75dddea3c02e2ca00dfc15c4a5707f 100755
--- a/test/alpine/test4.sh
+++ b/test/alpine/test4.sh
@@ -6,12 +6,12 @@
 mkdir -p "$ROOT"
 touch "$ROOT"/should-fail
 
-! $APK add --root $ROOT --initdb --repository $PWD/repo1 --repository $SYSREPO \
-	-U test-c
+$APK add --root "$ROOT" --initdb --repository "$PWD/repo1" --repository "$SYSREPO" \
+	-U test-c && exit 1
 
 # check that pre-install was executed
-test -f $ROOT/pre-install
+test -f "$ROOT"/pre-install
 
 # check that package was installed
-$APK info --root $ROOT -e test-c
+$APK info --root "$ROOT" -e test-c
 
diff --git a/test/alpine/test5.sh b/test/alpine/test5.sh
index ea3dd534b38930f8d3c885341051afddd12b2fa9..a777c64eb9bbb1b091a913524e5df412213c95c7 100755
--- a/test/alpine/test5.sh
+++ b/test/alpine/test5.sh
@@ -2,8 +2,8 @@
 
 # desc: test post-install script
 
-$APK add --root $ROOT --initdb -U --repository $PWD/repo1 \
-	--repository $SYSREPO test-d
+$APK add --root "$ROOT" --initdb -U --repository "$PWD/repo1" \
+	--repository "$SYSREPO" test-d
 
 test -f "$ROOT"/post-install
 
diff --git a/test/alpine/test6.sh b/test/alpine/test6.sh
index 1914ce871ee65a99851dcd90929a16eab4c8b8cc..9fe687bea6339fea259e8c1e4db529016935adb9 100755
--- a/test/alpine/test6.sh
+++ b/test/alpine/test6.sh
@@ -2,8 +2,8 @@
 
 # desc: test triggers in kernel package
 
-$APK add --root $ROOT --initdb -U --repository $PWD/repo1 \
-	--repository $SYSREPO alpine-keys alpine-baselayout linux-lts linux-firmware-none
+$APK add --root "$ROOT" --initdb -U --repository "$PWD/repo1" \
+	--repository "$SYSREPO" alpine-keys alpine-baselayout linux-lts linux-firmware-none
 
 test -e "$ROOT"/boot/vmlinuz-lts
 
diff --git a/test/alpine/test7.sh b/test/alpine/test7.sh
index e3e31131ceb6852bf13cc5fc09991ba8c8252a9a..8e2c3368b5f7fdb7b36edfe936077a7911a5ed25 100755
--- a/test/alpine/test7.sh
+++ b/test/alpine/test7.sh
@@ -4,15 +4,16 @@
 
 # we had a bug that caused apk fix --reinstall to segfault every second time
 
-$APK add --root $ROOT --initdb -U --repository $PWD/repo1 \
-	--repository $SYSREPO busybox
+$APK add --root "$ROOT" --initdb -U --repository "$PWD/repo1" \
+	--repository "$SYSREPO" busybox
 
+# shellcheck disable=SC2034 # i is unused
 for i in 0 1 2 3; do
 	# delete wget symlink
 	rm -f "$ROOT"/usr/bin/wget
 
 	# re-install so we run the trigger again
-	$APK fix --root $ROOT --repository $SYSREPO --reinstall  busybox
+	$APK fix --root "$ROOT" --repository "$SYSREPO" --reinstall  busybox
 
 	# verify wget symlink is there
 	test -L "$ROOT"/usr/bin/wget
diff --git a/test/alpine/test8.sh b/test/alpine/test8.sh
index 8a1f0da17e66df5505054dc4680ab2206cf3e423..ff7a08a2e424bec6a9ab557c7e7b6eadb3d2665e 100755
--- a/test/alpine/test8.sh
+++ b/test/alpine/test8.sh
@@ -2,6 +2,6 @@
 
 # desc: test if upgrade works when package is missing in repo
 
-$APK add --root $ROOT --initdb --repository $PWD/repo1 test-a
+$APK add --root "$ROOT" --initdb --repository "$PWD/repo1" test-a
 
-$APK upgrade --root $ROOT 
+$APK upgrade --root "$ROOT"
diff --git a/test/enum.sh b/test/enum.sh
index 5f8feebab498087e3cc3cfeed0049e2bc294b4dc..e5682c27839d74dc58275fb377ecda0c5d3d2c3b 100755
--- a/test/enum.sh
+++ b/test/enum.sh
@@ -1,5 +1,7 @@
 #!/bin/sh
 
+set -e
+
 cd "$(dirname "$0")"
 case "$1" in
 solver)
diff --git a/test/meson.build b/test/meson.build
index 617643db7e1b965dd6ca05cec935422bd1c6d01a..7f6857c9802da6cfe421548909db41d0ec73546a 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -2,19 +2,26 @@ subdir('unit')
 
 enum_sh = find_program('enum.sh', required: get_option('tests'))
 solver_sh = find_program('solver.sh', required: get_option('tests'))
-
-if not enum_sh.found() or not solver_sh.found()
-	subdir_done()
-endif
+shellcheck_sh = find_program('shellcheck.sh', required: get_option('tests'))
+shellcheck = find_program('shellcheck', required: false)
 
 cur_dir = meson.current_source_dir()
 env = environment()
+env.set('SRCDIR', cur_dir)
 env.set('APK', apk_exe.full_path())
 
-foreach t : run_command(enum_sh, 'shell', check: true).stdout().strip().split(' ')
-	test(t, files(cur_dir / t), suite: 'shell', depends: apk_exe, env: env, priority: 100)
-endforeach
+if shellcheck_sh.found() and shellcheck.found()
+	foreach shell : [ 'bash', 'dash', 'busybox' ]
+		test(shell, shellcheck_sh, suite: 'shellcheck', args: [ shell ], env: env, priority: 1000)
+	endforeach
+endif
 
-foreach t : run_command(enum_sh, 'solver', check: true).stdout().strip().split(' ')
-	test(t, solver_sh, suite: 'solver', args: [ cur_dir / t ], depends: apk_exe, env: env, priority: 10)
-endforeach
+if enum_sh.found() and solver_sh.found()
+	foreach t : run_command(enum_sh, 'shell', check: true).stdout().strip().split(' ')
+		test(t, files(cur_dir / t), suite: 'shell', depends: apk_exe, env: env, priority: 100)
+	endforeach
+
+	foreach t : run_command(enum_sh, 'solver', check: true).stdout().strip().split(' ')
+		test(t, solver_sh, suite: 'solver', args: [ cur_dir / t ], depends: apk_exe, env: env, priority: 10)
+	endforeach
+endif
diff --git a/test/shellcheck.sh b/test/shellcheck.sh
new file mode 100644
index 0000000000000000000000000000000000000000..bd7285fe040e5f27e58249d0153bad09a0a937af
--- /dev/null
+++ b/test/shellcheck.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+
+SHELL="${1:-bash}"
+
+err=0
+for path in . user alpine; do
+	# SC2001 "See if you can use ${variable//search/replace} instead" on bash conflicts with dash
+	(cd "${SRCDIR:-.}/$path"; shellcheck -x -e SC2001 -s "$SHELL" -- *.sh) || err=1
+done
+exit $err
diff --git a/test/solver.sh b/test/solver.sh
index dc1206c8aa15bbf114a9346a8e9b1315a5377a14..36db9e74b6a3668485c57d6b4afb59471a27bd79 100755
--- a/test/solver.sh
+++ b/test/solver.sh
@@ -15,15 +15,17 @@ update_repo() {
 
 run_test() {
 	local test="$1"
-	local testfile="$(realpath -e "$test")"
-	local testdir="$(dirname "$testfile")"
+	local testfile testdir
+
+	testfile="$(realpath -e "$test")"
+	testdir="$(dirname "$testfile")"
 
 	setup_apkroot
 	mkdir -p "$TEST_ROOT/data/src"
 
 	local args="" repo run_found
 	exec 4> /dev/null
-	while IFS="" read ln; do
+	while IFS="" read -r ln; do
 		case "$ln" in
 		"@ARGS "*)
 			args="$args ${ln#* }"
@@ -69,6 +71,7 @@ run_test() {
 
 	retcode=1
 	if [ "$run_found" = "yes" ]; then
+		# shellcheck disable=SC2086 # $args needs to be word splitted
 		$APK --allow-untrusted --simulate $args > "$TEST_ROOT/data/output" 2>&1
 
 		if ! cmp "$TEST_ROOT/data/output" "$TEST_ROOT/data/expected" > /dev/null 2>&1; then
diff --git a/test/testlib.sh b/test/testlib.sh
index 3bb24ae369c9f302b35ef030d319b1255e9040ce..8558d5e540cb6cd7f498ba5cc48a103c44994491 100644
--- a/test/testlib.sh
+++ b/test/testlib.sh
@@ -1,5 +1,7 @@
 #!/bin/sh
 
+# shellcheck disable=SC2034 # various variables are not used always
+
 set -e
 
 assert() {
@@ -8,12 +10,14 @@ assert() {
 }
 
 glob_one() {
-	for a in $@; do echo "$a"; done
+	# shellcheck disable=SC2048 # argument is wildcard needing expansion
+	for a in $*; do echo "$a"; done
 }
 
 setup_tmp() {
 	TMPDIR=$(mktemp -d -p /tmp apktest.XXXXXXXX)
 	[ -d "$TMPDIR" ] || return 1
+	# shellcheck disable=SC2064 # expand TMPDIR here
 	trap "rm -rf -- '$TMPDIR'" EXIT
 	cd "$TMPDIR"
 }
@@ -25,6 +29,7 @@ setup_apkroot() {
 	TEST_ROOT=$(mktemp -d -p /tmp apktest.XXXXXXXX)
 	[ -d "$TEST_ROOT" ] || return 1
 
+	# shellcheck disable=SC2064 # expand TMPDIR here
 	trap "rm -rf -- '$TEST_ROOT'" EXIT
 	APK="$APK --root $TEST_ROOT"
 
diff --git a/test/user/cache-clean.sh b/test/user/cache-clean.sh
index 84fe48ab96ba6ce1241d7b8d1a4582679a3df8d7..2b09b24189ac1a0dcb7c5fad51205202bd831dc1 100755
--- a/test/user/cache-clean.sh
+++ b/test/user/cache-clean.sh
@@ -1,6 +1,6 @@
 #!/bin/sh
 
-. $(dirname "$0")/../testlib.sh
+. "$(dirname "$0")"/../testlib.sh
 
 setup_apkroot
 APK="$APK --allow-untrusted --no-interactive"
@@ -23,8 +23,8 @@ CACHED_C=$(echo "$CACHED_B" | sed 's,test-b,test-c,')
 [ -f "$CACHED_B2" ] && assert "cached test-b not preset"
 [ -f "$CACHED_C" ] && assert "cached test-c preset"
 
-touch $CACHED_C $CACHED_B2
-dd if=/dev/zero of=$CACHED_B bs=1024 count=1 > /dev/null 2>&1
+touch "$CACHED_C" "$CACHED_B2"
+dd if=/dev/zero of="$CACHED_B" bs=1024 count=1 > /dev/null 2>&1
 
 $APK cache clean -vv
 
diff --git a/test/user/fetch.sh b/test/user/fetch.sh
index eed4396daef99c8ec53129a86e82542c4d82466d..2d48731dd75bf2b7442136bf2dd554ebea4c38b7 100755
--- a/test/user/fetch.sh
+++ b/test/user/fetch.sh
@@ -36,6 +36,7 @@ assert_downloaded meta-1.0.apk
 $APK fetch --recursive meta
 assert_downloaded meta-1.0.apk hello-1.0.apk
 
+# shellcheck disable=SC2016 # no expansion for pkgname-spec
 $APK fetch --pkgname-spec '${name}_${version}_${arch}.pkg' --recursive meta
 assert_downloaded meta_1.0_noarch.pkg hello_1.0_noarch.pkg
 
diff --git a/test/user/hardlink.sh b/test/user/hardlink.sh
index a81162450634a29de9cae3b47158a870b2898ce0..ed3ab4f9ee416955739e2b24515b5c62499c97e1 100755
--- a/test/user/hardlink.sh
+++ b/test/user/hardlink.sh
@@ -27,11 +27,11 @@ ln files/b/zzz files/b/bbb
 $APK mkpkg -I name:hardlink -I version:1.0 -F files -o hardlink-1.0.apk
 $APK add --initdb $TEST_USERMODE hardlink-1.0.apk
 
-cd $TEST_ROOT
+cd "$TEST_ROOT"
 A_INODE="$(dev_inode a/aaa)"
 B_INODE="$(dev_inode b/aaa)"
 [ "$A_INODE" != "$B_INODE" ] || assert "a != b"
-[ "$(dev_inode a/bbb)" = $A_INODE ] || assert "a/bbb"
-[ "$(dev_inode a/zzz)" = $A_INODE ] || assert "a/zzz"
-[ "$(dev_inode b/bbb)" = $B_INODE ] || assert "b/bbb"
-[ "$(dev_inode b/zzz)" = $B_INODE ] || assert "b/zzz"
+[ "$(dev_inode a/bbb)" = "$A_INODE" ] || assert "a/bbb"
+[ "$(dev_inode a/zzz)" = "$A_INODE" ] || assert "a/zzz"
+[ "$(dev_inode b/bbb)" = "$B_INODE" ] || assert "b/bbb"
+[ "$(dev_inode b/zzz)" = "$B_INODE" ] || assert "b/zzz"
diff --git a/test/user/mkndx.sh b/test/user/mkndx.sh
index 3383d2bddc73223bd6dd43f6bc10fe55dd72fac6..7f8ffabf76536bf91c37205c850015c21aa01258 100755
--- a/test/user/mkndx.sh
+++ b/test/user/mkndx.sh
@@ -1,6 +1,8 @@
 #!/bin/sh
 
-. $(dirname "$0")/../testlib.sh
+# shellcheck disable=SC2016 # no expansion for pkgname-spec
+
+. "$(dirname "$0")"/../testlib.sh
 
 setup_apkroot
 APK="$APK --allow-untrusted --no-interactive"
@@ -25,7 +27,7 @@ https://test/test-b-1.0.apk
 EOF
 
 $APK mkndx --pkgname-spec '${name:3}/${name}-${version}.apk' -o index.adb test-a-1.0.apk test-b-1.0.apk
-$APK fetch --url --simulate --from none --repository file://localhost/$PWD/index.adb --pkgname-spec '${name}_${version}.pkg' test-a test-b > fetch.log 2>&1
+$APK fetch --url --simulate --from none --repository "file://localhost/$PWD/index.adb" --pkgname-spec '${name}_${version}.pkg' test-a test-b > fetch.log 2>&1
 diff -u fetch.log - <<EOF || assert "wrong fetch result"
 file://localhost/$PWD/tes/test-a-1.0.apk
 file://localhost/$PWD/tes/test-b-1.0.apk