diff --git a/tests/qtest/meson.build b/tests/qtest/meson.build
index 3136d15e0f..305b662620 100644
--- a/tests/qtest/meson.build
+++ b/tests/qtest/meson.build
@@ -347,6 +347,7 @@ migration_files = [files(
   'migration/misc-tests.c',
   'migration/precopy-tests.c',
   'migration/postcopy-tests.c',
+  'migration/savevm-tests.c',
 )]
 
 migration_tls_files = []
diff --git a/tests/qtest/migration-test.c b/tests/qtest/migration-test.c
index 0893687174..b15f6d091b 100644
--- a/tests/qtest/migration-test.c
+++ b/tests/qtest/migration-test.c
@@ -55,6 +55,7 @@ int main(int argc, char **argv)
     migration_test_add_precopy(env);
     migration_test_add_cpr(env);
     migration_test_add_misc(env);
+    migration_test_add_savevm(env);
 
     ret = g_test_run();
 
diff --git a/tests/qtest/migration/framework.c b/tests/qtest/migration/framework.c
index 2311100dd6..42bda03693 100644
--- a/tests/qtest/migration/framework.c
+++ b/tests/qtest/migration/framework.c
@@ -28,8 +28,6 @@
 
 
 #define QEMU_VM_FILE_MAGIC 0x5145564d
-#define QEMU_ENV_SRC "QTEST_QEMU_BINARY_SRC"
-#define QEMU_ENV_DST "QTEST_QEMU_BINARY_DST"
 
 unsigned start_address;
 unsigned end_address;
@@ -207,7 +205,7 @@ static QList *migrate_start_get_qmp_capabilities(const MigrateStart *args)
     return capabilities;
 }
 
