diff --git a/tests/meson.build b/tests/meson.build
index 13b248dd320ab396b59e6e1cf009fe7aafebd700..3ead662eff677ed91cfb1a15f34c832311a9dd59 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -74,6 +74,7 @@ test_utils = static_library(
   sources : [
     'fake-home.c',
     'test-init.c',
+    'test-json-utils.c',
     'test-utils.c',
   ],
   c_args : [
diff --git a/tests/system-info-cli.c b/tests/system-info-cli.c
index a42076fb1f0d99dbcc6b139a7eaf715e3b88dd93..44ff386dbf2f43cbacb1b88fd7f208eb958046f6 100644
--- a/tests/system-info-cli.c
+++ b/tests/system-info-cli.c
@@ -43,6 +43,7 @@
 #include <json-glib/json-glib.h>
 
 #include "test-utils.h"
+#include "test-json-utils.h"
 #include "fake-home.h"
 
 #if defined(__i386__) || defined(__x86_64__)
@@ -737,34 +738,6 @@ static const JsonTest json_test[] =
   },
 };
 
-static void
-stdout_to_stderr_child_setup (gpointer nil)
-{
-  if (dup2 (STDERR_FILENO, STDOUT_FILENO) != STDOUT_FILENO)
-    _srt_async_signal_safe_error ("test-system-info-cli",
-                                  "Unable to redirect stdout to stderr",
-                                  1);
-}
-
-static gchar *
-pretty_print_json (const char *unformatted)
-{
-  g_autoptr(JsonParser) parser = NULL;
-  JsonNode *node = NULL;  /* not owned */
-  g_autoptr(JsonGenerator) generator = NULL;
-  g_autoptr(GError) error = NULL;
-
-  parser = json_parser_new ();
-  json_parser_load_from_data (parser, unformatted, -1, &error);
-  g_assert_no_error (error);
-  node = json_parser_get_root (parser);
-  g_assert_nonnull (node);
-  generator = json_generator_new ();
-  json_generator_set_root (generator, node);
-  json_generator_set_pretty (generator, TRUE);
-  return json_generator_to_data (generator, NULL);
-}
-
 static void
 json_parsing (Fixture *f,
               gconstpointer context)
