Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Redesign `_openassetio_test` tests
## What

Change `_openassetio_test`-based tests to be more representative of production use cases, in particular with respect to cross-dll interactions, where `_openassetio` is an isolated dll in production that doesn't share its internals with other dlls.

## Why

Following on from https://github.com/OpenAssetIO/OpenAssetIO/pull/1234#discussion_r1440435303

The `_openassetio_test` Python extension module is used to exercise low-level Python binding code that is generic across all our bindings.  It defines and binds C++ functions and types that are only useful for test purposes and shouldn't be distributed.

Code in the main `_openassetio` Python extension module is designed to be entirely private, with no public headers and no symbols exported. 

However, in order to re-use the same machinery in `_openassetio_test` that is used by `_openassetio`, we have to share code between them. This has led to compromises in project structure, and, most recently, exporting symbols (`HybridException`, see comment thread linked above) that should be hidden. This odd splitting of test functions/types yet sharing of internal machinery also means that tests are less representative of production scenarios.

## Acceptance Criteria
<!---
An exhaustive list of falsifiable outcomes that define fully whether
this task is complete.

Remember to consider:

- Release note updates.
- Documentation updates.
- Additional tests or test updates.
- Impact on dependent repos.
--->
- Test functionality is conditionally appended to `_openassetio` python module if testing is enabled
- `_openassetio_test` is removed
- Tests that depend on `_openassetio_test` are refactored to continue working
- "Test pollution" is kept to a minimum in non-test code.
- Test symbols moved into `_openassetio` are placed in a "_testutils" submodule (We can't just call it "test" because that's what we called api compliance suite.)
- Remove now-unnecessary `PYBIND11_EXPORT` from `HybridException`

Redesign `_openassetio_test` tests
## What

Change `_openassetio_test`-based tests to be more representative of production use cases, in particular with respect to cross-dll interactions, where `_openassetio` is an isolated dll in production that doesn't share its internals with other dlls.

## Why

Following on from https://github.com/OpenAssetIO/OpenAssetIO/pull/1234#discussion_r1440435303

The `_openassetio_test` Python extension module is used to exercise low-level Python binding code that is generic across all our bindings.  It defines and binds C++ functions and types that are only useful for test purposes and shouldn't be distributed.

Code in the main `_openassetio` Python extension module is designed to be entirely private, with no public headers and no symbols exported. 

However, in order to re-use the same machinery in `_openassetio_test` that is used by `_openassetio`, we have to share code between them. This has led to compromises in project structure, and, most recently, exporting symbols (`HybridException`, see comment thread linked above) that should be hidden. This odd splitting of test functions/types yet sharing of internal machinery also means that tests are less representative of production scenarios.

## Acceptance Criteria
<!---
An exhaustive list of falsifiable outcomes that define fully whether
this task is complete.

Remember to consider:

- Release note updates.
- Documentation updates.
- Additional tests or test updates.
- Impact on dependent repos.
--->
- Test functionality is conditionally appended to `_openassetio` python module if testing is enabled
- `_openassetio_test` is removed
- Tests that depend on `_openassetio_test` are refactored to continue working
- "Test pollution" is kept to a minimum in non-test code.
- Test symbols moved into `_openassetio` are placed in a "_testutils" submodule (We can't just call it "test" because that's what we called api compliance suite.)
- Remove now-unnecessary `PYBIND11_EXPORT` from `HybridException`



Discussion / hints:
Couple of ideas:
* Concatenate the contents of `_openassetio_test` to `_openassetio`, or vice-versa, so the corresponding tests only use one module.
    - Using an `#ifdef TESTING` guarding a `registerTestFunctions` call in `_openassetio`, then using `_openassetio` exclusively in tests; or
    - Duplicating the contents of `_openassetio` in `_openassetio_test` and appending test functions/types, then using `_openassetio_test` exclusively in tests.
* Perhaps we can split `_openassetio_test` into multiple Python extension modules, duplicating any subset of  `_openassetio` required for each mini module.

Couple of ideas:
* Concatenate the contents of `_openassetio_test` to `_openassetio`, or vice-versa, so the corresponding tests only use one module.
    - Using an `#ifdef TESTING` guarding a `registerTestFunctions` call in `_openassetio`, then using `_openassetio` exclusively in tests; or
    - Duplicating the contents of `_openassetio` in `_openassetio_test` and appending test functions/types, then using `_openassetio_test` exclusively in tests.
* Perhaps we can split `_openassetio_test` into multiple Python extension modules, duplicating any subset of  `_openassetio` required for each mini module.


Fix patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a08828f62..a23130b00 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -132,4 +132,4 @@ jobs:
     - name: pybind11 macros
       run: >
         ! grep -Pr '(?<!OPENASSETIO_)PYBIND11_OVERRIDE'
-        --exclude overrideMacros.hpp --exclude-dir _openassetio_test src
+        --exclude overrideMacros.hpp --exclude-dir _testutils src
diff --git a/src/openassetio-python/cmodule/CMakeLists.txt b/src/openassetio-python/cmodule/CMakeLists.txt
index c102b2d58..77cbd887e 100644
--- a/src/openassetio-python/cmodule/CMakeLists.txt
+++ b/src/openassetio-python/cmodule/CMakeLists.txt
@@ -108,3 +108,16 @@ if (UNIX)
 
     set_target_properties(openassetio-python-module PROPERTIES INSTALL_RPATH "${_rpath}")
 endif ()
+
+
+#-----------------------------------------------------------------------
+# Link in the test-specific bindings, if testing is enabled.
+#
+# In order to have representative tests of Python extension module
+# internals we, unfortunately, need to bundle test functionality within
+# the _openassetio module itself.
+
+if (OPENASSETIO_ENABLE_TESTS)
+    target_compile_definitions(openassetio-python-module PRIVATE OPENASSETIO_ENABLE_TESTS)
+    target_link_libraries(openassetio-python-module PRIVATE openassetio-python-module-test)
+endif ()
diff --git a/src/openassetio-python/cmodule/src/_openassetio.cpp b/src/openassetio-python/cmodule/src/_openassetio.cpp
index fb7bc9c0e..54c48cf84 100644
--- a/src/openassetio-python/cmodule/src/_openassetio.cpp
+++ b/src/openassetio-python/cmodule/src/_openassetio.cpp
@@ -3,6 +3,14 @@
 
 #include "_openassetio.hpp"
 
