diff --git a/.github/.wordlist.txt b/.github/.wordlist.txt
index c4f48058b70416..23e974cb2f4269 100644
--- a/.github/.wordlist.txt
+++ b/.github/.wordlist.txt
@@ -945,8 +945,6 @@ NitricOxideConcentrationMeasurement
 NitrogenDioxideConcentrationMeasurement
 nl
 nltest
-NLUnitTest
-NLUnitTests
 nmcli
 nmtui
 noc
diff --git a/build/chip/chip_test.gni b/build/chip/chip_test.gni
deleted file mode 100644
index b5b32f24d0b0b7..00000000000000
--- a/build/chip/chip_test.gni
+++ /dev/null
@@ -1,71 +0,0 @@
-# Copyright (c) 2020 Project CHIP Authors
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import("//build_overrides/build.gni")
-import("//build_overrides/chip.gni")
-import("//build_overrides/pigweed.gni")
-
-import("$dir_pw_build/python_action.gni")
-
-import("${chip_root}/build/chip/tests.gni")
-import("${chip_root}/src/platform/device.gni")
-import("${dir_pw_unit_test}/test.gni")
-
-assert(chip_build_tests)
-
-if (chip_link_tests) {
-  template("chip_test") {
-    _test_name = target_name
-
-    _test_output_dir = "${root_out_dir}/tests"
-    if (defined(invoker.output_dir)) {
-      _test_output_dir = invoker.output_dir
-    }
-
-    executable(_test_name) {
-      forward_variables_from(invoker, "*", [ "output_dir" ])
-      output_dir = _test_output_dir
-    }
-
-    group(_test_name + ".lib") {
-    }
-
-    if (chip_pw_run_tests) {
-      pw_python_action(_test_name + ".run") {
-        deps = [ ":${_test_name}" ]
-        inputs = [ pw_unit_test_AUTOMATIC_RUNNER ]
-        module = "pw_unit_test.test_runner"
-        python_deps = [
-          "$dir_pw_cli/py",
-          "$dir_pw_unit_test/py",
-        ]
-        args = [
-          "--runner",
-          rebase_path(pw_unit_test_AUTOMATIC_RUNNER, root_build_dir),
-          "--test",
-          rebase_path("$_test_output_dir/$_test_name", root_build_dir),
-        ]
-        stamp = true
-      }
-    }
-  }
-} else {
-  template("chip_test") {
-    group(target_name) {
-    }
-    group(target_name + ".lib") {
-    }
-    not_needed(invoker, "*")
-  }
-}
diff --git a/build/chip/chip_test_suite.gni b/build/chip/chip_test_suite.gni
index 60f29346a48fa6..de6b7c16848eb5 100644
--- a/build/chip/chip_test_suite.gni
+++ b/build/chip/chip_test_suite.gni
@@ -14,13 +14,20 @@
 
 import("//build_overrides/build.gni")
 import("//build_overrides/chip.gni")
+import("//build_overrides/pigweed.gni")
 
-import("${chip_root}/build/chip/chip_test.gni")
 import("${chip_root}/build/chip/tests.gni")
 import("${dir_pw_unit_test}/test.gni")
 
 assert(chip_build_tests)
 
+declare_args() {
+  # These may be overridden in args.gni to build platform-specific test binaries.
+  test_executable_output_name = ""
+  test_executable_output_name_suffix = ""
+  test_executable_ldflags = []
+}
+
 # Define CHIP unit tests
 #
 # Simple usage
@@ -41,50 +48,34 @@ assert(chip_build_tests)
 #     "${chip_root}/src/lib/foo",         # add dependencies here
 #   ]
 # }
