diff --git a/examples/ulib/.gitignore b/examples/ulib/.gitignore
new file mode 100644
index 00000000000..d2f0dfa7a93
--- /dev/null
+++ b/examples/ulib/.gitignore
@@ -0,0 +1,2 @@
+/demo
+/demo_static
diff --git a/examples/ulib/demo_helper.c b/examples/ulib/demo_helper.c
index 935443657bd..e3a2c6bdcfb 100644
--- a/examples/ulib/demo_helper.c
+++ b/examples/ulib/demo_helper.c
@@ -10,21 +10,19 @@
 
 void demo_show_banner(void)
 {
-	ub_printf("=================================\n");
-	ub_printf("    U-Boot Library Demo Helper\n");
-	ub_printf("=================================\n");
+	ub_printf("U-Boot Library Demo Helper\n");
+	ub_printf("==========================\n");
 }
 
 void demo_show_footer(void)
 {
 	ub_printf("=================================\n");
-	ub_printf("      Demo Complete!\n");
-	ub_printf("=================================\n");
+	ub_printf("Demo complete\n");
 }
 
 int demo_add_numbers(int a, int b)
 {
-	ub_printf("Helper: Adding %d + %d = %d\n", a, b, a + b);
+	ub_printf("helper: Adding %d + %d = %d\n", a, b, a + b);
 
 	return a + b;
 }
