[url_launcher] Add native unit tests for Windows (#4156)

Adds a unit test target based on googletest. This is intended to be both a set of unit tests for this plugin, and also a model of changes that can be made to the `flutter create` template for Windows plugins to include better testing out of the box (https://github.com/flutter/flutter/issues/82458).

In addition to the test binary being directly runnable, the integration between CMake, VS, and googletest means that these tests are visible—and runnable—in the VS Test Explorer UI after opening the generated .sln file.

Changes for testing in general:
- Moved the plugin class declaration to a header.
- Moved the C registration API implementation to a separate file.
- Added (opt-in, so it won't affect plugin client builds) plugin CMake rules to download googletest and build a new executable target that builds all the plugin sources, plus gtest and gmock.
- Added a line to the example app CMake rules to enable the unit tests.
- Added a unit test file.

url_launcher-specific changes:
- Wrapped all Win32 calls in a thin class for mockability in unit tests.
- Factored some logic into helpers for better maintainability while I was refactoring anyway.

Note: This unit test is not yet being run by CI. A tools command to run Windows plugin unit tests will be a separate PR.

Part of https://github.com/flutter/flutter/issues/82445
diff --git a/packages/url_launcher/url_launcher_windows/CHANGELOG.md b/packages/url_launcher/url_launcher_windows/CHANGELOG.md
index d26fe19..d095a52 100644
--- a/packages/url_launcher/url_launcher_windows/CHANGELOG.md
+++ b/packages/url_launcher/url_launcher_windows/CHANGELOG.md
@@ -1,3 +1,7 @@
+## NEXT
+
+* Added unit tests.
+
 ## 2.0.2
 
 * Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README.
diff --git a/packages/url_launcher/url_launcher_windows/example/windows/CMakeLists.txt b/packages/url_launcher/url_launcher_windows/example/windows/CMakeLists.txt
index abf9040..5b1622b 100644
--- a/packages/url_launcher/url_launcher_windows/example/windows/CMakeLists.txt
+++ b/packages/url_launcher/url_launcher_windows/example/windows/CMakeLists.txt
@@ -46,6 +46,9 @@
 # Application build
 add_subdirectory("runner")
 
+# Enable the test target.
+set(include_url_launcher_windows_tests TRUE)
+
 # Generated plugin build rules, which manage building the plugins and adding
 # them to the application.
 include(flutter/generated_plugins.cmake)
diff --git a/packages/url_launcher/url_launcher_windows/example/windows/flutter/CMakeLists.txt b/packages/url_launcher/url_launcher_windows/example/windows/flutter/CMakeLists.txt
index c7a8c76..744f08a 100644
--- a/packages/url_launcher/url_launcher_windows/example/windows/flutter/CMakeLists.txt
+++ b/packages/url_launcher/url_launcher_windows/example/windows/flutter/CMakeLists.txt
@@ -91,6 +91,7 @@
     ${FLUTTER_TOOL_ENVIRONMENT}
     "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
       windows-x64 $<CONFIG>
+  VERBATIM
 )
 add_custom_target(flutter_assemble DEPENDS
   "${FLUTTER_LIBRARY}"
diff --git a/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc b/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc
index d9fdd53..4f78848 100644
--- a/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc
+++ b/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc
@@ -6,9 +6,9 @@
 
 #include "generated_plugin_registrant.h"
 
-#include <url_launcher_windows/url_launcher_plugin.h>
+#include <url_launcher_windows/url_launcher_windows.h>
 
 void RegisterPlugins(flutter::PluginRegistry* registry) {
-  UrlLauncherPluginRegisterWithRegistrar(
-      registry->GetRegistrarForPlugin("UrlLauncherPlugin"));
+  UrlLauncherWindowsRegisterWithRegistrar(
+      registry->GetRegistrarForPlugin("UrlLauncherWindows"));
 }
diff --git a/packages/url_launcher/url_launcher_windows/pubspec.yaml b/packages/url_launcher/url_launcher_windows/pubspec.yaml
index 6435eda..a92e91e 100644
--- a/packages/url_launcher/url_launcher_windows/pubspec.yaml
+++ b/packages/url_launcher/url_launcher_windows/pubspec.yaml
@@ -13,7 +13,7 @@
     implements: url_launcher
     platforms:
       windows:
-        pluginClass: UrlLauncherPlugin
+        pluginClass: UrlLauncherWindows
 
 dependencies:
   flutter:
diff --git a/packages/url_launcher/url_launcher_windows/windows/CMakeLists.txt b/packages/url_launcher/url_launcher_windows/windows/CMakeLists.txt
index 57d87e3..a4185ac 100644
--- a/packages/url_launcher/url_launcher_windows/windows/CMakeLists.txt
+++ b/packages/url_launcher/url_launcher_windows/windows/CMakeLists.txt
@@ -4,12 +4,20 @@
 
 set(PLUGIN_NAME "${PROJECT_NAME}_plugin")
 
-add_library(${PLUGIN_NAME} SHARED
+list(APPEND PLUGIN_SOURCES
+  "system_apis.cpp"
+  "system_apis.h"
   "url_launcher_plugin.cpp"
+  "url_launcher_plugin.h"
+)
+
+add_library(${PLUGIN_NAME} SHARED
+  "include/url_launcher_windows/url_launcher_windows.h"
+  "url_launcher_windows.cpp"
+  ${PLUGIN_SOURCES}
 )
 apply_standard_settings(${PLUGIN_NAME})
-set_target_properties(${PLUGIN_NAME} PROPERTIES
-  CXX_VISIBILITY_PRESET hidden)
+set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden)
 target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
 target_include_directories(${PLUGIN_NAME} INTERFACE
   "${CMAKE_CURRENT_SOURCE_DIR}/include")
@@ -20,3 +28,44 @@
   ""
   PARENT_SCOPE
 )
+
+
+# === Tests ===
+
+if (${include_${PROJECT_NAME}_tests})
+set(TEST_RUNNER "${PROJECT_NAME}_test")
+enable_testing()
+# TODO(stuartmorgan): Consider using a single shared, pre-checked-in googletest
+# instance rather than downloading for each plugin. This approach makes sense
+# for a template, but not for a monorepo with many plugins.
+include(FetchContent)
+FetchContent_Declare(
+  googletest
+  URL https://github.com/google/googletest/archive/release-1.11.0.zip
+)
+# Prevent overriding the parent project's compiler/linker settings
+set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
+# Disable install commands for gtest so it doesn't end up in the bundle.
+set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE)
+
+FetchContent_MakeAvailable(googletest)
+
+# The plugin's C API is not very useful for unit testing, so build the sources
+# directly into the test binary rather than using the DLL.
+add_executable(${TEST_RUNNER}
+  test/url_launcher_windows_test.cpp
+  ${PLUGIN_SOURCES}
+)
+apply_standard_settings(${TEST_RUNNER})
+target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
+target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin)
+target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock)
+# flutter_wrapper_plugin has link dependencies on the Flutter DLL.
+add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD
+  COMMAND ${CMAKE_COMMAND} -E copy_if_different
+  "${FLUTTER_LIBRARY}" $<TARGET_FILE_DIR:${TEST_RUNNER}>
+)
+
+include(GoogleTest)
+gtest_discover_tests(${TEST_RUNNER})
+endif()
diff --git a/packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_plugin.h b/packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_windows.h
similarity index 92%
rename from packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_plugin.h
rename to packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_windows.h
index 8af3924..251471c 100644
--- a/packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_plugin.h
+++ b/packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_windows.h
@@ -16,7 +16,7 @@
 extern "C" {
 #endif
 
-FLUTTER_PLUGIN_EXPORT void UrlLauncherPluginRegisterWithRegistrar(
+FLUTTER_PLUGIN_EXPORT void UrlLauncherWindowsRegisterWithRegistrar(
     FlutterDesktopPluginRegistrarRef registrar);
 
 #if defined(__cplusplus)
diff --git a/packages/url_launcher/url_launcher_windows/windows/system_apis.cpp b/packages/url_launcher/url_launcher_windows/windows/system_apis.cpp
new file mode 100644
index 0000000..abd690b
--- /dev/null
+++ b/packages/url_launcher/url_launcher_windows/windows/system_apis.cpp
@@ -0,0 +1,38 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+#include "system_apis.h"
+
+#include <windows.h>
+
+namespace url_launcher_plugin {
+
+SystemApis::SystemApis() {}
+
+SystemApis::~SystemApis() {}
+
+SystemApisImpl::SystemApisImpl() {}
+
+SystemApisImpl::~SystemApisImpl() {}
+
+LSTATUS SystemApisImpl::RegCloseKey(HKEY key) { return ::RegCloseKey(key); }
+
+LSTATUS SystemApisImpl::RegOpenKeyExW(HKEY key, LPCWSTR sub_key, DWORD options,
+                                      REGSAM desired, PHKEY result) {
+  return ::RegOpenKeyExW(key, sub_key, options, desired, result);
+}
+
+LSTATUS SystemApisImpl::RegQueryValueExW(HKEY key, LPCWSTR value_name,
+                                         LPDWORD type, LPBYTE data,
+                                         LPDWORD data_size) {
+  return ::RegQueryValueExW(key, value_name, nullptr, type, data, data_size);
+}
+
+HINSTANCE SystemApisImpl::ShellExecuteW(HWND hwnd, LPCWSTR operation,
+                                        LPCWSTR file, LPCWSTR parameters,
+                                        LPCWSTR directory, int show_flags) {
+  return ::ShellExecuteW(hwnd, operation, file, parameters, directory,
+                         show_flags);
+}
+
+}  // namespace url_launcher_plugin
diff --git a/packages/url_launcher/url_launcher_windows/windows/system_apis.h b/packages/url_launcher/url_launcher_windows/windows/system_apis.h
new file mode 100644
index 0000000..7b56704
--- /dev/null
+++ b/packages/url_launcher/url_launcher_windows/windows/system_apis.h
@@ -0,0 +1,56 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+#include <windows.h>
+
+namespace url_launcher_plugin {
+
+// An interface wrapping system APIs used by the plugin, for mocking.
+class SystemApis {
+ public:
+  SystemApis();
+  virtual ~SystemApis();
+
+  // Disallow copy and move.
+  SystemApis(const SystemApis&) = delete;
+  SystemApis& operator=(const SystemApis&) = delete;
+
+  // Wrapper for RegCloseKey.
+  virtual LSTATUS RegCloseKey(HKEY key) = 0;
+
+  // Wrapper for RegQueryValueEx.
+  virtual LSTATUS RegQueryValueExW(HKEY key, LPCWSTR value_name, LPDWORD type,
+                                   LPBYTE data, LPDWORD data_size) = 0;
+
+  // Wrapper for RegOpenKeyEx.
+  virtual LSTATUS RegOpenKeyExW(HKEY key, LPCWSTR sub_key, DWORD options,
+                                REGSAM desired, PHKEY result) = 0;
+
+  // Wrapper for ShellExecute.
+  virtual HINSTANCE ShellExecuteW(HWND hwnd, LPCWSTR operation, LPCWSTR file,
+                                  LPCWSTR parameters, LPCWSTR directory,
+                                  int show_flags) = 0;
+};
+
+// Implementation of SystemApis using the Win32 APIs.
+class SystemApisImpl : public SystemApis {
+ public:
+  SystemApisImpl();
+  virtual ~SystemApisImpl();
+
+  // Disallow copy and move.
+  SystemApisImpl(const SystemApisImpl&) = delete;
+  SystemApisImpl& operator=(const SystemApisImpl&) = delete;
+
+  // SystemApis Implementation:
+  virtual LSTATUS RegCloseKey(HKEY key);
+  virtual LSTATUS RegOpenKeyExW(HKEY key, LPCWSTR sub_key, DWORD options,
+                                REGSAM desired, PHKEY result);
+  virtual LSTATUS RegQueryValueExW(HKEY key, LPCWSTR value_name, LPDWORD type,
+                                   LPBYTE data, LPDWORD data_size);
+  virtual HINSTANCE ShellExecuteW(HWND hwnd, LPCWSTR operation, LPCWSTR file,
+                                  LPCWSTR parameters, LPCWSTR directory,
+                                  int show_flags);
+};
+
+}  // namespace url_launcher_plugin
diff --git a/packages/url_launcher/url_launcher_windows/windows/test/url_launcher_windows_test.cpp b/packages/url_launcher/url_launcher_windows/windows/test/url_launcher_windows_test.cpp
new file mode 100644
index 0000000..191d51a
--- /dev/null
+++ b/packages/url_launcher/url_launcher_windows/windows/test/url_launcher_windows_test.cpp
@@ -0,0 +1,162 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+#include <flutter/method_call.h>
+#include <flutter/method_result_functions.h>
+#include <flutter/standard_method_codec.h>
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <windows.h>
+
+#include <memory>
+#include <string>
+
+#include "url_launcher_plugin.h"
+
+namespace url_launcher_plugin {
+namespace test {
+
+namespace {
+
+using flutter::EncodableMap;
+using flutter::EncodableValue;
+using ::testing::DoAll;
+using ::testing::Pointee;
+using ::testing::Return;
+using ::testing::SetArgPointee;
+
+class MockSystemApis : public SystemApis {
+ public:
+  MOCK_METHOD(LSTATUS, RegCloseKey, (HKEY key), (override));
+  MOCK_METHOD(LSTATUS, RegQueryValueExW,
+              (HKEY key, LPCWSTR value_name, LPDWORD type, LPBYTE data,
+               LPDWORD data_size),
+              (override));
+  MOCK_METHOD(LSTATUS, RegOpenKeyExW,
+              (HKEY key, LPCWSTR sub_key, DWORD options, REGSAM desired,
+               PHKEY result),
+              (override));
+  MOCK_METHOD(HINSTANCE, ShellExecuteW,
+              (HWND hwnd, LPCWSTR operation, LPCWSTR file, LPCWSTR parameters,
+               LPCWSTR directory, int show_flags),
+              (override));
+};
+
+class MockMethodResult : public flutter::MethodResult<> {
+ public:
+  MOCK_METHOD(void, SuccessInternal, (const EncodableValue* result),
+              (override));
+  MOCK_METHOD(void, ErrorInternal,
+              (const std::string& error_code, const std::string& error_message,
+               const EncodableValue* details),
+              (override));
+  MOCK_METHOD(void, NotImplementedInternal, (), (override));
+};
+
+std::unique_ptr<EncodableValue> CreateArgumentsWithUrl(const std::string& url) {
+  EncodableMap args = {
+      {EncodableValue("url"), EncodableValue(url)},
+  };
+  return std::make_unique<EncodableValue>(args);
+}
+
+}  // namespace
+
+TEST(UrlLauncherPlugin, CanLaunchSuccessTrue) {
+  std::unique_ptr<MockSystemApis> system = std::make_unique<MockSystemApis>();
+  std::unique_ptr<MockMethodResult> result =
+      std::make_unique<MockMethodResult>();
+
+  // Return success values from the registery commands.
+  HKEY fake_key = reinterpret_cast<HKEY>(1);
+  EXPECT_CALL(*system, RegOpenKeyExW)
+      .WillOnce(DoAll(SetArgPointee<4>(fake_key), Return(ERROR_SUCCESS)));
+  EXPECT_CALL(*system, RegQueryValueExW).WillOnce(Return(ERROR_SUCCESS));
+  EXPECT_CALL(*system, RegCloseKey(fake_key)).WillOnce(Return(ERROR_SUCCESS));
+  // Expect a success response.
+  EXPECT_CALL(*result, SuccessInternal(Pointee(EncodableValue(true))));
+
+  UrlLauncherPlugin plugin(std::move(system));
+  plugin.HandleMethodCall(
+      flutter::MethodCall("canLaunch",
+                          CreateArgumentsWithUrl("https://some.url.com")),
+      std::move(result));
+}
+
+TEST(UrlLauncherPlugin, CanLaunchQueryFailure) {
+  std::unique_ptr<MockSystemApis> system = std::make_unique<MockSystemApis>();
+  std::unique_ptr<MockMethodResult> result =
+      std::make_unique<MockMethodResult>();
+
+  // Return success values from the registery commands, except for the query,
+  // to simulate a scheme that is in the registry, but has no URL handler.
+  HKEY fake_key = reinterpret_cast<HKEY>(1);
+  EXPECT_CALL(*system, RegOpenKeyExW)
+      .WillOnce(DoAll(SetArgPointee<4>(fake_key), Return(ERROR_SUCCESS)));
+  EXPECT_CALL(*system, RegQueryValueExW).WillOnce(Return(ERROR_FILE_NOT_FOUND));
+  EXPECT_CALL(*system, RegCloseKey(fake_key)).WillOnce(Return(ERROR_SUCCESS));
+  // Expect a success response.
+  EXPECT_CALL(*result, SuccessInternal(Pointee(EncodableValue(false))));
+
+  UrlLauncherPlugin plugin(std::move(system));
+  plugin.HandleMethodCall(
+      flutter::MethodCall("canLaunch",
+                          CreateArgumentsWithUrl("https://some.url.com")),
+      std::move(result));
+}
+
+TEST(UrlLauncherPlugin, CanLaunchHandlesOpenFailure) {
+  std::unique_ptr<MockSystemApis> system = std::make_unique<MockSystemApis>();
+  std::unique_ptr<MockMethodResult> result =
+      std::make_unique<MockMethodResult>();
+
+  // Return failure for opening.
+  EXPECT_CALL(*system, RegOpenKeyExW).WillOnce(Return(ERROR_BAD_PATHNAME));
+  // Expect a success response.
+  EXPECT_CALL(*result, SuccessInternal(Pointee(EncodableValue(false))));
+
+  UrlLauncherPlugin plugin(std::move(system));
+  plugin.HandleMethodCall(
+      flutter::MethodCall("canLaunch",
+                          CreateArgumentsWithUrl("https://some.url.com")),
+      std::move(result));
+}
+
+TEST(UrlLauncherPlugin, LaunchSuccess) {
+  std::unique_ptr<MockSystemApis> system = std::make_unique<MockSystemApis>();
+  std::unique_ptr<MockMethodResult> result =
+      std::make_unique<MockMethodResult>();
+
+  // Return a success value (>32) from launching.
+  EXPECT_CALL(*system, ShellExecuteW)
+      .WillOnce(Return(reinterpret_cast<HINSTANCE>(33)));
+  // Expect a success response.
+  EXPECT_CALL(*result, SuccessInternal(Pointee(EncodableValue(true))));
+
+  UrlLauncherPlugin plugin(std::move(system));
+  plugin.HandleMethodCall(
+      flutter::MethodCall("launch",
+                          CreateArgumentsWithUrl("https://some.url.com")),
+      std::move(result));
+}
+
+TEST(UrlLauncherPlugin, LaunchReportsFailure) {
+  std::unique_ptr<MockSystemApis> system = std::make_unique<MockSystemApis>();
+  std::unique_ptr<MockMethodResult> result =
+      std::make_unique<MockMethodResult>();
+
+  // Return a faile value (<=32) from launching.
+  EXPECT_CALL(*system, ShellExecuteW)
+      .WillOnce(Return(reinterpret_cast<HINSTANCE>(32)));
+  // Expect an error response.
+  EXPECT_CALL(*result, ErrorInternal);
+
+  UrlLauncherPlugin plugin(std::move(system));
+  plugin.HandleMethodCall(
+      flutter::MethodCall("launch",
+                          CreateArgumentsWithUrl("https://some.url.com")),
+      std::move(result));
+}
+
+}  // namespace test
+}  // namespace url_launcher_plugin
diff --git a/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.cpp b/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.cpp
index 51740a3..748c75d 100644
--- a/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.cpp
+++ b/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.cpp
@@ -1,7 +1,7 @@
 // Copyright 2013 The Flutter Authors. All rights reserved.
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
-#include "include/url_launcher_windows/url_launcher_plugin.h"
+#include "url_launcher_plugin.h"
 
 #include <flutter/method_channel.h>
 #include <flutter/plugin_registrar_windows.h>
@@ -9,9 +9,12 @@
 #include <windows.h>
 
 #include <memory>
+#include <optional>
 #include <sstream>
 #include <string>
 
+namespace url_launcher_plugin {
+
 namespace {
 
 using flutter::EncodableMap;
@@ -54,19 +57,7 @@
   return url;
 }
 
-class UrlLauncherPlugin : public flutter::Plugin {
- public:
-  static void RegisterWithRegistrar(flutter::PluginRegistrar* registrar);
-
-  virtual ~UrlLauncherPlugin();
-
- private:
-  UrlLauncherPlugin();
-
-  // Called when a method is called on plugin channel;
-  void HandleMethodCall(const flutter::MethodCall<>& method_call,
-                        std::unique_ptr<flutter::MethodResult<>> result);
-};
+}  // namespace
 
 // static
 void UrlLauncherPlugin::RegisterWithRegistrar(
@@ -75,8 +66,8 @@
       registrar->messenger(), "plugins.flutter.io/url_launcher",
       &flutter::StandardMethodCodec::GetInstance());
 
-  // Uses new instead of make_unique due to private constructor.
-  std::unique_ptr<UrlLauncherPlugin> plugin(new UrlLauncherPlugin());
+  std::unique_ptr<UrlLauncherPlugin> plugin =
+      std::make_unique<UrlLauncherPlugin>();
 
   channel->SetMethodCallHandler(
       [plugin_pointer = plugin.get()](const auto& call, auto result) {
@@ -86,7 +77,11 @@
   registrar->AddPlugin(std::move(plugin));
 }
 
-UrlLauncherPlugin::UrlLauncherPlugin() = default;
+UrlLauncherPlugin::UrlLauncherPlugin()
+    : system_apis_(std::make_unique<SystemApisImpl>()) {}
+
+UrlLauncherPlugin::UrlLauncherPlugin(std::unique_ptr<SystemApis> system_apis)
+    : system_apis_(std::move(system_apis)) {}
 
 UrlLauncherPlugin::~UrlLauncherPlugin() = default;
 
@@ -99,17 +94,10 @@
       result->Error("argument_error", "No URL provided");
       return;
     }
-    std::wstring url_wide = Utf16FromUtf8(url);
 
-    int status = static_cast<int>(reinterpret_cast<INT_PTR>(
-        ::ShellExecute(nullptr, TEXT("open"), url_wide.c_str(), nullptr,
-                       nullptr, SW_SHOWNORMAL)));
-
-    if (status <= 32) {
-      std::ostringstream error_message;
-      error_message << "Failed to open " << url << ": ShellExecute error code "
-                    << status;
-      result->Error("open_error", error_message.str());
+    std::optional<std::string> error = LaunchUrl(url);
+    if (error) {
+      result->Error("open_error", error.value());
       return;
     }
     result->Success(EncodableValue(true));
@@ -120,29 +108,48 @@
       return;
     }
 
-    bool can_launch = false;
-    size_t separator_location = url.find(":");
-    if (separator_location != std::string::npos) {
-      std::wstring scheme = Utf16FromUtf8(url.substr(0, separator_location));
-      HKEY key = nullptr;
-      if (::RegOpenKeyEx(HKEY_CLASSES_ROOT, scheme.c_str(), 0, KEY_QUERY_VALUE,
-                         &key) == ERROR_SUCCESS) {
-        can_launch = ::RegQueryValueEx(key, L"URL Protocol", nullptr, nullptr,
-                                       nullptr, nullptr) == ERROR_SUCCESS;
-        ::RegCloseKey(key);
-      }
-    }
+    bool can_launch = CanLaunchUrl(url);
     result->Success(EncodableValue(can_launch));
   } else {
     result->NotImplemented();
   }
 }
 