-#
-#
-# Deprecated usage (writing own driver files):
-#
-# chip_test_suite("tests") {
-#   output_name = "libFooTests"
-#
-#   sources = [
-#     "TestDeclarations.h",
-#     "TestFoo.cpp",
-#     "TestBar.cpp",
-#   ]
-#
-#   public_deps = [
-#     "${chip_root}/src/lib/foo",         # add dependencies here
-#   ]
-#
-#   tests = [
-#     "TestFoo",  # Assumes TestFooDriver.cpp exists
-#     "TestBar",  # Assumes TestBarDriver.cpp exists
-#   ]
-# }
 
 #
 template("chip_test_suite") {
   _suite_name = target_name
 
-  # Ensures that the common library has sources containing both common
-  # and individual unit tests.
-  if (!defined(invoker.sources)) {
-    invoker.sources = []
-  }
-
-  if (defined(invoker.test_sources)) {
-    invoker.sources += invoker.test_sources
+  exclude_variables = [ "tests" ]
+  if (chip_link_tests && chip_device_platform != "darwin") {
+    # Common library shouldn't have all the individual unit tests, only the common sources.
+    exclude_variables += [ "test_sources" ]
+    # NOTE: For `Build on Darwin (clang, python_lib, simulated)` the test_sources must be in common lib.
+  } else {
+    # Common library should have all the individual unit tests, in addition to the common sources.
+    if (!defined(invoker.sources)) {
+      invoker.sources = []
+    }
+    if (defined(invoker.test_sources)) {
+      invoker.sources += invoker.test_sources
+    }
   }
 
+  # Target for the common library.  Contains all the common sources, and sometimes all the individual test sources.
   if (chip_build_test_static_libraries) {
     _target_type = "static_library"
   } else {
     _target_type = "source_set"
   }
   target(_target_type, "${_suite_name}.lib") {
-    forward_variables_from(invoker, "*", [ "tests" ])
+    forward_variables_from(invoker, "*", exclude_variables)
 
     output_dir = "${root_out_dir}/lib"
 
@@ -102,6 +93,8 @@ template("chip_test_suite") {
       public_deps += [ "${chip_root}/src/platform/logging:default" ]
     }
   }
+
+  # Build a source_set or a flashable executable for each individual unit test source, which also includes the common files.
   if (chip_link_tests) {
     tests = []
 
@@ -115,6 +108,7 @@ template("chip_test_suite") {
         }
 
         pw_test(_test_name) {
+          # Forward certain variables from the invoker.
           forward_variables_from(invoker,
                                  [
                                    "deps",
@@ -122,43 +116,30 @@ template("chip_test_suite") {
                                    "cflags",
                                    "configs",
                                  ])
+
+          # Link to the common lib for this suite so we get its `sources`.
           public_deps += [ ":${_suite_name}.lib" ]
-          sources = [ _test ]
-          output_dir = _test_output_dir
-        }
-        tests += [ _test_name ]
-      }
-    }
 
-    if (defined(invoker.tests)) {
-      foreach(_test, invoker.tests) {
-        _test_output_dir = "${root_out_dir}/tests"
-        if (defined(invoker.output_dir)) {
-          _test_output_dir = invoker.output_dir
-        }
+          # Set variables that the platform executable may need.
+          if (test_executable_output_name != "") {
+            output_name = test_executable_output_name + _test_name +
+                          test_executable_output_name_suffix
+          }
+          ldflags = test_executable_ldflags
+
+          # Add the individual test source file (e.g. "TestSomething.cpp").
+          sources = [ _test ]
 
-        pw_test(_test) {
-          forward_variables_from(invoker,
-                                 [
-                                   "deps",
-                                   "public_deps",
-                                   "cflags",
-                                   "configs",
-                                 ])
-          public_deps += [ ":${_suite_name}.lib" ]
-          test_main = ""
-          sources = [
-            "${_test}.cpp",
-            "${_test}Driver.cpp",
-          ]
           output_dir = _test_output_dir
         }
-        tests += [ _test ]
+        tests += [ _test_name ]
       }
     }
 
     group(_suite_name) {
       deps = []
+
+      # Add each individual unit test.
       foreach(_test, tests) {
         deps += [ ":${_test}" ]
       }
@@ -167,6 +148,8 @@ template("chip_test_suite") {
     if (chip_pw_run_tests) {
       group("${_suite_name}_run") {
         deps = []
+
+        # Add the .run targets created by pw_test.
         foreach(_test, tests) {
           deps += [ ":${_test}.run" ]
         }
diff --git a/build/toolchain/flashable_executable.gni b/build/toolchain/flashable_executable.gni
index 6233d58382b43d..b7f96b95f46f08 100644
--- a/build/toolchain/flashable_executable.gni
+++ b/build/toolchain/flashable_executable.gni
@@ -86,6 +86,10 @@ template("gen_flashing_script") {
 template("flashable_executable") {
   executable_target = "$target_name.executable"
 
+  if (!defined(invoker.output_dir)) {
+    invoker.output_dir = root_out_dir
+  }
+
   if (defined(invoker.flashing_script_name)) {
     # Generating the flashing script is the final target.
     final_target = "$target_name.flashing"
@@ -110,7 +114,10 @@ template("flashable_executable") {
       data_deps += invoker.data_deps
     }
 
-    write_runtime_deps = "${root_out_dir}/${flashbundle_name}"
+    # Invoker can stop this template from creating the flashbundle.txt by setting flashbundle_name to empty string.
+    if (flashbundle_name != "") {
+      write_runtime_deps = "${invoker.output_dir}/${flashbundle_name}"
+    }
   }
 
   if (defined(invoker.objcopy_image_name)) {
@@ -124,8 +131,8 @@ template("flashable_executable") {
     objcopy = invoker.objcopy
 
     objcopy_convert(image_target) {
-      conversion_input = "${root_out_dir}/${invoker.output_name}"
-      conversion_output = "${root_out_dir}/${image_name}"
+      conversion_input = "${invoker.output_dir}/${invoker.output_name}"
+      conversion_output = "${invoker.output_dir}/${image_name}"
       conversion_target_format = image_format
       deps = [ ":$executable_target" ]
     }
@@ -141,7 +148,8 @@ template("flashable_executable") {
     gen_flashing_script("$target_name.flashing") {
       flashing_script_generator = invoker.flashing_script_generator
       flashing_script_inputs = invoker.flashing_script_inputs
-      flashing_script_name = "$root_out_dir/${invoker.flashing_script_name}"
+      flashing_script_name =
+          "${invoker.output_dir}/${invoker.flashing_script_name}"
       if (defined(invoker.flashing_options)) {
         flashing_options = invoker.flashing_options
       } else {
@@ -155,7 +163,7 @@ template("flashable_executable") {
 
       flashing_options += [
         "--application",
-        rebase_path(image_name, root_out_dir, root_out_dir),
+        rebase_path(image_name, invoker.output_dir, invoker.output_dir),
       ]
       data_deps = [ ":$image_target" ]
     }
diff --git a/scripts/build/builders/efr32.py b/scripts/build/builders/efr32.py
index 3972dc7bb48eff..6545f75b55da6b 100644
--- a/scripts/build/builders/efr32.py
+++ b/scripts/build/builders/efr32.py
@@ -12,6 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import glob
+import logging
 import os
 import shlex
 import subprocess
@@ -78,7 +80,7 @@ def FlashBundleName(self):
         elif self == Efr32App.PUMP:
             return 'pump_app.flashbundle.txt'
         elif self == Efr32App.UNIT_TEST:
-            return 'efr32_device_tests.flashbundle.txt'
+            return os.path.join('tests', 'efr32_device_tests.flashbundle.txt')
         else:
             raise Exception('Unknown app type: %r' % self)
 
@@ -259,27 +261,64 @@ def __init__(self,
     def GnBuildArgs(self):
         return self.extra_gn_options
 
+    def _bundle(self):
+        # Only unit-test needs to generate the flashbundle here.  All other examples will generate a flashbundle via the silabs_executable template.
+        if self.app == Efr32App.UNIT_TEST:
+            flash_bundle_path = os.path.join(self.output_dir, self.app.FlashBundleName())
+            logging.info(f'Generating flashbundle {flash_bundle_path}')
+
+            patterns = [
+                os.path.join(self.output_dir, "tests", "*.flash.py"),
+                os.path.join(self.output_dir, "tests", "*.s37"),
+                os.path.join(self.output_dir, "tests", "silabs_firmware_utils.py"),
+                os.path.join(self.output_dir, "tests", "firmware_utils.py"),
+            ]
+
+            # Generate the list of files by globbing each pattern.
+            files = []
+            for pattern in patterns:
+                files.extend([os.path.basename(x) for x in glob.glob(pattern)])
+
+            # Create the bundle file.
+            with open(flash_bundle_path, 'w') as bundle_file:
+                bundle_file.write("\n".join(files))
+
     def build_outputs(self):
         extensions = ["out", "hex"]
         if self.options.enable_link_map_file:
             extensions.append("out.map")
-        for ext in extensions:
-            name = f"{self.app.AppNamePrefix()}.{ext}"
-            yield BuilderOutput(os.path.join(self.output_dir, name), name)
+
+        if self.app == Efr32App.UNIT_TEST:
+            # Efr32 unit-test generates the "tests" subdir with a set of files for each individual unit test source.
+            for ext in extensions:
+                pattern = os.path.join(self.output_dir, "tests", f"*.{ext}")
+                for name in [os.path.basename(x) for x in glob.glob(pattern)]:
+                    yield BuilderOutput(os.path.join(self.output_dir, "tests", name), name)
+        else:
+            # All other examples have just one set of files.
+            for ext in extensions:
+                name = f"{self.app.AppNamePrefix()}.{ext}"
+                yield BuilderOutput(os.path.join(self.output_dir, name), name)
 
         if self.app == Efr32App.UNIT_TEST:
             # Include test runner python wheels
-            for root, dirs, files in os.walk(os.path.join(self.output_dir, 'chip_nl_test_runner_wheels')):
+            for root, dirs, files in os.walk(os.path.join(self.output_dir, 'chip_pw_test_runner_wheels')):
                 for file in files:
                     yield BuilderOutput(
                         os.path.join(root, file),
-                        os.path.join("chip_nl_test_runner_wheels", file))
+                        os.path.join("chip_pw_test_runner_wheels", file))
 
-        # Figure out flash bundle files and build accordingly
+    def bundle_outputs(self):
+        # If flashbundle creation is enabled, the outputs will include the s37 and flash.py files, plus the two firmware utils scripts that support flash.py.
+        # For the unit-test example, there will be a s37 and flash.py file for each unit test source.
         with open(os.path.join(self.output_dir, self.app.FlashBundleName())) as f:
             for name in filter(None, [x.strip() for x in f.readlines()]):
+                if self.app == Efr32App.UNIT_TEST:
+                    sourcepath = os.path.join(self.output_dir, "tests", name)  # Unit tests are in the "tests" subdir.
+                else:
+                    sourcepath = os.path.join(self.output_dir, name)
                 yield BuilderOutput(
-                    os.path.join(self.output_dir, name),
+                    sourcepath,
                     os.path.join("flashbundle", name))
 
     def generate(self):
diff --git a/scripts/build/builders/host.py b/scripts/build/builders/host.py
index 4ea5a9aae0f836..35721dd381e8e1 100644
--- a/scripts/build/builders/host.py
+++ b/scripts/build/builders/host.py
@@ -216,7 +216,7 @@ def OutputNames(self):
         elif self == HostApp.PYTHON_BINDINGS:
             yield 'controller/python'  # Directory containing WHL files
         elif self == HostApp.EFR32_TEST_RUNNER:
-            yield 'chip_nl_test_runner_wheels'
+            yield 'chip_pw_test_runner_wheels'
         elif self == HostApp.TV_CASTING:
             yield 'chip-tv-casting-app'
             yield 'chip-tv-casting-app.map'
diff --git a/scripts/flashing/firmware_utils.py b/scripts/flashing/firmware_utils.py
index 6087360c0614ac..2b844e8e3eb550 100644
--- a/scripts/flashing/firmware_utils.py
+++ b/scripts/flashing/firmware_utils.py
@@ -23,6 +23,7 @@
 import subprocess
 import sys
 import textwrap
+import traceback
 
 # Here are the options that can be use to configure a `Flasher`
 # object (as dictionary keys) and/or passed as command line options.
@@ -409,7 +410,11 @@ def make_wrapper(self, argv):
 
         # Give platform-specific code a chance to manipulate the arguments
         # for the wrapper script.
-        self._platform_wrapper_args(args)
+        try:
+            self._platform_wrapper_args(args)
+        except OSError:
+            traceback.print_last()
+            return 1
 
         # Find any option values that differ from the class defaults.
         # These will be inserted into the wrapper script.
@@ -445,7 +450,7 @@ def make_wrapper(self, argv):
             os.chmod(args.output, (stat.S_IXUSR | stat.S_IRUSR | stat.S_IWUSR
                                    | stat.S_IXGRP | stat.S_IRGRP
                                    | stat.S_IXOTH | stat.S_IROTH))
-        except OSError as exception:
-            print(exception, sys.stderr)
+        except OSError:
+            traceback.print_last()
             return 1
         return 0
diff --git a/scripts/flashing/silabs_firmware_utils.py b/scripts/flashing/silabs_firmware_utils.py
index 5e0a689f5a62f4..bd7b64cf021bcd 100755
--- a/scripts/flashing/silabs_firmware_utils.py
+++ b/scripts/flashing/silabs_firmware_utils.py
@@ -49,6 +49,8 @@
                         Do not reset device after flashing
 """
 
+import os
+import shutil
 import sys
 
 import firmware_utils
@@ -169,6 +171,42 @@ def actions(self):
 
         return self
 
+    def _platform_wrapper_args(self, args):
+        """Called from make_wrapper() to optionally manipulate arguments."""
+        # Generate the flashbundle.txt file and copy the firmware utils.
+        if args.flashbundle_file is not None:
+            # Generate the flashbundle contents.
+            # Copy the platform-specific and general firmware utils to the same directory as the wrapper.
+            flashbundle_contents = os.path.basename(args.output)
+            if args.application is not None:
+                flashbundle_contents += "\n" + os.path.basename(args.application)
+            output_dir = os.path.dirname(args.output) or "."
+            if args.platform_firmware_utils is not None:
+                flashbundle_contents += "\n" + os.path.basename(args.platform_firmware_utils)
+                shutil.copy(args.platform_firmware_utils, output_dir)
+            if args.firmware_utils is not None:
+                flashbundle_contents += "\n" + os.path.basename(args.firmware_utils)
+                shutil.copy(args.firmware_utils, output_dir)
+
+            # Create the flashbundle file.
+            with open(args.flashbundle_file, 'w') as flashbundle_file:
+                flashbundle_file.write(flashbundle_contents.strip())
+
+    def make_wrapper(self, argv):
+        self.parser.add_argument(
+            '--flashbundle-file',
+            metavar='FILENAME',
+            help='path and name of the flashbundle text file to create')
+        self.parser.add_argument(
+            '--platform-firmware-utils',
+            metavar='FILENAME',
+            help='path and file of the platform-specific firmware utils script')
+        self.parser.add_argument(
+            '--firmware-utils',
+            metavar='FILENAME',
+            help='path and file of the general firmware utils script')
+        super().make_wrapper(argv)
+
 
 if __name__ == '__main__':
     sys.exit(Flasher().flash_command(sys.argv))
diff --git a/src/test_driver/efr32/BUILD.gn b/src/test_driver/efr32/BUILD.gn
index b15b16864548c9..0d10cb8d76a475 100644
--- a/src/test_driver/efr32/BUILD.gn
+++ b/src/test_driver/efr32/BUILD.gn
@@ -19,7 +19,6 @@ import("//build_overrides/pigweed.gni")
 
 import("${build_root}/config/defaults.gni")
 import("${efr32_sdk_build_root}/efr32_sdk.gni")
-import("${efr32_sdk_build_root}/silabs_executable.gni")
 
 import("${chip_root}/examples/common/pigweed/pigweed_rpcs.gni")
 import("${chip_root}/src/platform/device.gni")
@@ -62,9 +61,8 @@ efr32_sdk("sdk") {
   ]
 }
 
-silabs_executable("efr32_device_tests") {
-  output_name = "matter-silabs-device_tests.out"
-
+# This is the test runner.  `pw_test` will dep this for each `silabs_executable` target.
+source_set("efr32_test_main") {
   defines = [ "PW_RPC_ENABLED" ]
   sources = [
     "${chip_root}/examples/common/pigweed/RpcService.cpp",
@@ -83,7 +81,6 @@ silabs_executable("efr32_device_tests") {
     "$dir_pw_unit_test:rpc_service",
     "${chip_root}/config/efr32/lib/pw_rpc:pw_rpc",
     "${chip_root}/examples/common/pigweed:system_rpc_server",
-    "${chip_root}/src:tests",
     "${chip_root}/src/lib",
     "${chip_root}/src/lib/support:pw_tests_wrapper",
     "${chip_root}/src/platform/silabs/provision:provision-headers",
@@ -106,27 +103,18 @@ silabs_executable("efr32_device_tests") {
   ]
 
   include_dirs = [ "${chip_root}/examples/common/pigweed/efr32" ]
-
-  ldscript = "${examples_common_plat_dir}/ldscripts/${silabs_family}.ld"
-
-  inputs = [ ldscript ]
-
-  ldflags = [
-    "-T" + rebase_path(ldscript, root_build_dir),
-    "-Wl,--no-warn-rwx-segment",
-  ]
-
-  output_dir = root_out_dir
 }
 
+# This target is referred to by BuildRoot in scripts/build/builders/efr32.py, as well as the example in README.md.
+# It builds the root target "src:tests", which builds the chip_test_suite target in each test directory, which builds a pw_test target for each test source file, which builds a silabs_executable, which includes the "efr32_test_main" target defined above.
 group("efr32") {
-  deps = [ ":efr32_device_tests" ]
+  deps = [ "${chip_root}/src:tests" ]
 }
 
 group("runner") {
   deps = [
-    "${efr32_project_dir}/py:nl_test_runner.install",
-    "${efr32_project_dir}/py:nl_test_runner_wheel",
+    "${efr32_project_dir}/py:pw_test_runner.install",
+    "${efr32_project_dir}/py:pw_test_runner_wheel",
   ]
 }
 
diff --git a/src/test_driver/efr32/README.md b/src/test_driver/efr32/README.md
index c36812e484fdee..c846426890abaa 100644
--- a/src/test_driver/efr32/README.md
+++ b/src/test_driver/efr32/README.md
@@ -1,6 +1,6 @@
 #CHIP EFR32 Test Driver
 
-This builds and runs the NLUnitTest on the efr32 device
+This builds and runs the unit tests on the efr32 device.
 
 <hr>
 
@@ -14,9 +14,9 @@ This builds and runs the NLUnitTest on the efr32 device
 
 ## Introduction
 
-This builds a test binary which contains the NLUnitTests and can be flashed onto
-a device. The device is controlled using the included RPCs, through the python
-test runner.
+This builds a set of test binaries which contain the unit tests and can be
+flashed onto a device. The device is controlled using the included RPCs, through
+the python test runner.
 
 <a name="building"></a>
 
@@ -83,7 +83,7 @@ Or build using build script from the root
 
     ```
     cd <connectedhomeip>
-    ./scripts/build/build_examples.py --target linux-x64-nl-test-runner build
+    ./scripts/build/build_examples.py --target linux-x64-pw-test-runner build
     ```
 
 The runner will be installed into the venv and python wheels will be packaged in
@@ -92,7 +92,7 @@ the output folder for deploying.
 Then the python wheels need to installed using pip3.
 
     ```
-    pip3 install out/debug/chip_nl_test_runner_wheels/*.whl
+    pip3 install out/debug/chip_pw_test_runner_wheels/*.whl
     ```
 
 Other python libraries may need to be installed such as
@@ -101,8 +101,8 @@ Other python libraries may need to be installed such as
     pip3 install pyserial
     ```
 
--   To run the tests:
+-   To run all tests:
 
     ```
-    python -m nl_test_runner.nl_test_runner -d /dev/ttyACM1 -f out/debug/matter-silabs-device_tests.s37 -o out.log
+    python -m pw_test_runner.pw_test_runner -d /dev/ttyACM1 -f out/debug/matter-silabs-device_tests.s37 -o out.log
     ```
diff --git a/src/test_driver/efr32/args.gni b/src/test_driver/efr32/args.gni
index 71bf093f3e3a16..f7f52398a5889e 100644
--- a/src/test_driver/efr32/args.gni
+++ b/src/test_driver/efr32/args.gni
@@ -17,14 +17,15 @@ import("//build_overrides/pigweed.gni")
 import("${chip_root}/config/efr32/lib/pw_rpc/pw_rpc.gni")
 import("${chip_root}/examples/platform/silabs/args.gni")
 import("${chip_root}/src/platform/silabs/efr32/args.gni")
+import("${chip_root}/third_party/silabs/silabs_board.gni")  # silabs_family
 
 silabs_sdk_target = get_label_info(":sdk", "label_no_toolchain")
 
 chip_enable_pw_rpc = true
 chip_build_tests = true
+chip_link_tests = true
 chip_enable_openthread = true
 chip_openthread_ftd = false  # use mtd as it is smaller.
-chip_monolithic_tests = true
 
 openthread_external_platform =
     "${chip_root}/third_party/openthread/platforms/efr32:libopenthread-efr32"
@@ -35,3 +36,19 @@ pw_assert_BACKEND = "$dir_pw_assert_log"
 pw_log_BACKEND = "$dir_pw_log_basic"
 
 pw_unit_test_BACKEND = "$dir_pw_unit_test:light"
+
+# Override the executable type and the test main's target.
+pw_unit_test_EXECUTABLE_TARGET_TYPE = "silabs_executable"
+pw_unit_test_EXECUTABLE_TARGET_TYPE_FILE =
+    "${efr32_sdk_build_root}/silabs_executable.gni"
+pw_unit_test_MAIN = "//:efr32_test_main"
+
+# Additional variables needed by silabs_executable that must be passed in to pw_test.
+test_executable_output_name = "matter-silabs-device_tests-"
+test_executable_output_name_suffix = ".out"
+_ldscript =
+    "${chip_root}/examples/platform/silabs/ldscripts/${silabs_family}.ld"
+test_executable_ldflags = [
+  "-T" + rebase_path(_ldscript, root_build_dir),
+  "-Wl,--no-warn-rwx-segment",
+]
diff --git a/src/test_driver/efr32/py/BUILD.gn b/src/test_driver/efr32/py/BUILD.gn
index 615fe5603d00c9..6f36e8f3e4a839 100644
--- a/src/test_driver/efr32/py/BUILD.gn
+++ b/src/test_driver/efr32/py/BUILD.gn
@@ -19,32 +19,6 @@ import("$dir_pw_build/python.gni")
 import("$dir_pw_build/python_dist.gni")
 import("${chip_root}/examples/common/pigweed/pigweed_rpcs.gni")
 
-# TODO [PW_MIGRATION]: remove nl test runner script once transition away from nlunit-test is completed
-pw_python_package("nl_test_runner") {
-  setup = [
-    "pyproject.toml",
-    "setup.cfg",
-    "setup.py",
-  ]
-
-  sources = [
-    "nl_test_runner/__init__.py",
-    "nl_test_runner/nl_test_runner.py",
-  ]
-
-  python_deps = [
-    "$dir_pw_hdlc/py",
-    "$dir_pw_protobuf_compiler/py",
-    "$dir_pw_rpc/py",
-    "${chip_root}/src/test_driver/efr32:nl_test_service.python",
-  ]
-}
-
-pw_python_wheels("nl_test_runner_wheel") {
-  packages = [ ":nl_test_runner" ]
-  directory = "$root_out_dir/chip_nl_test_runner_wheels"
-}
-
 pw_python_package("pw_test_runner") {
   setup = [
     "pw_test_runner/pyproject.toml",
diff --git a/src/test_driver/efr32/py/nl_test_runner/nl_test_runner.py b/src/test_driver/efr32/py/nl_test_runner/nl_test_runner.py
deleted file mode 100644
index 0fdb2e7aea7e3d..00000000000000
--- a/src/test_driver/efr32/py/nl_test_runner/nl_test_runner.py
+++ /dev/null
@@ -1,141 +0,0 @@
-#
-#    Copyright (c) 2021 Project CHIP Authors
-#    All rights reserved.
-#
-#    Licensed under the Apache License, Version 2.0 (the "License");
-#    you may not use this file except in compliance with the License.
-#    You may obtain a copy of the License at
-#
-#        http://www.apache.org/licenses/LICENSE-2.0
-#
-#    Unless required by applicable law or agreed to in writing, software
-#    distributed under the License is distributed on an "AS IS" BASIS,
-#    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-#    See the License for the specific language governing permissions and
-#    limitations under the License.
-#
-
-import argparse
-import logging
-import subprocess
-import sys
-import time
-from typing import Any
-
-import serial  # type: ignore
-from pw_hdlc import rpc
-
-# RPC Protos
-from nl_test_service import nl_test_pb2  # isort:skip
-
-PW_LOG = logging.getLogger(__name__)
-
-PROTOS = [nl_test_pb2]
-
-
-class colors:
-    HEADER = '\033[95m'
-    OKBLUE = '\033[94m'
-    OKCYAN = '\033[96m'
-    OKGREEN = '\033[92m'
-    WARNING = '\033[93m'
-    FAIL = '\033[91m'
-    ENDC = '\033[0m'
-    BOLD = '\033[1m'
-
-
-PASS_STRING = colors.OKGREEN + u'\N{check mark}' + colors.ENDC
-FAIL_STRING = colors.FAIL + 'FAILED' + colors.ENDC
-
-
-def _parse_args():
-    """Parses and returns the command line arguments."""
-    parser = argparse.ArgumentParser(
-        description="CHIP on device unit test runner.")
-    parser.add_argument('-d', '--device', help='the serial port to use')
-    parser.add_argument('-b',
-                        '--baudrate',
-                        type=int,
-                        default=115200,
-                        help='the baud rate to use')
-    parser.add_argument('-f', '--flash_image',
-                        help='a firmware image which will be flashed berfore runnning the test')
-    parser.add_argument(
-        '-o',
-        '--output',
-        type=argparse.FileType('wb'),
-        default=sys.stdout.buffer,
-        help=('The file to which to write device output (HDLC channel 1); '
-              'provide - or omit for stdout.'))
-    return parser.parse_args()
-
-
-def flash_device(device: str, flash_image: str, **kwargs):
-    """flashes the EFR32 device using commander"""
-    err = subprocess.call(
-        ['commander', 'flash', '--device', 'EFR32', flash_image])
-    if err:
-        raise Exception("flash failed")
-
-
-def get_hdlc_rpc_client(device: str, baudrate: int, output: Any, **kwargs):
-    """Get the HdlcRpcClient based on arguments."""
-    serial_device = serial.Serial(device, baudrate, timeout=1)
-    reader = rpc.SerialReader(serial_device, 8192)
-    write = serial_device.write
-    return rpc.HdlcRpcClient(reader, PROTOS, rpc.default_channels(write),
-                             lambda data: rpc.write_to_file(data, output))
-
-
-def runner(client) -> int:
-    """ Run the tests"""
-    def on_error_callback(call_object, error):
-        raise Exception("Error running test RPC: {}".format(error))
-
-    rpc = client.client.channel(1).rpcs.chip.rpc.NlTest.Run
-    invoke = rpc.invoke(rpc.request(), on_error=on_error_callback)
-
-    total_failed = 0
-    total_run = 0
-    for streamed_data in invoke.get_responses():
-        if streamed_data.HasField("test_suite_start"):
-            print("\n{}".format(
-                colors.HEADER + streamed_data.test_suite_start.suite_name) + colors.ENDC)
-        if streamed_data.HasField("test_case_run"):
-            print("\t{}: {}".format(streamed_data.test_case_run.test_case_name,
-                                    FAIL_STRING if streamed_data.test_case_run.failed else PASS_STRING))
-        if streamed_data.HasField("test_suite_tests_run_summary"):
-            total_run += streamed_data.test_suite_tests_run_summary.total_count
-            total_failed += streamed_data.test_suite_tests_run_summary.failed_count
-            print("{}Total tests failed: {} of {}".format(
-                  colors.OKGREEN if streamed_data.test_suite_tests_run_summary.failed_count == 0 else colors.FAIL,
-                  streamed_data.test_suite_tests_run_summary.failed_count,
-                  streamed_data.test_suite_tests_run_summary.total_count) + colors.ENDC)
-        if streamed_data.HasField("test_suite_asserts_summary"):
-            print("{}Total asserts failed:  {} of {}".format(
-                  colors.OKGREEN if streamed_data.test_suite_asserts_summary.failed_count == 0 else colors.FAIL,
-                  streamed_data.test_suite_asserts_summary.failed_count,
-                  streamed_data.test_suite_asserts_summary.total_count) + colors.ENDC)
-        for step in ["test_suite_setup", "test_suite_teardown", "test_case_initialize", "test_case_terminate"]:
-            if streamed_data.HasField(step):
-                print(colors.OKCYAN + "\t{}: {}".format(step,
-                                                        FAIL_STRING if getattr(streamed_data, step).failed else PASS_STRING))
-    print(colors.OKBLUE + colors.BOLD +
-          "\n\nAll tests completed" + colors.ENDC)
-    print("{}Total of all tests failed: {} of {}".format(
-        colors.OKGREEN if total_failed == 0 else colors.FAIL,
-        total_failed, total_run) + colors.ENDC)
-    return total_failed
-
-
-def main() -> int:
-    args = _parse_args()
-    if args.flash_image:
-        flash_device(**vars(args))
-        time.sleep(1)  # Give time for device to boot
-    with get_hdlc_rpc_client(**vars(args)) as client:
-        return runner(client)
-
-
-if __name__ == '__main__':
-    sys.exit(main())
diff --git a/src/test_driver/efr32/py/nl_test_runner/__init__.py b/src/test_driver/efr32/py/pw_test_runner/__init__.py
similarity index 100%
rename from src/test_driver/efr32/py/nl_test_runner/__init__.py
rename to src/test_driver/efr32/py/pw_test_runner/__init__.py
diff --git a/src/test_driver/efr32/py/pw_test_runner/pw_test_runner.py b/src/test_driver/efr32/py/pw_test_runner/pw_test_runner.py
index c8fca307f015ff..8e10903920cc77 100644
--- a/src/test_driver/efr32/py/pw_test_runner/pw_test_runner.py
+++ b/src/test_driver/efr32/py/pw_test_runner/pw_test_runner.py
@@ -16,6 +16,7 @@
 #
 
 import argparse
+import glob
 import logging
 import os
 import subprocess
@@ -60,7 +61,7 @@ def _parse_args():
     parser.add_argument(
         "-f",
         "--flash_image",
-        help="a firmware image which will be flashed berfore runnning the test",
+        help="A firmware image which will be flashed berfore runnning the test. Or a directory containing firmware images, each of which will be flashed and then run.",
     )
     parser.add_argument(
         "-o",
@@ -75,7 +76,7 @@ def _parse_args():
     return parser.parse_args()
 
 
-def flash_device(device: str, flash_image: str, **kwargs):
+def flash_device(device: str, flash_image: str):
     """flashes the EFR32 device using commander"""
     err = subprocess.call(
         ["commander", "flash", "--device", "EFR32", flash_image])
@@ -96,22 +97,41 @@ def get_hdlc_rpc_client(device: str, baudrate: int, output: Any, **kwargs):
     )
 
 
-def runner(client: rpc.HdlcRpcClient) -> int:
-    """Run the tests"""
+def run(args) -> int:
+    """Run the tests. Return the number of failed tests."""
+    with get_hdlc_rpc_client(**vars(args)) as client:
+        test_records = run_tests(client.rpcs())
+        return len(test_records.failing_tests)
 
-    test_records = run_tests(client.rpcs())
 
-    return len(test_records.failing_tests)
+def list_images(flash_directory: str) -> list[str]:
+    filenames: list[str] = glob.glob(os.path.join(flash_directory, "*.s37"))
+    return list(map(lambda x: os.path.join(flash_directory, x), filenames))
 
 
 def main() -> int:
     args = _parse_args()
-    if args.flash_image:
-        flash_device(**vars(args))
-        time.sleep(1)  # Give time for device to boot
 
-    with get_hdlc_rpc_client(**vars(args)) as client:
-        return runner(client)
+    failures = 0
+    if args.flash_image:
+        if os.path.isdir(args.flash_image):
+            images = list_images(args.flash_image)
+            if not images:
+                raise Exception(f"No images found in `{args.flash_image}`")
+        elif os.path.isfile(args.flash_image):
+            images = [args.flash_image]
+        else:
+            raise Exception(f"File or directory not found `{args.flash_image}`")
+
+        for image in images:
+            flash_device(args.device, image)
+            time.sleep(1)  # Give time for device to boot
+
+            failures += run(args)
+    else:  # No image provided. Just run what's on the device.
+        failures += run(args)
+
+    return failures
 
 
 if __name__ == "__main__":
diff --git a/src/test_driver/efr32/py/setup.cfg b/src/test_driver/efr32/py/setup.cfg
index 77108ab8288747..cb949a38092b07 100644
--- a/src/test_driver/efr32/py/setup.cfg
+++ b/src/test_driver/efr32/py/setup.cfg
@@ -12,7 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 [metadata]
-name = nl_test_runner
+name = pw_test_runner
 version = 0.0.1
 
 [options]
diff --git a/third_party/silabs/silabs_executable.gni b/third_party/silabs/silabs_executable.gni
index a639a4d19ae62d..37b853c5df84b8 100644
--- a/third_party/silabs/silabs_executable.gni
+++ b/third_party/silabs/silabs_executable.gni
@@ -47,64 +47,93 @@ template("generate_rps_file") {
 }
 
 template("silabs_executable") {
+  # output_dir is optional and will default to root_out_dir
+  if (!defined(invoker.output_dir)) {
+    invoker.output_dir = root_out_dir
+  }
+
+  # output_name is optional and will default to "$target_name.bin"
+  if (!defined(invoker.output_name)) {
+    invoker.output_name = target_name + ".bin"
+  }
+
   output_base_name = get_path_info(invoker.output_name, "name")
   objcopy_image_name = output_base_name + ".s37"
   objcopy_image_format = "srec"
   objcopy = "arm-none-eabi-objcopy"
 
-  # Copy flashing dependencies to the output directory so that the output
-  # is collectively self-contained; this allows flashing to work reliably
-  # even if the build and flashing steps take place on different machines
-  # or in different containers.
-
   if (use_rps_extension) {
     flashing_image_name = output_base_name + ".rps"
   }
 
-  flashing_runtime_target = target_name + ".flashing_runtime"
-  flashing_script_inputs = [
-    "${chip_root}/scripts/flashing/silabs_firmware_utils.py",
-    "${chip_root}/scripts/flashing/firmware_utils.py",
-  ]
-  copy(flashing_runtime_target) {
-    sources = flashing_script_inputs
-    outputs = [ "${root_out_dir}/{{source_file_part}}" ]
-  }
+  # flashable_executable calls a generator script to do the following:
+  # Create a flash.py script with the name of the binary hardcoded in it.
+  # Copy flashing dependencies to the output directory so that the output
+  # is collectively self-contained; this allows flashing to work reliably
+  # even if the build and flashing steps take place on different machines
+  # or in different containers.
+  # Create *.flashbundle.txt with a list of all files needed for flashing
 
   flashing_script_generator =
       "${chip_root}/scripts/flashing/gen_flashing_script.py"
   flashing_script_name = output_base_name + ".flash.py"
-  flashing_options = [ "silabs" ]
+  _flashbundle_file = "${invoker.output_dir}/${target_name}.flashbundle.txt"
+  _platform_firmware_utils =
+      "${chip_root}/scripts/flashing/silabs_firmware_utils.py"
+  _firmware_utils = "${chip_root}/scripts/flashing/firmware_utils.py"
+  flashing_options = [
+    # Use module "{platform}_firmware_utils.py"
+    "silabs",
+
+    # flashbundle.txt file to create.
+    "--flashbundle-file",
+    rebase_path(_flashbundle_file, root_build_dir),
+
+    # Platform-specific firmware module to copy.
+    "--platform-firmware-utils",
+    rebase_path(_platform_firmware_utils, root_build_dir),
 
+    # General firmware module to copy.
+    "--firmware-utils",
+    rebase_path(_firmware_utils, root_build_dir),
+  ]
+  flashing_script_inputs = [
+    _platform_firmware_utils,
+    _firmware_utils,
+  ]
+  flashbundle_name = ""  # Stop flashable_executable from making flashbundle.
+
+  # Target to generate the s37 file, flashing script, and flashbundle.
   flash_target_name = target_name + ".flash_executable"
-  flashbundle_name = "${target_name}.flashbundle.txt"
   flashable_executable(flash_target_name) {
     forward_variables_from(invoker, "*")
-    data_deps = [ ":${flashing_runtime_target}" ]
   }
 
-  # Add a target which generates the hex file in addition to s37.
+  # Target to generate the hex file.
   executable_target = "$flash_target_name.executable"
   hex_image_name = output_base_name + ".hex"
   hex_target_name = target_name + ".hex"
   objcopy_convert(hex_target_name) {
-    conversion_input = "${root_out_dir}/${invoker.output_name}"
-    conversion_output = "${root_out_dir}/${hex_image_name}"
+    conversion_input = "${invoker.output_dir}/${invoker.output_name}"
+    conversion_output = "${invoker.output_dir}/${hex_image_name}"
     conversion_target_format = "ihex"
     deps = [ ":$executable_target" ]
   }
 
+  # Target to generate the rps file.
   if (use_rps_extension) {
     rps_target_name = target_name + ".rps"
     generate_rps_file(rps_target_name) {
-      conversion_input = "${root_out_dir}/${objcopy_image_name}"
-      conversion_output = "${root_out_dir}/${flashing_image_name}"
+      conversion_input = "${invoker.output_dir}/${objcopy_image_name}"
+      conversion_output = "${invoker.output_dir}/${flashing_image_name}"
       deps = [
         ":$executable_target",
         ":$flash_target_name.image",
       ]
     }
   }
+
+  # Main target that deps the targets defined above.
   group(target_name) {
     deps = [
       ":$flash_target_name",