Create and Visualize a Robotic Arm with URDF – ROS 2 Lyrical

In this tutorial, we will create a model of a robotic arm from scratch.

Our robotic arm model will be in the standard Unified Robot Description Format, also known as URDF. We will then visualize the robotic arm in RViz, a 3D visualization tool for ROS 2

This tutorial will follow a previous tutorial I created.

The official tutorial for creating a URDF file is here on the ROS 2 website; but that tutorial only deals with a fictitious robot.

It is far more fun and helpful to show you how to create a URDF file for a real-world robot, like the ones you will work with at your job or at school…like this one…a robotic arm made by Universal Robots that is making an omelette at the M Social Singapore Hotel: A robot made my omelette!

Within ROS 2, defining the URDF file of your robotic arm is important because it allows software tools to understand the robot’s structure, enabling tasks like simulation, motion planning, and sensor data interpretation. It is like giving the robot a digital body that software can interact with.

I will walk through all the steps below for creating the URDF for the myCobot 280 robotic arm by Elephant Robotics. Follow along with me click by click, keystroke by keystroke.  

Prerequisites

You can find all the code here on GitHub.

References for the myCobot 280 Robot

What is a URDF File?

A URDF (Universal Robot Description Format) file is an XML file that describes what a robot should look like in real life. It contains the complete physical description of the robot. Building the body of the robot is the first step when integrating your mobile robot or robotic arm with ROS 2.

The body of a robot consists of two components:

  1. Links
  2. Joints

Links are the rigid pieces of a robot. They are the “bones”. 

Links are connected to each other by joints. Joints are the pieces of the robot that move, enabling motion between connected links.

Consider the human arm below as an example. The shoulder, elbow, and wrist are joints. The upper arm, forearm and palm of the hand are links.

link_joint

For a robotic arm, links and joints look like this.

link-joint-robotic-arm

You can see that a robotic arm is made of rigid pieces (links) and non-rigid pieces (joints). Servo motors at the joints cause the links of a robotic arm to move.

For a mobile robot with LIDAR, links and joints look like this:

mobile-robot-joints-links

The wheel joints are revolute joints. Revolute joints cause rotational motion. The wheel joints in the photo connect the wheel link to the base link.

Fixed joints have no motion at all. You can see that the LIDAR is connected to the base of the robot via a fixed joint (i.e. this could be a simple screw that connects the LIDAR to the base of the robot).You can also have prismatic joints. The SCARA robot in this post has a prismatic joint. Prismatic joints cause linear motion between links (as opposed to rotational motion).

Create a Package

The first step is to create a ROS 2 package to store all your files.

Open a new terminal window, and create a new folder named mycobot_ros2.

cd ~/ros2_ws/src
mkdir mycobot_ros2
cd mycobot_ros2

Now create the package where we will store our URDF file.

ros2 pkg create --build-type ament_cmake --license BSD-3-Clause mycobot_description

Now, let’s create a metapackage.

I discuss the purpose of a metapackage in this post.

A metapackage doesn’t contain anything except a list of dependencies to other packages. You can use a metapackage to make it easier to install multiple related packages at once. 

If you were to make your package available to install publicly using the apt-get package manager on Ubuntu for example, a metapackage would enable someone to automatically install all the ROS2 packages that are referenced in your metapackage. 

ros2 pkg create --build-type ament_cmake --license BSD-3-Clause mycobot_ros2
cd mycobot_ros2

Configure your package.xml file.

gedit package.xml

Make your package.xml file look like this:

<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
  <name>mycobot_ros2</name>
  <version>0.0.0</version>
  <description>myCobot series robots by Elephant Robotics (metapackage).</description>
  <maintainer email="automaticaddison@todo.todo">ubuntu</maintainer>
  <license>BSD-3-Clause</license>

  <buildtool_depend>ament_cmake</buildtool_depend>
  
  <exec_depend>mycobot_description</exec_depend>

  <test_depend>ament_lint_auto</test_depend>
  <test_depend>ament_lint_common</test_depend>

  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

Add a README.md to describe what the package is about.

