diff --git a/test/fs/Makefile b/test/fs/Makefile
index a8fd1227a1d..0e7d167a345 100644
--- a/test/fs/Makefile
+++ b/test/fs/Makefile
@@ -2,3 +2,4 @@
 
 obj-y += fs_basic.o
 obj-$(CONFIG_FS_EXT4L) += ext4l.o
+obj-$(CONFIG_$(PHASE_)VFS) += vfs.o
diff --git a/test/fs/vfs.c b/test/fs/vfs.c
new file mode 100644
index 00000000000..aaf024fcf36
--- /dev/null
+++ b/test/fs/vfs.c
@@ -0,0 +1,503 @@
+// SPDX-License-Identifier: GPL-2.0+
+/*
+ * VFS tests on real filesystem images
+ *
+ * These tests are marked UTF_MANUAL and are called from test_vfs.py
+ * which creates the filesystem image.
+ *
+ * Copyright 2026 Simon Glass <sjg@chromium.org>
+ */
+
+#include <blk.h>
+#include <command.h>
+#include <dm.h>
+#include <env.h>
+#include <fs.h>
+#include <mapmem.h>
+#include <sandbox_host.h>
+#include <vfs.h>
+#include <dm/device-internal.h>
+#include <test/fs.h>
+#include <test/test.h>
+#include <test/ut.h>
+
+#define VFS_ARG_IMAGE	0	/* fs_image: path to filesystem image */
+#define BUF_ADDR	0x10000
+
+/* Regex for a stat timestamp line, e.g. "Modify: 2026-04-02 08:21:38" */
+#define TIMESTAMP_RE(label)	\
+	label ": [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9:][0-9:]+"
+
+/**
+ * vfs_is_fat() - Check whether /mnt is a FAT filesystem
+ *
+ * Return: true if the mount at /mnt uses the FAT driver
+ */
+static bool vfs_is_fat(void)
+{
+	struct udevice *vfs, *mnt;
+	struct vfsmount *m;
+	const char *subpath;
+
+	vfs = vfs_root();
+	if (!vfs)
+		return false;
+	if (vfs_find_mount(vfs, "/mnt", &mnt, &subpath) || !mnt)
+		return false;
+	m = dev_get_uclass_priv(mnt);
+
+	return !strcmp(m->target->driver->name, "fat_fs");
+}
+
+/**
+ * vfs_check_stat_end() - Check stat timestamps based on filesystem type
+ *
+ * FAT populates timestamps so we expect Modify/Access/Birth lines.
+ * ext4 (via the test images created with mkfs -d) does not.
+ */
+static int vfs_check_stat_end(struct unit_test_state *uts)
+{
+	if (vfs_is_fat()) {
+		ut_assert_nextline_regex(TIMESTAMP_RE("Modify"));
+		ut_assert_nextline_regex(TIMESTAMP_RE("Access"));
+		ut_assert_nextline_regex(TIMESTAMP_RE(" Birth"));
+	}
+	ut_assert_console_end();
+
+	return 0;
+}
+
+/**
+ * vfs_mount_image() - Bind a host image and mount it via VFS
+ *
+ * @uts: Unit test state
+ * @image: Path to filesystem image
+ * @devp: Returns the host device
+ * Return: 0 on success
+ */
+static int vfs_mount_image(struct unit_test_state *uts, const char *image,
+			   struct udevice **devp)
+{
+	struct udevice *blk;
+	struct blk_desc *desc;
+
+	ut_assertok(vfs_init());
+	ut_assertok(host_create_device("vfstest", true, DEFAULT_BLKSZ, devp));
+	ut_assertok(host_attach_file(*devp, image));
+	ut_assertok(blk_get_from_parent(*devp, &blk));
+	ut_assertok(device_probe(blk));
+	desc = dev_get_uclass_plat(blk);
+
+	ut_assertok(run_commandf("mount host %x:0 /mnt", desc->devnum));
+	ut_assert_console_end();
+
+	return 0;
+}
+
+/**
+ * vfs_umount_image() - Unmount and clean up a host image
+ *
+ * @uts: Unit test state
+ * @dev: The host device to detach
+ * Return: 0 on success
+ */
+static int vfs_umount_image(struct unit_test_state *uts, struct udevice *dev)
+{
+	ut_assertok(run_command("umount /mnt", 0));
+	ut_assert_console_end();
+	ut_assertok(host_detach_file(dev));
+	ut_assertok(device_unbind(dev));
+
+	return 0;
+}
+
+/* Test ls on VFS-mounted filesystem */
+static int fs_test_vfs_ls_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	ut_assertok(run_command("ls /mnt", 0));
+	if (!vfs_is_fat()) {
+		ut_assert_nextline("DIR          0 .");
+		ut_assert_nextline("DIR          0 ..");
+		ut_assert_nextline("DIR          0 lost+found");
+		ut_assert_nextline("DIR          0 subdir");
+		ut_assert_nextline("            12 testfile.txt");
+	} else {
+		ut_assert_nextline("DIR          0 subdir");
+		ut_assert_nextline("            12 testfile.txt");
+	}
+	ut_assert_console_end();
+
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_ls_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test stat on VFS-mounted filesystem */
+static int fs_test_vfs_stat_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	ut_assertok(run_command("stat /mnt/testfile.txt", 0));
+	ut_assert_nextline("  File: testfile.txt");
+	ut_assert_nextline("  Size: 12");
+	ut_assert_nextline("  Type: regular file");
+	ut_assertok(vfs_check_stat_end(uts));
+
+	/* Stat a directory */
+	ut_assertok(run_command("stat /mnt/subdir", 0));
+	ut_assert_nextline("  File: subdir");
+	ut_assert_nextline("  Size: %d", vfs_is_fat() ? 0 : 4096);
+	ut_assert_nextline("  Type: directory");
+	ut_assertok(vfs_check_stat_end(uts));
+
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_stat_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test load from VFS-mounted filesystem */
+static int fs_test_vfs_load_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+	char *buf;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	buf = map_sysmem(BUF_ADDR, 0x100);
+	memset(buf, '\0', 0x100);
+	ut_assertok(run_commandf("load %x /mnt/testfile.txt", BUF_ADDR));
+	ut_assert_nextline("12 bytes read");
+	ut_assert_console_end();
+	ut_asserteq_str("hello world\n", buf);
+	unmap_sysmem(buf);
+
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_load_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test save to VFS-mounted filesystem */
+static int fs_test_vfs_save_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+	char *buf;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	/* Write a file */
+	buf = map_sysmem(BUF_ADDR, 0x10);
+	memcpy(buf, "test data\n", 10);
+	ut_assertok(run_commandf("save %x /mnt/newfile.txt a", BUF_ADDR));
+	ut_assert_nextline("10 bytes written");
+	ut_assert_console_end();
+
+	/* Read it back */
+	memset(buf, '\0', 0x10);
+	ut_assertok(run_commandf("load %x /mnt/newfile.txt", BUF_ADDR));
+	ut_assert_nextline("10 bytes read");
+	ut_assert_console_end();
+	ut_asserteq_mem("test data\n", buf, 10);
+	unmap_sysmem(buf);
+
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_save_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test mkdir on VFS-mounted filesystem */
+static int fs_test_vfs_mkdir_norun(struct unit_test_state *uts)
+{
+	static int mkdir_seq;
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+	char name[32];
+
+	/*
+	 * Use a unique name each run because the test image is shared
+	 * between live-tree and flat-tree passes, and VFS has no rmdir
+	 * to clean up afterwards.
+	 */
+	snprintf(name, sizeof(name), ".vfs_mkdir_%d", mkdir_seq++);
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	/* Verify it does not exist yet */
+	ut_asserteq(1, run_commandf("stat /mnt/%s", name));
+	console_record_reset_enable();
+
+	ut_assertok(run_commandf("mkdir /mnt/%s", name));
+	ut_assert_console_end();
+
+	ut_assertok(run_commandf("stat /mnt/%s", name));
+	ut_assert_nextline("  File: %s", name);
+	ut_assert_nextline("  Size: %d", vfs_is_fat() ? 0 : 4096);
+	ut_assert_nextline("  Type: directory");
+	ut_assertok(vfs_check_stat_end(uts));
+
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_mkdir_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test rm on VFS-mounted filesystem */
+static int fs_test_vfs_rm_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+	char *buf;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	/* Write a file then delete it */
+	buf = map_sysmem(BUF_ADDR, 0x10);
+	memcpy(buf, "delete me\n", 10);
+	ut_assertok(run_commandf("save %x /mnt/.rm_test a", BUF_ADDR));
+	ut_assert_nextline("10 bytes written");
+	ut_assert_console_end();
+
+	ut_assertok(run_command("rm /mnt/.rm_test", 0));
+	ut_assert_console_end();
+
+	/* Verify it is gone */
+	ut_asserteq(1, run_command("stat /mnt/.rm_test", 0));
+	ut_assert_nextline("Error: -2: No such file or directory");
+	ut_assert_console_end();
+
+	unmap_sysmem(buf);
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_rm_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test mv on VFS-mounted filesystem */
+static int fs_test_vfs_mv_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+	char *buf;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	/* Write a file */
+	buf = map_sysmem(BUF_ADDR, 0x10);
+	memcpy(buf, "move me\n", 8);
+	ut_assertok(run_commandf("save %x /mnt/.mv_src 8", BUF_ADDR));
+	ut_assert_nextline("8 bytes written");
+	ut_assert_console_end();
+
+	/* Rename it */
+	ut_assertok(run_command("mv /mnt/.mv_src /mnt/.mv_dst", 0));
+	ut_assert_console_end();
+
+	/* Old name should be gone */
+	ut_asserteq(1, run_command("stat /mnt/.mv_src", 0));
+	ut_assert_nextline("Error: -2: No such file or directory");
+	ut_assert_console_end();
+
+	/* New name should have the data */
+	memset(buf, '\0', 0x10);
+	ut_assertok(run_commandf("load %x /mnt/.mv_dst", BUF_ADDR));
+	ut_assert_nextline("8 bytes read");
+	ut_assert_console_end();
+	ut_asserteq_mem("move me\n", buf, 8);
+
+	unmap_sysmem(buf);
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_mv_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test cat on VFS-mounted filesystem */
+static int fs_test_vfs_cat_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	ut_assertok(run_command("cat /mnt/testfile.txt", 0));
+	ut_assert_nextline("hello world");
+	ut_assert_console_end();
+
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_cat_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test size on VFS-mounted filesystem */
+static int fs_test_vfs_size_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	env_set("filesize", NULL);
+	ut_assertok(run_command("size /mnt/testfile.txt", 0));
+	ut_assert_console_end();
+	ut_asserteq(12, env_get_hex("filesize", 0));
+
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_size_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test df on VFS-mounted filesystem */
+static int fs_test_vfs_df_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	ut_assertok(run_command("df /mnt", 0));
+	ut_assert_nextline(
+		"Filesystem        Blksz      Total       Used       Free  Size");
+	ut_assert_nextlinen("/mnt");
+	ut_assert_console_end();
+
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_df_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test nested path access on VFS-mounted filesystem */
+static int fs_test_vfs_nested_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+	char *buf;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	/* Load file from subdirectory */
+	buf = map_sysmem(BUF_ADDR, 0x100);
+	memset(buf, '\0', 0x100);
+	ut_assertok(run_commandf("load %x /mnt/subdir/nested.txt", BUF_ADDR));
+	ut_assert_nextline("12 bytes read");
+	ut_assert_console_end();
+	ut_asserteq_str("hello world\n", buf);
+
+	/* Stat the subdirectory file */
+	ut_assertok(run_command("stat /mnt/subdir/nested.txt", 0));
+	ut_assert_nextline("  File: nested.txt");
+	ut_assert_nextline("  Size: 12");
+	ut_assert_nextline("  Type: regular file");
+	ut_assertok(vfs_check_stat_end(uts));
+
+	unmap_sysmem(buf);
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_nested_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test cd and pwd on VFS-mounted filesystem */
+static int fs_test_vfs_cd_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	ut_assertok(run_command("cd /mnt", 0));
+	ut_assert_console_end();
+
+	ut_assertok(run_command("pwd", 0));
+	ut_assert_nextline("/mnt");
+	ut_assert_console_end();
+
+	/* ls without path should list cwd */
+	ut_assertok(run_command("ls", 0));
+	if (!vfs_is_fat()) {
+		ut_assert_nextline("DIR          0 .");
+		ut_assert_nextline("DIR          0 ..");
+		ut_assert_nextline("DIR          0 lost+found");
+		ut_assert_nextline("DIR          0 subdir");
+		ut_assert_nextline("            12 testfile.txt");
+	} else {
+		ut_assert_nextline("DIR          0 subdir");
+		ut_assert_nextline("            12 testfile.txt");
+	}
+	ut_assert_console_end();
+
+	/* cd back to root */
+	ut_assertok(run_command("cd /", 0));
+	ut_assert_console_end();
+
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_cd_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
+
+/* Test ln (symlink) on VFS-mounted ext4 */
+static int fs_test_vfs_ext4_ln_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(VFS_ARG_IMAGE);
+	struct udevice *dev;
+
+	ut_assertok(vfs_mount_image(uts, fs_image, &dev));
+
+	/* Create a symlink to testfile.txt */
+	ut_assertok(run_command("ln testfile.txt /mnt/link.txt", 0));
+	ut_assert_console_end();
+
+	/* Stat should show it as a symlink */
+	ut_assertok(run_command("stat /mnt/link.txt", 0));
+	ut_assert_nextline("  File: link.txt");
+	ut_assert_nextline("  Size: 12");
+	ut_assert_nextline("  Type: symbolic link");
+	ut_assertok(vfs_check_stat_end(uts));
+
+	ut_assertok(vfs_umount_image(uts, dev));
+
+	return 0;
+}
+FS_TEST_ARGS(fs_test_vfs_ext4_ln_norun,
+	     UTF_SCAN_FDT | UTF_CONSOLE | UTF_MANUAL,
+	     { "fs_image", UT_ARG_STR });
diff --git a/test/py/tests/test_fs/test_vfs.py b/test/py/tests/test_fs/test_vfs.py
new file mode 100644
index 00000000000..7f0fbdc802e
--- /dev/null
+++ b/test/py/tests/test_fs/test_vfs.py
@@ -0,0 +1,98 @@
+# SPDX-License-Identifier: GPL-2.0+
+# Copyright 2026 Simon Glass <sjg@chromium.org>
+#
+# Test VFS operations on real filesystem images
+
+"""
+Test VFS mount, ls, stat, load, save, cat, mkdir, rm, mv on ext4 and
+FAT images. Python creates the filesystem image; C tests mount it via
+VFS and exercise the operations.
+"""
+
+import os
+
+import pytest
+
+from tests.fs_helper import FsHelper
+
+
+# Tests shared by all filesystems. Each entry 'foo' runs the C test
+# fs_test_vfs_foo. Filesystem-specific tests (e.g. ext4-only 'ln') are
+# listed separately and use the fs_test_vfs_<fstype>_<name> naming.
+VFS_TESTS = [
+    'cat', 'cd', 'df', 'load', 'ls', 'mkdir', 'mv', 'nested', 'rm', 'save',
+    'size', 'stat',
+]
+
+EXT4_TESTS = ['ln']
+
+
+def _define_test(name, prefix='vfs'):
+    """Create a test method that runs fs_test_<prefix>_<name>."""
+
+    def test(self, ubman, fs_image):
+        ubman.run_ut('fs', f'fs_test_{prefix}_{name}',
+                     fs_image=fs_image)
+
+    test.__name__ = f'test_{name}'
+    test.__doc__ = f'Test {name} on a VFS-mounted filesystem.'
+    return test
+
+
+def _populate_srcdir(srcdir):
+    """Create the standard test files in a source directory."""
+    with open(os.path.join(srcdir, 'testfile.txt'), 'w') as f:
+        f.write('hello world\n')
+    subdir = os.path.join(srcdir, 'subdir')
+    os.mkdir(subdir)
+    with open(os.path.join(subdir, 'nested.txt'), 'w') as f:
+        f.write('hello world\n')
+
+
+@pytest.mark.buildconfigspec('cmd_vfs')
+@pytest.mark.buildconfigspec('fs_ext4', 'fs_ext4l')
+class TestVfsExt4:
+    """Test VFS operations on an ext4 filesystem."""
+
+    @pytest.fixture(scope='class')
+    def fs_image(self, u_boot_config):
+        """Create an ext4 filesystem image with test files."""
+        fsh = FsHelper(u_boot_config, 'ext4', 64, 'vfs')
+        fsh.setup()
+        _populate_srcdir(fsh.srcdir)
+        fsh.mk_fs()
+
+        yield fsh.fs_img
+
+        fsh.cleanup()
+
+
+# Attach shared and ext4-specific test methods to TestVfsExt4
+for _name in VFS_TESTS:
+    setattr(TestVfsExt4, f'test_{_name}', _define_test(_name))
+for _name in EXT4_TESTS:
+    setattr(TestVfsExt4, f'test_{_name}',
+            _define_test(_name, 'vfs_ext4'))
+
+
+@pytest.mark.buildconfigspec('cmd_vfs')
+@pytest.mark.buildconfigspec('fs_fat')
+class TestVfsFat:
+    """Test VFS operations on a FAT filesystem."""
+
+    @pytest.fixture(scope='class')
+    def fs_image(self, u_boot_config):
+        """Create a FAT filesystem image with test files."""
+        fsh = FsHelper(u_boot_config, 'fat32', 64, 'vfs')
+        fsh.setup()
+        _populate_srcdir(fsh.srcdir)
+        fsh.mk_fs()
+
+        yield fsh.fs_img
+
+        fsh.cleanup()
+
+
+# Attach shared test methods to TestVfsFat
+for _name in VFS_TESTS:
+    setattr(TestVfsFat, f'test_{_name}', _define_test(_name))