-}  // namespace
+bool UrlLauncherPlugin::CanLaunchUrl(const std::string& url) {
+  size_t separator_location = url.find(":");
+  if (separator_location == std::string::npos) {
+    return false;
+  }
+  std::wstring scheme = Utf16FromUtf8(url.substr(0, separator_location));
 
-void UrlLauncherPluginRegisterWithRegistrar(
-    FlutterDesktopPluginRegistrarRef registrar) {
-  UrlLauncherPlugin::RegisterWithRegistrar(
-      flutter::PluginRegistrarManager::GetInstance()
-          ->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
+  HKEY key = nullptr;
+  if (system_apis_->RegOpenKeyExW(HKEY_CLASSES_ROOT, scheme.c_str(), 0,
+                                  KEY_QUERY_VALUE, &key) != ERROR_SUCCESS) {
+    return false;
+  }
+  bool has_handler =
+      system_apis_->RegQueryValueExW(key, L"URL Protocol", nullptr, nullptr,
+                                     nullptr) == ERROR_SUCCESS;
+  system_apis_->RegCloseKey(key);
+  return has_handler;
 }
+
+std::optional<std::string> UrlLauncherPlugin::LaunchUrl(
+    const std::string& url) {
+  std::wstring url_wide = Utf16FromUtf8(url);
+
+  int status = static_cast<int>(reinterpret_cast<INT_PTR>(
+      system_apis_->ShellExecuteW(nullptr, TEXT("open"), url_wide.c_str(),
+                                  nullptr, nullptr, SW_SHOWNORMAL)));
+
+  // Per ::ShellExecuteW documentation, anything >32 indicates success.
+  if (status <= 32) {
+    std::ostringstream error_message;
+    error_message << "Failed to open " << url << ": ShellExecute error code "
+                  << status;
+    return std::optional<std::string>(error_message.str());
+  }
+  return std::nullopt;
+}
+
+}  // namespace url_launcher_plugin
diff --git a/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.h b/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.h
new file mode 100644
index 0000000..45e70e5
--- /dev/null
+++ b/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.h
@@ -0,0 +1,48 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+#include <flutter/method_channel.h>
+#include <flutter/plugin_registrar_windows.h>
+#include <windows.h>
+
+#include <memory>
+#include <optional>
+#include <sstream>
+#include <string>
+
+#include "system_apis.h"
+
+namespace url_launcher_plugin {
+
+class UrlLauncherPlugin : public flutter::Plugin {
+ public:
+  static void RegisterWithRegistrar(flutter::PluginRegistrar* registrar);
+
+  UrlLauncherPlugin();
+
+  // Creates a plugin instance with the given SystemApi instance.
+  //
+  // Exists for unit testing with mock implementations.
+  UrlLauncherPlugin(std::unique_ptr<SystemApis> system_apis);
+
+  virtual ~UrlLauncherPlugin();
+
+  // Disallow copy and move.
+  UrlLauncherPlugin(const UrlLauncherPlugin&) = delete;
+  UrlLauncherPlugin& operator=(const UrlLauncherPlugin&) = delete;
+
+  // Called when a method is called on the plugin channel.
+  void HandleMethodCall(const flutter::MethodCall<>& method_call,
+                        std::unique_ptr<flutter::MethodResult<>> result);
+
+ private:
+  // Returns whether or not the given URL has a registered handler.
+  bool CanLaunchUrl(const std::string& url);
+
+  // Attempts to launch the given URL. On failure, returns an error string.
+  std::optional<std::string> LaunchUrl(const std::string& url);
+
+  std::unique_ptr<SystemApis> system_apis_;
+};
+
+}  // namespace url_launcher_plugin
diff --git a/packages/url_launcher/url_launcher_windows/windows/url_launcher_windows.cpp b/packages/url_launcher/url_launcher_windows/windows/url_launcher_windows.cpp
new file mode 100644
index 0000000..05de586
--- /dev/null
+++ b/packages/url_launcher/url_launcher_windows/windows/url_launcher_windows.cpp
@@ -0,0 +1,15 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+#include "include/url_launcher_windows/url_launcher_windows.h"
+
+#include <flutter/plugin_registrar_windows.h>
+
+#include "url_launcher_plugin.h"
+
+void UrlLauncherWindowsRegisterWithRegistrar(
+    FlutterDesktopPluginRegistrarRef registrar) {
+  url_launcher_plugin::UrlLauncherPlugin::RegisterWithRegistrar(
+      flutter::PluginRegistrarManager::GetInstance()
+          ->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
+}