cd ..
gedit README.md
# mycobot_ros2 #
![OS](https://img.shields.io/ubuntu/v/ubuntu-wallpapers/resolute)
![ROS_2](https://img.shields.io/ros/v/lyrical/rclcpp)

I also recommend adding placeholder README.md files to the mycobot_ros2 folder.

# mycobot_ros2 #

The my_cobot_ros2 package is a metapackage. It contains lists of dependencies to other packages.

… as well as the mycobot_description folder.

# mycobot_description #

The mycobot_description package contains the robot description files that define the physical aspects of a robot, including its geometry, kinematics, dynamics, and visual aspects.

Now let’s build our new package:

cd ~/ros2_ws
colcon build

Let’s see if our new package is recognized by ROS 2.

Either open a new terminal window or source the bashrc file like this:

source ~/.bashrc
ros2 pkg list

You can see the newly created package of you scroll up to the “m” packages.

1-mycobot-package

Now let’s create the following folders:

mkdir -p ~/ros2_ws/src/mycobot_ros2/mycobot_description/meshes/mycobot_280/visual
mkdir -p ~/ros2_ws/src/mycobot_ros2/mycobot_description/urdf/control
mkdir -p ~/ros2_ws/src/mycobot_ros2/mycobot_description/urdf/mech
mkdir -p ~/ros2_ws/src/mycobot_ros2/mycobot_description/urdf/sensors
mkdir -p ~/ros2_ws/src/mycobot_ros2/mycobot_description/urdf/robots/

Add the Meshes

Mesh files are what make your robotic arm look realistic in robotics simulation and visualization programs.

Mesh files visually represent the 3D shape of the robot parts. These files are typically in formats such as STL (Stereo Lithography – .stl) or COLLADA (.dae).

The mesh files we are going to use were already available in the GitHub repository for Elephant Robotics, the manufacturers of the robotic arm we will be using in this tutorial. We didn’t have to create these files from scratch.

However, if you want to create your own custom 3D printed robotic arm in the future, for example, you can make your own mesh file. Here is how:

  1. Design the robot’s body using CAD programs like Onshape, Fusion 360, AutoCAD, or Solidworks. These tools help you create 3D models of the robot parts.
  2. Export the 3D models as mesh files in formats like STL or COLLADA. These files contain information about the robot’s shape, including vertices, edges, and faces.
  3. If needed, use a tool like Blender to simplify the mesh files. This makes them easier to use in simulations and visualizations.
  4. Add the simplified mesh files to your URDF file to visually represent what the robot looks like.

Let’s pull these mesh files off GitHub. 

First, open a new terminal window, and type:

cd ~/Downloads/

Clone the mycobot repository to your machine.

git clone -b lyrical https://github.com/automaticaddison/mycobot_ros2.git

Move to the mesh files for the robotic arm we are going to model:

cp -r mycobot_ros2/mycobot_description/meshes/* ~/ros2_ws/src/mycobot_ros2/mycobot_description/meshes/
ls ~/ros2_ws/src/mycobot_ros2/mycobot_description/meshes/mycobot_280/visual/

You can see the mesh files (.dae) and the corresponding .png files for the robotic arm.

While you have the download folder open, grab two more folders as well:

cp -r mycobot_ros2/mycobot_description/urdf/control ~/ros2_ws/src/mycobot_ros2/mycobot_description/urdf/
cp -r mycobot_ros2/mycobot_description/urdf/sensors ~/ros2_ws/src/mycobot_ros2/mycobot_description/urdf/

These hold the control and camera pieces of the robot. We build them up properly in the Gazebo and MoveIt tutorials later on. You need them sitting there now because the main robot file we are about to write refers to them, and without them the robot will not load.

2-see-mesh-files

Configure CMakeLists.txt

Now let’s configure the CMakeLists.txt for the mycobot_description package. Open it in gedit:

gedit ~/ros2_ws/src/mycobot_ros2/mycobot_description/CMakeLists.txt

Make sure it looks like this:

cmake_minimum_required(VERSION 3.20)
project(mycobot_description)
 
# Check if the compiler being used is GNU's C++ compiler (g++) or Clang.
# Add compiler flags for all targets that will be defined later in the 
# CMakeLists file. These flags enable extra warnings to help catch
# potential issues in the code.
# Add options to the compilation process
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()
 
# Locate and configure packages required by the project.
find_package(ament_cmake REQUIRED)
find_package(urdf_tutorial REQUIRED)
 
# Copy necessary files to designated locations in the project
install (
  DIRECTORY meshes urdf
  DESTINATION share/${PROJECT_NAME}
)
 
# Automates the process of setting up linting for the package, which
# is the process of running tools that analyze the code for potential
# errors, style issues, and other discrepancies that do not adhere to
# specified coding standards or best practices.
if(BUILD_TESTING)
  find_package(ament_lint_auto REQUIRED)
  # the following line skips the linter which checks for copyrights
  # comment the line when a copyright and license is added to all source files
  set(ament_cmake_copyright_FOUND TRUE)
  # the following line skips cpplint (only works in a git repo)
  # comment the line when this package is in a git repo and when
  # a copyright and license is added to all source files
  set(ament_cmake_cpplint_FOUND TRUE)
  ament_lint_auto_find_test_dependencies()
endif()
 
ament_package()

Configure package.xml

Make sure your package.xml for the mycobot_description package looks like this:

<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
  <name>mycobot_description</name>
  <version>0.0.0</version>
  <description>Contains the robot description files that define the physical
    aspects of a robot, including its geometry, kinematics, dynamics, and
    visual aspects.</description>
  <maintainer email="automaticaddison@todo.todo">ubuntu</maintainer>
  <license>BSD-3-Clause</license>

  <buildtool_depend>ament_cmake</buildtool_depend>
  <depend>urdf_tutorial</depend>

  <test_depend>ament_lint_auto</test_depend>
  <test_depend>ament_lint_common</test_depend>

  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

Build the Package

Now let’s build the package.

cd ~/ros2_ws/
rosdep install -i --from-path src --rosdistro $ROS_DISTRO -y

You will now see this in the terminal:

3-ros-distro-dependencies-check

Type your password, and press Enter to install.

Open a terminal window, and type:

build

If this command doesn’t work, type these commands:

echo "alias build='cd ~/ros2_ws/ && colcon build && source ~/.bashrc'" >> ~/.bashrc
build

Create the URDF File

Now let’s create our URDF file. We will actually create it in XACRO format. I will use the terms URDF and XACRO interchangeably going forward.

XACRO files are like blueprints for URDF files, using macros and variables to simplify complex robot descriptions.

Imagine XACRO as the architect drawing up plans, and URDF as the final, ready-to-use construction document. Both file types represent the robotic arm, but XACRO offers more flexibility and organization.

Before a ROS tool or component can use the information in a XACRO file, it must first be processed (translated) into a URDF file. This step allows for the dynamic generation of robot descriptions based on the specific configurations defined in the XACRO file.

Open a terminal window, and type this command to create all the files we need. This is all a single command:

touch ~/ros2_ws/src/mycobot_ros2/mycobot_description/urdf/mech/{g_shape_base_v2_0.urdf.xacro,adaptive_gripper.urdf.xacro,mycobot_280_arm.urdf.xacro} ~/ros2_ws/src/mycobot_ros2/mycobot_description/urdf/robots/mycobot_280.urdf.xacro

Let’s start with creating our gripper: adaptive_gripper.urdf.xacro. Add this code.

Now let’s create the robot base: g_shape_base_v2_0.urdf.xacro. Add this code.

Now let’s create the robot arm: mycobot_280_arm.urdf.xacro. Add this code.

Now let’s create the full robot: mycobot_280.urdf.xacro. Add this code.

Understanding the URDF

Let’s walk through each file so we can understand what is going on.

adaptive_gripper.urdf.xacro

At the very start, we begin with our XML declaration and robot tag – this is standard for any URDF file. The xacro namespace tells us we’re using Xacro macros for more maintainable robot descriptions.

Looking at the first block of properties, these define the core characteristics of our gripper’s joints:

<xacro:property name="joint_effort" value="56.0"/>
<xacro:property name="joint_velocity" value="2.792527"/>
<xacro:property name="joint_damping" value="0.0"/>
<xacro:property name="joint_friction" value="0.0"/>

These values control how much force our joints can exert (effort), how fast they can move (velocity), and their friction/damping characteristics. Think of these as the physical limitations we’re putting on our gripper’s movements.

Next, we define some mass and inertial properties:

<xacro:property name="gripper_link_mass" value="0.007"/>
<xacro:property name="gripper_link_ixx" value="1e-6"/>
<xacro:property name="gripper_link_iyy" value="1e-6"/>
<xacro:property name="gripper_link_izz" value="1e-6"/>

These properties describe the mass of our gripper components and how their mass is distributed (inertia). These values are important for accurate physics simulation.

We then see a macro called gripper_link_inertial. This is a reusable template for inertial properties that we’ll use multiple times:

<xacro:macro name="gripper_link_inertial">
    <inertial>
      <origin xyz="0 0 0.0" rpy="0 0 0"/>
      <mass value="${gripper_link_mass}"/>
      <inertia ixx="${gripper_link_ixx}" ixy="0.0" ixz="0.0"
               iyy="${gripper_link_iyy}" iyz="0.0"
               izz="${gripper_link_izz}"/>
    </inertial>
</xacro:macro>

The main gripper definition starts with <xacro:macro name=”adaptive_gripper”>. This macro takes three parameters:

  • parent: what the gripper attaches to
  • prefix: a namespace prefix for unique naming
  • origin: where the gripper mounts

Looking at the links, we start with the gripper base. Each link has three main components:

<link name="${prefix}gripper_base">
    <inertial>...</inertial>    <!-- Physical properties -->
    <visual>...</visual>        <!-- How it looks -->
    <collision>...</collision>  <!-- Simplified shape for collision detection -->
</link>

The visual elements use mesh files (.dae format) for realistic appearance, while collision uses simple geometric shapes (boxes) for efficient collision checking.

Moving down, we see several joints. The key joint is gripper_controller, which is the main control point. Other joints are marked as “mimic” joints, meaning they follow the controller’s movement.

In the mimic element:

  • joint: The name of the joint to mimic.
  • multiplier: Scales the movement.
  • offset: Adds an offset to the movement.
   <joint name="${prefix}gripper_controller" type="revolute">
      <axis xyz="0 0 1"/>
      <limit effort="${joint_effort}" lower="-0.7" upper="0.15" velocity="${joint_velocity}"/>
      <parent link="${prefix}gripper_base"/>
      <child link="${prefix}gripper_left3"/>
      <origin xyz="-0.012 0.005 0" rpy="0 0 0"/>
      <dynamics damping="${joint_damping}" friction="${joint_friction}"/>
    </joint>

    <joint name="${prefix}gripper_base_to_${prefix}gripper_left2" type="revolute">
      <axis xyz="0 0 1"/>
      <limit effort="${joint_effort}" lower="-0.8" upper="0.5" velocity="${joint_velocity}"/>
      <parent link="${prefix}gripper_base"/>
      <child link="${prefix}gripper_left2"/>
      <origin xyz="-0.005 0.027 0" rpy="0 0 0"/>
      <mimic joint="${prefix}gripper_controller" multiplier="1.0" offset="0"/>
      <dynamics damping="${joint_damping}" friction="${joint_friction}"/>
    </joint>

The joint definitions include:

  • axis: which direction it rotates
  • limits: how far it can move
  • parent/child relationships: how parts connect
  • origin: where the joint is located relative to its parent

Finally, at the bottom, we have Gazebo-specific elements that define how the gripper appears in simulation:

<gazebo reference="${prefix}gripper_base">
    <visual>
        <material>...</material>
    </visual>
</gazebo>

The overall structure creates a gripper with six synchronized moving parts (three on each side) that can open and close to grasp objects. The gripper’s movement is controlled through a single main joint, with other joints following in a coordinated fashion.

g_shape_base_v2_0.urdf.xacro

Starting at the top, we have the standard XML declaration and robot tag with the xacro namespace. This is the same setup as our previous file.

The file defines a single macro called g_shape_base that takes two parameters:

  • base_link: The name of the base link
  • prefix: A namespace prefix for unique naming

Inside this macro, we define a single link with standard URDF components.

The inertial properties describe the physical characteristics.

  • origin: The position (xyz) and orientation (rpy: roll, pitch, yaw) of the link’s center of mass.
  • mass: The mass of the link in kilograms.
  • inertia: This describes how the mass is distributed, affecting how the link resists rotational motion.
<inertial>
    <origin xyz="0 0 0.0" rpy="0 0 0"/>  <!-- Center of mass at origin -->
    <mass value="0.33"/>                  <!-- Mass in kilograms -->
    <inertia                              <!-- Inertia matrix values -->
        ixx="0.000784" ixy="0.0" ixz="0.0"
        iyy="0.000867" iyz="0.0"
        izz="0.001598"/>
</inertial>

The visual component uses a mesh file for appearance. In this section, we specify how the link appears in simulations:

  • geometry: The shape of the link, defined here using a mesh file.
  • origin: The position and orientation of the visual representation.
<visual>
    <geometry>
        <mesh filename="file://$(find mycobot_description)/meshes/g_shape_base_v2_0/visual/base_link.dae"/>
    </geometry>
    <origin xyz="0.0 0 -0.03" rpy="0 0 ${pi/2}"/>  <!-- Offset and rotated 90 degrees -->
</visual>

The collision geometry uses a simple box shape for efficient collision detection:

  • geometry: Again, the shape, using the same mesh file.
  • origin: Position and orientation for collision purposes.
<collision>
    <geometry>
        <box size="0.105 0.14 0.02"/>  <!-- Box dimensions in meters -->
    </geometry>
    <origin xyz="0.0 0.0 -0.015" rpy="0 0 0"/>
</collision>

Finally, there’s a Gazebo-specific section that defines how the base appears in simulation, with gray coloring (0.5, 0.5, 0.5):

<gazebo reference="${prefix}${base_link}">
    <visual>
        <material>
            <ambient>0.5 0.5 0.5 1</ambient>
            <diffuse>0.5 0.5 0.5 1</diffuse>
            <specular>0.5 0.5 0.5 1</specular>
        </material>
    </visual>
</gazebo>

This file is much simpler than the gripper because it’s just describing a static base piece – there are no joints or moving parts. It’s essentially defining a gray platform that other robot components can be mounted to.

mycobot_280_arm.urdf.xacro

At the top, we start with our common joint properties. These will be used for all the moving joints in the arm:

<xacro:property name="joint_effort" value="56.0"/>      <!-- Maximum force the joint can exert -->
<xacro:property name="joint_velocity" value="2.792527"/> <!-- Maximum joint velocity -->
<xacro:property name="joint_damping" value="0.0"/>      <!-- Joint damping coefficient -->
<xacro:property name="joint_friction" value="0.0"/>     <!-- Joint friction coefficient →

Then we define the masses for each link in the arm:

<xacro:property name="link1_mass" value="0.12"/>
<xacro:property name="link2_mass" value="0.19"/>
<!-- ... and so on for links 3-6 and flange -->

The file includes two helpful macros to reduce code repetition:

  1. link_inertial: A template for defining inertial properties of links
  2. material_visual: A template for defining how links appear in Gazebo simulation

The main robot arm definition is in the mycobot_280_arm macro. This takes parameters for the base link name, flange link name, and a prefix for unique naming.

For each link (link1 through link6 plus the flange), we define:

<link name="${prefix}linkN">
    <inertial>...</inertial>    <!-- Physical properties using the link_inertial macro -->
    <visual>                    <!-- Visual appearance using mesh files -->
        <geometry>
            <mesh filename="..."/>
        </geometry>
    </visual>
    <collision>                 <!-- Simplified shapes for collision detection -->
        <geometry>
            <cylinder/> or <box/>
        </geometry>
    </collision>
</link>

The joints connecting these links are defined next. The first joint is fixed, while the others are revolute (rotating) joints:

  • name: The name of the joint.
  • type: The type of joint, which can be ‘fixed’, ‘revolute’, or others. A ‘fixed’ joint means no relative motion between the connected links.
  • parent and child: The links this joint connects.
  • origin: The position and orientation of the joint relative to the parent link.
<joint name="${prefix}linkN_to_${prefix}linkN+1" type="revolute">
    <axis xyz="0 0 1"/>         <!-- Rotation axis -->
    <limit effort="${joint_effort}" 
           lower="-2.879793" 
           upper="2.879793" 
           velocity="${joint_velocity}"/>  <!-- Motion limits -->
    <parent link="${prefix}linkN"/>       <!-- Which link it's attached to -->
    <child link="${prefix}linkN+1"/>      <!-- Which link it moves -->
    <origin xyz="x y z" rpy="r p y"/>     <!-- Position and orientation -->
</joint>

For revolute joints, we typically specify the following parameters:

  • axis: The axis of rotation.
    • 0 0 1 means we have rotation around the z axis of the parent coordinate frame.
  • limit: This parameter defines the motion limits.
    • effort: Maximum torque the joint can apply. Torque is the twisting force that makes something turn or rotate.
    • lower and upper: The range of allowed angles in radians.
    • velocity: Maximum angular velocity in radians per second.
  • damping: This parameter measured in Newton-meters per radian per second (N⋅m⋅s/rad), determines how much the joint resists moving at a certain speed, similar to how a shock absorber in a car slows down the movement of the wheels over bumps.

Finally, at the bottom, we set the visual properties for each link in Gazebo:

<xacro:material_visual ref_link="${prefix}link1" 
    ambient="0.5 0.5 0.5 1" 
    diffuse="0.5 0.5 0.5 1" 
    specular="0.5 0.5 0.5 1"/>

Most links are set to white (1 1 1), while the base and flange are gray (0.5 0.5 0.5).

This URDF describes a complete 6-axis robot arm with:

  • Realistic mass and inertial properties
  • Detailed 3D meshes for visualization
  • Simplified collision geometries for physics
  • Joint limits and dynamics
  • Consistent materials for simulation

mycobot_280.urdf.xacro

This file brings everything together.

At the top, we define several arguments that control how the robot is configured:
<xacro:arg name="add_world" default="true"/>      <!-- Whether to add a world link -->
<xacro:arg name="base_link" default="base_link"/> <!-- Name of the base link -->
<xacro:arg name="base_type" default="g_shape"/>   <!-- Type of base to use -->
<xacro:arg name="flange_link" default="link6_flange"/> <!-- Name of end flange -->
<xacro:arg name="gripper_type" default="adaptive_gripper"/> <!-- Type of gripper -->
<xacro:arg name="prefix" default=""/>        <!-- Prefix for naming -->
<xacro:arg name="use_gripper" default="true"/>    <!-- Whether to add a gripper -->

If add_world is true, we create a world link and connect our robot to it:

<xacro:if value="$(arg add_world)">
    <link name="world"/>
    <joint name="$(arg prefix)virtual_joint" type="fixed">
        <parent link="world"/>
        <child link="$(arg prefix)$(arg base_link)"/>
        <origin xyz="0 0 0" rpy="0 0 0"/>
    </joint>
</xacro:if>

Next, we handle the base:

<xacro:if value="${current_base == 'g_shape'}">
    <xacro:include filename="...g_shape_base_v2_0.urdf.xacro"/>
    <xacro:g_shape_base
        base_link="$(arg base_link)"
        prefix="$(arg prefix)"/>
</xacro:if>

Then we include and configure the main robot arm:

<xacro:include filename="...mycobot_280_arm.urdf.xacro"/>
<xacro:mycobot_280_arm
    base_link="$(arg base_link)"
    flange_link="$(arg flange_link)"
    prefix="$(arg prefix)">
    <origin xyz="0 0 0" rpy="0 0 0"/>
</xacro:mycobot_280_arm>

Finally, if we want a gripper, we add it:

<xacro:if value="$(arg use_gripper)">
    <xacro:if value="${current_gripper == 'adaptive_gripper'}">
        <xacro:include filename="...adaptive_gripper.urdf.xacro"/>
        <xacro:adaptive_gripper
            parent="$(arg flange_link)"
            prefix="$(arg prefix)">
            <origin xyz="0 0 0.034" rpy="1.579 0 0"/>
        </xacro:adaptive_gripper>
    </xacro:if>
</xacro:if>

This file acts as the main assembly file, bringing together:

  • Optional world connection
  • The base
  • The 6-axis robot arm
  • Optional adaptive gripper

Each component is included as a separate file and configured using the arguments at the top. This modular approach makes it easy to swap out different bases or grippers, or create multiple robots with different configurations.

And that’s a detailed walkthrough of our XACRO file.

We’ve covered everything from declaring the robot model, defining links and joints, to setting up properties and mimics. 

XACRO files look complex the first time you see one. I remember the first time I looked at a XACRO file, and I got scared at how complicated it looked. Breaking them down into their components helps us understand how each part contributes to the robot’s functionality.

Build the Package

Now let’s build the package.

build

Visualize the URDF File

Let’s see the URDF file in RViz. 

Launch the URDF file. The conversion from XACRO to URDF happens behind the scenes. Be sure to have the correct path to your XACRO file.

ros2 launch urdf_tutorial display.launch.py model:=/home/ubuntu/ros2_ws/src/mycobot_ros2/mycobot_description/urdf/robots/mycobot_280.urdf.xacro

By convention, the red axis is the x-axis, the green axis in the y-axis, and the blue axis is the z-axis.

4-display-robot-arm

Uncheck the TF checkbox to turn off the axes.

5-uncheck-tf

You can use the Joint State Publisher GUI pop-up window to move the links around.

6-joint-state-publisher-gui

On the left panel under Displays, play around by checking and unchecking different options.

For example, under Robot Model, you can see how the mass is distributed for the robot arm by unchecking “Visual Enabled” and “Collision Enabled” and checking the “Mass” checkbox under “Mass Properties”.

7-mass-checkbox

You can also see what simulation engines will use to detect collisions when the robotic arm is commanded to go to a certain point.

Uncheck “Visual Enabled” under Robot Model and check “Collision Enabled.”

8-collision-enabled
9-visual-and-collision-enabled-checked

You can also see the coordinate frames. 

Open a new terminal window, and type the following commands:

cd ~/Documents/
ros2 run tf2_tools view_frames

To see the coordinate frames, type:

dir
evince frames_YYYY-MM-DD_HH.MM.SS.pdf
10-coordinate-frames-arm

To close RViz, press CTRL + C.

So we can quickly visualize our robot in the future, let’s add a bash command that will enable us to quickly see our URDF.

echo "alias elephant='ros2 launch urdf_tutorial display.launch.py model:=/home/ubuntu/ros2_ws/src/mycobot_ros2/mycobot_description/urdf/robots/mycobot_280.urdf.xacro'" >> ~/.bashrc

To see it was added, type:

cat ~/.bashrc
build

Going forward, if you want to see your URDF file, type this command in the terminal window:

elephant

That’s it. Keep building, and I will see you in the next tutorial!

How to Create Unit Tests with GTest – ROS 2 Lyrical

This tutorial introduces unit testing in ROS 2 using GTest (GoogleTest), Google’s C++ testing framework. We’ll explore how to write and run tests for ROS 2 nodes, ensuring your robotic software functions correctly at the component level.

By the end, you’ll be equipped to implement comprehensive unit tests, enhancing the reliability and maintainability of your ROS 2 projects.

Prerequisites

You can find all the code here on GitHub.

Why Use GTest

Imagine you’re building a complex robot using ROS 2, with multiple C++ nodes working together to control its behavior. Every time you update your code, install a new version of ROS 2, or upgrade any dependencies, something could break without you realizing it. This is where GTest comes in handy.

GTest is like a safety net for your robot’s C++ code. It automatically checks that everything still works correctly after you make changes. For example, if you update your navigation code or install a new version of a sensor driver, GTest can quickly verify that your nodes are still publishing the right messages at the right times. This helps you catch problems early – before your robot starts behaving unexpectedly in the real world.

Create the Unit Test

Let’s write a basic unit test for our ROS 2 C++ publisher node. The test code we will write sets up a testing environment for a ROS 2 publisher node, making sure it can properly start up and shut down. 

The first test checks if the node is created with the right name and is publishing on the correct topic. The second test verifies that the node actually publishes messages with the correct “Hello World!” format we expect.

Open a terminal window and move to your workspace:

cd ~/ros2_ws/
colcon build && source ~/.bashrc
cd ~/ros2_ws/src/ros2_fundamentals_examples/

Create a new folder:

mkdir -p test/gtest

Create the test file test/gtest/test_publisher.cpp:

/**
 * @file test_publisher.cpp
 * @brief Unit tests for the ROS 2 minimal publisher node.
 *
 * This file contains test cases to verify the functionality of our minimal publisher.
 * We test two main things:
 * 1. That the node is created correctly with the right name and topic
 * 2. That it publishes the expected "Hello World!" message
 *
 * Testing Framework:
 *   Google Test (gtest) for C++ unit testing
 *
 * Tests:
 *   TestNodeCreation: Verifies node name and publisher setup
 *   TestMessageContent: Verifies published message format
 *
 * @author Addison Sears-Collins
 * @date August 25, 2026
 */

// Include necessary header files for testing
#include <gtest/gtest.h>
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"

// Tell the compiler our node class exists without including full definition yet
class MinimalCppPublisher;

// This prevents the main() function in the implementation file from being included
// which would conflict with our test's main() function
#define TESTING_EXCLUDE_MAIN
#include "../../src/cpp_minimal_publisher.cpp"

/**
 * @class TestMinimalPublisher
 * @brief Test fixture for the MinimalCppPublisher tests
 *
 * This class sets up and tears down the testing environment for each test.
 * It initializes ROS 2 and creates our publisher node before each test,
 * and cleans up afterward.
 */
class TestMinimalPublisher : public ::testing::Test
{
protected:
    /**
     * @brief Set up the test fixture before each test
     *
     * Initializes ROS 2 and creates a new publisher node
     */
    void SetUp() override
    {
        rclcpp::init(0, nullptr);
        node = std::make_shared<MinimalCppPublisher>();
    }

    /**
     * @brief Clean up after each test completes
     *
     * Shuts down the node and ROS 2
     */
    void TearDown() override
    {
        node.reset();
        rclcpp::shutdown();
    }

    // The node we're testing
    std::shared_ptr<MinimalCppPublisher> node;
};

/**
 * @test TestNodeCreation
 * @brief Verify the publisher node is created correctly
 *
 * This test checks two things:
 * 1. The node has the correct name "minimal_cpp_publisher"
 * 2. The node has exactly one publisher on the topic "/cpp_example_topic"
 */
TEST_F(TestMinimalPublisher, TestNodeCreation)
{
    // Check if the node name matches what we expect
    EXPECT_EQ(std::string(node->get_name()), std::string("minimal_cpp_publisher"));

    // Get information about publishers on our topic and verify there's exactly one
    auto pub_endpoints = node->get_publishers_info_by_topic("/cpp_example_topic");
    EXPECT_EQ(pub_endpoints.size(), 1u);
}

/**
 * @test TestMessageContent
 * @brief Verify the published message has correct content
 *
 * This test:
 * 1. Creates a subscription to capture the published message
 * 2. Triggers the publisher to send a message
 * 3. Verifies the message starts with "Hello World!"
 */
TEST_F(TestMinimalPublisher, TestMessageContent)
{
    // Variable to store the received message
    std::shared_ptr<std_msgs::msg::String> received_msg;

    // Create a subscription to capture the published message
    auto subscription = node->create_subscription<std_msgs::msg::String>(
        "/cpp_example_topic", 10,
        [&received_msg](const std_msgs::msg::String::SharedPtr msg) {
            received_msg = std::make_shared<std_msgs::msg::String>(*msg);
        });

    // Trigger the publisher to send a message
    node->timerCallback();

    // Process any incoming messages
    rclcpp::executors::SingleThreadedExecutor executor;
    executor.add_node(node);
    executor.spin_some();

    // Check if the message starts with "Hello World!"
    EXPECT_EQ(received_msg->data.substr(0, 12), "Hello World!");
}

/**
 * @brief Main function to run all tests
 *
 * This function initializes the Google Test framework and runs all tests
 */
int main(int argc, char** argv)
{
    testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

Edit the C++ Publisher

Open cpp_minimal_publisher.cpp.

We need to wrap the main function in the TESTING_EXCLUDE_MAIN condition so it won’t be included during testing. Here’s how to modify it:

#ifndef TESTING_EXCLUDE_MAIN
int main(int argc, char * argv[])
{
  // Initialize ROS 2.
  rclcpp::init(argc, argv);

  // Create an instance of the MinimalCppPublisher node and keep it running.
  auto minimal_cpp_publisher_node = std::make_shared<MinimalCppPublisher>();
  rclcpp::spin(minimal_cpp_publisher_node);

  // Shutdown ROS 2 upon node termination.
  rclcpp::shutdown();

  // End of program.
  return 0;
}
#endif

By adding the #ifndef TESTING_EXCLUDE_MAIN and #endif, this main function will only be compiled when we’re not running tests. When we include this file in our test file (where we defined TESTING_EXCLUDE_MAIN), this main function will be skipped.

Edit CMakeLists.txt

Update CMakeLists.txt. Find the if(BUILD_TESTING) block and replace it with:

Notice we hook the test program up to its dependencies with target_link_libraries. Older tutorials used ament_target_dependencies here. That command is gone now, so using it will stop your build with an error.

if(BUILD_TESTING)
  # Find required test packages
  find_package(ament_lint_auto REQUIRED)
  find_package(ament_cmake_pytest REQUIRED)
  find_package(ament_cmake_gtest REQUIRED)
  
  # Python tests
  ament_add_pytest_test(minimal_publisher_test test/pytest/test_publisher.py
    TIMEOUT 60
  )
  
  # C++ tests
  ament_add_gtest(cpp_minimal_publisher_test
    test/gtest/test_publisher.cpp
  )
  
  # Add dependencies for the C++ test executable
  target_link_libraries(cpp_minimal_publisher_test
    ${dependencies}
  )
  
  # Optional: Enable other linting tools
  #set(ament_cmake_copyright_FOUND TRUE)
  #set(ament_cmake_cpplint_FOUND TRUE)
  #ament_lint_auto_find_test_dependencies()
endif()

Edit package.xml

We need to add this to the package.xml so that our tests are discoverable to cmake:

<test_depend>ament_cmake_gtest</test_depend>

Save the file, and close it.

Build and Run

Build and run the tests:

cd ~/ros2_ws
colcon build --packages-select ros2_fundamentals_examples
source ~/.bashrc
colcon test --packages-select ros2_fundamentals_examples

To see detailed test results, type:

colcon test --packages-select ros2_fundamentals_examples --event-handlers console_direct+
1-tests-passed

When we run the tests using colcon test, it first runs the Python tests which all pass (“3 passed in 0.37s”). Then it runs our two C++ GTest tests: one checking the node name and publisher setup, and another checking the message content. Both C++ tests pass as shown by the output “[  PASSED  ] 2 tests” and “100% tests passed, 0 tests failed out of 2”, indicating that our publisher node is working exactly as intended.

To check test results:

colcon test-result --all

To see which test cases failed (if any):

colcon test-result --all --verbose

That’s it. Keep building!

How to Create Unit Tests with Pytest – ROS 2 Lyrical

Imagine you’re building a complex robot using ROS 2. Your robot is made up of many different parts, each controlled by a piece of software. Now, how do you make sure all these pieces work correctly? That’s where unit testing comes in.

Unit testing is like doing a health check-up for each part of your robot’s software. It’s a way to automatically test small pieces of your code to make sure they’re doing exactly what they’re supposed to do. 

Unit tests are essential tools in robotics software development for two key reasons:

  • Early Bug Detection: Automate the testing of code to ensure that changes or updates don’t introduce bugs.
  • Improved Code Design: Writing testable code leads to cleaner, more modular software.

In this tutorial, I’ll show you how to use a tool called Pytest to write and run these unit tests. With Pytest, you can write tests for individual parts of your code, such as functions and classes, to make sure each part behaves as expected.

Pytest is designed primarily for Python code, so it’s commonly used for testing Python-based ROS 2 packages. For C++ code in ROS 2, other testing frameworks, like GTest, are typically used instead.

Prerequisites

You can find all the code here on GitHub.

Create a Unit Test 

Let’s write a basic unit test for our ROS 2 publisher node. This test will verify three key things: 

  1. that our node is created correctly
  2. that its message counter works properly
  3. that it formats messages as expected. 

We’ll create a Python test file that uses Pytest to run these checks automatically.

Open a terminal window, and move to your workspace.

cd ~/ros2_ws/
colcon build && source ~/.bashrc
cd ~/ros2_ws/src/ros2_fundamentals_examples/
mkdir -p test/pytest

Create a test file test/pytest/test_publisher.py:

#!/usr/bin/env python3
"""
Test suite for the ROS2 minimal publisher node.

This script contains unit tests for verifying the functionality of a minimal ROS2 publisher.
It tests the node creation, message counter increment, and message content formatting.

Subscription Topics:
    None

Publishing Topics:
    /py_example_topic (std_msgs/String): Example messages with incrementing counter

:author: Addison Sears-Collins
:date: August 25, 2026
"""

import pytest
import rclpy
from std_msgs.msg import String
from ros2_fundamentals_examples.py_minimal_publisher import MinimalPyPublisher


def test_publisher_creation():
    """
    Test if the publisher node is created correctly.

    This test verifies:
    1. The node name is set correctly
    2. The publisher object exists
    3. The topic name is correct

    :raises: AssertionError if any of the checks fail
    """
    # Initialize ROS2 communication
    rclpy.init()
    try:
        # Create an instance of our publisher node
        node = MinimalPyPublisher()

        # Test 1: Verify the node has the expected name
        assert node.get_name() == 'minimal_py_publisher'

        # Test 2: Verify the publisher exists and has the correct topic name
        assert hasattr(node, 'publisher_1')
        assert node.publisher_1.topic_name == '/py_example_topic'
    finally:
        # Clean up ROS2 communication
        rclpy.shutdown()


def test_message_counter():
    """
    Test if the message counter increments correctly.

    This test verifies that the counter (node.i) increases by 1 after
    each timer callback execution.

    :raises: AssertionError if the counter doesn't increment properly
    """
    rclpy.init()
    try:
        node = MinimalPyPublisher()
        initial_count = node.i
        node.timer_callback()
        assert node.i == initial_count + 1
    finally:
        rclpy.shutdown()


def test_message_content():
    """
    Test if the message content is formatted correctly.

    This test verifies that the message string is properly formatted
    using an f-string with the current counter value.

    :raises: AssertionError if the message format doesn't match expected output
    """
    rclpy.init()
    try:
        node = MinimalPyPublisher()
        # Set counter to a known value for testing
        node.i = 5
        msg = String()
        # Using f-string instead of % formatting
        msg.data = f'Hello World: {node.i}'
        assert msg.data == 'Hello World: 5'
    finally:
        rclpy.shutdown()


if __name__ == '__main__':
    pytest.main(['-v'])

Edit CMakeLists.txt

Now open CMakeLists.txt, and replace the if(BUILD_TESTING) block with this code:

if(BUILD_TESTING)
  #find_package(ament_lint_auto REQUIRED)
  find_package(ament_cmake_pytest REQUIRED)
  #set(ament_cmake_copyright_FOUND TRUE)
  #set(ament_cmake_cpplint_FOUND TRUE)
  #ament_lint_auto_find_test_dependencies()

  ament_add_pytest_test(minimal_publisher_test test/pytest/test_publisher.py
   TIMEOUT 60
  )
endif()

Edit the package.xml File

We need to add this to the package.xml so that our tests are discoverable to cmake:

<test_depend>ament_cmake_pytest</test_depend>

Save the file, and close it.

Build and Run

Compile and run the tests.

cd ~/ros2_ws
rosdep install --from-paths src --ignore-src -r -y
colcon build --packages-select ros2_fundamentals_examples
source ~/.bashrc
colcon test --packages-select ros2_fundamentals_examples
1-colcon-test

To get more detail, you could also have done:

colcon test --packages-select ros2_fundamentals_examples --event-handlers console_direct+

When we run our unit tests with this command above (Starting >>> ros2_fundamentals_examples), it first starts by loading our ROS 2 package.

The system then collects and runs our three test cases, shown as the three passing tests in the output (test/pytest/test_publisher.py …). These dots represent successful runs of our three tests – publisher node creation, message counter, and message content.

Your test results should show all tests passed successfully (============================== 3 passed in 0.28s ===============================). 

My final summary shows that our single test suite (1/1 Test #1: minimal_publisher_test) passed successfully in 0.76 seconds, with CMake reporting 100% success rate as no tests failed.

Now once that is finished, check the results:

colcon test-result --all

To see which test cases failed, type:

colcon test-result --all --verbose

That’s it.

Keep building!