diff --git a/compressed_image_transport/CMakeLists.txt b/compressed_image_transport/CMakeLists.txt
index 25778a7..cd1a263 100644
--- a/compressed_image_transport/CMakeLists.txt
+++ b/compressed_image_transport/CMakeLists.txt
@@ -55,6 +55,23 @@ pluginlib_export_plugin_description_file(image_transport compressed_plugins.xml)
if(BUILD_TESTING)
find_package(ament_lint_auto REQUIRED)
ament_lint_auto_find_test_dependencies()
+
+ find_package(ament_cmake_gtest REQUIRED)
+ # Codec-level round-trip: exercises the JPEG/PNG codecs directly (OpenCV),
+ # with no ROS node or transport.
+ ament_add_gtest(test_compressed_roundtrip test/test_compressed_roundtrip.cpp)
+ target_include_directories(test_compressed_roundtrip PRIVATE ${OpenCV_INCLUDE_DIRS})
+ target_link_libraries(test_compressed_roundtrip
+ ament_cmake_ros_core::ament_ros_defaults
+ ${OpenCV_LIBRARIES})
+
+ # End-to-end integration over the "compressed" image transport through a real
+ # node, plus a pluginlib-discovery check.
+ ament_add_gtest(test_compressed_transport test/test_compressed_transport.cpp)
+ target_link_libraries(test_compressed_transport
+ image_transport::image_transport
+ rclcpp::rclcpp
+ ${sensor_msgs_TARGETS})
endif()
ament_package()
diff --git a/compressed_image_transport/package.xml b/compressed_image_transport/package.xml
index 03a1d06..917b093 100644
--- a/compressed_image_transport/package.xml
+++ b/compressed_image_transport/package.xml
@@ -21,6 +21,7 @@
ament_lint_auto
ament_lint_common
+ ament_cmake_gtest
ament_cmake
diff --git a/compressed_image_transport/test/test_compressed_roundtrip.cpp b/compressed_image_transport/test/test_compressed_roundtrip.cpp
new file mode 100644
index 0000000..9f83bda
--- /dev/null
+++ b/compressed_image_transport/test/test_compressed_roundtrip.cpp
@@ -0,0 +1,114 @@
+// Copyright (c) 2026, Open Source Robotics Foundation, Inc.
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+//
+// * Redistributions in binary form must reproduce the above copyright
+// notice, this list of conditions and the following disclaimer in the
+// documentation and/or other materials provided with the distribution.
+//
+// * Neither the name of the copyright holder nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+// Codec-level round-trip tests: they exercise the OpenCV image codecs the
+// compressed transport relies on (JPEG / PNG), with no ROS node or transport.
+
+#include
+
+#include
+
+#include
+#include
+
+namespace
+{
+
+// Build a bgr8 image with a deterministic per-pixel pattern.
+cv::Mat makeBgr8(int w, int h)
+{
+ cv::Mat img(h, w, CV_8UC3);
+ for (int y = 0; y < h; ++y) {
+ for (int x = 0; x < w; ++x) {
+ img.at(y, x) = cv::Vec3b(
+ static_cast((x * 7) & 0xFF),
+ static_cast((y * 5) & 0xFF),
+ static_cast((x + y) & 0xFF));
+ }
+ }
+ return img;
+}
+
+int maxAbsDiff(const cv::Mat & a, const cv::Mat & b)
+{
+ cv::Mat diff;
+ cv::absdiff(a, b, diff);
+ double maxval = 0.0;
+ cv::minMaxLoc(diff.reshape(1), nullptr, &maxval);
+ return static_cast(maxval);
+}
+
+} // namespace
+
+// PNG is lossless: encode -> decode must reproduce the image exactly.
+TEST(CompressedCodecRoundTrip, PngIsLossless)
+{
+ const cv::Mat original = makeBgr8(32, 24);
+ std::vector buffer;
+ const std::vector params = {cv::IMWRITE_PNG_COMPRESSION, 3};
+ ASSERT_TRUE(cv::imencode(".png", original, buffer, params));
+
+ const cv::Mat decoded = cv::imdecode(buffer, cv::IMREAD_UNCHANGED);
+ ASSERT_FALSE(decoded.empty());
+ EXPECT_EQ(decoded.size(), original.size());
+ EXPECT_EQ(decoded.type(), original.type());
+ EXPECT_EQ(maxAbsDiff(original, decoded), 0);
+}
+
+// JPEG is lossy: structure exact, pixel values within a tolerance.
+TEST(CompressedCodecRoundTrip, JpegApproximate)
+{
+ const cv::Mat original = makeBgr8(32, 24);
+ std::vector buffer;
+ const std::vector params = {cv::IMWRITE_JPEG_QUALITY, 95};
+ ASSERT_TRUE(cv::imencode(".jpg", original, buffer, params));
+
+ const cv::Mat decoded = cv::imdecode(buffer, cv::IMREAD_COLOR);
+ ASSERT_FALSE(decoded.empty());
+ EXPECT_EQ(decoded.size(), original.size());
+ EXPECT_LE(maxAbsDiff(original, decoded), 20) << "JPEG round-trip drifted more than expected";
+}
+
+// A single-channel 16-bit image (depth-like) must survive PNG losslessly.
+TEST(CompressedCodecRoundTrip, Png16BitIsLossless)
+{
+ cv::Mat original(24, 32, CV_16UC1);
+ for (int y = 0; y < original.rows; ++y) {
+ for (int x = 0; x < original.cols; ++x) {
+ original.at(y, x) = static_cast((x * 337 + y * 71) & 0xFFFF);
+ }
+ }
+ std::vector buffer;
+ ASSERT_TRUE(cv::imencode(".png", original, buffer));
+
+ const cv::Mat decoded = cv::imdecode(buffer, cv::IMREAD_UNCHANGED);
+ ASSERT_FALSE(decoded.empty());
+ EXPECT_EQ(decoded.type(), original.type());
+ EXPECT_EQ(maxAbsDiff(original, decoded), 0);
+}
diff --git a/compressed_image_transport/test/test_compressed_transport.cpp b/compressed_image_transport/test/test_compressed_transport.cpp
new file mode 100644
index 0000000..af5c4af
--- /dev/null
+++ b/compressed_image_transport/test/test_compressed_transport.cpp
@@ -0,0 +1,139 @@
+// Copyright (c) 2026, Open Source Robotics Foundation, Inc.
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+//
+// * Redistributions in binary form must reproduce the above copyright
+// notice, this list of conditions and the following disclaimer in the
+// documentation and/or other materials provided with the distribution.
+//
+// * Neither the name of the copyright holder nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+using namespace std::chrono_literals;
+
+namespace
+{
+
+// Build a constant-colour bgr8 image (constant colour survives JPEG cleanly).
+sensor_msgs::msg::Image makeBgr8(uint32_t w, uint32_t h, uint8_t b, uint8_t g, uint8_t r)
+{
+ sensor_msgs::msg::Image img;
+ img.header.frame_id = "camera";
+ img.height = h;
+ img.width = w;
+ img.encoding = "bgr8";
+ img.is_bigendian = 0;
+ img.step = w * 3;
+ img.data.resize(static_cast(img.step) * h);
+ for (size_t i = 0; i < img.data.size(); i += 3) {
+ img.data[i] = b;
+ img.data[i + 1] = g;
+ img.data[i + 2] = r;
+ }
+ return img;
+}
+
+} // namespace
+
+class CompressedTransportTest : public ::testing::Test
+{
+protected:
+ static void SetUpTestSuite()
+ {
+ if (!rclcpp::ok()) {
+ rclcpp::init(0, nullptr);
+ }
+ }
+ static void TearDownTestSuite()
+ {
+ rclcpp::shutdown();
+ }
+};
+
+// The compressed transport plugin must be discoverable through pluginlib.
+TEST_F(CompressedTransportTest, TransportIsLoadable)
+{
+ bool found = false;
+ for (const auto & name : image_transport::getLoadableTransports()) {
+ if (name.find("compressed") != std::string::npos) {
+ found = true;
+ break;
+ }
+ }
+ EXPECT_TRUE(found) << "compressed transport not found among loadable transports";
+}
+
+// Full integration over the compressed transport (default JPEG codec, lossy):
+// structure must match exactly, pixel values within a tolerance.
+TEST_F(CompressedTransportTest, PublishSubscribeRoundTrip)
+{
+ auto node = std::make_shared("compressed_transport_test");
+
+ sensor_msgs::msg::Image::ConstSharedPtr received;
+ auto sub = image_transport::create_subscription(
+ *node, "test_image",
+ [&received](const sensor_msgs::msg::Image::ConstSharedPtr & msg) {received = msg;},
+ "compressed", rclcpp::SystemDefaultsQoS());
+ auto pub = image_transport::create_publisher(*node, "test_image", rclcpp::SystemDefaultsQoS());
+
+ const auto original = makeBgr8(32, 24, 10, 120, 200);
+
+ rclcpp::executors::SingleThreadedExecutor exec;
+ const auto deadline = std::chrono::steady_clock::now() + 15s;
+ while (!received && std::chrono::steady_clock::now() < deadline && rclcpp::ok()) {
+ pub.publish(original);
+ exec.spin_node_some(node);
+ std::this_thread::sleep_for(50ms);
+ }
+
+ ASSERT_TRUE(received) << "no image received through the compressed transport within the timeout";
+ EXPECT_EQ(received->width, original.width);
+ EXPECT_EQ(received->height, original.height);
+ EXPECT_EQ(received->encoding, original.encoding);
+ ASSERT_EQ(received->data.size(), original.data.size());
+
+ int max_err = 0;
+ for (size_t i = 0; i < original.data.size(); ++i) {
+ max_err = std::max(max_err, std::abs(static_cast(received->data[i]) -
+ static_cast(original.data[i])));
+ }
+ EXPECT_LE(max_err, 12) << "JPEG round-trip drifted more than expected";
+}
+
+int main(int argc, char ** argv)
+{
+ testing::InitGoogleTest(&argc, argv);
+ return RUN_ALL_TESTS();
+}
diff --git a/theora_image_transport/CMakeLists.txt b/theora_image_transport/CMakeLists.txt
index dec4be8..2346a19 100644
--- a/theora_image_transport/CMakeLists.txt
+++ b/theora_image_transport/CMakeLists.txt
@@ -123,6 +123,17 @@ if(BUILD_TESTING)
find_package(ament_lint_auto REQUIRED)
set(ament_cmake_cppcheck_LANGUAGE "c++")
ament_lint_auto_find_test_dependencies()
+
+ find_package(ament_cmake_gtest REQUIRED)
+ # theora exposes no standalone codec API (encode/decode live in the stateful
+ # plugin), so it is covered only by the end-to-end transport test below, which
+ # exercises TheoraPublisher (encode) and TheoraSubscriber (decode) through a
+ # real node, plus a pluginlib-discovery check.
+ ament_add_gtest(test_theora_transport test/test_theora_transport.cpp)
+ target_link_libraries(test_theora_transport
+ image_transport::image_transport
+ rclcpp::rclcpp
+ ${sensor_msgs_TARGETS})
endif()
pluginlib_export_plugin_description_file(image_transport theora_plugins.xml)
diff --git a/theora_image_transport/package.xml b/theora_image_transport/package.xml
index 7c5b21b..e27dd5f 100644
--- a/theora_image_transport/package.xml
+++ b/theora_image_transport/package.xml
@@ -36,6 +36,7 @@
ament_lint_auto
ament_lint_common
+ ament_cmake_gtest
rosidl_interface_packages
diff --git a/theora_image_transport/test/test_theora_transport.cpp b/theora_image_transport/test/test_theora_transport.cpp
new file mode 100644
index 0000000..ec21b77
--- /dev/null
+++ b/theora_image_transport/test/test_theora_transport.cpp
@@ -0,0 +1,147 @@
+// Copyright (c) 2026, Open Source Robotics Foundation, Inc.
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+//
+// * Redistributions in binary form must reproduce the above copyright
+// notice, this list of conditions and the following disclaimer in the
+// documentation and/or other materials provided with the distribution.
+//
+// * Neither the name of the copyright holder nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+using namespace std::chrono_literals;
+
+namespace
+{
+
+// Build a constant-colour bgr8 image with dimensions that are multiples of 16
+// (theora encodes in 16x16 macroblocks).
+sensor_msgs::msg::Image makeBgr8(uint32_t w, uint32_t h, uint8_t b, uint8_t g, uint8_t r)
+{
+ sensor_msgs::msg::Image img;
+ img.header.frame_id = "camera";
+ img.height = h;
+ img.width = w;
+ img.encoding = "bgr8";
+ img.is_bigendian = 0;
+ img.step = w * 3;
+ img.data.resize(static_cast(img.step) * h);
+ for (size_t i = 0; i < img.data.size(); i += 3) {
+ img.data[i] = b;
+ img.data[i + 1] = g;
+ img.data[i + 2] = r;
+ }
+ return img;
+}
+
+} // namespace
+
+class TheoraTransportTest : public ::testing::Test
+{
+protected:
+ static void SetUpTestSuite()
+ {
+ if (!rclcpp::ok()) {
+ rclcpp::init(0, nullptr);
+ }
+ }
+ static void TearDownTestSuite()
+ {
+ rclcpp::shutdown();
+ }
+};
+
+// The theora transport plugin must be discoverable through pluginlib.
+TEST_F(TheoraTransportTest, TransportIsLoadable)
+{
+ bool found = false;
+ for (const auto & name : image_transport::getLoadableTransports()) {
+ if (name.find("theora") != std::string::npos) {
+ found = true;
+ break;
+ }
+ }
+ EXPECT_TRUE(found) << "theora transport not found among loadable transports";
+}
+
+// Full integration over the theora transport. Theora is a stateful streaming
+// codec (header packets + a keyframe must arrive before a frame decodes), so a
+// stream of frames is published. Theora is lossy: only structure is checked.
+TEST_F(TheoraTransportTest, PublishSubscribeRoundTrip)
+{
+ auto node = std::make_shared("theora_transport_test");
+
+ const auto qos = rclcpp::QoS(rclcpp::KeepLast(10));
+ sensor_msgs::msg::Image::ConstSharedPtr received;
+ auto sub = image_transport::create_subscription(
+ *node, "test_image",
+ [&received](const sensor_msgs::msg::Image::ConstSharedPtr & msg) {received = msg;},
+ "theora", qos);
+ auto pub = image_transport::create_publisher(*node, "test_image", qos);
+
+ const auto original = makeBgr8(64, 48, 40, 90, 160);
+
+ rclcpp::executors::SingleThreadedExecutor exec;
+
+ // Wait for the subscription to be matched before publishing: theora sends its
+ // stream headers with the first frame, so a late-matched subscriber would miss
+ // them and never be able to decode.
+ const auto match_deadline = std::chrono::steady_clock::now() + 10s;
+ while (pub.getNumSubscribers() == 0 &&
+ std::chrono::steady_clock::now() < match_deadline && rclcpp::ok())
+ {
+ exec.spin_node_some(node);
+ std::this_thread::sleep_for(10ms);
+ }
+
+ const auto deadline = std::chrono::steady_clock::now() + 20s;
+ while (!received && std::chrono::steady_clock::now() < deadline && rclcpp::ok()) {
+ pub.publish(original);
+ for (int i = 0; i < 20 && !received; ++i) {
+ exec.spin_node_some(node);
+ std::this_thread::sleep_for(10ms);
+ }
+ }
+
+ ASSERT_TRUE(received) << "no image decoded through the theora transport within the timeout";
+ EXPECT_EQ(received->width, original.width);
+ EXPECT_EQ(received->height, original.height);
+ EXPECT_FALSE(received->data.empty());
+ EXPECT_FALSE(received->encoding.empty());
+}
+
+int main(int argc, char ** argv)
+{
+ testing::InitGoogleTest(&argc, argv);
+ return RUN_ALL_TESTS();
+}
diff --git a/zstd_image_transport/CMakeLists.txt b/zstd_image_transport/CMakeLists.txt
index 82676bc..3a2f5c2 100644
--- a/zstd_image_transport/CMakeLists.txt
+++ b/zstd_image_transport/CMakeLists.txt
@@ -52,6 +52,23 @@ pluginlib_export_plugin_description_file(image_transport zstd_plugins.xml)
if(BUILD_TESTING)
find_package(ament_lint_auto REQUIRED)
ament_lint_auto_find_test_dependencies()
+
+ find_package(ament_cmake_gtest REQUIRED)
+ # Compile the codec sources directly into the test so it does not depend on
+ # image_transport (and its transitive links) to exercise the compression core.
+ ament_add_gtest(test_zstd_roundtrip test/test_zstd_roundtrip.cpp src/zlib_cpp.cpp)
+ target_include_directories(test_zstd_roundtrip PRIVATE src)
+ target_link_libraries(test_zstd_roundtrip
+ ament_cmake_ros_core::ament_ros_defaults
+ ZLIB::ZLIB)
+
+ # End-to-end integration over the "zstd" image transport through a real node
+ # (requires the installed plugin, discovered via pluginlib at runtime).
+ ament_add_gtest(test_zstd_transport test/test_zstd_transport.cpp)
+ target_link_libraries(test_zstd_transport
+ image_transport::image_transport
+ rclcpp::rclcpp
+ ${sensor_msgs_TARGETS})
endif()
ament_package()
diff --git a/zstd_image_transport/package.xml b/zstd_image_transport/package.xml
index 3924816..d265b59 100644
--- a/zstd_image_transport/package.xml
+++ b/zstd_image_transport/package.xml
@@ -19,6 +19,7 @@
ament_lint_auto
ament_lint_common
+ ament_cmake_gtest
ament_cmake
diff --git a/zstd_image_transport/test/test_zstd_roundtrip.cpp b/zstd_image_transport/test/test_zstd_roundtrip.cpp
new file mode 100644
index 0000000..1932ff5
--- /dev/null
+++ b/zstd_image_transport/test/test_zstd_roundtrip.cpp
@@ -0,0 +1,117 @@
+// Copyright (c) 2026, Open Source Robotics Foundation, Inc.
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+//
+// * Redistributions in binary form must reproduce the above copyright
+// notice, this list of conditions and the following disclaimer in the
+// documentation and/or other materials provided with the distribution.
+//
+// * Neither the name of the copyright holder nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+#include
+
+#include
+#include
+#include
+
+#include // NOLINT(build/include_order) cpplint misclassifies
+
+#include "byte_order.hpp"
+#include "zlib_cpp.hpp"
+
+namespace
+{
+
+// Build a deterministic byte buffer of `n` bytes.
+std::vector makeBuffer(std::size_t n, std::uint8_t fill_pattern = 0)
+{
+ std::vector b(n);
+ for (std::size_t i = 0; i < n; ++i) {
+ b[i] = fill_pattern ? fill_pattern : static_cast((i * 31u + 7u) & 0xFFu);
+ }
+ return b;
+}
+
+std::vector roundtrip(const std::vector & input)
+{
+ zlib::Comp comp(zlib::Comp::Level::Level_6, true);
+ EXPECT_TRUE(comp.IsSucc());
+ const std::vector compressed =
+ comp.Process(std::span(input.data(), input.size()), true);
+
+ zlib::Decomp decomp;
+ return decomp.Process(std::span(compressed.data(), compressed.size()));
+}
+
+} // namespace
+
+// zlib is lossless: compress -> decompress must reproduce the input exactly,
+// across a range of sizes (incl. the empty payload).
+TEST(ZstdCodecRoundTrip, LosslessAcrossSizes)
+{
+ for (std::size_t n : {std::size_t{0}, std::size_t{1}, std::size_t{17}, std::size_t{1024},
+ std::size_t{100000}})
+ {
+ const auto original = makeBuffer(n);
+ const auto restored = roundtrip(original);
+ EXPECT_EQ(restored, original) << "roundtrip mismatch for size " << n;
+ }
+}
+
+// A highly repetitive payload must actually shrink (proves compression runs).
+TEST(ZstdCodecRoundTrip, ShrinksCompressibleData)
+{
+ const auto original = makeBuffer(65536, 0xAB);
+ zlib::Comp comp(zlib::Comp::Level::Level_6, true);
+ const auto compressed =
+ comp.Process(std::span(original.data(), original.size()), true);
+ EXPECT_GT(compressed.size(), 0u);
+ EXPECT_LT(compressed.size(), original.size());
+ EXPECT_EQ(roundtrip(original), original);
+}
+
+// Invalid input must be rejected gracefully (no crash, empty output).
+TEST(ZstdCodecRoundTrip, RejectsGarbage)
+{
+ const auto garbage = makeBuffer(64, 0x5A);
+ zlib::Decomp decomp;
+ const auto out = decomp.Process(std::span(garbage.data(), garbage.size()));
+ EXPECT_TRUE(out.empty());
+}
+
+// The little-endian header helpers used by the (de)serializer must round-trip
+// exactly, including values with the high bit set (the old shift code was UB).
+TEST(ZstdByteOrder, StoreLoadRoundTrip)
+{
+ using zstd_image_transport::load_le;
+ using zstd_image_transport::store_le;
+ for (std::uint32_t v : {0u, 1u, 255u, 256u, 0x80000000u, 0xFFFFFFFFu, 0xDEADBEEFu}) {
+ std::array b{};
+ store_le(std::span(b), v);
+ EXPECT_EQ(load_le(std::span(b)), v);
+ }
+ // Little-endian byte order: least-significant byte first.
+ std::array b{};
+ store_le(std::span(b), 0x11223344u);
+ EXPECT_EQ(b[0], 0x44);
+ EXPECT_EQ(b[3], 0x11);
+}
diff --git a/zstd_image_transport/test/test_zstd_transport.cpp b/zstd_image_transport/test/test_zstd_transport.cpp
new file mode 100644
index 0000000..0413118
--- /dev/null
+++ b/zstd_image_transport/test/test_zstd_transport.cpp
@@ -0,0 +1,129 @@
+// Copyright (c) 2026, Open Source Robotics Foundation, Inc.
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+//
+// * Redistributions in binary form must reproduce the above copyright
+// notice, this list of conditions and the following disclaimer in the
+// documentation and/or other materials provided with the distribution.
+//
+// * Neither the name of the copyright holder nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+using namespace std::chrono_literals;
+
+namespace
+{
+
+// Build a bgr8 image with a varying (non-constant) pattern to exercise the codec.
+sensor_msgs::msg::Image makeImage(uint32_t w, uint32_t h)
+{
+ sensor_msgs::msg::Image img;
+ img.header.frame_id = "camera";
+ img.height = h;
+ img.width = w;
+ img.encoding = "bgr8";
+ img.is_bigendian = 0;
+ img.step = w * 3;
+ img.data.resize(static_cast(img.step) * h);
+ for (size_t i = 0; i < img.data.size(); ++i) {
+ img.data[i] = static_cast((i * 131u + 17u) & 0xFFu);
+ }
+ return img;
+}
+
+} // namespace
+
+class ZstdTransportTest : public ::testing::Test
+{
+protected:
+ static void SetUpTestSuite()
+ {
+ if (!rclcpp::ok()) {
+ rclcpp::init(0, nullptr);
+ }
+ }
+ static void TearDownTestSuite()
+ {
+ rclcpp::shutdown();
+ }
+};
+
+// The zstd transport plugin must be discoverable through pluginlib.
+TEST_F(ZstdTransportTest, TransportIsLoadable)
+{
+ bool found = false;
+ for (const auto & name : image_transport::getLoadableTransports()) {
+ if (name.find("zstd") != std::string::npos) {
+ found = true;
+ break;
+ }
+ }
+ EXPECT_TRUE(found) << "zstd transport not found among loadable transports";
+}
+
+// Full integration over the zstd transport. zstd is lossless, so the received
+// image must equal the original exactly (data and layout metadata).
+TEST_F(ZstdTransportTest, PublishSubscribeRoundTrip)
+{
+ auto node = std::make_shared("zstd_transport_test");
+
+ sensor_msgs::msg::Image::ConstSharedPtr received;
+ auto sub = image_transport::create_subscription(
+ *node, "test_image",
+ [&received](const sensor_msgs::msg::Image::ConstSharedPtr & msg) {received = msg;},
+ "zstd", rclcpp::SystemDefaultsQoS());
+ auto pub = image_transport::create_publisher(*node, "test_image", rclcpp::SystemDefaultsQoS());
+
+ const auto original = makeImage(64, 48);
+
+ rclcpp::executors::SingleThreadedExecutor exec;
+ const auto deadline = std::chrono::steady_clock::now() + 15s;
+ while (!received && std::chrono::steady_clock::now() < deadline && rclcpp::ok()) {
+ pub.publish(original);
+ exec.spin_node_some(node);
+ std::this_thread::sleep_for(50ms);
+ }
+
+ ASSERT_TRUE(received) << "no image received through the zstd transport within the timeout";
+ EXPECT_EQ(received->width, original.width);
+ EXPECT_EQ(received->height, original.height);
+ EXPECT_EQ(received->step, original.step);
+ EXPECT_EQ(received->encoding, original.encoding);
+ EXPECT_EQ(received->data, original.data); // lossless
+}
+
+int main(int argc, char ** argv)
+{
+ testing::InitGoogleTest(&argc, argv);
+ return RUN_ALL_TESTS();
+}