diff --git a/test/py/tests/test_ulib.py b/test/py/tests/test_ulib.py
new file mode 100644
index 00000000000..76d40e1385c
--- /dev/null
+++ b/test/py/tests/test_ulib.py
@@ -0,0 +1,141 @@
+# SPDX-License-Identifier: GPL-2.0+
+# Copyright (c) 2025, Canonical Ltd.
+
+import os
+import subprocess
+import pytest
+import utils
+
+def check_output(out):
+    """Check output from the ulib test"""
+    assert 'Hello, world from ub_printf' in out
+    assert '- U-Boot' in out
+    assert 'Uses libc printf before ulib_init' in out
+    assert 'another printf()' in out
+
+@pytest.mark.buildconfigspec("ulib")
+def test_ulib_shared(ubman):
+    """Test the ulib shared library test program"""
+
+    build = ubman.config.build_dir
+    prog = os.path.join(build, 'test', 'ulib', 'ulib_test')
+
+    # Skip test if ulib_test doesn't exist (clang)
+    if not os.path.exists(prog):
+        pytest.skip('ulib_test not found - library build may be disabled')
+
+    out = utils.run_and_log(ubman, [prog], cwd=build)
+    check_output(out)
+    assert 'dynamically linked' in out
+
+@pytest.mark.boardspec('sandbox')
+def test_ulib_static(ubman):
+    """Test the ulib static library test program"""
+
+    build = ubman.config.build_dir
+    prog = os.path.join(build, 'test', 'ulib', 'ulib_test_static')
+
+    # Skip test if ulib_test_static doesn't exist (clang)
+    if not os.path.exists(prog):
+        pytest.skip('ulib_test_static not found - library build may be disabled')
+
+    out = utils.run_and_log(ubman, [prog])
+    check_output(out)
+    assert 'statically linked' in out
+
+def check_demo_output(ubman, out):
+    """Check output from the ulib demo programs exactly line by line"""
+    lines = out.split('\n')
+
+    # Read the actual system version from /proc/version
+    with open('/proc/version', 'r', encoding='utf-8') as f:
+        proc_version = f.read().strip()
+
+    expected = [
+        'U-Boot Library Demo Helper\r',
+        '==========================\r',
+        'System version:helper: Adding 42 + 13 = 55\r',
+        '=================================\r',
+        'Demo complete\r',
+        f'U-Boot version: {ubman.u_boot_version_string}',
+        '',
+        f'  {proc_version}',
+        '',
+        'Read 1 line(s) using U-Boot library functions.',
+        'Helper function result: 55',
+        ''
+    ]
+
+    assert len(lines) == len(expected), \
+        f"Expected {len(expected)} lines, got {len(lines)}"
+
+    for i, expected in enumerate(expected):
+        # Exact match for all other lines
+        assert lines[i] == expected, \
+            f"Line {i}: expected '{expected}', got '{lines[i]}'"
+
+@pytest.mark.boardspec('sandbox')
+def test_ulib_demos(ubman):
+    """Test both ulib demo programs (dynamic and static)."""
+
+    build = ubman.config.build_dir
+    src = ubman.config.source_dir
+    examples = os.path.join(src, 'examples', 'ulib')
+    test_program = os.path.join(build, 'test', 'ulib', 'ulib_test')
+
+    # Skip test if ulib_test doesn't exist (clang)
+    if not os.path.exists(test_program):
+        pytest.skip('ulib_test not found - library build may be disabled')
+
+    # Build the demo programs - clean first to ensure fresh build, since this
+    # test is run in the source directory
+    cmd = ['make', 'clean']
+    utils.run_and_log(ubman, cmd, cwd=examples)
+
+    cmd = ['make', f'UBOOT_BUILD={os.path.abspath(build)}', f'srctree={src}']
+    utils.run_and_log(ubman, cmd, cwd=examples)
+
+    # Test static demo program
+    demo_static = os.path.join(examples, 'demo_static')
+    out_static = utils.run_and_log(ubman, [demo_static])
+    check_demo_output(ubman, out_static)
+
+    # Test dynamic demo program (with proper LD_LIBRARY_PATH)
+    demo = os.path.join(examples, 'demo')
+    env = os.environ.copy()
+    env['LD_LIBRARY_PATH'] = os.path.abspath(build)
+    out_dynamic = utils.run_and_log(ubman, [demo], env=env)
+    check_demo_output(ubman, out_dynamic)
+
+@pytest.mark.boardspec('sandbox')
+def test_ulib_api_header(ubman):
+    """Test that the u-boot-api.h header is generated correctly."""
+
+    hdr = os.path.join(ubman.config.build_dir, 'include', 'u-boot-api.h')
+
+    # Skip if header doesn't exist (clang)
+    if not os.path.exists(hdr):
+        pytest.skip('u-boot-api.h not found - library build may be disabled')
+
+    # Read and verify header content
+    with open(hdr, 'r', encoding='utf-8') as inf:
+        out = inf.read()
+
+    # Check header guard
+    assert '#ifndef __ULIB_API_H' in out
+    assert '#define __ULIB_API_H' in out
+    assert '#endif /* __ULIB_API_H */' in out
+
+    # Check required includes
+    assert '#include <stdarg.h>' in out
+    assert '#include <stddef.h>' in out
+
+    # Check for renamed function declarations
+    assert 'ub_printf' in out
+    assert 'ub_snprintf' in out
+    assert 'ub_vprintf' in out
+
+    # Check that functions have proper signatures
+    assert 'ub_printf(const char *fmt, ...)' in out
+    assert 'ub_snprintf(char *buf, size_t size, const char *fmt, ...)' in out
+    assert 'ub_vprintf(const char *fmt, va_list args)' in out
diff --git a/test/run b/test/run
index 2ba8324a0c4..80f79317aed 100755
--- a/test/run
+++ b/test/run
@@ -18,6 +18,14 @@ quiet=-q
 # Clean up things the Makefile created
 unset MAKE MAKEFLAGS MAKELEVEL MAKEOVERRIDES MAKE_TERMERR MAKE_TERMOUT
 
+# Unset this since this script is generally run from 'make qcheck' et al, which
+# targets are in no-dot-config-targets and thus dot-config is 0 and thus
+# config.mk was not included in the main Makefile, thus PLATFORM_LIBS does not
+# have the arch-specific settings (e.g. SDL libraries on sandbox). Better to
+# leave it empty than have it be wrong. This particularly affects
+# example/ulib/Makefile when called from 'make qcheck'
+unset PLATFORM_LIBS
+
 # Select test attributes
 ut_mark_expr=test_ut
 if [ "$1" = "quick" ]; then