+#ifdef OPENASSETIO_ENABLE_TESTS
+// In order to have representative tests of internal functionality, we
+// must augment this extension module with test-specific bindings. The
+// static library providing the implementation of this entry point is
+// conditionally linked into this extension module.
+void registerTestUtils(py::module& mod);
+#endif
+
 PYBIND11_MODULE(_openassetio, mod) {
   namespace py = pybind11;
 
@@ -43,4 +51,8 @@ PYBIND11_MODULE(_openassetio, mod) {
   registerManager(hostApi);
   registerManagerFactory(hostApi);
   registerUtils(utils);
+
+#ifdef OPENASSETIO_ENABLE_TESTS
+  registerTestUtils(mod);
+#endif
 }
diff --git a/src/openassetio-python/cmodule/src/errors/exceptionsAsserts.cpp b/src/openassetio-python/cmodule/src/errors/exceptionsAsserts.cpp
index dae173875..4a991a1b1 100644
--- a/src/openassetio-python/cmodule/src/errors/exceptionsAsserts.cpp
+++ b/src/openassetio-python/cmodule/src/errors/exceptionsAsserts.cpp
@@ -1,11 +1,13 @@
 // SPDX-License-Identifier: Apache-2.0
 // Copyright 2013-2023 The Foundry Visionmongers Ltd
+#include <cstddef>
+#include <utility>
+
 #include <openassetio/errors/exceptions.hpp>
-#include <openassetio/private/python/exceptions.hpp>
 
-namespace {
-using openassetio::python::exceptions::CppExceptionsAndPyClassNames;
+#include "./exceptionsConverter.hpp"
 
+namespace {
 /**
  * @section asserts Compile-time assertions.
  *
diff --git a/src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp b/src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp
index ce899e4b7..335e0ae15 100644
--- a/src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp
+++ b/src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp
@@ -1,16 +1,19 @@
 // SPDX-License-Identifier: Apache-2.0
 // Copyright 2013-2023 The Foundry Visionmongers Ltd
-#include <map>
+#include <cstddef>
+#include <exception>
 #include <string_view>
+#include <unordered_map>
+#include <utility>
 
 #include <pybind11/eval.h>
-#include <pybind11/operators.h>
 #include <pybind11/pybind11.h>
+#include <pyerrors.h>
 
 #include <openassetio/errors/exceptions.hpp>
-#include <openassetio/private/python/exceptions.hpp>
 
 #include "../_openassetio.hpp"
+#include "./exceptionsConverter.hpp"
 
 namespace {
 using openassetio::errors::BatchElementException;
@@ -19,8 +22,6 @@ using openassetio::errors::InputValidationException;
 using openassetio::errors::NotImplementedException;
 using openassetio::errors::OpenAssetIOException;
 using openassetio::errors::UnhandledException;
-using openassetio::python::exceptions::CppExceptionsAndPyClassNames;
-using openassetio::python::exceptions::HybridException;
 
 /**
  * @section topy Conversion from C++ exception to Python exception.
@@ -226,8 +227,7 @@ void registerPyExceptionClasses(const py::module &mod,
 void registerExceptions(const py::module &mod) {
   // Ensure module name matches what we expect, since it must be
   // imported by name in `register_exception_translator`, below.
-  assert(mod.attr("__name__").cast<std::string>() ==
-         openassetio::python::exceptions::kErrorsModuleName);
+  assert(mod.attr("__name__").cast<std::string>() == kErrorsModuleName);
   // Register new Python exception types. Note that this is not
   // sufficient to cause C++ exceptions to be translated. See
   // `register_exception_translator` below.
@@ -243,8 +243,7 @@ void registerExceptions(const py::module &mod) {
     if (!pexc) {
       return;
     }
-    const py::module_ pyModule =
-        py::module_::import(openassetio::python::exceptions::kErrorsModuleName.data());
+    const py::module_ pyModule = py::module_::import(kErrorsModuleName.data());
 
     // Handle the different possible C++ exceptions, creating the
     // corresponding Python exception and setting it as the active
diff --git a/src/openassetio-python/private/include/openassetio/private/python/exceptions.hpp b/src/openassetio-python/cmodule/src/errors/exceptionsConverter.hpp
similarity index 72%
rename from src/openassetio-python/private/include/openassetio/private/python/exceptions.hpp
rename to src/openassetio-python/cmodule/src/errors/exceptionsConverter.hpp
index f0f753a45..4686989a0 100644
--- a/src/openassetio-python/private/include/openassetio/private/python/exceptions.hpp
+++ b/src/openassetio-python/cmodule/src/errors/exceptionsConverter.hpp
@@ -1,27 +1,18 @@
 // SPDX-License-Identifier: Apache-2.0
 // Copyright 2023 The Foundry Visionmongers Ltd
 #pragma once
+#include <array>
+#include <cstddef>
 #include <string>
 #include <string_view>
 #include <tuple>
-#include <type_traits>
+#include <utility>
 
 #include <pybind11/pybind11.h>
 
-#include <openassetio/export.h>
+#include <openassetio/errors/BatchElementError.hpp>
 #include <openassetio/errors/exceptions.hpp>
 
-namespace openassetio {
-inline namespace OPENASSETIO_CORE_ABI_VERSION {
-namespace python::exceptions {
-namespace py = pybind11;
-using openassetio::errors::BatchElementException;
-using openassetio::errors::ConfigurationException;
-using openassetio::errors::InputValidationException;
-using openassetio::errors::NotImplementedException;
-using openassetio::errors::OpenAssetIOException;
-using openassetio::errors::UnhandledException;
-
 /**
  * @section list List of exceptions.
  *
@@ -50,11 +41,12 @@ struct TypesAndIds {
  * such that more-derived exceptions come before less-derived. See
  * `tryCatch`.
  */
-constexpr auto kCppExceptionsAndPyClassNames =
-    TypesAndIds<BatchElementException, NotImplementedException, UnhandledException,
-                ConfigurationException, InputValidationException, OpenAssetIOException>{
-        "BatchElementException",  "NotImplementedException",  "UnhandledException",
-        "ConfigurationException", "InputValidationException", "OpenAssetIOException"};
+constexpr auto kCppExceptionsAndPyClassNames = TypesAndIds<
+    openassetio::errors::BatchElementException, openassetio::errors::NotImplementedException,
+    openassetio::errors::UnhandledException, openassetio::errors::ConfigurationException,
+    openassetio::errors::InputValidationException, openassetio::errors::OpenAssetIOException>{
+    "BatchElementException",  "NotImplementedException",  "UnhandledException",
+    "ConfigurationException", "InputValidationException", "OpenAssetIOException"};
 
 /**
  * Convenience struct deriving compile-time properties from the above
@@ -107,20 +99,13 @@ struct CppExceptionsAndPyClassNames {
  *
  * @tparam CppException C++ exception type corresponding to given
  * Python exception.
- *
- * @todo Remove PYBIND11_EXPORT once tests that require it are
- *   redesigned. I.e. _openassetio_test throws HybridException which is
- *   then caught in _openassetio, requiring global RTTI info for libc++.
- *   This is not representative of real-world use, since HybridException
- *   should be private to _openassetio. See
- *   https://github.com/OpenAssetIO/OpenAssetIO/issues/1237
  */
 template <class CppException>
-struct PYBIND11_EXPORT HybridException : CppException {
-  explicit HybridException(const py::error_already_set &pyExc)
+struct HybridException : CppException {
+  explicit HybridException(const pybind11::error_already_set &pyExc)
       : CppException{pyExc.what()}, originalPyExc{pyExc} {}
 
-  py::error_already_set originalPyExc;
+  pybind11::error_already_set originalPyExc;
 };
 
 /**
@@ -130,15 +115,16 @@ struct PYBIND11_EXPORT HybridException : CppException {
  * generalisation, above, for more details.
  */
 template <>
-struct HybridException<BatchElementException> : BatchElementException {
-  explicit HybridException(const py::error_already_set &pyExc)
-      : BatchElementException{py::cast<std::size_t>(pyExc.value().attr("index")),
-                              py::cast<openassetio::errors::BatchElementError>(
+struct HybridException<openassetio::errors::BatchElementException>
+    : openassetio::errors::BatchElementException {
+  explicit HybridException(const pybind11::error_already_set &pyExc)
+      : BatchElementException{pybind11::cast<std::size_t>(pyExc.value().attr("index")),
+                              pybind11::cast<openassetio::errors::BatchElementError>(
                                   pyExc.value().attr("error")),
                               pyExc.what()},
         originalPyExc{pyExc} {}
 
-  py::error_already_set originalPyExc;
+  pybind11::error_already_set originalPyExc;
 };
 
 /**
@@ -156,7 +142,7 @@ struct HybridException<BatchElementException> : BatchElementException {
  */
 template <class Exception>
 void throwHybridExceptionIfMatches(const std::string_view expectedPyExcName,
-                                   const py::error_already_set &thrownPyExc,
+                                   const pybind11::error_already_set &thrownPyExc,
                                    const std::string_view thrownPyExcName) {
   if (thrownPyExcName == expectedPyExcName) {
     throw HybridException<Exception>{thrownPyExc};
@@ -178,14 +164,14 @@ constexpr std::string_view kErrorsModuleName = "openassetio._openassetio.errors"
  * @param thrownPyExc pybind11-wrapped Python exception.
  */
 template <std::size_t... I>
-void convertPyExceptionAndThrow(const py::error_already_set &thrownPyExc,
+void convertPyExceptionAndThrow(const pybind11::error_already_set &thrownPyExc,
                                 [[maybe_unused]] std::index_sequence<I...> unused) {
   // We need values from the Python exception object, so must hold the
-  // GIL. py::error_already_set::what() does this itself, but we need
+  // GIL. pybind11::error_already_set::what() does this itself, but we need
   // additional attributes too. Note that acquiring the GIL can cause
   // crashes if the Python interpreter is finalizing (i.e. has been
   // destroyed).
-  const py::gil_scoped_acquire gil{};
+  const pybind11::gil_scoped_acquire gil{};
   // Check module name of Python exception.
   if (thrownPyExc.type().attr("__module__").cast<std::string_view>() != kErrorsModuleName) {
     // Just in case another exception is defined by managers/hosts with
@@ -194,7 +180,7 @@ void convertPyExceptionAndThrow(const py::error_already_set &thrownPyExc,
   }
 
   // Extract class name of Python exception.
-  const std::string thrownPyExcName{py::str{thrownPyExc.type().attr("__name__")}};
+  const std::string thrownPyExcName{pybind11::str{thrownPyExc.type().attr("__name__")}};
 
   (throwHybridExceptionIfMatches<CppExceptionsAndPyClassNames::Exceptions<I>>(
        CppExceptionsAndPyClassNames::kClassNames[I], thrownPyExc, thrownPyExcName),
@@ -209,7 +195,7 @@ void convertPyExceptionAndThrow(const py::error_already_set &thrownPyExc,
  *
  * @param thrownPyExc pybind11-wrapped Python exception.
  */
-inline void convertPyExceptionAndThrow(const py::error_already_set &thrownPyExc) {
+inline void convertPyExceptionAndThrow(const pybind11::error_already_set &thrownPyExc) {
   convertPyExceptionAndThrow(thrownPyExc, CppExceptionsAndPyClassNames::kIndices);
 }
 
@@ -226,7 +212,7 @@ template <class Fn>
 auto decorateWithExceptionConverter(Fn &&func) {
   try {
     return func();
-  } catch (const py::error_already_set &exc) {
+  } catch (const pybind11::error_already_set &exc) {
     convertPyExceptionAndThrow(exc);
     // Can't convert exception, so rethrow as-is.
     throw;
@@ -235,6 +221,3 @@ auto decorateWithExceptionConverter(Fn &&func) {
 /**
  * @}
  */
-}  // namespace python::exceptions
-}  // namespace OPENASSETIO_CORE_ABI_VERSION
-}  // namespace openassetio
diff --git a/src/openassetio-python/cmodule/src/overrideMacros.hpp b/src/openassetio-python/cmodule/src/overrideMacros.hpp
index 686141210..8b99b7114 100644
--- a/src/openassetio-python/cmodule/src/overrideMacros.hpp
+++ b/src/openassetio-python/cmodule/src/overrideMacros.hpp
@@ -3,24 +3,22 @@
 #pragma once
 #include <pybind11/pybind11.h>
 
-#include <openassetio/private/python/exceptions.hpp>
+#include "./errors/exceptionsConverter.hpp"
 
 /// @note Update errorsTest.cpp if adding more override macros below.
 
 /**
  * Decorate PYBIND11_OVERRIDE_NAME with exception type translation.
  */
-#define OPENASSETIO_PYBIND11_OVERRIDE_NAME(ret_type, cname, name, fn, ...)            \
-  do {                                                                                \
-    /* Must explicitly specify decorated lambda return type, since    */              \
-    /* PYBIND11_OVERRIDE_IMPL return type can be PyRetainingSharedPtr,*/              \
-    /* which confuses the compiler.                                   */              \
-    return openassetio::python::exceptions::decorateWithExceptionConverter(           \
-        [&]() -> decltype(cname::fn(__VA_ARGS__)) {                                   \
-          PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), name, \
-                                 __VA_ARGS__);                                        \
-          return cname::fn(__VA_ARGS__);                                              \
-        });                                                                           \
+#define OPENASSETIO_PYBIND11_OVERRIDE_NAME(ret_type, cname, name, fn, ...)                      \
+  do {                                                                                          \
+    /* Must explicitly specify decorated lambda return type, since    */                        \
+    /* PYBIND11_OVERRIDE_IMPL return type can be PyRetainingSharedPtr,*/                        \
+    /* which confuses the compiler.                                   */                        \
+    return decorateWithExceptionConverter([&]() -> decltype(cname::fn(__VA_ARGS__)) {           \
+      PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), name, __VA_ARGS__); \
+      return cname::fn(__VA_ARGS__);                                                            \
+    });                                                                                         \
   } while (false)
 
 /**
@@ -47,13 +45,12 @@
  * parentheses-enclosed) are passed to the Python override
  * implementation, if one exists.
  */
-#define OPENASSETIO_PYBIND11_OVERRIDE_ARGS(Ret, Class, Fn, CppArgs, ... /* PyArgs */)         \
-  do {                                                                                        \
-    return openassetio::python::exceptions::decorateWithExceptionConverter(                   \
-        [&]() -> decltype(Class::Fn CppArgs) {                                                \
-          PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(Ret), PYBIND11_TYPE(Class), #Fn, __VA_ARGS__); \
-          return Class::Fn CppArgs;                                                           \
-        });                                                                                   \
+#define OPENASSETIO_PYBIND11_OVERRIDE_ARGS(Ret, Class, Fn, CppArgs, ... /* PyArgs */)     \
+  do {                                                                                    \
+    return decorateWithExceptionConverter([&]() -> decltype(Class::Fn CppArgs) {          \
+      PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(Ret), PYBIND11_TYPE(Class), #Fn, __VA_ARGS__); \
+      return Class::Fn CppArgs;                                                           \
+    });                                                                                   \
   } while (false)
 
 /**
@@ -71,16 +68,14 @@
  *
  * @todo Revert GIL workaround once upstream fix is available.
  */
-#define OPENASSETIO_PYBIND11_OVERRIDE_PURE_NAME(ret_type, cname, name, fn, ...)                   \
-  do {                                                                                            \
-    return openassetio::python::exceptions::decorateWithExceptionConverter(                       \
-        [&]() -> decltype(cname::fn(__VA_ARGS__)) {                                               \
-          PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), name,             \
-                                 __VA_ARGS__);                                                    \
-          const pybind11::gil_scoped_acquire gil{};                                               \
-          pybind11::pybind11_fail(                                                                \
-              "Tried to call pure virtual function \"" PYBIND11_STRINGIFY(cname) "::" name "\""); \
-        });                                                                                       \
+#define OPENASSETIO_PYBIND11_OVERRIDE_PURE_NAME(ret_type, cname, name, fn, ...)                 \
+  do {                                                                                          \
+    return decorateWithExceptionConverter([&]() -> decltype(cname::fn(__VA_ARGS__)) {           \
+      PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), name, __VA_ARGS__); \
+      const pybind11::gil_scoped_acquire gil{};                                                 \
+      pybind11::pybind11_fail(                                                                  \
+          "Tried to call pure virtual function \"" PYBIND11_STRINGIFY(cname) "::" name "\"");   \
+    });                                                                                         \
   } while (false)
 
 /**


Test patch:
diff --git a/src/openassetio-python/tests/cmodule/CMakeLists.txt b/src/openassetio-python/tests/cmodule/CMakeLists.txt
index ce8172ba1..a336f736c 100644
--- a/src/openassetio-python/tests/cmodule/CMakeLists.txt
+++ b/src/openassetio-python/tests/cmodule/CMakeLists.txt
@@ -3,9 +3,9 @@
 
 
 #-----------------------------------------------------------------------
-# Python test module target for creating test-only bindings.
+# Python test utils static library for creating test-only bindings.
 
-add_subdirectory(resources/_openassetio_test)
+add_subdirectory(resources/_testutils)
 
 
 #-----------------------------------------------------------------------
diff --git a/src/openassetio-python/tests/cmodule/gil/conftest.py b/src/openassetio-python/tests/cmodule/gil/conftest.py
index e1d85bd7b..9636e19df 100644
--- a/src/openassetio-python/tests/cmodule/gil/conftest.py
+++ b/src/openassetio-python/tests/cmodule/gil/conftest.py
@@ -16,7 +16,7 @@
 """
 Common test utils for our GIL handling behaviour
 """
-# pylint: disable=redefined-outer-name
+# pylint: disable=redefined-outer-name,protected-access
 # pylint: disable=invalid-name,c-extension-no-member
 # pylint: disable=missing-class-docstring,missing-function-docstring
 import inspect
@@ -24,7 +24,7 @@
 import pytest
 
 # pylint: disable=no-name-in-module
-from openassetio import _openassetio_test, EntityReference, Context
+from openassetio import _openassetio, EntityReference, Context
 from openassetio.trait import TraitsData
 
 
@@ -48,7 +48,7 @@ def finder(target_class, test_class):
 
 @pytest.fixture
 def a_threaded_mock_manager_interface(mock_manager_interface):
-    return _openassetio_test.gil.wrapInThreadedManagerInterface(mock_manager_interface)
+    return _openassetio._testutils.gil.wrapInThreadedManagerInterface(mock_manager_interface)
 
 
 @pytest.fixture
diff --git a/src/openassetio-python/tests/cmodule/gil/test_entityreferencepager_gil.py b/src/openassetio-python/tests/cmodule/gil/test_entityreferencepager_gil.py
index 78010d0d5..71392527d 100644
--- a/src/openassetio-python/tests/cmodule/gil/test_entityreferencepager_gil.py
+++ b/src/openassetio-python/tests/cmodule/gil/test_entityreferencepager_gil.py
@@ -17,13 +17,13 @@
 Testing that EntityReferencePager/EntityReferencePagerInterface methods
 release the GIL.
 """
-# pylint: disable=redefined-outer-name
+# pylint: disable=redefined-outer-name,protected-access
 # pylint: disable=invalid-name,c-extension-no-member
 # pylint: disable=missing-class-docstring,missing-function-docstring
 import pytest
 
 # pylint: disable=no-name-in-module
-from openassetio import _openassetio_test
+from openassetio import _openassetio
 from openassetio.hostApi import EntityReferencePager
 from openassetio.managerApi import EntityReferencePagerInterface
 
@@ -126,6 +126,6 @@ def a_threaded_entity_ref_pager(a_threaded_entity_ref_pager_interface, a_host_se
 
 @pytest.fixture
 def a_threaded_entity_ref_pager_interface(mock_entity_reference_pager_interface):
-    return _openassetio_test.gil.wrapInThreadedEntityReferencePagerInterface(
+    return _openassetio._testutils.gil.wrapInThreadedEntityReferencePagerInterface(
         mock_entity_reference_pager_interface
     )
diff --git a/src/openassetio-python/tests/cmodule/gil/test_host_gil.py b/src/openassetio-python/tests/cmodule/gil/test_host_gil.py
index 42b7403ec..b97f23fce 100644
--- a/src/openassetio-python/tests/cmodule/gil/test_host_gil.py
+++ b/src/openassetio-python/tests/cmodule/gil/test_host_gil.py
@@ -16,13 +16,13 @@
 """
 Testing that Host/HostInterface methods release the GIL.
 """
-# pylint: disable=redefined-outer-name
+# pylint: disable=redefined-outer-name,protected-access
 # pylint: disable=invalid-name,c-extension-no-member
 # pylint: disable=missing-class-docstring,missing-function-docstring
 import pytest
 
 # pylint: disable=no-name-in-module
-from openassetio import _openassetio_test
+from openassetio import _openassetio
 from openassetio.hostApi import HostInterface
 from openassetio.managerApi import Host
 
@@ -114,4 +114,4 @@ def a_threaded_host(a_threaded_host_interface):
 
 @pytest.fixture
 def a_threaded_host_interface(mock_host_interface):
-    return _openassetio_test.gil.wrapInThreadedHostInterface(mock_host_interface)
+    return _openassetio._testutils.gil.wrapInThreadedHostInterface(mock_host_interface)
diff --git a/src/openassetio-python/tests/cmodule/gil/test_loggerinterface_gil.py b/src/openassetio-python/tests/cmodule/gil/test_loggerinterface_gil.py
index 45ff2b78c..3f48b5331 100644
--- a/src/openassetio-python/tests/cmodule/gil/test_loggerinterface_gil.py
+++ b/src/openassetio-python/tests/cmodule/gil/test_loggerinterface_gil.py
@@ -16,13 +16,13 @@
 """
 Testing that LoggerInterface methods release the GIL.
 """
-# pylint: disable=redefined-outer-name
+# pylint: disable=redefined-outer-name,protected-access
 # pylint: disable=invalid-name,c-extension-no-member
 # pylint: disable=missing-class-docstring,missing-function-docstring
 import pytest
 
 # pylint: disable=no-name-in-module
-from openassetio import _openassetio_test
+from openassetio import _openassetio
 from openassetio.log import LoggerInterface
 
 
@@ -80,4 +80,4 @@ def test_warning(self, a_threaded_logger_interface):
 
 @pytest.fixture
 def a_threaded_logger_interface(mock_logger):
-    return _openassetio_test.gil.wrapInThreadedLoggerInterface(mock_logger)
+    return _openassetio._testutils.gil.wrapInThreadedLoggerInterface(mock_logger)
diff --git a/src/openassetio-python/tests/cmodule/gil/test_managerfactory_gil.py b/src/openassetio-python/tests/cmodule/gil/test_managerfactory_gil.py
index 07a4f4144..cb0ae7bbe 100644
--- a/src/openassetio-python/tests/cmodule/gil/test_managerfactory_gil.py
+++ b/src/openassetio-python/tests/cmodule/gil/test_managerfactory_gil.py
@@ -17,7 +17,7 @@
 Testing that ManagerFactory/ManagerImplementationFactoryInterface
 methods release the GIL.
 """
-# pylint: disable=redefined-outer-name
+# pylint: disable=redefined-outer-name,protected-access
 # pylint: disable=invalid-name,c-extension-no-member
 # pylint: disable=missing-class-docstring,missing-function-docstring
 from unittest import mock
@@ -25,7 +25,7 @@
 import pytest
 
 # pylint: disable=no-name-in-module
-from openassetio import _openassetio_test
+from openassetio import _openassetio
 from openassetio.hostApi import ManagerImplementationFactoryInterface, ManagerFactory
 
 
@@ -208,7 +208,7 @@ def a_threaded_manager_factory(mock_host_interface, a_threaded_manager_impl_fact
 
 @pytest.fixture
 def a_threaded_manager_impl_factory(mock_manager_impl_factory, mock_logger):
-    return _openassetio_test.gil.wrapInThreadedManagerImplFactory(
+    return _openassetio._testutils.gil.wrapInThreadedManagerImplFactory(
         mock_logger, mock_manager_impl_factory
     )
 
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt
deleted file mode 100644
index b7cd0bf8b..000000000
--- a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt
+++ /dev/null
@@ -1,79 +0,0 @@
-#-----------------------------------------------------------------------
-# Python test module target for creating test-only bindings.
-
-pybind11_add_module(openassetio-python-test MODULE)
-# Set target file name to `_openassetio_test`
-openassetio_set_default_target_properties(openassetio-python-test)
-set_target_properties(
-    openassetio-python-test PROPERTIES
-    OUTPUT_NAME _openassetio_test
-    SOVERSION ""
-    VERSION ""
-)
-
-set(_install_subdir "${OPENASSETIO_PYTHON_SITEDIR}/openassetio")
-
-# Add to the set of installable targets.
-install(
-    TARGETS openassetio-python-test
-    EXPORT ${PROJECT_NAME}_EXPORTED_TARGETS
-    DESTINATION ${_install_subdir}
-)
-
-if (WIN32)
-    install(
-        FILES $<TARGET_PDB_FILE:openassetio-python-test>
-        TYPE BIN
-        CONFIGURATIONS "Debug" "RelWithDebInfo"
-    )
-endif ()
-
-target_sources(
-    openassetio-python-test
-    PRIVATE
-    _openassetio_test.cpp
-    PyRetainingSharedPtrTest.cpp
-    errorsTest.cpp
-    gilTest.cpp
-)
-
-# Give access to private headers.
-target_include_directories(openassetio-python-test
-    PRIVATE ${PROJECT_SOURCE_DIR}/src/openassetio-python/cmodule/src)
-
-target_link_libraries(openassetio-python-test
-    PRIVATE
-    # Mocking framework.
-    trompeloeil::trompeloeil
-    # Core C++ library.
-    openassetio-core
-    # Python C++ bridge library.
-    openassetio-python-bridge
-    # pybind, including its handy transitive Python-specific properties.
-    pybind11::module pybind11::windows_extras)
-
-add_dependencies(openassetio-python-test openassetio-python-module)
-
-# Override build tree to look like install tree.
-set_target_properties(openassetio-python-test
-    PROPERTIES
-    RUNTIME_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/${_install_subdir}
-    LIBRARY_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/${_install_subdir})
-
-# Override RPATH in (usual) case that Python .so and core .so live in
-# different locations.
-if (UNIX)
-    # Calculate relative path from site-packages to lib directory.
-    file(RELATIVE_PATH
-        install_dir_rel_to_lib
-        "${CMAKE_INSTALL_PREFIX}/${_install_subdir}"
-        "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_LIBDIR}")
-
-    if (APPLE)
-        set(_rpath "@loader_path/${install_dir_rel_to_lib}")
-    else ()
-        set(_rpath "$ORIGIN/${install_dir_rel_to_lib}")
-    endif ()
-
-    set_target_properties(openassetio-python-test PROPERTIES INSTALL_RPATH "${_rpath}")
-endif ()
diff --git a/src/openassetio-python/tests/cmodule/resources/_testutils/CMakeLists.txt b/src/openassetio-python/tests/cmodule/resources/_testutils/CMakeLists.txt
new file mode 100644
index 000000000..a3323f564
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/resources/_testutils/CMakeLists.txt
@@ -0,0 +1,34 @@
+#-----------------------------------------------------------------------
+# Python test target for creating test-only bindings.
+#
+# This is a static library to be consumed by the _openassetio Python
+# extension module at build time for adding test-specific bindings.
+
+add_library(openassetio-python-module-test STATIC)
+openassetio_set_default_target_properties(openassetio-python-module-test)
+
+target_sources(
+    openassetio-python-module-test
+    PRIVATE
+    _testutils.cpp
+    PyRetainingSharedPtrTest.cpp
+    errorsTest.cpp
+    gilTest.cpp
+)
+
+# Give access to private headers.
+target_include_directories(openassetio-python-module-test
+    PRIVATE ${PROJECT_SOURCE_DIR}/src/openassetio-python/cmodule/src)
+
+target_link_libraries(
+    openassetio-python-module-test
+    PRIVATE
+    # Mocking framework.
+    trompeloeil::trompeloeil
+    # Core C++ library.
+    openassetio-core
+    # Python C++ bridge library.
+    openassetio-python-bridge
+    # pybind, including its handy transitive Python-specific properties.
+    pybind11::module pybind11::windows_extras
+)
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/PyRetainingSharedPtrTest.cpp b/src/openassetio-python/tests/cmodule/resources/_testutils/PyRetainingSharedPtrTest.cpp
similarity index 100%
rename from src/openassetio-python/tests/cmodule/resources/_openassetio_test/PyRetainingSharedPtrTest.cpp
rename to src/openassetio-python/tests/cmodule/resources/_testutils/PyRetainingSharedPtrTest.cpp
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp b/src/openassetio-python/tests/cmodule/resources/_testutils/_testutils.cpp
similarity index 56%
rename from src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp
rename to src/openassetio-python/tests/cmodule/resources/_testutils/_testutils.cpp
index 34c1bf80c..74ca68ae4 100644
--- a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp
+++ b/src/openassetio-python/tests/cmodule/resources/_testutils/_testutils.cpp
@@ -8,8 +8,9 @@ void registerPyRetainingSharedPtrTestTypes(py::module_&);
 void registerExceptionThrower(py::module_& mod);
 void registerRunInThread(py::module_& mod);
 
-PYBIND11_MODULE(_openassetio_test, mod) {
-  registerPyRetainingSharedPtrTestTypes(mod);
-  registerExceptionThrower(mod);
-  registerRunInThread(mod);
+void registerTestUtils(py::module& mod) {
+  py::module_ testutils = mod.def_submodule("_testutils");
+  registerPyRetainingSharedPtrTestTypes(testutils);
+  registerExceptionThrower(testutils);
+  registerRunInThread(testutils);
 }
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp b/src/openassetio-python/tests/cmodule/resources/_testutils/errorsTest.cpp
similarity index 99%
rename from src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp
rename to src/openassetio-python/tests/cmodule/resources/_testutils/errorsTest.cpp
index 6a95a79b5..8c8534a38 100644
--- a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp
+++ b/src/openassetio-python/tests/cmodule/resources/_testutils/errorsTest.cpp
@@ -17,8 +17,6 @@
 namespace py = pybind11;
 
 namespace {
-using openassetio::python::exceptions::CppExceptionsAndPyClassNames;
-
 /**
  * Throw given exception type.
  *
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/gilTest.cpp b/src/openassetio-python/tests/cmodule/resources/_testutils/gilTest.cpp
similarity index 100%
rename from src/openassetio-python/tests/cmodule/resources/_openassetio_test/gilTest.cpp
rename to src/openassetio-python/tests/cmodule/resources/_testutils/gilTest.cpp
diff --git a/src/openassetio-python/tests/cmodule/test_PyRetainingSharedPtr.py b/src/openassetio-python/tests/cmodule/test_PyRetainingSharedPtr.py
index eff625993..2c8fcd524 100644
--- a/src/openassetio-python/tests/cmodule/test_PyRetainingSharedPtr.py
+++ b/src/openassetio-python/tests/cmodule/test_PyRetainingSharedPtr.py
@@ -18,21 +18,21 @@
 shouldn't be included in the main sources.
 """
 
-# pylint: disable=invalid-name,redefined-outer-name
+# pylint: disable=invalid-name,redefined-outer-name,protected-access
 # pylint: disable=missing-class-docstring,missing-function-docstring
 import weakref
 from unittest import mock
 
 import pytest
 
-from openassetio import _openassetio_test  # pylint: disable=no-name-in-module
+from openassetio import _openassetio  # pylint: disable=no-name-in-module
 
 
 class Test_PyRetainingSharedPtr_arg:
     def test_when_not_using_PyRetainingSharedPtr_in_constructor_then_python_implementation_is_lost(
         self,
     ):
-        container = _openassetio_test.SimpleCppContainer(SimpleCppType())
+        container = _openassetio._testutils.SimpleCppContainer(SimpleCppType())
         element = container.heldObject()
 
         with pytest.raises(RuntimeError) as err:
@@ -43,7 +43,7 @@ def test_when_not_using_PyRetainingSharedPtr_in_constructor_then_python_implemen
     def test_when_not_using_PyRetainingSharedPtr_in_factory_then_python_implementation_is_lost(
         self,
     ):
-        container = _openassetio_test.SimpleCppContainer.make(SimpleCppType())
+        container = _openassetio._testutils.SimpleCppContainer.make(SimpleCppType())
         element = container.heldObject()
 
         with pytest.raises(RuntimeError) as err:
@@ -52,7 +52,9 @@ def test_when_not_using_PyRetainingSharedPtr_in_factory_then_python_implementati
         assert str(err.value) == 'Tried to call pure virtual function "SimpleBaseCppType::value"'
 
     def test_when_not_using_PyRetainingSharedPtr_in_list_then_python_implementation_is_lost(self):
-        container = _openassetio_test.SimpleCppListContainer([SimpleCppType(), SimpleCppType()])
+        container = _openassetio._testutils.SimpleCppListContainer(
+            [SimpleCppType(), SimpleCppType()]
+        )
         elements = container.heldObjects()
 
         with pytest.raises(RuntimeError) as err:
@@ -62,24 +64,24 @@ def test_when_not_using_PyRetainingSharedPtr_in_list_then_python_implementation_
 
     def test_when_using_constructor_then_TypeError_reports_expected_type_in_error_message(self):
         with pytest.raises(TypeError) as err:
-            _openassetio_test.PyRetainingSimpleCppContainer(123)
+            _openassetio._testutils.PyRetainingSimpleCppContainer(123)
 
         assert "SimpleBaseCppType" in str(err.value)
 
     def test_when_using_factory_then_TypeError_reports_expected_type_in_error_message(self):
         with pytest.raises(TypeError) as err:
-            _openassetio_test.PyRetainingSimpleCppContainer.makeFromPtrValue(123)
+            _openassetio._testutils.PyRetainingSimpleCppContainer.makeFromPtrValue(123)
 
         assert "SimpleBaseCppType" in str(err.value)
 
     def test_when_using_constructor_then_python_implementation_is_retained(self):
-        container = _openassetio_test.PyRetainingSimpleCppContainer(SimpleCppType())
+        container = _openassetio._testutils.PyRetainingSimpleCppContainer(SimpleCppType())
         element = container.heldObject()
 
         assert element.value() == 2
 
     def test_when_using_factory_then_python_implementation_is_retained(self):
-        container = _openassetio_test.PyRetainingSimpleCppContainer.makeFromPtrValue(
+        container = _openassetio._testutils.PyRetainingSimpleCppContainer.makeFromPtrValue(
             SimpleCppType()
         )
         element = container.heldObject()
@@ -89,7 +91,7 @@ def test_when_using_factory_then_python_implementation_is_retained(self):
     def test_when_using_factory_taking_const_ref_argument_then_python_implementation_is_retained(
         self,
     ):
-        container = _openassetio_test.PyRetainingSimpleCppContainer.makeFromConstRefPtr(
+        container = _openassetio._testutils.PyRetainingSimpleCppContainer.makeFromConstRefPtr(
             SimpleCppType()
         )
         element = container.heldObject()
@@ -97,7 +99,7 @@ def test_when_using_factory_taking_const_ref_argument_then_python_implementation
         assert element.value() == 2
 
     def test_when_using_multi_arg_constructor_then_python_implementation_is_retained(self):
-        container = _openassetio_test.PyRetainingMultiElementCppContainer(
+        container = _openassetio._testutils.PyRetainingMultiElementCppContainer(
             SimpleCppType(), OtherSimpleCppType(), SimpleCppType()
         )
         element1 = container.heldObject1()
@@ -109,7 +111,7 @@ def test_when_using_multi_arg_constructor_then_python_implementation_is_retained
         assert element3.value() == 2
 
     def test_when_using_multi_arg_factory_then_python_implementation_is_retained(self):
-        container = _openassetio_test.PyRetainingMultiElementCppContainer.make(
+        container = _openassetio._testutils.PyRetainingMultiElementCppContainer.make(
             # Note: the False has no effect, just used to check
             # signature matching.
             SimpleCppType(),
@@ -127,7 +129,7 @@ def test_when_using_multi_arg_factory_then_python_implementation_is_retained(sel
         assert element3.value() == 2
 
     def test_when_using_list_container_then_python_implementation_is_retained(self):
-        container = _openassetio_test.PyRetainingSimpleCppListContainer(
+        container = _openassetio._testutils.PyRetainingSimpleCppListContainer(
             [SimpleCppType(), SimpleCppType()]
         )
         elements = container.heldObjects()
@@ -174,7 +176,7 @@ def create_container():
             element = DeathwatchedSimpleCppType(self.death_watcher)
             self.weak_ref = weakref.ref(element)
 
-            container = _openassetio_test.PyRetainingSimpleCppContainer(element)
+            container = _openassetio._testutils.PyRetainingSimpleCppContainer(element)
 
             # Double-check
             assert element.value() == 3
@@ -227,7 +229,7 @@ def create_container():
 
             # Store element in container. `element` will be "cast" to a
             # new PyRetainingSharedPtr.
-            container = _openassetio_test.PyRetainingSimpleCppContainer(element)
+            container = _openassetio._testutils.PyRetainingSimpleCppContainer(element)
 
             # Double-check
             assert element.value() == 3
@@ -256,37 +258,37 @@ def test_when_interpreter_dies_before_object_then_cleanup_happens_gracefully(sel
         # segfaults/errors/hangs when cleaning up the PyObject during
         # destruction of the C++ object after the interpreter has shut
         # down.
-        _openassetio_test.setSimplePyRetainedSingleton(SimpleCppType())
+        _openassetio._testutils.setSimplePyRetainedSingleton(SimpleCppType())
 
 
-class SimpleCppType(_openassetio_test.SimpleBaseCppType):
+class SimpleCppType(_openassetio._testutils.SimpleBaseCppType):
     def value(self):
         return 2
 
 
-class OtherSimpleCppType(_openassetio_test.OtherSimpleBaseCppType):
+class OtherSimpleCppType(_openassetio._testutils.OtherSimpleBaseCppType):
     def otherValue(self):
         return 3
 
 
-class SimpleBaseCppFactory(_openassetio_test.SimpleBaseCppFactory):
+class SimpleBaseCppFactory(_openassetio._testutils.SimpleBaseCppFactory):
     def createNewObject(self):
         return SimpleCppType()
 
 
-class PyRetainingSimpleCppFactory(_openassetio_test.PyRetainingSimpleBaseCppFactory):
+class PyRetainingSimpleCppFactory(_openassetio._testutils.PyRetainingSimpleBaseCppFactory):
     def createNewObject(self):
         return SimpleCppType()
 
 
-class DeathwatchedSimpleCppType(_openassetio_test.DeathwatchedSimpleCppType):
+class DeathwatchedSimpleCppType(_openassetio._testutils.DeathwatchedSimpleCppType):
     def value(self):
         return 3
 
 
-class PyRetainingDeathwatchedFactory(_openassetio_test.PyRetainingSimpleBaseCppFactory):
+class PyRetainingDeathwatchedFactory(_openassetio._testutils.PyRetainingSimpleBaseCppFactory):
     def __init__(self, callback):
-        _openassetio_test.PyRetainingSimpleBaseCppFactory.__init__(self)
+        _openassetio._testutils.PyRetainingSimpleBaseCppFactory.__init__(self)
         self.__callback = callback
 
     def createNewObject(self):
diff --git a/src/openassetio-python/tests/cmodule/test_errors.py b/src/openassetio-python/tests/cmodule/test_errors.py
index d4f6db054..a976db279 100644
--- a/src/openassetio-python/tests/cmodule/test_errors.py
+++ b/src/openassetio-python/tests/cmodule/test_errors.py
@@ -16,14 +16,15 @@
 """
 Tests of C++ binding utilities for exception types.
 """
+
+# pylint: disable=invalid-name,redefined-outer-name,protected-access
+# pylint: disable=missing-class-docstring,missing-function-docstring
 import inspect
 from unittest import mock
 
-# pylint: disable=invalid-name,redefined-outer-name
-# pylint: disable=missing-class-docstring,missing-function-docstring
 import pytest
 
-from openassetio import _openassetio_test  # pylint: disable=no-name-in-module
+from openassetio import _openassetio  # pylint: disable=no-name-in-module
 from openassetio import errors
 
 
@@ -48,28 +49,30 @@ def test_when_cpp_exception_thrown_then_can_be_caught_as_corresponding_python_ex
         self, exception_type
     ):
         with pytest.raises(exception_type, match="Explosion!"):
-            _openassetio_test.throwException(exception_type.__name__, "Explosion!")
+            _openassetio._testutils.throwException(exception_type.__name__, "Explosion!")
 
     @pytest.mark.parametrize("exception_type", all_exceptions)
     def test_when_cpp_exception_thrown_then_can_be_caught_as_OpenAssetIOException(
         self, exception_type
     ):
-        assert _openassetio_test.isThrownExceptionCatchableAs(
+        assert _openassetio._testutils.isThrownExceptionCatchableAs(
             throwExceptionName=exception_type.__name__,
             catchExceptionName=errors.OpenAssetIOException.__name__,
         )
         with pytest.raises(errors.OpenAssetIOException, match="Explosion!"):
-            _openassetio_test.throwException(exception_type.__name__, "Explosion!")
+            _openassetio._testutils.throwException(exception_type.__name__, "Explosion!")
 
     def test_when_ConfigurationException_thrown_then_can_be_caught_as_InputValidationException(
         self,
     ):
-        assert _openassetio_test.isThrownExceptionCatchableAs(
+        assert _openassetio._testutils.isThrownExceptionCatchableAs(
             throwExceptionName=errors.ConfigurationException.__name__,
             catchExceptionName=errors.InputValidationException.__name__,
         )
         with pytest.raises(errors.InputValidationException, match="Explosion!"):
-            _openassetio_test.throwException(errors.ConfigurationException.__name__, "Explosion!")
+            _openassetio._testutils.throwException(
+                errors.ConfigurationException.__name__, "Explosion!"
+            )
 
     @pytest.mark.parametrize("exception_type", all_exceptions)
     def test_when_python_exception_thrown_then_can_be_caught_in_cpp(
@@ -79,7 +82,7 @@ def test_when_python_exception_thrown_then_can_be_caught_in_cpp(
 
         exception_thrower.callee.side_effect = exception
 
-        assert _openassetio_test.isPythonExceptionCatchableAs(
+        assert _openassetio._testutils.isPythonExceptionCatchableAs(
             exception_thrower, exception_type.__name__
         )
 
@@ -91,7 +94,7 @@ def test_when_python_exception_thrown_then_can_be_caught_in_cpp_as_stl_base_type
 
         exception_thrower.callee.side_effect = exception
 
-        _openassetio_test.throwPythonExceptionAndCatchAsStdException(exception_thrower)
+        _openassetio._testutils.throwPythonExceptionAndCatchAsStdException(exception_thrower)
 
     @pytest.mark.parametrize("exception_type", all_exceptions)
     def test_when_python_exception_thrown_then_can_be_caught_in_cpp_rethrown_and_caught_in_python(
@@ -102,7 +105,7 @@ def test_when_python_exception_thrown_then_can_be_caught_in_cpp_rethrown_and_cau
         exception_thrower.callee.side_effect = exception
 
         with pytest.raises(exception_type) as err:
-            _openassetio_test.throwPythonExceptionCatchAsCppExceptionAndRethrow(
+            _openassetio._testutils.throwPythonExceptionCatchAsCppExceptionAndRethrow(
                 exception_thrower, exception_type.__name__
             )
 
@@ -121,7 +124,7 @@ class OpenAssetIOException(Exception):
 
         with pytest.raises(OpenAssetIOException):
             # Should fail to catch and propagate back.
-            _openassetio_test.isPythonExceptionCatchableAs(
+            _openassetio._testutils.isPythonExceptionCatchableAs(
                 exception_thrower, "OpenAssetIOException"
             )
 
@@ -138,7 +141,7 @@ def make_exception(exception_type):
     return exception
 
 
-class PyExceptionThrower(_openassetio_test.ExceptionThrower):
+class PyExceptionThrower(_openassetio._testutils.ExceptionThrower):
     """
     Subclass of pybind11-bound abstract C++ class, which is expected to
     throw the same exception from each of its methods.
@@ -153,7 +156,7 @@ class PyExceptionThrower(_openassetio_test.ExceptionThrower):
     """
 
     def __init__(self):
-        _openassetio_test.ExceptionThrower.__init__(self)
+        _openassetio._testutils.ExceptionThrower.__init__(self)
         self.callee = mock.Mock()
 
     def throwFromOverride(self):
diff --git a/src/openassetio-python/tests/cmodule/test_gil.py b/src/openassetio-python/tests/cmodule/test_gil.py
index 1f126efa9..c1174c1d4 100644
--- a/src/openassetio-python/tests/cmodule/test_gil.py
+++ b/src/openassetio-python/tests/cmodule/test_gil.py
@@ -17,13 +17,13 @@
 Testing pybind11's GIL handling behaviour
 """
 
-# pylint: disable=redefined-outer-name
+# pylint: disable=redefined-outer-name,protected-access
 # pylint: disable=invalid-name,c-extension-no-member
 # pylint: disable=missing-class-docstring,missing-function-docstring
 import threading
 
 # pylint: disable=no-name-in-module
-from openassetio import _openassetio_test
+from openassetio import _openassetio
 
 
 class Test_gil:
@@ -36,7 +36,7 @@ def update_state():
             assert threading.current_thread().ident != main_thread_id
             self.state_to_update = True
 
-        _openassetio_test.runCallableInThread(update_state)
+        _openassetio._testutils.runCallableInThread(update_state)
 
         assert self.state_to_update is True
 
@@ -48,7 +48,7 @@ def update_state():
             assert threading.current_thread().ident != main_thread_id
             self.state_to_update = True
 
-        _openassetio_test.runPyFunctionInThread(update_state)
+        _openassetio._testutils.runPyFunctionInThread(update_state)
 
         assert self.state_to_update is True
 
@@ -60,15 +60,15 @@ def update_state():
             assert threading.current_thread().ident != main_thread_id
             self.state_to_update = True
 
-        _openassetio_test.runStdFunctionInThread(update_state)
+        _openassetio._testutils.runStdFunctionInThread(update_state)
 
         assert self.state_to_update is True
 
     def test_when_calling_bound_member_in_another_thread_then_doesnt_error(self):
-        class MyFlag(_openassetio_test.Flag):
+        class MyFlag(_openassetio._testutils.Flag):
             def __init__(self):
                 self.value = None
-                _openassetio_test.Flag.__init__(self)
+                _openassetio._testutils.Flag.__init__(self)
 
             def set(self, value):
                 assert threading.current_thread().ident != main_thread_id
@@ -80,7 +80,7 @@ def get(self):
         flag = MyFlag()
         main_thread_id = threading.current_thread().ident
 
-        returned_flag_value = _openassetio_test.flagInThread(flag)
+        returned_flag_value = _openassetio._testutils.flagInThread(flag)
 
         assert flag.value is True
         assert returned_flag_value is flag.value