diff --git a/test/meson.build b/test/meson.build
index 5027b7b1..fc72d105 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -35,6 +35,7 @@ subdir('media_device')
 subdir('process')
 subdir('py')
 subdir('serialization')
+subdir('software_isp')
 subdir('stream')
 subdir('v4l2_compat')
 subdir('v4l2_subdevice')
diff --git a/test/software_isp/debayer-crop.cpp b/test/software_isp/debayer-crop.cpp
new file mode 100644
index 00000000..40355b5a
--- /dev/null
+++ b/test/software_isp/debayer-crop.cpp
@@ -0,0 +1,102 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ * Copyright (C) 2026, Red Hat Inc.
+ *
+ * CPU debayer crop configuration tests
+ */
+
+#include <functional>
+#include <iostream>
+#include <memory>
+#include <tuple>
+#include <utility>
+#include <vector>
+
+#include <libcamera/camera_manager.h>
+#include <libcamera/formats.h>
+#include <libcamera/stream.h>
+
+#include "libcamera/internal/formats.h"
+#include "libcamera/internal/software_isp/swstats_cpu.h"
+
+#include "debayer_cpu.h"
+#include "test.h"
+
+using namespace libcamera;
+
+class DebayerCropTest : public Test
+{
+protected:
+	int run() override
+	{
+		CameraManager manager;
+		auto stats = std::make_unique<SwStatsCpu>(manager);
+		if (!stats->isValid())
+			return TestFail;
+
+		DebayerCpu debayer(std::move(stats), manager);
+		static const struct {
+			PixelFormat input;
+			int rgbShift;
+			int bgrShift;
+		} cases[] = {
+			{ formats::SBGGR8, 0, 1 },
+			{ formats::SGBRG8, 1, 0 },
+			{ formats::SGRBG8, 0, 1 },
+			{ formats::SRGGB8, 1, 0 },
+			{ formats::SBGGR10, 0, 1 },
+			{ formats::SGBRG10, 1, 0 },
+			{ formats::SGRBG10, 0, 1 },
+			{ formats::SRGGB10, 1, 0 },
+			{ formats::SBGGR12, 0, 1 },
+			{ formats::SGBRG12, 1, 0 },
+			{ formats::SGRBG12, 0, 1 },
+			{ formats::SRGGB12, 1, 0 },
+			/* Packed kernels use each Bayer order without a phase shift. */
+			{ formats::SBGGR10_CSI2P, 0, 0 },
+			{ formats::SGBRG10_CSI2P, 0, 0 },
+			{ formats::SGRBG10_CSI2P, 0, 0 },
+			{ formats::SRGGB10_CSI2P, 0, 0 },
+			{ formats::SBGGR12_CSI2P, 0, 0 },
+			{ formats::SGBRG12_CSI2P, 0, 0 },
+			{ formats::SGRBG12_CSI2P, 0, 0 },
+			{ formats::SRGGB12_CSI2P, 0, 0 },
+		};
+
+		for (const auto &entry : cases) {
+			StreamConfiguration input;
+			input.pixelFormat = entry.input;
+			input.size = { 1928, 1092 };
+			input.stride = PixelFormatInfo::info(entry.input).stride(input.size.width, 0);
+
+			for (PixelFormat format : { formats::RGB888, formats::BGR888,
+						    formats::XRGB8888, formats::XBGR8888 }) {
+				StreamConfiguration output;
+				output.pixelFormat = format;
+				output.size = { 1280, 720 };
+				std::tie(output.stride, output.frameSize) =
+					debayer.strideAndFrameSize(format, output.size);
+				const std::vector<std::reference_wrapper<const StreamConfiguration>> outputs{ output };
+				if (debayer.configure(input, outputs, false)) {
+					std::cerr << "Unable to configure " << entry.input << " -> " << format
+						  << std::endl;
+					return TestFail;
+				}
+
+				int shift = (format == formats::RGB888 || format == formats::XRGB8888)
+						    ? entry.rgbShift
+						    : entry.bgrShift;
+				Rectangle expected(324 + shift, 186, 1280, 720);
+				if (debayer.inputCrop() != expected) {
+					std::cerr << "Crop for " << entry.input << " -> " << format << " is "
+						  << debayer.inputCrop() << ", expected " << expected << std::endl;
+					return TestFail;
+				}
+			}
+		}
+
+		return TestPass;
+	}
+};
+
+TEST_REGISTER(DebayerCropTest)
diff --git a/test/software_isp/meson.build b/test/software_isp/meson.build
new file mode 100644
index 00000000..ebabde59
--- /dev/null
+++ b/test/software_isp/meson.build
@@ -0,0 +1,20 @@
+# SPDX-License-Identifier: CC0-1.0
+
+if not softisp_enabled
+    subdir_done()
+endif
+
+software_isp_tests = [
+    {'name': 'debayer-crop', 'sources': ['debayer-crop.cpp']},
+]
+
+foreach test : software_isp_tests
+    exe = executable(test['name'], test['sources'],
+                     dependencies : libcamera_private,
+                     implicit_include_directories : false,
+                     link_with : test_libraries,
+                     include_directories : [test_includes_internal,
+                                            '../../src/libcamera/software_isp/'])
+
+    test(test['name'], exe, suite : 'software_isp')
+endforeach