@@ -782,7 +755,6 @@ json_parsing (Fixture *f,
       g_auto(GStrv) envp = NULL;
       g_autoptr(GError) error = NULL;
       const gchar *argv[] = { "steam-runtime-system-info", "--verbose", NULL };
-      gsize len;
 
       g_test_message ("%s: input=%s output=%s", test->description, test->input_name, test->output_name);
 
@@ -791,10 +763,7 @@ json_parsing (Fixture *f,
       output_json = g_build_filename (f->srcdir, "json-report", multiarch_tuples[0],
                                       test->output_name, NULL);
 
-      g_file_get_contents (output_json, &unformatted, &len, &error);
-      g_assert_no_error (error);
-      g_assert_cmpuint (len, ==, strlen (unformatted));
-      expectation = pretty_print_json (unformatted);
+      expectation = _srt_json_pretty_from_file (output_json);
       g_clear_pointer (&unformatted, g_free);
 
       envp = g_get_environ ();
@@ -814,51 +783,9 @@ json_parsing (Fixture *f,
       g_assert_true (result);
       g_assert_cmpint (exit_status, ==, 0);
       g_assert_nonnull (unformatted);
-      output = pretty_print_json (unformatted);
-
-      if (g_strcmp0 (output, expectation) != 0)
-        {
-          const char *artifacts = g_getenv ("AUTOPKGTEST_ARTIFACTS");
-          g_autofree gchar *tmpdir = NULL;
-          g_autofree gchar *expected_path = NULL;
-          g_autofree gchar *output_path = NULL;
-          const gchar *diff_argv[] = { "diff", "-u", "<expected>", "<output>", NULL };
-
-          if (artifacts == NULL)
-            {
-              tmpdir = g_dir_make_tmp ("srt-tests-XXXXXX", &error);
-              g_assert_no_error (error);
-              artifacts = tmpdir;
-            }
-
-          expected_path = g_build_filename (artifacts, "expected.json", NULL);
-          g_file_set_contents (expected_path, expectation, -1, &error);
-          g_assert_no_error (error);
-
-          output_path = g_build_filename (artifacts, "output.json", NULL);
-          g_file_set_contents (output_path, output, -1, &error);
-          g_assert_no_error (error);
-
-          g_assert_cmpstr (diff_argv[2], ==, "<expected>");
-          diff_argv[2] = expected_path;
-          g_assert_cmpstr (diff_argv[3], ==, "<output>");
-          diff_argv[3] = output_path;
-          g_assert_null (diff_argv[4]);
-
-          /* Ignore error from calling diff, if any: we're running it
-           * for its side-effect of producing output on our stderr. */
-          g_spawn_sync (NULL, (gchar **) diff_argv, NULL, G_SPAWN_SEARCH_PATH,
-                        stdout_to_stderr_child_setup, NULL,
-                        NULL, NULL, NULL, NULL);
-
-          g_test_message ("Output for comparison: %s %s",
-                          expected_path, output_path);
-
-          if (tmpdir != NULL)
-            _srt_rm_rf (tmpdir);
-        }
+      output = _srt_json_pretty (unformatted);
 
-      g_assert_cmpstr (output, ==, expectation);
+      _srt_assert_streq_diff (expectation, output);
     }
 }
 
diff --git a/tests/test-json-utils.c b/tests/test-json-utils.c
new file mode 100644
index 0000000000000000000000000000000000000000..06f9d8e98ca3964eb37b886969e28017f8c8c510
--- /dev/null
+++ b/tests/test-json-utils.c
@@ -0,0 +1,72 @@
+/*
+ * Copyright © 2019-2025 Collabora Ltd.
+ *
+ * SPDX-License-Identifier: MIT
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included
+ * in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+#include "tests/test-json-utils.h"
+
+#include <glib.h>
+
+#include "steam-runtime-tools/glib-backports-internal.h"
+#include "steam-runtime-tools/json-glib-backports-internal.h"
+
+/**
+ * Returns a new string containing the input JSON but pretty-printed.
+ */
+gchar *
+_srt_json_pretty (const char *unformatted)
+{
+  g_autoptr(JsonParser) parser = NULL;
+  JsonNode *node = NULL;  /* not owned */
+  g_autoptr(JsonGenerator) generator = NULL;
+  g_autoptr(GError) error = NULL;
+
+  parser = json_parser_new ();
+  json_parser_load_from_data (parser, unformatted, -1, &error);
+  g_assert_no_error (error);
+  node = json_parser_get_root (parser);
+  g_assert_nonnull (node);
+  generator = json_generator_new ();
+  json_generator_set_root (generator, node);
+  json_generator_set_pretty (generator, TRUE);
+  return json_generator_to_data (generator, NULL);
+}
+
+/**
+ * Returns the contents of the given JSON file, pretty-printed via
+ * _srt_json_pretty().
+ **/
+gchar *
+_srt_json_pretty_from_file (const char *path)
+{
+  g_autoptr(GError) error = NULL;
+  g_autofree char *contents = NULL;
+  gsize len;
+
+  g_file_get_contents (path,
+                       &contents, &len, &error);
+  g_assert_no_error (error);
+  g_assert_cmpuint (len, ==, strlen (contents));
+
+  return _srt_json_pretty (contents);
+}
diff --git a/tests/test-json-utils.h b/tests/test-json-utils.h
new file mode 100644
index 0000000000000000000000000000000000000000..290b5f03fd6e4292fb4d2866ecb8f82aeb1f7b99
--- /dev/null
+++ b/tests/test-json-utils.h
@@ -0,0 +1,31 @@
+/*
+ * Copyright © 2019-2025 Collabora Ltd.
+ *
+ * SPDX-License-Identifier: MIT
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included
+ * in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+#pragma once
+
+#include <glib.h>
+
+gchar * _srt_json_pretty (const char *unformatted);
+gchar * _srt_json_pretty_from_file (const char *path);
diff --git a/tests/test-utils.c b/tests/test-utils.c
index 99d55776455c1b01abdb1ca3428e7ecea01229fc..f71b47c2d24f4b1db1a38cad1ae53f42ebb8b98d 100644
--- a/tests/test-utils.c
+++ b/tests/test-utils.c
@@ -317,3 +317,85 @@ _srt_tests_skip_if_really_in_steam_runtime (void)
 
   return FALSE;
 }
+
+/**
+ * Show a diff of both strings, additionally saving their contents to a
+ * temporary directory for later inspections. Performs a diff via `$SRT_DIFF`
+ * (defaults to `diff`) with the options in `$SRT_DIFF_OPTS` (defaults to `-u`
+ * if not given *and* `$SRT_DIFF` was unset).
+ */
+void
+_srt_show_diff (const char *expected,
+                const char *actual)
+{
+  g_autoptr(GPtrArray) diff_argv = g_ptr_array_new ();
+  g_autoptr(GSubprocessLauncher) launcher =
+    g_subprocess_launcher_new (G_SUBPROCESS_FLAGS_STDERR_MERGE);
+  g_autoptr(GSubprocess) diff = NULL;
+  g_autoptr(GError) error = NULL;
+  g_auto(GStrv) diff_opts_split = NULL;
+  g_autofree gchar *tmpdir = NULL;
+  g_autofree gchar *expected_path = NULL;
+  g_autofree gchar *actual_path = NULL;
+  glnx_autofd int stderr_dup = -1;
+  const char *artifacts = g_getenv ("AUTOPKGTEST_ARTIFACTS");
+  const char *diff_exe = g_getenv ("SRT_DIFF");
+  const char *diff_opts = g_getenv ("SRT_DIFF_OPTS");
+
+  g_assert_no_errno ((stderr_dup = dup (STDERR_FILENO)));
+  g_subprocess_launcher_take_stdout_fd (launcher, glnx_steal_fd (&stderr_dup));
+
+  if (artifacts == NULL)
+    {
+      tmpdir = g_dir_make_tmp ("srt-tests-XXXXXX", &error);
+      g_assert_no_error (error);
+      artifacts = tmpdir;
+    }
+
+  expected_path = g_build_filename (artifacts, "expected", NULL);
+  g_file_set_contents (expected_path, expected, -1, &error);
+  g_assert_no_error (error);
+
+  actual_path = g_build_filename (artifacts, "actual", NULL);
+  g_file_set_contents (actual_path, actual, -1, &error);
+  g_assert_no_error (error);
+
+  g_ptr_array_add (diff_argv, (gpointer) (diff_exe ?: "diff"));
+  /* Only add custom diff opts if given; but otherwise, if no diff tool
+     was given, default to '-u' for easier-to-read unified diffs. */
+  if (diff_opts != NULL)
+    {
+      char **p;
+      if (!g_shell_parse_argv (diff_opts, NULL, &diff_opts_split, &error))
+        {
+          if (g_error_matches (error, G_SHELL_ERROR, G_SHELL_ERROR_EMPTY_STRING))
+            g_clear_error (&error);
+          else
+            g_error ("Parsing $SRT_DIFF_OPTS: %s", error->message);
+        }
+
+      for (p = diff_opts_split; p != NULL && *p != NULL; p++)
+        g_ptr_array_add (diff_argv, *p);
+    }
+  else if (diff_exe == NULL)
+    {
+      g_ptr_array_add (diff_argv, (gpointer) "-u");
+    }
+  g_ptr_array_add (diff_argv, expected_path);
+  g_ptr_array_add (diff_argv, actual_path);
+  g_ptr_array_add (diff_argv, NULL);
+
+  /* Ignore error from calling diff, if any: we're running it
+   * for its side-effect of producing output on our stderr. */
+  diff = g_subprocess_launcher_spawnv (launcher,
+                                       (const char * const *) diff_argv->pdata,
+                                       NULL);
+  if (diff != NULL)
+    g_subprocess_wait (diff, NULL, NULL);
+
+  g_test_message ("Files for comparison: %s %s",
+                  expected_path, actual_path);
+
+  if (tmpdir != NULL)
+    _srt_rm_rf (tmpdir);
+}
diff --git a/tests/test-utils.h b/tests/test-utils.h
index 3268f8cd9af2f2f6ad752d1c09e023594cc6e68b..5585e193d9f6c2d4a1beb3ca1bd639d0264b3434 100644
--- a/tests/test-utils.h
+++ b/tests/test-utils.h
@@ -51,3 +51,20 @@ gchar *_srt_global_setup_sysroots (const char *argv0);
 gboolean _srt_global_teardown_sysroots (void);
 
 gboolean _srt_tests_skip_if_really_in_steam_runtime (void);
+
+void _srt_show_diff (const char *expected,
+                     const char *actual);
+
+/**
+ * Asserts two strings as equal, showing a line-based diff of their contents via
+ * _srt_show_diff() if they don't match.
+ */
+#define _srt_assert_streq_diff(a, b) \
+  G_STMT_START { \
+    const char *__a = (a), *__b = (b); \
+    if (!g_str_equal (__a, __b)) {\
+      _srt_show_diff (__a, __b); \
+      g_assertion_message (G_LOG_DOMAIN, __FILE__, __LINE__, G_STRFUNC,\
+                           "assertion failed (" #a " == " #b ")"); \
+    } \
+  } G_STMT_END