-static char *migrate_resolve_alias(const char *arch)
+char *migrate_resolve_alias(const char *arch)
 {
     const char *machine_alias;
 
diff --git a/tests/qtest/migration/framework.h b/tests/qtest/migration/framework.h
index e4a11870f6..66823267af 100644
--- a/tests/qtest/migration/framework.h
+++ b/tests/qtest/migration/framework.h
@@ -17,6 +17,9 @@
 #define FILE_TEST_OFFSET 0x1000
 #define FILE_TEST_MARKER 'X'
 
+#define QEMU_ENV_SRC "QTEST_QEMU_BINARY_SRC"
+#define QEMU_ENV_DST "QTEST_QEMU_BINARY_DST"
+
 typedef struct MigrationTestEnv {
     bool has_kvm;
     bool has_tcg;
@@ -225,6 +228,7 @@ void test_file_common(MigrateCommon *args, bool stop_src);
 void *migrate_hook_start_precopy_tcp_multifd_common(QTestState *from,
                                                     QTestState *to,
                                                     const char *method);
+char *migrate_resolve_alias(const char *arch);
 
 typedef struct QTestMigrationState QTestMigrationState;
 QTestMigrationState *get_src(void);
@@ -240,5 +244,6 @@ void migration_test_add_file(MigrationTestEnv *env);
 void migration_test_add_precopy(MigrationTestEnv *env);
 void migration_test_add_cpr(MigrationTestEnv *env);
 void migration_test_add_misc(MigrationTestEnv *env);
+void migration_test_add_savevm(MigrationTestEnv *env);
 
 #endif /* TEST_FRAMEWORK_H */
diff --git a/tests/qtest/migration/savevm-tests.c b/tests/qtest/migration/savevm-tests.c
new file mode 100644
index 0000000000..5904c4b07e
--- /dev/null
+++ b/tests/qtest/migration/savevm-tests.c
@@ -0,0 +1,144 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+
+#include "qemu/osdep.h"
+#include "libqtest.h"
+#include "migration/framework.h"
+#include "migration/migration-qmp.h"
+#include "migration/migration-util.h"
+#include "qapi/qapi-types-migration.h"
+
+char *disk_path;
+
+static char *savevm_make_cmdline(void)
+{
+    MigrationTestEnv *env = migration_get_env();
+    g_autofree char *drive_opts = NULL;
+    g_autofree char *arch_opts = NULL;
+    g_autofree char *machine_opts = NULL;
+    g_autofree char *machine = NULL;
+
+    disk_path = g_strdup_printf("%s/qtest-savevm-%d.qcow2", g_get_tmp_dir(),
+                                getpid());
+    drive_opts = g_strdup_printf("-drive if=none,file=%s,format=qcow2,node-name=disk0 ",
+                                disk_path);
+
+    g_assert(mkimg(disk_path, "qcow2", 100));
+
+    machine = migrate_resolve_alias(env->arch);
+
+    if (machine) {
+        machine_opts = g_strdup_printf("-machine %s", machine);
+    }
+
+    return g_strdup_printf("%s %s %s",
+                           drive_opts,
+                           arch_opts ?: "",
+                           machine_opts ?: "");
+}
+
+static void teardown_savevm_test(void)
+{
+    unlink(disk_path);
+    g_free(disk_path);
+}
+
+/*
+ * Enabling capabilities before savevm/loadvm should either apply the
+ * appropriate feature or reject the command. Crashing or ignoring the
+ * capability is not acceptable. Most (all?) migration capabilities
+ * are incompatible with snapshots, but they've historically not been
+ * rejected. Since there are compatibility concerns with simply
+ * rejecting all caps, for now this test only validates that nothing
+ * crashes.
+ */
+static void test_savevm_caps(void)
+{
+    MigrationTestEnv *env = migration_get_env();
+    g_autofree char *cmdline = savevm_make_cmdline();
+    QTestState *vm;
+
+    /*
+     * Only one VM to avoid having to shutdown the machine several
+     * times to release the disks lock.
+     */
+    if (env->qemu_src || env->qemu_dst) {
+        g_test_skip("Only one QEMU binary is supported");
+        return;
+    }
+
+    vm = qtest_init(cmdline);
+
+    for (int i = 0; i < MIGRATION_CAPABILITY__MAX; i++) {
+        const char *cap = MigrationCapability_str(i);
+        g_autofree char *error_str = NULL;
+        bool ret;
+
+        switch (i) {
+        case MIGRATION_CAPABILITY_ZERO_BLOCKS:          /* deprecated */
+        case MIGRATION_CAPABILITY_ZERO_COPY_SEND:       /* requires multifd */
+        case MIGRATION_CAPABILITY_POSTCOPY_PREEMPT:     /* requires postcopy-ram */
+        case MIGRATION_CAPABILITY_SWITCHOVER_ACK:       /* requires return-path */
+        case MIGRATION_CAPABILITY_DIRTY_LIMIT:          /* requires dirty ring setup */
+        case MIGRATION_CAPABILITY_BACKGROUND_SNAPSHOT:  /* requires uffd setup */
+            continue;
+        default:
+            break;
+        }
+
+        if (getenv("QTEST_LOG")) {
+            g_test_message("%s", MigrationCapability_str(i));
+        }
+        migrate_set_capability(vm, MigrationCapability_str(i), true);
+
+        ret = snapshot_save_qmp_sync(vm, &error_str);
+
+        if (ret) {
+            g_assert(snapshot_load_qmp_sync(vm, NULL));
+            g_assert(snapshot_delete_qmp_sync(vm, NULL));
+        } else {
+            g_autofree char *msg = g_strdup_printf(
+                "Snapshots are not compatible with %s", cap);
+
+            g_assert(error_str);
+            g_assert(g_str_equal(msg, error_str));
+        }
+
+        migrate_set_capability(vm, MigrationCapability_str(i), false);
+    }
+
+    qtest_quit(vm);
+    teardown_savevm_test();
+}
+
+static void test_savevm_loadvm(void)
+{
+    g_autofree char *cmdline = savevm_make_cmdline();
+    QTestState *src, *dst;
+    bool ret;
+
+    src = qtest_init_with_env(QEMU_ENV_SRC, cmdline, true);
+
+    ret = snapshot_save_qmp_sync(src, NULL);
+    qtest_quit(src);
+
+    if (ret) {
+        dst = qtest_init_with_env(QEMU_ENV_DST, cmdline, true);
+
+        g_assert(snapshot_load_qmp_sync(dst, NULL));
+        g_assert(snapshot_delete_qmp_sync(dst, NULL));
+        qtest_quit(dst);
+    }
+
+    teardown_savevm_test();
+}
+
+void migration_test_add_savevm(MigrationTestEnv *env)
+{
+    if (!getenv("QTEST_QEMU_IMG")) {
+        g_test_message("savevm tests require QTEST_QEMU_IMG");
+        return;
+    }
+
+    migration_test_add("/migration/savevm/save-load", test_savevm_loadvm);
+    migration_test_add("/migration/savevm/capabilities", test_savevm_caps);
+}
